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">
|
||||
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 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
|
||||
changes so irrelevant commits do not keep a fork permanently actionable.
|
||||
|
||||
- **OpenCode workspaces**
|
||||
Agent work happens in an isolated workspace with a file tree, browser editor,
|
||||
diff viewer, command panel, logs, artifacts, and draft PR actions.
|
||||
- **One persistent per-user box**
|
||||
Every user owns a single long-running Fedora container `spoon-box-{username}`
|
||||
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**
|
||||
AI provider profiles, Codex/OpenCode auth, and per-Spoon project secrets are
|
||||
encrypted. Secrets are redacted from logs and refused from commits when
|
||||
materialized into env files.
|
||||
AI provider profiles, Codex/OpenCode/Anthropic auth, and per-Spoon project
|
||||
secrets are encrypted. Secrets are written `0600`, redacted from logs, and
|
||||
refused from commits when materialized into env files.
|
||||
|
||||
- **Draft PR handoff**
|
||||
Code changes become branches and draft pull requests. Spoon does not
|
||||
@@ -138,12 +163,23 @@ custom work exists.
|
||||
</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
|
||||
worker claims queued jobs, clones the current GitHub fork, creates a branch,
|
||||
starts an isolated workspace, and exposes workspace operations to the Next app
|
||||
through server-only API proxies.
|
||||
worker claims queued jobs, clones the current GitHub fork into the owner's
|
||||
persistent home, creates a branch, and exposes workspace operations to the Next
|
||||
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:
|
||||
|
||||
@@ -154,16 +190,18 @@ Workspace capabilities:
|
||||
- inspect diffs
|
||||
- send thread messages to the agent
|
||||
- run configured commands
|
||||
- use an interactive terminal (xterm.js → box PTY; see
|
||||
[docs/agent-terminal.md](docs/agent-terminal.md))
|
||||
- store logs and artifacts
|
||||
- push a branch
|
||||
- open a draft PR
|
||||
|
||||
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
|
||||
workspace records and ask the active worker to remove orphaned job containers
|
||||
and inactive work directories.
|
||||
Worker cleanup is available in `Settings -> Worker`. It can delete stale
|
||||
workspace records and ask the active worker to remove orphaned containers and
|
||||
inactive work directories (persistent per-user homes are preserved).
|
||||
|
||||
Local worker development:
|
||||
|
||||
@@ -174,20 +212,53 @@ bun dev:next:worker
|
||||
bun dev:next:worker:staging
|
||||
```
|
||||
|
||||
Local host-run worker commands still load env through Infisical, then
|
||||
`scripts/dev-agent-worker` selects Podman when available, falls back to Docker,
|
||||
and publishes the OpenCode server on a localhost port so the host worker can
|
||||
reach the job container. Override with:
|
||||
Local host-run worker commands load env through Infisical, then
|
||||
`scripts/dev-agent-worker` selects Podman when available and falls back to
|
||||
Docker. Override the container CLI with:
|
||||
|
||||
```env
|
||||
SPOON_AGENT_CONTAINER_RUNTIME=podman
|
||||
SPOON_AGENT_CONTAINER_ACCESS=host_port
|
||||
```
|
||||
|
||||
</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:
|
||||
|
||||
@@ -197,31 +268,36 @@ git.gbrown.org/gib/spoon-agent-worker: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 isolated workbench that the worker launches for each agent job. For the MVP,
|
||||
production should use the repo-provided JS/TS workbench image:
|
||||
The worker image is the long-running service that polls Convex. The
|
||||
`spoon-agent-job` image is the **box** image — the per-user Fedora dev box the
|
||||
worker `exec`s into (it is not launched fresh per job). Point the worker at it
|
||||
with:
|
||||
|
||||
```env
|
||||
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,
|
||||
ripgrep, Python, build tools, OpenCode, and the Codex CLI. It is not the forked
|
||||
project's production runtime; it is the agent execution environment.
|
||||
The box image is Fedora 41 with Node, Bun, pnpm and yarn, npm, git, ripgrep,
|
||||
Python, build tools, plus interactive tooling (neovim, tmux, fzf, fd, bat, eza,
|
||||
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:
|
||||
|
||||
- `spoon-agent-worker` must run as a separate service.
|
||||
- The worker needs `/var/run/docker.sock` mounted so it can launch job
|
||||
containers.
|
||||
- Production should keep `SPOON_AGENT_CONTAINER_RUNTIME=docker` and
|
||||
`SPOON_AGENT_CONTAINER_ACCESS=network`.
|
||||
- The production Docker host must be logged into `git.gbrown.org` so worker jobs
|
||||
can pull the private `spoon-agent-job` image.
|
||||
- The worker needs `/var/run/docker.sock` mounted so it can create and `exec`
|
||||
into per-user boxes on the host daemon.
|
||||
- Production should keep `SPOON_AGENT_CONTAINER_RUNTIME=docker`.
|
||||
- `SPOON_AGENT_HOST_WORKDIR` must equal the absolute host path backing
|
||||
`SPOON_AGENT_WORKDIR` (identical inside and outside the worker container) so
|
||||
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-next` needs `SPOON_AGENT_WORKER_URL=http://spoon-agent-worker:3921` and
|
||||
`SPOON_AGENT_WORKER_INTERNAL_TOKEN` so Next API routes can proxy workspace
|
||||
file, diff, message, command, and draft PR actions.
|
||||
`SPOON_AGENT_WORKER_INTERNAL_TOKEN` so Next API routes can proxy workspace and
|
||||
box file, diff, message, command, terminal, and draft PR actions.
|
||||
- `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
|
||||
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:
|
||||
|
||||
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`,
|
||||
`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`.
|
||||
4. `Settings -> Worker` reports the expected job image, runtime, network, and
|
||||
active workspace count.
|
||||
4. `Settings -> Worker` reports the expected box image, runtime, network, and
|
||||
active box count.
|
||||
5. The first test thread uses a configured API-key provider or a trusted Codex
|
||||
login profile.
|
||||
6. If a worker restart leaves stale workspace state, use the workspace recovery
|
||||
panel or `Settings -> Worker` cleanup.
|
||||
6. If a worker restart leaves stale state, use the workspace recovery panel or
|
||||
`Settings -> Worker` cleanup.
|
||||
|
||||
API-key based AI provider profiles run through OpenCode. Codex ChatGPT login
|
||||
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.
|
||||
Treat that saved auth file like a password and only use it on trusted workers.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<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>
|
||||
|
||||
@@ -284,45 +395,54 @@ Treat that saved auth file like a password and only use it on trusted workers.
|
||||
<details>
|
||||
<summary><strong>Core tables</strong></summary>
|
||||
|
||||
| Table | Purpose |
|
||||
| ------------------------ | --------------------------------------------------------- |
|
||||
| `spoons` | Managed fork records |
|
||||
| `threads` | Durable maintenance and work conversations |
|
||||
| `threadMessages` | Messages inside threads |
|
||||
| `syncRuns` | Upstream checks, sync attempts, and maintenance decisions |
|
||||
| `ignoredUpstreamChanges` | Intentional ignore records that affect effective drift |
|
||||
| `gitConnections` | Git provider connection metadata |
|
||||
| `spoonRepositoryStates` | Latest cached upstream/fork state |
|
||||
| `spoonCommits` | Cached upstream and fork-only commits |
|
||||
| `spoonPullRequests` | Cached fork/upstream pull requests |
|
||||
| `spoonSecrets` | Encrypted per-Spoon environment variables |
|
||||
| `spoonAgentSettings` | Per-Spoon runtime, branch, command, and env-file settings |
|
||||
| `aiProviderProfiles` | Encrypted provider/auth profiles used by OpenCode |
|
||||
| `agentJobs` | Worker-executed workspace jobs and PR lifecycle |
|
||||
| `agentJobEvents` | Append-only worker event log |
|
||||
| `agentJobArtifacts` | Diffs, summaries, command output, PR body drafts |
|
||||
| `agentWorkspaceChanges` | Recorded user, agent, and command file changes |
|
||||
| Table | Purpose |
|
||||
| ------------------------- | ----------------------------------------------------------- |
|
||||
| `spoons` | Managed fork records |
|
||||
| `threads` | Durable maintenance and work conversations |
|
||||
| `threadMessages` | Messages inside threads |
|
||||
| `syncRuns` | Upstream checks, sync attempts, and maintenance decisions |
|
||||
| `ignoredUpstreamChanges` | Intentional ignore records that affect effective drift |
|
||||
| `gitConnections` | Git provider connection metadata |
|
||||
| `spoonRepositoryStates` | Latest cached upstream/fork state |
|
||||
| `spoonCommits` | Cached upstream and fork-only commits |
|
||||
| `spoonPullRequests` | Cached fork/upstream pull requests |
|
||||
| `spoonSecrets` | Encrypted per-Spoon environment variables |
|
||||
| `spoonAgentSettings` | Per-Spoon runtime, branch, command, and env-file settings |
|
||||
| `aiProviderProfiles` | Encrypted provider/auth profiles (Codex/OpenCode/Anthropic) |
|
||||
| `agentJobs` | Worker-executed workspace jobs and PR lifecycle |
|
||||
| `agentJobEvents` | Append-only worker event log |
|
||||
| `agentJobArtifacts` | Diffs, summaries, command output, PR body drafts |
|
||||
| `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>
|
||||
<summary><strong>Important routes</strong></summary>
|
||||
|
||||
| Route | Purpose |
|
||||
| --------------------------------- | --------------------------------------- |
|
||||
| `/` | Public product landing page |
|
||||
| `/dashboard` | Maintenance overview |
|
||||
| `/spoons` | Managed fork list |
|
||||
| `/spoons/new` | Manual/GitHub Spoon creation |
|
||||
| `/spoons/[spoonId]` | Spoon detail dashboard |
|
||||
| `/spoons/[spoonId]/agent/[jobId]` | Interactive workspace |
|
||||
| `/threads` | Global thread queue |
|
||||
| `/threads/[threadId]` | Thread detail |
|
||||
| `/settings/profile` | User profile settings |
|
||||
| `/settings/integrations` | GitHub and service integration settings |
|
||||
| `/settings/ai-providers` | AI/OpenCode provider profiles |
|
||||
| Route | Purpose |
|
||||
| --------------------------------- | ----------------------------------------------- |
|
||||
| `/` | Public product landing page |
|
||||
| `/dashboard` | Maintenance overview |
|
||||
| `/spoons` | Managed fork list |
|
||||
| `/spoons/new` | Manual/GitHub Spoon creation |
|
||||
| `/spoons/[spoonId]` | Spoon detail dashboard |
|
||||
| `/spoons/[spoonId]/agent/[jobId]` | Interactive workspace |
|
||||
| `/threads` | Global thread queue |
|
||||
| `/threads/[threadId]` | Thread detail |
|
||||
| `/machine` | My Machine: box status, terminal, home |
|
||||
| `/settings/profile` | User profile settings |
|
||||
| `/settings/integrations` | GitHub and service integration settings |
|
||||
| `/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>
|
||||
|
||||
@@ -423,16 +543,17 @@ not call Infisical.
|
||||
<details open>
|
||||
<summary><strong>Public Next variables</strong></summary>
|
||||
|
||||
| Variable | Used for |
|
||||
| --------------------------------- | ------------------------------------------- |
|
||||
| `NEXT_PUBLIC_SITE_URL` | Canonical Spoon web URL |
|
||||
| `NEXT_PUBLIC_CONVEX_URL` | Convex client URL |
|
||||
| `NEXT_PUBLIC_DEPLOYMENT_URL` | Convex dashboard/deployment URL when needed |
|
||||
| `NEXT_PUBLIC_PLAUSIBLE_URL` | Plausible analytics endpoint |
|
||||
| `NEXT_PUBLIC_SENTRY_DSN` | Browser Sentry DSN |
|
||||
| `NEXT_PUBLIC_SENTRY_URL` | Sentry instance URL |
|
||||
| `NEXT_PUBLIC_SENTRY_ORG` | Sentry organization |
|
||||
| `NEXT_PUBLIC_SENTRY_PROJECT_NAME` | Sentry project name |
|
||||
| Variable | Used for |
|
||||
| --------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_SITE_URL` | Canonical Spoon web 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_PLAUSIBLE_URL` | Plausible analytics endpoint |
|
||||
| `NEXT_PUBLIC_SENTRY_DSN` | Browser Sentry DSN |
|
||||
| `NEXT_PUBLIC_SENTRY_URL` | Sentry instance URL |
|
||||
| `NEXT_PUBLIC_SENTRY_ORG` | Sentry organization |
|
||||
| `NEXT_PUBLIC_SENTRY_PROJECT_NAME` | Sentry project name |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -487,15 +608,16 @@ not call Infisical.
|
||||
| `SPOON_AGENT_WORKER_URL` | Internal worker HTTP URL used by Next |
|
||||
| `SPOON_AGENT_WORKER_HTTP_PORT` | Worker HTTP port |
|
||||
| `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_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_JOB_TIMEOUT_MS` | Job timeout |
|
||||
| `SPOON_AGENT_WORKDIR` | Worker work directory |
|
||||
| `SPOON_AGENT_JOB_TIMEOUT_MS` | Agent turn timeout |
|
||||
| `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_NETWORK` | Optional job container network |
|
||||
| `SPOON_AGENT_NETWORK` | Optional box container network |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -523,12 +645,20 @@ not call Infisical.
|
||||
- GitHub drift refresh, commit cache, PR cache, and sync-run history
|
||||
- Effective drift and ignored upstream change records
|
||||
- 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
|
||||
- Diff viewer, command panel, worker logs, and artifacts
|
||||
- Encrypted Spoon secrets and bulk `.env` import
|
||||
- Encrypted AI provider profiles, including Codex auth JSON and API-key
|
||||
provider support
|
||||
- Encrypted AI provider profiles, including Codex/Anthropic auth JSON and
|
||||
API-key provider support
|
||||
- Authentik, GitHub, and password auth through Convex Auth
|
||||
- Self-hosted Convex/Postgres deployment model
|
||||
|
||||
|
||||
@@ -357,6 +357,114 @@ export const normalizeCodexJsonLine = (
|
||||
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 = (
|
||||
input: unknown,
|
||||
): NormalizedAgentEvent[] => {
|
||||
@@ -464,3 +572,56 @@ export const normalizeOpenCodeEvent = (
|
||||
}
|
||||
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',
|
||||
containerVolumeOptions:
|
||||
process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS?.trim(),
|
||||
containerAccess:
|
||||
process.env.SPOON_AGENT_CONTAINER_ACCESS?.trim() === 'host_port'
|
||||
? 'host_port'
|
||||
: 'network',
|
||||
jobImage:
|
||||
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
|
||||
// Interactive terminal: image for the persistent shell container (defaults to
|
||||
// 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',
|
||||
// Secret shared with the Next app for verifying interactive terminal tokens.
|
||||
terminalSecret:
|
||||
process.env.SPOON_AGENT_TERMINAL_SECRET?.trim() ??
|
||||
process.env.SPOON_AGENT_WORKER_INTERNAL_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.
|
||||
boxIdleMs: intEnv('SPOON_AGENT_BOX_IDLE_MS', 1_800_000),
|
||||
// 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_WORKER_TOKEN?.trim() ??
|
||||
'',
|
||||
maxConcurrentJobs: intEnv('SPOON_AGENT_MAX_CONCURRENT_JOBS', 1),
|
||||
jobTimeoutMs: intEnv('SPOON_AGENT_JOB_TIMEOUT_MS', 1_800_000),
|
||||
githubAppId: requiredEnv('GITHUB_APP_ID'),
|
||||
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 { startWorkerServer } from './server';
|
||||
import { reconcileExistingBoxes } from './user-container';
|
||||
import { startWorker } from './worker';
|
||||
|
||||
// Dev-only watchdog: the dev runner chain (turbo → with-env → dotenv → bash)
|
||||
@@ -26,4 +27,5 @@ if (env.devWatchdog) {
|
||||
}
|
||||
|
||||
startWorkerServer();
|
||||
await reconcileExistingBoxes();
|
||||
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 = [
|
||||
/ghs_[A-Za-z0-9_]+/g,
|
||||
/github_pat_[A-Za-z0-9_]+/g,
|
||||
/sk-[A-Za-z0-9_-]+/g,
|
||||
/(client_secret|auth_secret|api_key|token|password)=([^ \n\r]+)/gi,
|
||||
const secretPatterns: { pattern: RegExp; replacement: string }[] = [
|
||||
{ pattern: /ghs_[A-Za-z0-9_]+/g, replacement: '[redacted]' },
|
||||
{ pattern: /github_pat_[A-Za-z0-9_]+/g, replacement: '[redacted]' },
|
||||
{ pattern: /sk-[A-Za-z0-9_-]+/g, replacement: '[redacted]' },
|
||||
{
|
||||
pattern: /(client_secret|auth_secret|api_key|token|password)=([^ \n\r]+)/gi,
|
||||
replacement: '$1=[redacted]',
|
||||
},
|
||||
];
|
||||
|
||||
export const createRedactor = (values: string[]) => {
|
||||
@@ -12,8 +15,8 @@ export const createRedactor = (values: string[]) => {
|
||||
for (const secret of secrets) {
|
||||
output = output.split(secret).join('[redacted]');
|
||||
}
|
||||
for (const pattern of secretPatterns) {
|
||||
output = output.replace(pattern, '$1=[redacted]');
|
||||
for (const { pattern, replacement } of secretPatterns) {
|
||||
output = output.replace(pattern, replacement);
|
||||
}
|
||||
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 path from 'node:path';
|
||||
import type { Readable } from 'node:stream';
|
||||
import Docker from 'dockerode';
|
||||
import { execa } from 'execa';
|
||||
|
||||
import { env } from '../env';
|
||||
@@ -10,6 +11,13 @@ type CommandResult = {
|
||||
output: string;
|
||||
};
|
||||
|
||||
let dockerClient: Docker | undefined;
|
||||
|
||||
export const getDockerClient = () => {
|
||||
dockerClient ??= new Docker();
|
||||
return dockerClient;
|
||||
};
|
||||
|
||||
const environmentArgs = (environment: Record<string, string>) =>
|
||||
Object.entries(environment).flatMap(([name, value]) => [
|
||||
'-e',
|
||||
@@ -95,138 +103,6 @@ export const jobWorkspaceVolumeSpec = (
|
||||
: `${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
|
||||
// (used by both `docker run` and `docker exec` paths).
|
||||
type StreamingSubprocess = {
|
||||
@@ -288,51 +164,6 @@ const streamSubprocess = async (
|
||||
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
|
||||
// (Phase 2). Started once, reused; the home volume persists state across stops.
|
||||
export const userContainerName = (username: string) =>
|
||||
@@ -362,6 +193,7 @@ export const ensureUserContainer = async (args: {
|
||||
[
|
||||
'run',
|
||||
'-d',
|
||||
'--init',
|
||||
'--name',
|
||||
name,
|
||||
'--memory',
|
||||
@@ -382,6 +214,127 @@ export const ensureUserContainer = async (args: {
|
||||
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: {
|
||||
containerName: 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) => {
|
||||
const result = await execa(
|
||||
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 {
|
||||
getBoxStatus,
|
||||
listBoxTree,
|
||||
readBoxFile,
|
||||
restartBox,
|
||||
startBox,
|
||||
stopBox,
|
||||
writeBoxFile,
|
||||
} from './box';
|
||||
import { env } from './env';
|
||||
import { attachTerminalServer } from './terminal';
|
||||
import {
|
||||
@@ -56,6 +65,11 @@ const jobRoute = (pathname: string) => {
|
||||
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 = () => {
|
||||
const server = createServer((request, response) => {
|
||||
void (async () => {
|
||||
@@ -73,6 +87,59 @@ export const startWorkerServer = () => {
|
||||
sendJson(response, 200, await cleanupOrphanedWorkspaces());
|
||||
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);
|
||||
if (!route) {
|
||||
sendJson(response, 404, { error: 'Not found' });
|
||||
@@ -175,9 +242,11 @@ export const startWorkerServer = () => {
|
||||
const status =
|
||||
message === 'Unauthorized'
|
||||
? 401
|
||||
: message.includes('not supported')
|
||||
? 409
|
||||
: 500;
|
||||
: message.startsWith('Refusing to')
|
||||
? 400
|
||||
: message.includes('not supported')
|
||||
? 409
|
||||
: 500;
|
||||
sendJson(response, status, {
|
||||
error: message,
|
||||
});
|
||||
|
||||
@@ -27,3 +27,41 @@ export const verifyTerminalToken = (
|
||||
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 { Duplex } from 'node:stream';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
import type { BoxHandle } from './user-container';
|
||||
import { boxHomePaths } from './box';
|
||||
import { env } from './env';
|
||||
import { verifyTerminalToken } from './terminal-token';
|
||||
import { acquireUserBox, releaseUserBox } from './user-container';
|
||||
import { getDockerClient } from './runtime/docker';
|
||||
import {
|
||||
parseBoxTokenUsername,
|
||||
verifyBoxTerminalToken,
|
||||
verifyTerminalToken,
|
||||
} from './terminal-token';
|
||||
import { acquireUserBox } from './user-container';
|
||||
import { ensureBashProfile } from './user-environment';
|
||||
import { getTerminalWorkspace } from './worker';
|
||||
|
||||
const clampDimension = (value: unknown) => {
|
||||
@@ -15,55 +22,79 @@ const clampDimension = (value: unknown) => {
|
||||
return Math.min(Math.max(n, 1), 1000);
|
||||
};
|
||||
|
||||
// Single-quote a string for a POSIX shell.
|
||||
const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`;
|
||||
|
||||
const bridge = async (ws: WebSocket, jobId: string) => {
|
||||
const workspace = getTerminalWorkspace(jobId);
|
||||
if (!workspace) {
|
||||
ws.close(1011, 'Workspace is not active.');
|
||||
return;
|
||||
export const parseResizeMessage = (
|
||||
data: Buffer,
|
||||
isBinary: boolean,
|
||||
): { cols: number; rows: number } | null => {
|
||||
if (isBinary) return null;
|
||||
try {
|
||||
const message = JSON.parse(data.toString('utf8')) as {
|
||||
type?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
};
|
||||
if (message.type !== 'resize') return null;
|
||||
const cols = clampDimension(message.cols);
|
||||
const rows = clampDimension(message.rows);
|
||||
if (!cols || !rows) return null;
|
||||
return { cols, rows };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 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 } = {};
|
||||
// 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) => {
|
||||
if (!isBinary) {
|
||||
// Text frames are control messages (resize); anything else is raw input.
|
||||
try {
|
||||
const message = JSON.parse(data.toString('utf8')) as {
|
||||
type?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
};
|
||||
if (message.type === 'resize') {
|
||||
const c = clampDimension(message.cols);
|
||||
const r = clampDimension(message.rows);
|
||||
if (c && r) {
|
||||
cols = c;
|
||||
rows = r;
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through: treat as raw input
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (procHolder.current) procHolder.current.stdin.write(data);
|
||||
if (execHolder.current) execHolder.current.stream.write(data);
|
||||
else pendingInput.push(data);
|
||||
});
|
||||
|
||||
let acquired = false;
|
||||
const handleHolder: { current?: BoxHandle } = {};
|
||||
let released = false;
|
||||
// Read through a function so TS doesn't narrow `released` to a constant — the
|
||||
// cleanup handler flips it asynchronously when the socket closes.
|
||||
@@ -71,8 +102,9 @@ const bridge = async (ws: WebSocket, jobId: string) => {
|
||||
const cleanup = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
procHolder.current?.kill();
|
||||
if (acquired) releaseUserBox(workspace.username);
|
||||
execHolder.current?.stream.end();
|
||||
execHolder.current?.stream.destroy();
|
||||
handleHolder.current?.release();
|
||||
};
|
||||
ws.on('close', 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).
|
||||
let boxName: string;
|
||||
try {
|
||||
boxName = await acquireUserBox({
|
||||
username: workspace.username,
|
||||
workdir: workspace.workdir,
|
||||
containerHome: workspace.containerHome,
|
||||
});
|
||||
acquired = true;
|
||||
if (opts.prepare) await opts.prepare();
|
||||
const handle = await opts.acquire();
|
||||
handleHolder.current = handle;
|
||||
boxName = handle.boxName;
|
||||
} catch (error) {
|
||||
ws.close(
|
||||
1011,
|
||||
@@ -95,64 +125,90 @@ const bridge = async (ws: WebSocket, jobId: string) => {
|
||||
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
|
||||
// plain login shell. `stty` sizes the PTY to the client's viewport up front.
|
||||
const launcher =
|
||||
`stty rows ${rows} cols ${cols} 2>/dev/null; ` +
|
||||
// Reattach a persistent tmux session when tmux is present; otherwise fall back
|
||||
// to an interactive login shell (`-i` so it prints a prompt and line-edits).
|
||||
// Check with `command -v` rather than `exec tmux || …`: a failed `exec` makes a
|
||||
// non-interactive shell exit before the `||`, so the fallback never runs.
|
||||
'if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s spoon; ' +
|
||||
'else exec bash -il; fi';
|
||||
const envFlags = [
|
||||
'-e',
|
||||
'TERM=xterm-256color',
|
||||
'-e',
|
||||
`HOME=${workspace.containerHome}`,
|
||||
...workspace.secrets.flatMap((s) => ['-e', `${s.name}=${s.value}`]),
|
||||
];
|
||||
const docker = getDockerClient();
|
||||
const container = docker.getContainer(boxName);
|
||||
const exec = await container.exec({
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Tty: true,
|
||||
Cmd: SHELL_CMD,
|
||||
Env: ['TERM=xterm-256color', ...opts.envFlags],
|
||||
WorkingDir: opts.cwd,
|
||||
});
|
||||
const stream = await exec.start({ hijack: true, stdin: true, Tty: true });
|
||||
execHolder.current = { exec, stream };
|
||||
|
||||
const proc = spawn(
|
||||
env.containerRuntime,
|
||||
[
|
||||
'exec',
|
||||
'-i',
|
||||
...envFlags,
|
||||
'-w',
|
||||
workspace.containerRepo,
|
||||
boxName,
|
||||
'/bin/bash',
|
||||
'-lc',
|
||||
`exec script -qfc ${shellQuote(launcher)} /dev/null`,
|
||||
],
|
||||
{ stdio: ['pipe', 'pipe', 'pipe'] },
|
||||
);
|
||||
procHolder.current = proc;
|
||||
if (isReleased()) {
|
||||
stream.end();
|
||||
stream.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
await exec.resize({ h: rows, w: cols }).catch(() => undefined);
|
||||
|
||||
// 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;
|
||||
|
||||
const forward = (chunk: Buffer) => {
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
if (ws.readyState === ws.OPEN) ws.send(chunk, { binary: true });
|
||||
};
|
||||
proc.stdout.on('data', forward);
|
||||
proc.stderr.on('data', forward);
|
||||
proc.on('exit', () => {
|
||||
});
|
||||
stream.on('end', () => {
|
||||
if (ws.readyState === ws.OPEN) ws.close();
|
||||
});
|
||||
proc.on('error', () => {
|
||||
stream.on('error', () => {
|
||||
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
|
||||
* server. Browser connects to `/jobs/:jobId/terminal?token=…` with a short-lived
|
||||
* token minted by the Next app (which has already verified job ownership).
|
||||
* Attaches the interactive-terminal WebSocket endpoints to the worker's HTTP
|
||||
* server. Two routes, each authorized by a short-lived token minted by the Next
|
||||
* 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) => {
|
||||
if (env.runtime !== 'docker') return;
|
||||
@@ -160,20 +216,43 @@ export const attachTerminalServer = (server: Server) => {
|
||||
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
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);
|
||||
if (!match?.[1]) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const jobId = decodeURIComponent(match[1]);
|
||||
const token = url.searchParams.get('token') ?? '';
|
||||
if (!verifyTerminalToken(token, jobId, env.terminalSecret)) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
ensureUserContainer,
|
||||
listWorkspaceContainerNames,
|
||||
stopWorkspaceContainer,
|
||||
userContainerName,
|
||||
} from './runtime/docker';
|
||||
|
||||
// Phase 2: one persistent "box" container per user that all of their threads
|
||||
// (agent turns + terminal + commands) exec into. Reference-counted so it stays
|
||||
// up while any thread workspace is active or a terminal is connected, and is
|
||||
// reaped after an idle period once nothing holds it.
|
||||
type Box = { refs: number; idleTimer?: NodeJS.Timeout };
|
||||
const boxes = new Map<string, Box>();
|
||||
// Phase 2: one persistent "box" container per user. Reference-counted by opaque
|
||||
// handles (not a shared integer), serialized per-username with an async mutex,
|
||||
// and idle-reaped once no handle is held.
|
||||
export type BoxHandle = { boxName: string; release: () => void };
|
||||
|
||||
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;
|
||||
workdir: string;
|
||||
containerHome: string;
|
||||
}): Promise<string> => {
|
||||
const name = await ensureUserContainer(args);
|
||||
const box = boxes.get(args.username) ?? { refs: 0 };
|
||||
if (box.idleTimer) {
|
||||
clearTimeout(box.idleTimer);
|
||||
box.idleTimer = undefined;
|
||||
}): Promise<BoxHandle> =>
|
||||
withLock(args.username, async () => {
|
||||
let box = boxes.get(args.username);
|
||||
if (box?.idleTimer) {
|
||||
clearTimeout(box.idleTimer);
|
||||
box.idleTimer = undefined;
|
||||
}
|
||||
if (!box) {
|
||||
box = {
|
||||
name: userContainerName(args.username),
|
||||
initialized: false,
|
||||
refs: new Set(),
|
||||
};
|
||||
boxes.set(args.username, box);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
box.refs += 1;
|
||||
boxes.set(args.username, box);
|
||||
return name;
|
||||
};
|
||||
|
||||
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);
|
||||
if (!box) return;
|
||||
box.refs = Math.max(0, box.refs - 1);
|
||||
if (box.refs > 0) return;
|
||||
box.idleTimer = setTimeout(() => {
|
||||
void stopWorkspaceContainer(userContainerName(username));
|
||||
boxes.delete(username);
|
||||
}, env.boxIdleMs);
|
||||
if (box.idleTimer) clearTimeout(box.idleTimer);
|
||||
boxes.delete(username);
|
||||
};
|
||||
|
||||
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 { env } from './env';
|
||||
import { assertContainedRealPath } from './path-containment';
|
||||
import { runExecInContainer } from './runtime/docker';
|
||||
|
||||
const client = new ConvexHttpClient(env.convexUrl);
|
||||
@@ -31,14 +32,21 @@ export const fetchUserEnvironment = async (
|
||||
|
||||
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||
|
||||
// Keep a written path inside the home directory.
|
||||
const safeHomeJoin = (homeDir: string, relPath: string) => {
|
||||
const target = path.resolve(homeDir, relPath);
|
||||
const root = path.resolve(homeDir);
|
||||
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
|
||||
throw new Error(`Refusing to write dotfile outside home: ${relPath}`);
|
||||
}
|
||||
return target;
|
||||
/**
|
||||
* A mounted home has no /etc/skel, so login shells wouldn't source ~/.bashrc.
|
||||
* Write a minimal `.bash_profile` (only if absent) so an interactive login
|
||||
* shell — the agent's, or the user's box terminal — loads their environment.
|
||||
* Shared by `materializeUserHome` and the box terminal bridge.
|
||||
*/
|
||||
export const ensureBashProfile = async (homeDir: string): Promise<void> => {
|
||||
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;
|
||||
}): Promise<void> => {
|
||||
const { homeDir, containerHome, boxName, userEnv, redact } = args;
|
||||
await mkdir(homeDir, { recursive: true });
|
||||
|
||||
// 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',
|
||||
);
|
||||
});
|
||||
await ensureBashProfile(homeDir);
|
||||
|
||||
if (!userEnv.enabled) return;
|
||||
|
||||
@@ -111,7 +110,9 @@ export const materializeUserHome = async (args: {
|
||||
|
||||
// Editable overlay tree (wins over the repo/setup output).
|
||||
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 writeFile(target, file.content);
|
||||
if (file.isExecutable) await chmod(target, 0o755);
|
||||
|
||||
+377
-728
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 {
|
||||
normalizeClaudeJsonLine,
|
||||
normalizeCodexJsonLine,
|
||||
normalizeOpenCodeEvent,
|
||||
normalizeOpenCodeRunLine,
|
||||
} from '../../src/agent-events';
|
||||
|
||||
describe('agent event normalization', () => {
|
||||
@@ -291,4 +293,110 @@ describe('agent event normalization', () => {
|
||||
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 { MetricCard } from '@/components/dashboard/metric-card';
|
||||
import { SpoonCard } from '@/components/spoons/spoon-card';
|
||||
import { ListSkeleton } from '@/components/ui/list-skeleton';
|
||||
import { MaintenanceQueue } from '@/components/threads/maintenance-queue';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { GitBranch, MessageSquare, RefreshCw, ShieldCheck } from 'lucide-react';
|
||||
|
||||
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 spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
||||
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
|
||||
const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
|
||||
const activeSpoons = spoons.filter(
|
||||
const spoons = useQuery(api.spoons.listMineWithState, {});
|
||||
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 });
|
||||
const threads = useQuery(api.threads.listMine, { limit: 25 });
|
||||
const spoonList = spoons ?? [];
|
||||
const threadList = threads ?? [];
|
||||
const metricsLoading = spoons === undefined || threads === undefined;
|
||||
const activeSpoons = spoonList.filter(
|
||||
(spoon) => spoon.status === 'active',
|
||||
).length;
|
||||
const behind = spoons.filter(
|
||||
const behind = spoonList.filter(
|
||||
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy === 0,
|
||||
).length;
|
||||
const diverged = spoons.filter(
|
||||
const diverged = spoonList.filter(
|
||||
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy > 0,
|
||||
).length;
|
||||
const openPullRequests = spoons.reduce(
|
||||
const openPullRequests = spoonList.reduce(
|
||||
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
|
||||
0,
|
||||
);
|
||||
@@ -43,49 +54,68 @@ const DashboardPage = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<MetricCard
|
||||
label='Spoons'
|
||||
value={spoons.length}
|
||||
note={`${activeSpoons} active`}
|
||||
icon={GitBranch}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Behind upstream'
|
||||
value={behind}
|
||||
note={`${diverged} diverged`}
|
||||
icon={RefreshCw}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Open threads'
|
||||
value={
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
|
||||
thread.status,
|
||||
),
|
||||
).length
|
||||
}
|
||||
note='Across all Spoons'
|
||||
icon={MessageSquare}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Upstream commits'
|
||||
value={openPullRequests}
|
||||
note='Actionable after ignores'
|
||||
icon={ShieldCheck}
|
||||
/>
|
||||
</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'>
|
||||
<MetricCard
|
||||
label='Spoons'
|
||||
value={spoonList.length}
|
||||
note={`${activeSpoons} active`}
|
||||
icon={GitBranch}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Behind upstream'
|
||||
value={behind}
|
||||
note={`${diverged} diverged`}
|
||||
icon={RefreshCw}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Open threads'
|
||||
value={
|
||||
threadList.filter(
|
||||
(thread) =>
|
||||
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
|
||||
thread.status,
|
||||
),
|
||||
).length
|
||||
}
|
||||
note='Across all Spoons'
|
||||
icon={MessageSquare}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Upstream commits'
|
||||
value={openPullRequests}
|
||||
note='Actionable after ignores'
|
||||
icon={ShieldCheck}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className='space-y-3'>
|
||||
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
|
||||
<MaintenanceQueue threads={threads} />
|
||||
{threads === undefined ? (
|
||||
<ListSkeleton rows={2} />
|
||||
) : (
|
||||
<MaintenanceQueue threads={threads} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<section className='space-y-3'>
|
||||
<h2 className='text-lg font-semibold'>Recent Spoons</h2>
|
||||
{spoons.length ? (
|
||||
{spoons === undefined ? (
|
||||
<ListSkeleton rows={3} />
|
||||
) : spoons.length ? (
|
||||
spoons
|
||||
.slice(0, 3)
|
||||
.map((spoon) => <SpoonCard key={spoon._id} spoon={spoon} />)
|
||||
@@ -108,7 +138,9 @@ const DashboardPage = () => {
|
||||
<CardTitle className='text-base'>Upstream checks</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{syncRuns.length ? (
|
||||
{syncRuns === undefined ? (
|
||||
<ListSkeleton rows={3} rowClassName='h-14 w-full' />
|
||||
) : syncRuns.length ? (
|
||||
<div className='space-y-3'>
|
||||
{syncRuns.map((run) => (
|
||||
<div
|
||||
@@ -137,7 +169,9 @@ const DashboardPage = () => {
|
||||
<CardTitle className='text-base'>Recent threads</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{threads.length ? (
|
||||
{threads === undefined ? (
|
||||
<ListSkeleton rows={3} rowClassName='h-14 w-full' />
|
||||
) : threads.length ? (
|
||||
<div className='space-y-3'>
|
||||
{threads.slice(0, 5).map((thread) => (
|
||||
<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 Link from 'next/link';
|
||||
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';
|
||||
|
||||
@@ -12,6 +20,7 @@ const settingsItems = [
|
||||
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
|
||||
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
|
||||
{ href: '/settings/dotfiles', label: 'Dotfiles', icon: FileCog },
|
||||
{ href: '/settings/notifications', label: 'Notifications', icon: Bell },
|
||||
{ href: '/settings/worker', label: 'Worker', icon: ServerCog },
|
||||
{ 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 {
|
||||
AvatarUpload,
|
||||
ProfileHeader,
|
||||
|
||||
@@ -21,7 +21,24 @@ const AgentWorkspacePage = () => {
|
||||
if (job?.threadId) router.replace(`/threads/${job.threadId}`);
|
||||
}, [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 (
|
||||
<main className='text-muted-foreground p-6'>
|
||||
Opening thread workspace...
|
||||
@@ -37,7 +54,7 @@ const AgentWorkspacePage = () => {
|
||||
Back to Spoon
|
||||
</Link>
|
||||
</Button>
|
||||
<AgentWorkspaceShell jobId={jobId} />
|
||||
<AgentWorkspaceShell key={jobId} jobId={jobId} />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
|
||||
import { ListSkeleton } from '@/components/ui/list-skeleton';
|
||||
import { useQuery } from 'convex/react';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -31,16 +32,19 @@ const formatDate = (value?: number) =>
|
||||
: 'Never';
|
||||
|
||||
const SpoonsPage = () => {
|
||||
const router = useRouter();
|
||||
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
||||
const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
|
||||
const active = spoons.filter((spoon) => spoon.status === 'active').length;
|
||||
const needsReview = threads.filter(
|
||||
const spoons = useQuery(api.spoons.listMineWithState, {});
|
||||
const threads = useQuery(api.threads.listMine, { limit: 100 });
|
||||
const spoonList = spoons ?? [];
|
||||
const threadList = threads ?? [];
|
||||
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.spoonId &&
|
||||
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
|
||||
).length;
|
||||
const upstreamWaiting = spoons.reduce(
|
||||
const upstreamWaiting = spoonList.reduce(
|
||||
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
|
||||
0,
|
||||
);
|
||||
@@ -65,7 +69,11 @@ const SpoonsPage = () => {
|
||||
<CardContent className='flex items-center justify-between p-4'>
|
||||
<div>
|
||||
<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>
|
||||
<GitBranch className='text-muted-foreground size-5' />
|
||||
</CardContent>
|
||||
@@ -74,7 +82,11 @@ const SpoonsPage = () => {
|
||||
<CardContent className='flex items-center justify-between p-4'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-sm'>Active</p>
|
||||
<p className='text-2xl font-semibold'>{active}</p>
|
||||
{spoonsLoading ? (
|
||||
<Skeleton className='mt-1 h-8 w-10' />
|
||||
) : (
|
||||
<p className='text-2xl font-semibold'>{active}</p>
|
||||
)}
|
||||
</div>
|
||||
<RefreshCw className='text-muted-foreground size-5' />
|
||||
</CardContent>
|
||||
@@ -83,14 +95,20 @@ const SpoonsPage = () => {
|
||||
<CardContent className='flex items-center justify-between p-4'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-sm'>Open threads</p>
|
||||
<p className='text-2xl font-semibold'>{needsReview}</p>
|
||||
{metricsLoading ? (
|
||||
<Skeleton className='mt-1 h-8 w-10' />
|
||||
) : (
|
||||
<p className='text-2xl font-semibold'>{needsReview}</p>
|
||||
)}
|
||||
</div>
|
||||
<MessageSquare className='text-muted-foreground size-5' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{spoons.length ? (
|
||||
{spoons === undefined ? (
|
||||
<ListSkeleton rows={4} rowClassName='h-16 w-full' />
|
||||
) : spoons.length ? (
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='p-0'>
|
||||
<Table>
|
||||
@@ -111,22 +129,12 @@ const SpoonsPage = () => {
|
||||
return (
|
||||
<TableRow
|
||||
key={spoon._id}
|
||||
role='link'
|
||||
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);
|
||||
}
|
||||
}}
|
||||
className='hover:bg-muted/50 relative cursor-pointer'
|
||||
>
|
||||
<TableCell className='pl-4'>
|
||||
<Link
|
||||
href={href}
|
||||
className='group inline-flex min-w-0 flex-col'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className='group inline-flex min-w-0 flex-col after:absolute after:inset-0 after:content-[""]'
|
||||
>
|
||||
<span className='group-hover:text-primary font-medium transition-colors'>
|
||||
{spoon.name}
|
||||
@@ -170,10 +178,7 @@ const SpoonsPage = () => {
|
||||
<TableCell>{formatDate(spoon.lastCheckedAt)}</TableCell>
|
||||
<TableCell className='pr-4 text-right'>
|
||||
<Button size='sm' variant='outline' asChild>
|
||||
<Link
|
||||
href={href}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Link href={href} className='relative z-10'>
|
||||
Open
|
||||
<ArrowUpRight className='size-3' />
|
||||
</Link>
|
||||
@@ -201,7 +206,7 @@ const SpoonsPage = () => {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{spoons.length ? (
|
||||
{spoonList.length ? (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Actionable upstream commits waiting across all Spoons:{' '}
|
||||
{upstreamWaiting}
|
||||
|
||||
@@ -43,6 +43,14 @@ const ThreadDetailPage = () => {
|
||||
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;
|
||||
if (latestJob && spoon) {
|
||||
return (
|
||||
@@ -53,7 +61,7 @@ const ThreadDetailPage = () => {
|
||||
Back to Spoon
|
||||
</Link>
|
||||
</Button>
|
||||
<AgentWorkspaceShell jobId={latestJob._id} />
|
||||
<AgentWorkspaceShell key={latestJob._id} jobId={latestJob._id} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { DeleteThreadButton } from '@/components/threads/delete-thread-button';
|
||||
import { ListSkeleton } from '@/components/ui/list-skeleton';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { MessageSquare, Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -48,8 +49,7 @@ const ThreadsPage = () => {
|
||||
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
||||
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||
const defaultProfile = profiles.find((profile) => profile.isDefault);
|
||||
const threads =
|
||||
useQuery(api.threads.listMine, {
|
||||
const threads = useQuery(api.threads.listMine, {
|
||||
source: source as
|
||||
| 'all'
|
||||
| 'user_request'
|
||||
@@ -69,9 +69,10 @@ const ThreadsPage = () => {
|
||||
| 'ignored'
|
||||
| 'failed'
|
||||
| 'cancelled',
|
||||
limit: 100,
|
||||
}) ?? [];
|
||||
const visibleThreads = threads.filter((thread) => {
|
||||
limit: 100,
|
||||
});
|
||||
const threadsLoading = threads === undefined;
|
||||
const visibleThreads = (threads ?? []).filter((thread) => {
|
||||
if (spoonFilter !== 'all' && thread.spoonId !== spoonFilter) return false;
|
||||
if (priorityFilter !== 'all' && thread.priority !== priorityFilter) {
|
||||
return false;
|
||||
@@ -312,25 +313,25 @@ const ThreadsPage = () => {
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
{visibleThreads.length ? (
|
||||
{threadsLoading ? (
|
||||
<ListSkeleton rows={4} />
|
||||
) : visibleThreads.length ? (
|
||||
visibleThreads.map((thread) => (
|
||||
<Card
|
||||
key={thread._id}
|
||||
role='link'
|
||||
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));
|
||||
}
|
||||
}}
|
||||
className='hover:border-primary/50 relative cursor-pointer shadow-none transition-colors'
|
||||
>
|
||||
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
|
||||
<div className='min-w-0'>
|
||||
<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 ? (
|
||||
<Badge variant='outline'>{thread.spoonName}</Badge>
|
||||
) : null}
|
||||
@@ -361,15 +362,10 @@ const ThreadsPage = () => {
|
||||
{thread.latestJobWorkspaceStatus.replaceAll('_', ' ')}
|
||||
</p>
|
||||
) : 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 ? (
|
||||
<Button size='sm' variant='outline' asChild>
|
||||
<Link
|
||||
href={threadTarget(thread)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
Open workspace
|
||||
</Link>
|
||||
<Link href={threadTarget(thread)}>Open workspace</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{thread.latestJobPullRequestUrl ? (
|
||||
@@ -378,7 +374,6 @@ const ThreadsPage = () => {
|
||||
href={thread.latestJobPullRequestUrl}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
PR
|
||||
</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(() => {
|
||||
setEmail(values.email);
|
||||
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) {
|
||||
console.error('Error resetting password: ', error);
|
||||
toast.error('Error resetting password.');
|
||||
} finally {
|
||||
forgotPasswordForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -121,12 +123,14 @@ const ForgotPassword = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
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('/');
|
||||
} catch (error) {
|
||||
console.error('Error resetting password: ', error);
|
||||
toast.error('Error resetting password.');
|
||||
} finally {
|
||||
resetVerificationForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -230,7 +234,7 @@ const ForgotPassword = () => {
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Please enter the one-time password sent to your
|
||||
phone.
|
||||
email.
|
||||
</FormDescription>
|
||||
<div className='flex w-full flex-col items-center'>
|
||||
<FormMessage className='w-5/6 text-center' />
|
||||
@@ -265,7 +269,7 @@ const ForgotPassword = () => {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>
|
||||
Confirm Passsword
|
||||
Confirm Password
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<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);
|
||||
setLoading(true);
|
||||
try {
|
||||
await signIn('password', formData).then(() => router.push('/dashboard'));
|
||||
await signIn('password', formData).then(() => {
|
||||
signInForm.reset();
|
||||
router.push('/dashboard');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error signing in:', error);
|
||||
toast.error('Error signing in.');
|
||||
} finally {
|
||||
signInForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -153,6 +155,7 @@ const SignIn = () => {
|
||||
if (values.confirmPassword !== values.password)
|
||||
throw new ConvexError('Passwords do not match.');
|
||||
await signIn('password', formData).then(() => {
|
||||
signUpForm.reset();
|
||||
setEmail(values.email);
|
||||
setFlow('email-verification');
|
||||
});
|
||||
@@ -160,7 +163,6 @@ const SignIn = () => {
|
||||
console.error('Error signing up:', error);
|
||||
toast.error('Error signing up.');
|
||||
} finally {
|
||||
signUpForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -174,12 +176,14 @@ const SignIn = () => {
|
||||
formData.append('email', email);
|
||||
setLoading(true);
|
||||
try {
|
||||
await signIn('password', formData).then(() => router.push('/dashboard'));
|
||||
await signIn('password', formData).then(() => {
|
||||
verifyEmailForm.reset();
|
||||
router.push('/dashboard');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error verifying email:', error);
|
||||
toast.error('Error verifying email.');
|
||||
} finally {
|
||||
verifyEmailForm.reset();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -232,7 +236,7 @@ const SignIn = () => {
|
||||
/>
|
||||
<SubmitButton
|
||||
disabled={loading}
|
||||
pendingText='Signing Up...'
|
||||
pendingText='Verifying...'
|
||||
className='mx-auto w-2/3 text-xl font-semibold'
|
||||
>
|
||||
Verify Email
|
||||
@@ -423,7 +427,7 @@ const SignIn = () => {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='text-xl'>
|
||||
Confirm Passsword
|
||||
Confirm Password
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<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 },
|
||||
);
|
||||
}
|
||||
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 canSendToWorker =
|
||||
latestJob &&
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ArrowDown,
|
||||
Ban,
|
||||
FilePenLine,
|
||||
MessagesSquare,
|
||||
@@ -16,6 +17,7 @@ import { Badge, Button, Textarea } from '@spoon/ui';
|
||||
|
||||
import { DiffFileView, useDiffTheme } from './diff-file-view';
|
||||
import { parseDiffFileForPath } from './diff-utils';
|
||||
import { isNearBottom } from './workspace-helpers';
|
||||
|
||||
type ActivityFilter = 'all' | 'chat' | 'activity' | 'files' | 'errors';
|
||||
|
||||
@@ -70,6 +72,7 @@ export const AgentThread = ({
|
||||
const [filter, setFilter] = useState<ActivityFilter>('all');
|
||||
const diffTheme = useDiffTheme();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showJumpToLatest, setShowJumpToLatest] = useState(false);
|
||||
const chatMessages = useMemo(
|
||||
() =>
|
||||
messages.filter((message) => {
|
||||
@@ -111,20 +114,35 @@ export const AgentThread = ({
|
||||
? []
|
||||
: workspaceChanges;
|
||||
|
||||
const scrollToLatest = () => {
|
||||
const node = scrollRef.current;
|
||||
if (!node) return;
|
||||
if (typeof node.scrollTo === 'function') {
|
||||
node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' });
|
||||
} else {
|
||||
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;
|
||||
const distanceFromBottom =
|
||||
node.scrollHeight - node.scrollTop - node.clientHeight;
|
||||
if (distanceFromBottom < 160 || agentTurnActive) {
|
||||
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,
|
||||
interactions.length,
|
||||
messages.length,
|
||||
@@ -227,8 +245,13 @@ export const AgentThread = ({
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className='relative flex min-h-0 flex-1 flex-col'>
|
||||
<div
|
||||
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'
|
||||
>
|
||||
{(filter === 'all' || filter === 'chat') && interactions.length > 0
|
||||
@@ -430,6 +453,19 @@ export const AgentThread = ({
|
||||
yet.
|
||||
</p>
|
||||
) : 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 className='border-border flex-none space-y-2 border-t p-3'>
|
||||
<Textarea
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
|
||||
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 {
|
||||
FileCode,
|
||||
GitCompare,
|
||||
Loader2,
|
||||
MessagesSquare,
|
||||
PanelLeft,
|
||||
SquareTerminal,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -25,6 +26,11 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
Button,
|
||||
cn,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
@@ -41,6 +47,7 @@ import { FileTree } from './file-tree';
|
||||
import { JobStatusBar } from './job-status-bar';
|
||||
import { WorkspaceActions } from './workspace-actions';
|
||||
import { WorkspaceTerminal } from './workspace-terminal';
|
||||
import { createRequestSequencer, shouldShowRecovery } from './workspace-helpers';
|
||||
|
||||
type WorkspaceTab = 'editor' | 'diff' | 'thread' | 'terminal';
|
||||
|
||||
@@ -51,6 +58,9 @@ type OpenFileState = {
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
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 = {
|
||||
@@ -61,17 +71,27 @@ type PendingOverwrite = {
|
||||
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
const job = useQuery(api.agentJobs.get, { jobId });
|
||||
const messages =
|
||||
useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? [];
|
||||
useQuery(
|
||||
api.agentJobs.listMessages,
|
||||
job ? { jobId, limit: 200 } : 'skip',
|
||||
) ?? [];
|
||||
const events =
|
||||
useQuery(api.agentJobs.listEvents, { jobId, limit: 200 }) ?? [];
|
||||
useQuery(api.agentJobs.listEvents, job ? { jobId, limit: 200 } : 'skip') ??
|
||||
[];
|
||||
const workspaceChanges =
|
||||
useQuery(api.agentJobs.listWorkspaceChanges, { jobId, limit: 200 }) ?? [];
|
||||
useQuery(
|
||||
api.agentJobs.listWorkspaceChanges,
|
||||
job ? { jobId, limit: 200 } : 'skip',
|
||||
) ?? [];
|
||||
const interactions =
|
||||
useQuery(api.agentJobs.listInteractionRequests, {
|
||||
jobId,
|
||||
status: 'all',
|
||||
}) ?? [];
|
||||
const uiState = useQuery(api.agentJobs.getWorkspaceUiState, { jobId });
|
||||
useQuery(
|
||||
api.agentJobs.listInteractionRequests,
|
||||
job ? { jobId, status: 'all' } : 'skip',
|
||||
) ?? [];
|
||||
const uiState = useQuery(
|
||||
api.agentJobs.getWorkspaceUiState,
|
||||
job ? { jobId } : 'skip',
|
||||
);
|
||||
const patchUiState = useMutation(api.agentJobs.patchWorkspaceUiState);
|
||||
const createJobForThread = useMutation(api.agentJobs.createForThread);
|
||||
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
|
||||
@@ -94,6 +114,39 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
useState<WorkspaceTab>('editor');
|
||||
const [pendingOverwrite, setPendingOverwrite] = useState<PendingOverwrite>();
|
||||
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 =
|
||||
!job ||
|
||||
@@ -122,20 +175,24 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
);
|
||||
|
||||
const loadTree = useCallback(async () => {
|
||||
const token = treeSequencer.next();
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/tree`);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const data = (await response.json()) as { tree: FileTreeNode | null };
|
||||
setWorkspaceError(undefined);
|
||||
if (!treeSequencer.isLatest(token)) return;
|
||||
recordWorkspaceSuccess();
|
||||
setTree(data.tree);
|
||||
}, [jobId]);
|
||||
}, [jobId, recordWorkspaceSuccess, treeSequencer]);
|
||||
|
||||
const loadDiff = useCallback(async () => {
|
||||
const token = diffSequencer.next();
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/diff`);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const data = (await response.json()) as DiffResponse;
|
||||
setWorkspaceError(undefined);
|
||||
if (!diffSequencer.isLatest(token)) return;
|
||||
recordWorkspaceSuccess();
|
||||
setDiff(data.diff);
|
||||
}, [jobId]);
|
||||
}, [diffSequencer, jobId, recordWorkspaceSuccess]);
|
||||
|
||||
const loadAgentStatus = useCallback(async () => {
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/agent/status`);
|
||||
@@ -143,14 +200,14 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
setAgentTurnActive(false);
|
||||
const body = await response.text();
|
||||
if (body.includes('workspace is not active')) {
|
||||
setWorkspaceError(body);
|
||||
recordWorkspaceFailure(body);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = (await response.json()) as { active?: boolean };
|
||||
setWorkspaceError(undefined);
|
||||
recordWorkspaceSuccess();
|
||||
setAgentTurnActive(Boolean(data.active));
|
||||
}, [jobId]);
|
||||
}, [jobId, recordWorkspaceFailure, recordWorkspaceSuccess]);
|
||||
|
||||
const loadFile = useCallback(
|
||||
async (path: string) => {
|
||||
@@ -183,6 +240,40 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
[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(
|
||||
(path: string) => {
|
||||
setOpenFilePaths((current) =>
|
||||
@@ -209,9 +300,10 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
console.error(error);
|
||||
setWorkspaceError(error instanceof Error ? error.message : String(error));
|
||||
};
|
||||
const timeout = window.setTimeout(() => {
|
||||
void loadTree().catch(handleError);
|
||||
@@ -249,6 +341,11 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
(latest, change) => Math.max(latest, change._creationTime),
|
||||
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(() => {
|
||||
if (!workspaceReady) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
@@ -264,6 +361,46 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
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(() => {
|
||||
if (!uiState || hydratedUiState) return;
|
||||
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 recoverWorkspace = async () => {
|
||||
if (!job.threadId) return;
|
||||
@@ -374,6 +519,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
}));
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
conflictToastPathsRef.current.delete(path);
|
||||
setFiles((current) => ({
|
||||
...current,
|
||||
[path]: {
|
||||
@@ -384,6 +530,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
content,
|
||||
savedContent: content,
|
||||
saving: false,
|
||||
conflicted: false,
|
||||
},
|
||||
}));
|
||||
await loadDiff();
|
||||
@@ -473,7 +620,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
};
|
||||
|
||||
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} />
|
||||
{workspacePending && !workspaceError ? (
|
||||
<div className='border-border bg-background border-b p-4'>
|
||||
@@ -555,9 +702,40 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
|
||||
<WorkspaceActions job={job} disabled={workspaceDisabled} />
|
||||
<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} />
|
||||
</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
|
||||
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={
|
||||
@@ -566,7 +744,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
} 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'>
|
||||
<h2 className='text-sm font-semibold'>Files</h2>
|
||||
<p className='text-muted-foreground text-xs'>Current workspace</p>
|
||||
@@ -638,6 +816,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
savedContent={activeFile?.savedContent ?? ''}
|
||||
readOnly={workspaceDisabled}
|
||||
vimEnabled={vimEnabled}
|
||||
conflicted={activeFile?.conflicted}
|
||||
onSave={saveFile}
|
||||
onVimEnabledChange={setVimEnabled}
|
||||
onChange={(content) => {
|
||||
@@ -659,11 +838,19 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
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
|
||||
jobId={jobId}
|
||||
active={activeWorkspaceTab === 'terminal' && workspaceReady}
|
||||
active={workspaceReady}
|
||||
visible={activeWorkspaceTab === 'terminal'}
|
||||
waitingLabel={
|
||||
workspacePending ? 'Waiting for workspace…' : undefined
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value='diff' className='m-0 min-h-0 flex-1'>
|
||||
|
||||
@@ -24,6 +24,7 @@ const EDITOR_FONT_FAMILY =
|
||||
|
||||
type MonacoEditorInstance = {
|
||||
getModel?: () => unknown;
|
||||
dispose?: () => void;
|
||||
};
|
||||
|
||||
type VimMode = {
|
||||
@@ -36,6 +37,7 @@ export const CodeEditor = ({
|
||||
savedContent,
|
||||
readOnly,
|
||||
vimEnabled,
|
||||
conflicted,
|
||||
onSave,
|
||||
onChange,
|
||||
onVimEnabledChange,
|
||||
@@ -45,6 +47,7 @@ export const CodeEditor = ({
|
||||
savedContent: string;
|
||||
readOnly: boolean;
|
||||
vimEnabled: boolean;
|
||||
conflicted?: boolean;
|
||||
onSave: (content: string) => Promise<void>;
|
||||
onChange: (content: string) => void;
|
||||
onVimEnabledChange: (enabled: boolean) => void;
|
||||
@@ -56,6 +59,18 @@ export const CodeEditor = ({
|
||||
const { resolvedTheme } = useTheme();
|
||||
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(() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
@@ -102,7 +117,11 @@ export const CodeEditor = ({
|
||||
Editor
|
||||
</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>
|
||||
) : null}
|
||||
</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';
|
||||
|
||||
import type { ITheme, Terminal } from '@xterm/xterm';
|
||||
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',
|
||||
};
|
||||
import { XtermSession } from './xterm-session';
|
||||
|
||||
export const WorkspaceTerminal = ({
|
||||
jobId,
|
||||
active,
|
||||
visible = true,
|
||||
waitingLabel,
|
||||
}: {
|
||||
jobId: string;
|
||||
active: boolean;
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const themeIsLight = resolvedTheme === 'light';
|
||||
const [status, setStatus] = useState<Status>('connecting');
|
||||
const [errorText, setErrorText] = useState<string>();
|
||||
const [reconnectKey, setReconnectKey] = useState(0);
|
||||
|
||||
// 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>
|
||||
);
|
||||
};
|
||||
visible?: boolean;
|
||||
waitingLabel?: string;
|
||||
}) => (
|
||||
<XtermSession
|
||||
active={active}
|
||||
visible={visible}
|
||||
waitingLabel={waitingLabel}
|
||||
tokenUrl={`/api/agent-jobs/${jobId}/terminal-token`}
|
||||
sessionKey={jobId}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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'
|
||||
| 'custom_openai_compatible'
|
||||
| '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';
|
||||
|
||||
const saveProfileRef = makeFunctionReference<
|
||||
@@ -69,33 +73,75 @@ const setDefaultProfileRef = makeFunctionReference<
|
||||
>('aiProviderProfiles:setDefault');
|
||||
|
||||
const providerOptions: {
|
||||
key: string;
|
||||
value: Provider;
|
||||
label: string;
|
||||
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',
|
||||
label: 'Cloudflare AI Gateway',
|
||||
authType: 'api_key',
|
||||
},
|
||||
{
|
||||
key: 'custom_openai_compatible',
|
||||
value: 'custom_openai_compatible',
|
||||
label: 'Custom OpenAI-compatible',
|
||||
authType: 'api_key',
|
||||
},
|
||||
{
|
||||
key: 'opencode_openai_login',
|
||||
value: 'opencode_openai_login',
|
||||
label: 'Codex ChatGPT login',
|
||||
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[] = [
|
||||
'none',
|
||||
'minimal',
|
||||
@@ -112,17 +158,14 @@ export const AiProviderProfilesPanel = () => {
|
||||
const removeProfile = useMutation(api.aiProviderProfiles.remove);
|
||||
const [profileId, setProfileId] = useState<Id<'aiProviderProfiles'>>();
|
||||
const [name, setName] = useState('OpenAI');
|
||||
const [provider, setProvider] = useState<Provider>('openai');
|
||||
const [providerKey, setProviderKey] = useState('openai');
|
||||
const selectedProvider = useMemo(
|
||||
() =>
|
||||
providerOptions.find((option) => option.value === provider) ??
|
||||
({
|
||||
value: 'openai',
|
||||
label: 'OpenAI API key',
|
||||
authType: 'api_key',
|
||||
} satisfies (typeof providerOptions)[number]),
|
||||
[provider],
|
||||
providerOptions.find((option) => option.key === providerKey) ??
|
||||
defaultProviderOption,
|
||||
[providerKey],
|
||||
);
|
||||
const provider = selectedProvider.value;
|
||||
const [secret, setSecret] = useState('');
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
const [defaultModelValue, setDefaultModelValue] = useState(
|
||||
@@ -146,7 +189,7 @@ export const AiProviderProfilesPanel = () => {
|
||||
|
||||
const reset = () => {
|
||||
setProfileId(undefined);
|
||||
setProvider('openai');
|
||||
setProviderKey('openai');
|
||||
setSecret('');
|
||||
setBaseUrl('');
|
||||
setDefaultModelValue('');
|
||||
@@ -160,7 +203,16 @@ export const AiProviderProfilesPanel = () => {
|
||||
const edit = (profile: (typeof profiles)[number]) => {
|
||||
setProfileId(profile._id);
|
||||
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('');
|
||||
setBaseUrl(profile.baseUrl ?? '');
|
||||
setDefaultModelValue(profile.defaultModel);
|
||||
@@ -233,10 +285,18 @@ export const AiProviderProfilesPanel = () => {
|
||||
<Select
|
||||
value={defaultProfile?._id ?? ''}
|
||||
onValueChange={async (value) => {
|
||||
await setDefaultProfile({
|
||||
profileId: value as Id<'aiProviderProfiles'>,
|
||||
});
|
||||
toast.success('Default AI provider updated.');
|
||||
try {
|
||||
await setDefaultProfile({
|
||||
profileId: value as Id<'aiProviderProfiles'>,
|
||||
});
|
||||
toast.success('Default AI provider updated.');
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Could not update default AI provider.',
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -280,8 +340,16 @@ export const AiProviderProfilesPanel = () => {
|
||||
size='icon'
|
||||
aria-label='Remove provider'
|
||||
onClick={async () => {
|
||||
await removeProfile({ profileId: profile._id });
|
||||
toast.success('AI provider removed.');
|
||||
try {
|
||||
await removeProfile({ profileId: profile._id });
|
||||
toast.success('AI provider removed.');
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Could not remove AI provider.',
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
@@ -314,16 +382,14 @@ export const AiProviderProfilesPanel = () => {
|
||||
<div className='grid gap-2'>
|
||||
<Label>Provider</Label>
|
||||
<Select
|
||||
value={provider}
|
||||
value={providerKey}
|
||||
onValueChange={(value) => {
|
||||
const nextProvider = value as Provider;
|
||||
setProvider(nextProvider);
|
||||
resetModelOptions(nextProvider);
|
||||
setName(
|
||||
providerOptions
|
||||
.find((option) => option.value === nextProvider)
|
||||
?.label.replace(' API key', '') ?? 'AI provider',
|
||||
);
|
||||
const nextOption =
|
||||
providerOptions.find((option) => option.key === value) ??
|
||||
defaultProviderOption;
|
||||
setProviderKey(nextOption.key);
|
||||
resetModelOptions(nextOption.value);
|
||||
setName(nextOption.label.replace(' API key', ''));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -331,7 +397,7 @@ export const AiProviderProfilesPanel = () => {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItem key={option.key} value={option.key}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -342,7 +408,9 @@ export const AiProviderProfilesPanel = () => {
|
||||
<Label>
|
||||
{selectedProvider.authType === 'opencode_auth_json'
|
||||
? 'Codex auth JSON'
|
||||
: 'API key'}
|
||||
: selectedProvider.authType === 'anthropic_oauth_json'
|
||||
? 'Anthropic credentials JSON'
|
||||
: 'API key'}
|
||||
</Label>
|
||||
{selectedProvider.authType === 'opencode_auth_json' ? (
|
||||
<>
|
||||
@@ -361,6 +429,23 @@ export const AiProviderProfilesPanel = () => {
|
||||
be treated like a password.
|
||||
</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
|
||||
type='password'
|
||||
|
||||
@@ -48,6 +48,36 @@ export const GithubIntegrationPanel = () => {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<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 ? (
|
||||
<div className='grid gap-2 text-sm'>
|
||||
<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',
|
||||
);
|
||||
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) {
|
||||
return (
|
||||
@@ -61,16 +65,16 @@ export const AvatarDropdown = () => {
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
{(user?.name ?? user?.email) && (
|
||||
{displayName && (
|
||||
<>
|
||||
<DropdownMenuLabel className='text-center font-bold'>
|
||||
{user.name?.trim() ?? user.email?.trim()}
|
||||
{displayName}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href='/profile' className='w-full cursor-pointer'>
|
||||
<Link href='/settings/profile' className='w-full cursor-pointer'>
|
||||
Edit Profile
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ThemeToggleProps } from '@spoon/ui';
|
||||
import { ThemeToggle } from '@spoon/ui';
|
||||
|
||||
import { AvatarDropdown } from './AvatarDropdown';
|
||||
import { NotificationBell } from './notification-bell';
|
||||
|
||||
export const Controls = (themeToggleProps?: ThemeToggleProps) => {
|
||||
return (
|
||||
@@ -16,6 +17,7 @@ export const Controls = (themeToggleProps?: ThemeToggleProps) => {
|
||||
...themeToggleProps?.buttonProps,
|
||||
}}
|
||||
/>
|
||||
<NotificationBell />
|
||||
<AvatarDropdown />
|
||||
</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,
|
||||
LayoutDashboard,
|
||||
MessagesSquare,
|
||||
Server,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
@@ -37,6 +38,11 @@ const Header = (headerProps: ComponentProps<'header'>) => {
|
||||
icon: MessagesSquare,
|
||||
label: 'Threads',
|
||||
},
|
||||
{
|
||||
href: '/machine',
|
||||
icon: Server,
|
||||
label: 'Machine',
|
||||
},
|
||||
{
|
||||
href: '/settings/profile',
|
||||
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,13 +160,17 @@ export const DotfilesManager = () => {
|
||||
|
||||
const saveSelected = async (next: string) => {
|
||||
if (!selected) return;
|
||||
await putFile({
|
||||
path: selected.path,
|
||||
content: next,
|
||||
isExecutable: selected.isExecutable,
|
||||
});
|
||||
setSavedContent(next);
|
||||
toast.success('Saved.');
|
||||
try {
|
||||
await putFile({
|
||||
path: selected.path,
|
||||
content: next,
|
||||
isExecutable: selected.isExecutable,
|
||||
});
|
||||
setSavedContent(next);
|
||||
toast.success('Saved.');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Save failed.');
|
||||
}
|
||||
};
|
||||
|
||||
const importAll = async (incoming: UploadFile[]) => {
|
||||
@@ -231,11 +235,15 @@ export const DotfilesManager = () => {
|
||||
|
||||
const deleteSelected = async () => {
|
||||
if (!selected) return;
|
||||
await removeFile({ fileId: selected._id as never });
|
||||
setSelected(undefined);
|
||||
setContent('');
|
||||
setSavedContent('');
|
||||
toast.success('Deleted.');
|
||||
try {
|
||||
await removeFile({ fileId: selected._id as never });
|
||||
setSelected(undefined);
|
||||
setContent('');
|
||||
setSavedContent('');
|
||||
toast.success('Deleted.');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Delete failed.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -345,8 +353,12 @@ export const DotfilesManager = () => {
|
||||
<RepoPanel
|
||||
settings={settings}
|
||||
onSave={async (values) => {
|
||||
await updateEnv(values);
|
||||
toast.success('Saved.');
|
||||
try {
|
||||
await updateEnv(values);
|
||||
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;
|
||||
runtime: string;
|
||||
containerRuntime: string;
|
||||
containerAccess: string;
|
||||
jobImage: string;
|
||||
workdir: string;
|
||||
network?: string;
|
||||
httpPort: number;
|
||||
activeWorkspaceCount: number;
|
||||
workspaceContainers: string[];
|
||||
boxContainers: string[];
|
||||
};
|
||||
|
||||
type CleanupResult = {
|
||||
removedContainers: string[];
|
||||
removedWorkdirs: string[];
|
||||
boxContainers: string[];
|
||||
};
|
||||
|
||||
export const WorkerHealthPanel = () => {
|
||||
@@ -103,9 +102,7 @@ export const WorkerHealthPanel = () => {
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const result = (await response.json()) as CleanupResult;
|
||||
toast.success(
|
||||
`Cleaned ${result.removedContainers.length} containers and ${result.removedWorkdirs.length} workdirs.`,
|
||||
);
|
||||
toast.success(`Cleaned ${result.removedWorkdirs.length} workdirs.`);
|
||||
await refreshHealth();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -165,9 +162,7 @@ export const WorkerHealthPanel = () => {
|
||||
{health.ok ? 'healthy' : 'unhealthy'}
|
||||
</Badge>
|
||||
<Badge variant='outline'>{health.workerId}</Badge>
|
||||
<Badge variant='outline'>
|
||||
{health.containerRuntime} / {health.containerAccess}
|
||||
</Badge>
|
||||
<Badge variant='outline'>{health.containerRuntime}</Badge>
|
||||
</div>
|
||||
<dl className='grid gap-3 text-sm md:grid-cols-2'>
|
||||
<div>
|
||||
@@ -198,12 +193,10 @@ export const WorkerHealthPanel = () => {
|
||||
</div>
|
||||
</dl>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Workspace containers
|
||||
</p>
|
||||
<p className='text-muted-foreground text-sm'>Box containers</p>
|
||||
<p className='mt-1 font-mono text-sm'>
|
||||
{health.workspaceContainers.length
|
||||
? health.workspaceContainers.join(', ')
|
||||
{health.boxContainers.length
|
||||
? health.boxContainers.join(', ')
|
||||
: 'none'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -283,8 +276,7 @@ export const WorkerHealthPanel = () => {
|
||||
<div>
|
||||
<p className='text-sm font-medium'>Orphaned worker resources</p>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Remove inactive Spoon job containers and inactive directories
|
||||
under the configured worker workdir.
|
||||
Remove inactive directories under the configured worker workdir.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -24,9 +24,17 @@ import {
|
||||
} from '@spoon/ui';
|
||||
|
||||
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 = {
|
||||
enabled: boolean;
|
||||
runtime?: 'opencode' | 'openai_direct';
|
||||
runtime?: RuntimeName | 'openai_direct';
|
||||
defaultBaseBranch?: string;
|
||||
branchPrefix: string;
|
||||
installCommand?: string;
|
||||
@@ -91,6 +99,7 @@ export const SpoonAgentSettingsForm = ({
|
||||
const [aiProviderProfileId, setAiProviderProfileId] = useState(
|
||||
settings?.aiProviderProfileId ?? '__default',
|
||||
);
|
||||
const [runtime, setRuntime] = useState<RuntimeName | undefined>();
|
||||
const selectedProfile = profiles.find(
|
||||
(profile) =>
|
||||
profile._id ===
|
||||
@@ -118,6 +127,59 @@ export const SpoonAgentSettingsForm = ({
|
||||
: 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
|
||||
? selectedModelProfile.models
|
||||
: [];
|
||||
@@ -135,7 +197,7 @@ export const SpoonAgentSettingsForm = ({
|
||||
await update({
|
||||
spoonId: spoon._id,
|
||||
enabled,
|
||||
runtime: 'opencode',
|
||||
runtime: effectiveRuntime,
|
||||
defaultBaseBranch,
|
||||
branchPrefix,
|
||||
installCommand: installCommand || undefined,
|
||||
@@ -186,7 +248,25 @@ export const SpoonAgentSettingsForm = ({
|
||||
<div className='grid gap-3 md:grid-cols-2'>
|
||||
<div className='grid gap-2'>
|
||||
<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 className='grid gap-2'>
|
||||
<Label>AI provider profile</Label>
|
||||
@@ -400,6 +480,7 @@ export const SpoonAgentSettingsForm = ({
|
||||
onClick={save}
|
||||
disabled={
|
||||
!selectedProfile?.configured ||
|
||||
!runtimeValid ||
|
||||
!selectableModels.some((model) => model.id === selectedAgentModel)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -223,8 +223,16 @@ export const SpoonClonePanel = ({ spoon }: { spoon: Doc<'spoons'> }) => {
|
||||
remoteName={remote.remoteName}
|
||||
url={remote.url}
|
||||
onRemove={async () => {
|
||||
await removeRemote({ remoteId: remote._id });
|
||||
toast.success('Remote removed.');
|
||||
try {
|
||||
await removeRemote({ remoteId: remote._id });
|
||||
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;
|
||||
}
|
||||
setBulkSaving(true);
|
||||
try {
|
||||
for (const secret of parsedBulk.secrets) {
|
||||
let imported = 0;
|
||||
const failed: string[] = [];
|
||||
for (const secret of parsedBulk.secrets) {
|
||||
try {
|
||||
await createSecret({
|
||||
spoonId,
|
||||
name: secret.name,
|
||||
value: secret.value,
|
||||
description: 'Imported from .env',
|
||||
});
|
||||
imported += 1;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
failed.push(secret.name);
|
||||
}
|
||||
setBulkText('');
|
||||
setBulkDialogOpen(false);
|
||||
toast.success(`${parsedBulk.secrets.length} secrets imported.`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not import secrets.');
|
||||
} finally {
|
||||
setBulkSaving(false);
|
||||
}
|
||||
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('');
|
||||
setBulkDialogOpen(false);
|
||||
};
|
||||
|
||||
const readEnvFile = async (file?: File) => {
|
||||
@@ -310,8 +321,16 @@ export const SpoonSecretsForm = ({ spoonId }: { spoonId: Id<'spoons'> }) => {
|
||||
size='icon'
|
||||
aria-label={`Remove ${secret.name}`}
|
||||
onClick={async () => {
|
||||
await removeSecret({ secretId: secret._id });
|
||||
toast.success('Secret removed.');
|
||||
try {
|
||||
await removeSecret({ secretId: secret._id });
|
||||
toast.success('Secret removed.');
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Could not remove secret.',
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
|
||||
@@ -13,6 +13,7 @@ const labels: Record<string, string> = {
|
||||
checking: 'Checking',
|
||||
conflict: 'Conflict',
|
||||
error: 'Error',
|
||||
rate_limited: 'Rate limited',
|
||||
unknown: 'Unknown',
|
||||
active: 'Active',
|
||||
draft: 'Draft',
|
||||
@@ -35,10 +36,16 @@ const variants: Record<
|
||||
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 }) => {
|
||||
const value = status ?? 'unknown';
|
||||
return (
|
||||
<Badge variant={variants[value] ?? 'outline'}>
|
||||
<Badge variant={variants[value] ?? 'outline'} className={classNames[value]}>
|
||||
{labels[value] ?? value.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { MessageSquarePlus } from 'lucide-react';
|
||||
@@ -25,9 +25,17 @@ import {
|
||||
Textarea,
|
||||
} from '@spoon/ui';
|
||||
|
||||
type RuntimeName = 'codex' | 'opencode' | 'claude';
|
||||
|
||||
const runtimeLabels: Record<RuntimeName, string> = {
|
||||
codex: 'Codex',
|
||||
opencode: 'OpenCode',
|
||||
claude: 'Claude',
|
||||
};
|
||||
|
||||
type AgentSettings = {
|
||||
defaultBaseBranch?: string;
|
||||
runtime?: 'opencode' | 'openai_direct';
|
||||
runtime?: RuntimeName | 'openai_direct';
|
||||
agentModel: string;
|
||||
reasoningEffort: string;
|
||||
envFilePath?: string;
|
||||
@@ -72,7 +80,35 @@ export const ThreadWorkspaceForm = ({
|
||||
const [aiProviderProfileId, setAiProviderProfileId] = useState(
|
||||
agentSettings?.aiProviderProfileId ?? '__settings',
|
||||
);
|
||||
const [runtime, setRuntime] = useState<RuntimeName | undefined>();
|
||||
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 =
|
||||
aiProviderProfileId === '__settings'
|
||||
? (agentSettings?.aiProviderProfileId ?? defaultProfile?._id)
|
||||
@@ -87,6 +123,18 @@ export const ThreadWorkspaceForm = ({
|
||||
(agentSettings?.aiProviderProfileId ?? defaultProfile?._id)
|
||||
: 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>) => {
|
||||
event.preventDefault();
|
||||
@@ -97,6 +145,7 @@ export const ThreadWorkspaceForm = ({
|
||||
prompt,
|
||||
baseBranch,
|
||||
requestedBranchName: requestedBranchName || undefined,
|
||||
runtime: effectiveRuntime,
|
||||
materializeEnvFile,
|
||||
envFilePath: materializeEnvFile ? envFilePath : undefined,
|
||||
aiProviderProfileId:
|
||||
@@ -140,7 +189,22 @@ export const ThreadWorkspaceForm = ({
|
||||
<div className='grid gap-3 md:grid-cols-2'>
|
||||
<div className='grid gap-2'>
|
||||
<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 className='grid gap-2'>
|
||||
<Label>AI provider</Label>
|
||||
@@ -219,7 +283,10 @@ export const ThreadWorkspaceForm = ({
|
||||
<strong>{selectedProfile?.reasoningEffort ?? 'medium'}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<Button type='submit' disabled={submitting || !hasProvider}>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={submitting || !hasProvider || !runtimeValid}
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create thread'}
|
||||
</Button>
|
||||
</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 { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
|
||||
import { buildBoxToken } from './box-terminal-token';
|
||||
|
||||
const terminalSecret = () =>
|
||||
env.SPOON_AGENT_TERMINAL_SECRET ??
|
||||
env.SPOON_AGENT_WORKER_INTERNAL_TOKEN ??
|
||||
@@ -29,6 +31,18 @@ export const mintTerminalToken = (jobId: Id<'agentJobs'>) => {
|
||||
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 = {
|
||||
params: Promise<{ jobId: string }> | { jobId: string };
|
||||
};
|
||||
@@ -144,3 +158,74 @@ export const withOwnedJob = async (
|
||||
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([
|
||||
'/dashboard(.*)',
|
||||
'/spoons(.*)',
|
||||
'/updates(.*)',
|
||||
'/agents(.*)',
|
||||
'/threads(.*)',
|
||||
'/github(.*)',
|
||||
'/settings(.*)',
|
||||
'/profile(.*)',
|
||||
]);
|
||||
|
||||
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