Compare commits
33 Commits
930fbf5965
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b09295570d | |||
| 3f1fee4e44 | |||
| 573246ce98 | |||
| 5fc1e2caf6 | |||
| ca5c623392 | |||
| 8d2a089268 | |||
| c6b27063a4 | |||
| c103430c7d | |||
| c0ff6d8bed | |||
| 2cd03b6a83 | |||
| 4c0de2cbf3 | |||
| 683fc62129 | |||
| 32a71f00ca | |||
| 65aae85369 | |||
| 5f7d56369f | |||
| fd48dcfc28 | |||
| 24a516c2b5 | |||
| 15407e7e9c | |||
| c1263b2e69 | |||
| 1072cf10cd | |||
| ae90681d9b | |||
| bb471a0917 | |||
| 40a6dd78e4 | |||
| a2976481d7 | |||
| 9643cb197b | |||
| 980a2c07e8 | |||
| 4fee7bf50d | |||
| 30a17196f5 | |||
| c3d265d428 | |||
| 5567a4be95 | |||
| a6f7ea7f78 | |||
| d207b8b0b8 | |||
| fe72fc2957 |
+2
-1
@@ -45,7 +45,8 @@ packages/backend/.convex
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker
|
docker/*
|
||||||
|
!docker/agent-job-rootfs
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ jobs:
|
|||||||
printf '%s\n' "$DOTENV_PROD" > "$env_file"
|
printf '%s\n' "$DOTENV_PROD" > "$env_file"
|
||||||
CI_ENV_FILE="$env_file" ./scripts/build-next-app production
|
CI_ENV_FILE="$env_file" ./scripts/build-next-app production
|
||||||
- name: Build agent images
|
- name: Build agent images
|
||||||
run: ./scripts/build-agent-images
|
env:
|
||||||
|
SPOON_BUILD_SHA: ${{ gitea.sha }}
|
||||||
|
run: SPOON_AGENT_CONTAINER_RUNTIME=docker ./scripts/build-agent-images
|
||||||
- name: Tag and push images
|
- name: Tag and push images
|
||||||
run: |
|
run: |
|
||||||
docker tag spoon-next:latest git.gbrown.org/gib/spoon-next:${{ gitea.sha }}
|
docker tag spoon-next:latest git.gbrown.org/gib/spoon-next:${{ gitea.sha }}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
bunx lint-staged --concurrent 1
|
bunx lint-staged --concurrent 1
|
||||||
|
infisical scan git-changes --staged
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
- `packages/backend/convex`: self-hosted Convex functions, schema, and auth.
|
- `packages/backend/convex`: self-hosted Convex functions, schema, and auth.
|
||||||
- `packages/ui`: shared shadcn-based UI components.
|
- `packages/ui`: shared shadcn-based UI components.
|
||||||
- `tools`: shared ESLint, Prettier, Tailwind, TypeScript, and Vitest config.
|
- `tools`: shared ESLint, Prettier, Tailwind, TypeScript, and Vitest config.
|
||||||
|
- Threads are the canonical user-facing workspace route. Normal navigation
|
||||||
|
should open `/threads/[threadId]`; legacy job URLs under
|
||||||
|
`/spoons/[spoonId]/agent/[jobId]` are compatibility routes for jobs that do
|
||||||
|
not have a thread yet.
|
||||||
- Local development uses host-run apps, local Convex on ports 3210/3211, local
|
- Local development uses host-run apps, local Convex on ports 3210/3211, local
|
||||||
Postgres on port 5432 for Convex storage, and the Convex dashboard on port 6791.
|
Postgres on port 5432 for Convex storage, and the Convex dashboard on port 6791.
|
||||||
Agent jobs are opt-in; build `docker/agent-job.Dockerfile` as
|
Agent jobs are opt-in; build `docker/agent-job.Dockerfile` as
|
||||||
@@ -23,6 +27,8 @@
|
|||||||
access to the host Docker socket. API-key provider jobs run through OpenCode;
|
access to the host Docker socket. API-key provider jobs run through OpenCode;
|
||||||
Codex ChatGPT login profiles run through the Codex CLI with an injected
|
Codex ChatGPT login profiles run through the Codex CLI with an injected
|
||||||
`CODEX_HOME/.codex/auth.json` inside the isolated job workspace.
|
`CODEX_HOME/.codex/auth.json` inside the isolated job workspace.
|
||||||
|
The job image must keep Node, npm, Bun, pnpm, yarn, git, ripgrep, jq,
|
||||||
|
Python, OpenCode, and Codex available.
|
||||||
|
|
||||||
## Protected and generated files
|
## Protected and generated files
|
||||||
|
|
||||||
@@ -52,7 +58,21 @@
|
|||||||
- Agent workspace proxy env uses `SPOON_AGENT_WORKER_URL`,
|
- Agent workspace proxy env uses `SPOON_AGENT_WORKER_URL`,
|
||||||
`SPOON_AGENT_WORKER_HTTP_PORT`, and `SPOON_AGENT_WORKER_INTERNAL_TOKEN`.
|
`SPOON_AGENT_WORKER_HTTP_PORT`, and `SPOON_AGENT_WORKER_INTERNAL_TOKEN`.
|
||||||
Keep these server-only; the browser must never receive worker tokens.
|
Keep these server-only; the browser must never receive worker tokens.
|
||||||
|
- Host-run worker dev uses `scripts/dev-agent-worker` after Infisical env
|
||||||
|
loading. It prefers Podman, sets `SPOON_AGENT_CONTAINER_ACCESS=host_port`,
|
||||||
|
and expects `spoon-agent-job:latest` to exist locally.
|
||||||
|
- Containerized production workers that control the host Docker socket must set
|
||||||
|
`SPOON_AGENT_HOST_WORKDIR` to the host-side path backing
|
||||||
|
`SPOON_AGENT_WORKDIR`. Docker bind mount source paths are resolved on the host,
|
||||||
|
not inside the worker container.
|
||||||
|
- `bun smoke:agent-container` checks that the local job image has Node, npm,
|
||||||
|
Bun, pnpm, yarn, git, ripgrep, jq, Python, OpenCode, and Codex available.
|
||||||
|
- Old terminal workspaces can be deleted from `Settings -> Worker`; orphaned
|
||||||
|
containers/workdirs are cleaned through the worker HTTP API, not from the
|
||||||
|
browser directly.
|
||||||
- CI uses Gitea-injected secrets or `CI_ENV_FILE` and must not call Infisical.
|
- CI uses Gitea-injected secrets or `CI_ENV_FILE` and must not call Infisical.
|
||||||
|
- Gitea image builds force `SPOON_AGENT_CONTAINER_RUNTIME=docker`; keep local
|
||||||
|
Podman auto-detection out of CI image tagging/pushing.
|
||||||
- CI must provide Convex deployment env for codegen, either
|
- CI must provide Convex deployment env for codegen, either
|
||||||
`CONVEX_SELF_HOSTED_URL` plus `CONVEX_SELF_HOSTED_ADMIN_KEY`, or
|
`CONVEX_SELF_HOSTED_URL` plus `CONVEX_SELF_HOSTED_ADMIN_KEY`, or
|
||||||
`CONVEX_DEPLOYMENT`.
|
`CONVEX_DEPLOYMENT`.
|
||||||
@@ -77,6 +97,7 @@
|
|||||||
bun db:up # start Postgres, Convex, and dashboard
|
bun db:up # start Postgres, Convex, and dashboard
|
||||||
bun dev:next # host Next + deploy/watch local Convex functions
|
bun dev:next # host Next + deploy/watch local Convex functions
|
||||||
bun dev:agent # run the optional coding-agent worker on the host
|
bun dev:agent # run the optional coding-agent worker on the host
|
||||||
|
bun dev:next:worker # run Next, backend, and agent worker together
|
||||||
bun sync:convex # sync Infisical values into Convex
|
bun sync:convex # sync Infisical values into Convex
|
||||||
bun db:down # stop and preserve local data
|
bun db:down # stop and preserve local data
|
||||||
bun db:down:wipe # remove local data volumes and generated admin key
|
bun db:down:wipe # remove local data volumes and generated admin key
|
||||||
|
|||||||
@@ -111,6 +111,12 @@ Common thread sources:
|
|||||||
Threads hold messages, status, outcomes, related sync runs, related jobs,
|
Threads hold messages, status, outcomes, related sync runs, related jobs,
|
||||||
workspace links, draft PR links, and ignored upstream decisions.
|
workspace links, draft PR links, and ignored upstream decisions.
|
||||||
|
|
||||||
|
Opening a thread opens its workspace when a run exists. The workspace is the
|
||||||
|
primary surface for that thread: agent messages, tool activity, file edits,
|
||||||
|
manual edits, diffs, commands, and draft PR actions all happen there. Legacy
|
||||||
|
job URLs under `/spoons/[spoonId]/agent/[jobId]` are kept for compatibility,
|
||||||
|
but normal navigation targets `/threads/[threadId]`.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details open>
|
<details open>
|
||||||
@@ -144,6 +150,7 @@ Workspace capabilities:
|
|||||||
- browse repository files
|
- browse repository files
|
||||||
- edit files in a browser editor
|
- edit files in a browser editor
|
||||||
- use optional Vim keybindings
|
- use optional Vim keybindings
|
||||||
|
- resize the agent thread panel on desktop
|
||||||
- inspect diffs
|
- inspect diffs
|
||||||
- send thread messages to the agent
|
- send thread messages to the agent
|
||||||
- run configured commands
|
- run configured commands
|
||||||
@@ -154,6 +161,29 @@ Workspace capabilities:
|
|||||||
The browser never receives worker tokens and never talks directly to the worker
|
The browser never receives worker tokens and never talks directly to the worker
|
||||||
or job container.
|
or job container.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Local worker development:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/build-agent-images
|
||||||
|
bun smoke:agent-container
|
||||||
|
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:
|
||||||
|
|
||||||
|
```env
|
||||||
|
SPOON_AGENT_CONTAINER_RUNTIME=podman
|
||||||
|
SPOON_AGENT_CONTAINER_ACCESS=host_port
|
||||||
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -175,8 +205,8 @@ production should use the repo-provided JS/TS workbench image:
|
|||||||
SPOON_AGENT_JOB_IMAGE="git.gbrown.org/gib/spoon-agent-job:latest"
|
SPOON_AGENT_JOB_IMAGE="git.gbrown.org/gib/spoon-agent-job:latest"
|
||||||
```
|
```
|
||||||
|
|
||||||
The job image includes Node 22, Bun, package managers through Corepack, git,
|
The job image includes Node 22, Bun, pnpm and yarn through Corepack, npm, git,
|
||||||
ripgrep, Python, build tools, and the OpenCode CLI. It is not the forked
|
ripgrep, Python, build tools, OpenCode, and the Codex CLI. It is not the forked
|
||||||
project's production runtime; it is the agent execution environment.
|
project's production runtime; it is the agent execution environment.
|
||||||
|
|
||||||
Production worker runtime requirements:
|
Production worker runtime requirements:
|
||||||
@@ -184,6 +214,8 @@ Production worker runtime requirements:
|
|||||||
- `spoon-agent-worker` must run as a separate service.
|
- `spoon-agent-worker` must run as a separate service.
|
||||||
- The worker needs `/var/run/docker.sock` mounted so it can launch job
|
- The worker needs `/var/run/docker.sock` mounted so it can launch job
|
||||||
containers.
|
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
|
- The production Docker host must be logged into `git.gbrown.org` so worker jobs
|
||||||
can pull the private `spoon-agent-job` image.
|
can pull the private `spoon-agent-job` image.
|
||||||
- `SPOON_WORKER_TOKEN` must match the value stored in Convex production env.
|
- `SPOON_WORKER_TOKEN` must match the value stored in Convex production env.
|
||||||
@@ -191,15 +223,35 @@ Production worker runtime requirements:
|
|||||||
`SPOON_AGENT_WORKER_INTERNAL_TOKEN` so Next API routes can proxy workspace
|
`SPOON_AGENT_WORKER_INTERNAL_TOKEN` so Next API routes can proxy workspace
|
||||||
file, diff, message, command, and draft PR actions.
|
file, diff, message, command, and draft PR actions.
|
||||||
- `spoon-agent-worker` also needs `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`.
|
- `spoon-agent-worker` also needs `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`.
|
||||||
|
If the private key is stored in a single-line dotenv value, encode newlines as
|
||||||
|
literal `\n` characters so the worker can restore the PEM before using it.
|
||||||
|
|
||||||
Useful production checks:
|
Useful production checks:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
docker login git.gbrown.org
|
||||||
|
docker pull git.gbrown.org/gib/spoon-agent-worker:latest
|
||||||
|
docker pull git.gbrown.org/gib/spoon-agent-job:latest
|
||||||
docker logs --tail=200 spoon-agent-worker
|
docker logs --tail=200 spoon-agent-worker
|
||||||
curl -H "Authorization: Bearer $SPOON_AGENT_WORKER_INTERNAL_TOKEN" \
|
curl -H "Authorization: Bearer $SPOON_AGENT_WORKER_INTERNAL_TOKEN" \
|
||||||
http://spoon-agent-worker:3921/health
|
http://spoon-agent-worker:3921/health
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Deployment readiness checklist:
|
||||||
|
|
||||||
|
1. Production Convex env has `SPOON_WORKER_TOKEN`, `SPOON_ENCRYPTION_KEY`,
|
||||||
|
GitHub App env, 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.
|
||||||
|
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.
|
||||||
|
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.
|
||||||
|
|
||||||
API-key based AI provider profiles run through OpenCode. Codex ChatGPT login
|
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
|
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.
|
the isolated job workspace as `CODEX_HOME/.codex/auth.json` before execution.
|
||||||
@@ -422,25 +474,28 @@ not call Infisical.
|
|||||||
<details>
|
<details>
|
||||||
<summary><strong>Convex, storage, and runtime</strong></summary>
|
<summary><strong>Convex, storage, and runtime</strong></summary>
|
||||||
|
|
||||||
| Variable | Used for |
|
| Variable | Used for |
|
||||||
| ----------------------------------- | ----------------------------------------------- |
|
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||||
| `CONVEX_SELF_HOSTED_URL` | Self-hosted Convex API URL |
|
| `CONVEX_SELF_HOSTED_URL` | Self-hosted Convex API URL |
|
||||||
| `CONVEX_SELF_HOSTED_ADMIN_KEY` | Admin key for deploying/syncing Convex |
|
| `CONVEX_SELF_HOSTED_ADMIN_KEY` | Admin key for deploying/syncing Convex |
|
||||||
| `CONVEX_CLOUD_ORIGIN` | Convex backend origin |
|
| `CONVEX_CLOUD_ORIGIN` | Convex backend origin |
|
||||||
| `CONVEX_SITE_ORIGIN` | Convex site-function origin |
|
| `CONVEX_SITE_ORIGIN` | Convex site-function origin |
|
||||||
| `CONVEX_SITE_URL` | Site URL seen by Convex Auth |
|
| `CONVEX_SITE_URL` | Site URL seen by Convex Auth |
|
||||||
| `POSTGRES_URL` | Convex storage database URL |
|
| `POSTGRES_URL` | Convex storage database URL |
|
||||||
| `SPOON_ENCRYPTION_KEY` | Encryption key for stored secrets/provider auth |
|
| `SPOON_ENCRYPTION_KEY` | Encryption key for stored secrets/provider auth |
|
||||||
| `SPOON_WORKER_TOKEN` | Worker token for Convex worker mutations |
|
| `SPOON_WORKER_TOKEN` | Worker token for Convex worker mutations |
|
||||||
| `SPOON_AGENT_WORKER_URL` | Internal worker HTTP URL used by Next |
|
| `SPOON_AGENT_WORKER_URL` | Internal worker HTTP URL used by Next |
|
||||||
| `SPOON_AGENT_WORKER_HTTP_PORT` | Worker HTTP port |
|
| `SPOON_AGENT_WORKER_HTTP_PORT` | Worker HTTP port |
|
||||||
| `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | Server-only token for Next-to-worker proxy |
|
| `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | Server-only token for Next-to-worker proxy |
|
||||||
| `SPOON_AGENT_JOB_IMAGE` | Agent job container image |
|
| `SPOON_AGENT_JOB_IMAGE` | Agent job container image |
|
||||||
| `SPOON_AGENT_RUNTIME` | Runtime mode, currently Docker/Podman-oriented |
|
| `SPOON_AGENT_RUNTIME` | Runtime mode, currently Docker/Podman-oriented |
|
||||||
| `SPOON_AGENT_MAX_CONCURRENT_JOBS` | Worker concurrency limit |
|
| `SPOON_AGENT_CONTAINER_RUNTIME` | Container CLI used by worker, `docker`/`podman` |
|
||||||
| `SPOON_AGENT_JOB_TIMEOUT_MS` | Job timeout |
|
| `SPOON_AGENT_CONTAINER_ACCESS` | `network` in prod, `host_port` for host dev |
|
||||||
| `SPOON_AGENT_WORKDIR` | Worker work directory |
|
| `SPOON_AGENT_MAX_CONCURRENT_JOBS` | Worker concurrency limit |
|
||||||
| `SPOON_AGENT_NETWORK` | Optional job container network |
|
| `SPOON_AGENT_JOB_TIMEOUT_MS` | Job timeout |
|
||||||
|
| `SPOON_AGENT_WORKDIR` | Worker work directory |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun with-env src/index.ts",
|
"dev": "bun with-env bash ../../scripts/dev-agent-worker -- bun src/index.ts",
|
||||||
"start": "bun src/index.ts",
|
"start": "bun src/index.ts",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
||||||
@@ -19,14 +19,18 @@
|
|||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
"@opencode-ai/sdk": "latest",
|
"@opencode-ai/sdk": "latest",
|
||||||
"convex": "catalog:convex",
|
"convex": "catalog:convex",
|
||||||
|
"dockerode": "^4.0.7",
|
||||||
"execa": "latest",
|
"execa": "latest",
|
||||||
|
"ws": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@spoon/eslint-config": "workspace:*",
|
"@spoon/eslint-config": "workspace:*",
|
||||||
"@spoon/prettier-config": "workspace:*",
|
"@spoon/prettier-config": "workspace:*",
|
||||||
"@spoon/tsconfig": "workspace:*",
|
"@spoon/tsconfig": "workspace:*",
|
||||||
|
"@types/dockerode": "^3.3.42",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
export type NormalizedAgentEvent =
|
||||||
|
| { kind: 'assistant_delta'; content: string; externalMessageId?: string }
|
||||||
|
| {
|
||||||
|
kind: 'assistant_completed';
|
||||||
|
content?: string;
|
||||||
|
externalMessageId?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'tool_started';
|
||||||
|
name: string;
|
||||||
|
input?: string;
|
||||||
|
externalMessageId?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'tool_completed';
|
||||||
|
name: string;
|
||||||
|
output?: string;
|
||||||
|
externalMessageId?: string;
|
||||||
|
}
|
||||||
|
| { kind: 'file_edited'; path: string }
|
||||||
|
| {
|
||||||
|
kind: 'command_executed';
|
||||||
|
command: string;
|
||||||
|
exitCode?: number;
|
||||||
|
output?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'permission_requested';
|
||||||
|
externalRequestId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
metadata?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'question_requested';
|
||||||
|
externalRequestId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
options?: string[];
|
||||||
|
metadata?: string;
|
||||||
|
}
|
||||||
|
| { kind: 'session'; sessionId: string }
|
||||||
|
| { kind: 'status'; status: string; metadata?: string }
|
||||||
|
| { kind: 'error'; message: string; metadata?: string };
|
||||||
|
|
||||||
|
const stringify = (value: unknown) => {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (value === undefined || value === null) return '';
|
||||||
|
if (
|
||||||
|
typeof value === 'number' ||
|
||||||
|
typeof value === 'boolean' ||
|
||||||
|
typeof value === 'bigint'
|
||||||
|
) {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
||||||
|
value && typeof value === 'object'
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const textFromPart = (part: Record<string, unknown>) => {
|
||||||
|
const text = part.text ?? part.content ?? part.delta;
|
||||||
|
return typeof text === 'string' ? text : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const commandString = (value: unknown) => {
|
||||||
|
if (Array.isArray(value))
|
||||||
|
return value.map((part) => stringify(part)).join(' ');
|
||||||
|
return stringify(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolNameFromRecord = (record: Record<string, unknown> | null) =>
|
||||||
|
stringify(
|
||||||
|
record?.tool ??
|
||||||
|
record?.tool_name ??
|
||||||
|
record?.toolName ??
|
||||||
|
record?.name ??
|
||||||
|
record?.function ??
|
||||||
|
(stringify(record?.type).toLowerCase().includes('exec') || record?.command
|
||||||
|
? 'Command'
|
||||||
|
: record?.type) ??
|
||||||
|
'tool',
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolInputFromRecord = (record: Record<string, unknown> | null) =>
|
||||||
|
commandString(
|
||||||
|
record?.input ??
|
||||||
|
record?.arguments ??
|
||||||
|
record?.args ??
|
||||||
|
record?.params ??
|
||||||
|
record?.command ??
|
||||||
|
record?.cmd,
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolOutputFromRecord = (
|
||||||
|
record: Record<string, unknown> | null,
|
||||||
|
fallback?: unknown,
|
||||||
|
) =>
|
||||||
|
stringify(
|
||||||
|
record?.output ??
|
||||||
|
record?.aggregated_output ??
|
||||||
|
record?.stdout ??
|
||||||
|
record?.stderr ??
|
||||||
|
record?.result ??
|
||||||
|
record?.content ??
|
||||||
|
record?.text ??
|
||||||
|
(record?.exit_code !== undefined
|
||||||
|
? `exit code: ${stringify(record.exit_code)}`
|
||||||
|
: undefined) ??
|
||||||
|
fallback,
|
||||||
|
);
|
||||||
|
|
||||||
|
const recordLooksLikeTool = (
|
||||||
|
type: string,
|
||||||
|
record: Record<string, unknown> | null,
|
||||||
|
) => {
|
||||||
|
const recordType = stringify(record?.type).toLowerCase();
|
||||||
|
const lowerType = type.toLowerCase();
|
||||||
|
return (
|
||||||
|
lowerType.includes('tool') ||
|
||||||
|
lowerType.includes('function_call') ||
|
||||||
|
recordType.includes('tool') ||
|
||||||
|
recordType.includes('function_call') ||
|
||||||
|
recordType.includes('local_shell_call') ||
|
||||||
|
recordType.includes('exec_command') ||
|
||||||
|
recordType.includes('command') ||
|
||||||
|
recordType.includes('mcp') ||
|
||||||
|
Boolean(
|
||||||
|
record?.tool ?? record?.tool_name ?? record?.name ?? record?.command,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCodexConfigWarning = (message: string) =>
|
||||||
|
message.includes('`[features].codex_hooks` is deprecated') ||
|
||||||
|
message.includes('Use `[features].hooks` instead');
|
||||||
|
|
||||||
|
// Handles the legacy `codex-rs` `{ id, msg: { type, ... } }` envelope.
|
||||||
|
const normalizeCodexMsgEvent = (
|
||||||
|
msg: Record<string, unknown>,
|
||||||
|
envelope: Record<string, unknown>,
|
||||||
|
): NormalizedAgentEvent[] => {
|
||||||
|
const msgType = stringify(msg.type).toLowerCase();
|
||||||
|
const events: NormalizedAgentEvent[] = [];
|
||||||
|
if (msgType === 'session_configured' || msgType.includes('session')) {
|
||||||
|
const sessionId = stringify(
|
||||||
|
msg.session_id ?? envelope.session_id ?? envelope.id,
|
||||||
|
);
|
||||||
|
if (sessionId) events.push({ kind: 'session', sessionId });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
msgType === 'agent_message_delta' ||
|
||||||
|
msgType === 'agent_reasoning_delta'
|
||||||
|
) {
|
||||||
|
const delta = stringify(msg.delta ?? msg.text);
|
||||||
|
if (delta) events.push({ kind: 'assistant_delta', content: delta });
|
||||||
|
}
|
||||||
|
if (msgType === 'agent_message') {
|
||||||
|
const text = stringify(msg.message ?? msg.text);
|
||||||
|
if (text) {
|
||||||
|
events.push({ kind: 'assistant_delta', content: `${text.trim()}\n\n` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msgType === 'exec_command_begin') {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_started',
|
||||||
|
name: 'Command',
|
||||||
|
input: commandString(msg.command),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (msgType === 'exec_command_end') {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: 'Command',
|
||||||
|
output: toolOutputFromRecord(msg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
msgType === 'error' ||
|
||||||
|
msgType === 'turn_failed' ||
|
||||||
|
msgType === 'task_error'
|
||||||
|
) {
|
||||||
|
const message = stringify(msg.message ?? msg.error ?? msg);
|
||||||
|
if (isCodexConfigWarning(message)) {
|
||||||
|
events.push({ kind: 'status', status: message });
|
||||||
|
} else {
|
||||||
|
events.push({ kind: 'error', message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msgType === 'task_complete' || msgType === 'turn_complete') {
|
||||||
|
events.push({ kind: 'assistant_completed' });
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeCodexJsonLine = (
|
||||||
|
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 [];
|
||||||
|
// Older Codex (`codex-rs`) protocol wraps events as `{ id, msg: { type, ... } }`
|
||||||
|
// instead of the newer `{ type, item: { ... } }` shape. Unwrap it so version
|
||||||
|
// skew between the pinned image and an upstream build degrades gracefully
|
||||||
|
// instead of silently producing an empty assistant response.
|
||||||
|
const msg = asRecord(event.msg);
|
||||||
|
if (msg) {
|
||||||
|
const msgEvents = normalizeCodexMsgEvent(msg, event);
|
||||||
|
if (msgEvents.length > 0) return msgEvents;
|
||||||
|
}
|
||||||
|
const type = stringify(event.type ?? event.event);
|
||||||
|
const id =
|
||||||
|
event.id ??
|
||||||
|
event.session_id ??
|
||||||
|
event.sessionId ??
|
||||||
|
event.thread_id ??
|
||||||
|
event.threadId;
|
||||||
|
const sessionId =
|
||||||
|
typeof id === 'string' &&
|
||||||
|
(type.toLowerCase().includes('session') ||
|
||||||
|
type.toLowerCase().includes('thread.started'))
|
||||||
|
? id
|
||||||
|
: undefined;
|
||||||
|
const events: NormalizedAgentEvent[] = sessionId
|
||||||
|
? [{ kind: 'session', sessionId }]
|
||||||
|
: [];
|
||||||
|
const message = asRecord(event.message);
|
||||||
|
const item = asRecord(event.item);
|
||||||
|
const data = asRecord(event.data);
|
||||||
|
const part = asRecord(event.part);
|
||||||
|
const itemType = item ? stringify(item.type) : '';
|
||||||
|
const lowerType = type.toLowerCase();
|
||||||
|
const lowerItemType = itemType.toLowerCase();
|
||||||
|
if (
|
||||||
|
item &&
|
||||||
|
recordLooksLikeTool(type, item) &&
|
||||||
|
(lowerType.includes('started') ||
|
||||||
|
lowerType.includes('in_progress') ||
|
||||||
|
lowerType.includes('created'))
|
||||||
|
) {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_started',
|
||||||
|
name: toolNameFromRecord(item),
|
||||||
|
input: toolInputFromRecord(item),
|
||||||
|
externalMessageId: stringify(item.id ?? event.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
item &&
|
||||||
|
recordLooksLikeTool(type, item) &&
|
||||||
|
(lowerType.includes('completed') ||
|
||||||
|
lowerType.includes('done') ||
|
||||||
|
lowerType.includes('finished'))
|
||||||
|
) {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: toolNameFromRecord(item),
|
||||||
|
output: toolOutputFromRecord(item, event.output ?? data?.output),
|
||||||
|
externalMessageId: stringify(item.id ?? event.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const delta = event.delta ?? data?.delta;
|
||||||
|
if (typeof delta === 'string') {
|
||||||
|
events.push({ kind: 'assistant_delta', content: delta });
|
||||||
|
}
|
||||||
|
const text =
|
||||||
|
(part ? textFromPart(part) : '') ||
|
||||||
|
(message ? stringify(message.content ?? message.text) : '') ||
|
||||||
|
(item ? stringify(item.content ?? item.text) : '');
|
||||||
|
if (
|
||||||
|
text &&
|
||||||
|
(type.includes('message') ||
|
||||||
|
type.includes('response.output_text') ||
|
||||||
|
type.includes('agent_message') ||
|
||||||
|
itemType.includes('message') ||
|
||||||
|
itemType.includes('agent_message'))
|
||||||
|
) {
|
||||||
|
events.push({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: itemType.includes('agent_message') ? `${text.trim()}\n\n` : text,
|
||||||
|
externalMessageId: stringify(item?.id ?? event.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const error = event.error ?? item?.error;
|
||||||
|
if (error || itemType === 'error') {
|
||||||
|
const message = stringify(error ?? item?.message ?? event.message);
|
||||||
|
if (isCodexConfigWarning(message)) {
|
||||||
|
events.push({ kind: 'status', status: message });
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
events.push({
|
||||||
|
kind: 'error',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const command =
|
||||||
|
event.command ??
|
||||||
|
data?.command ??
|
||||||
|
(lowerItemType.includes('shell') ? item?.command : undefined);
|
||||||
|
if (typeof command === 'string') {
|
||||||
|
events.push({
|
||||||
|
kind: 'command_executed',
|
||||||
|
command,
|
||||||
|
output: stringify(event.output ?? data?.output),
|
||||||
|
});
|
||||||
|
} else if (Array.isArray(command)) {
|
||||||
|
events.push({
|
||||||
|
kind: 'command_executed',
|
||||||
|
command: command.map((part) => stringify(part)).join(' '),
|
||||||
|
output: stringify(event.output ?? data?.output ?? item?.output),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const file =
|
||||||
|
event.file ??
|
||||||
|
event.path ??
|
||||||
|
data?.file ??
|
||||||
|
data?.path ??
|
||||||
|
item?.file ??
|
||||||
|
item?.path;
|
||||||
|
if (typeof file === 'string' && type.includes('file')) {
|
||||||
|
events.push({ kind: 'file_edited', path: file });
|
||||||
|
}
|
||||||
|
if (type.includes('error')) {
|
||||||
|
events.push({
|
||||||
|
kind: 'error',
|
||||||
|
message: stringify(event.message ?? event.error ?? data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
type.includes('completed') &&
|
||||||
|
itemType !== 'error' &&
|
||||||
|
!itemType.includes('message') &&
|
||||||
|
!itemType.includes('agent_message') &&
|
||||||
|
!recordLooksLikeTool(type, item)
|
||||||
|
) {
|
||||||
|
events.push({ kind: 'assistant_completed' });
|
||||||
|
}
|
||||||
|
if (type.includes('turn.done')) {
|
||||||
|
events.push({ kind: 'assistant_completed' });
|
||||||
|
}
|
||||||
|
if (events.length === 0) {
|
||||||
|
events.push({ kind: 'status', status: type || 'codex_event' });
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeOpenCodeEvent = (
|
||||||
|
input: unknown,
|
||||||
|
): NormalizedAgentEvent[] => {
|
||||||
|
const event = asRecord(input);
|
||||||
|
if (!event) return [];
|
||||||
|
const type = stringify(event.type);
|
||||||
|
const properties =
|
||||||
|
asRecord(event.properties) ?? asRecord(event.data) ?? event;
|
||||||
|
const events: NormalizedAgentEvent[] = [];
|
||||||
|
const sessionId = properties.sessionID ?? properties.sessionId;
|
||||||
|
if (typeof sessionId === 'string' && type.includes('session')) {
|
||||||
|
events.push({ kind: 'session', sessionId });
|
||||||
|
}
|
||||||
|
if (type === 'message.part.delta') {
|
||||||
|
const part = asRecord(properties.part) ?? properties;
|
||||||
|
const text = textFromPart(part);
|
||||||
|
if (text) {
|
||||||
|
events.push({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: text,
|
||||||
|
externalMessageId: stringify(properties.messageID),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === 'message.updated' || type === 'message.part.updated') {
|
||||||
|
const part = asRecord(properties.part);
|
||||||
|
const text = part ? textFromPart(part) : stringify(properties.message);
|
||||||
|
if (text) {
|
||||||
|
events.push({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: text,
|
||||||
|
externalMessageId: stringify(properties.messageID),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type.includes('tool.started')) {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_started',
|
||||||
|
name: stringify(properties.tool ?? properties.name ?? 'tool'),
|
||||||
|
input: stringify(properties.input),
|
||||||
|
externalMessageId: stringify(properties.messageID),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type.includes('tool.finished') || type.includes('tool.completed')) {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: stringify(properties.tool ?? properties.name ?? 'tool'),
|
||||||
|
output: stringify(properties.output ?? properties.result),
|
||||||
|
externalMessageId: stringify(properties.messageID),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type.includes('tool.updated') || type.includes('tool.output')) {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: stringify(properties.tool ?? properties.name ?? 'tool'),
|
||||||
|
output: stringify(properties.output ?? properties.result ?? properties),
|
||||||
|
externalMessageId: stringify(properties.messageID),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type === 'file.edited') {
|
||||||
|
const file = properties.file;
|
||||||
|
if (typeof file === 'string')
|
||||||
|
events.push({ kind: 'file_edited', path: file });
|
||||||
|
}
|
||||||
|
if (type === 'command.executed') {
|
||||||
|
events.push({
|
||||||
|
kind: 'command_executed',
|
||||||
|
command: stringify(properties.command),
|
||||||
|
output: stringify(properties.output),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type.includes('permission') && type.includes('asked')) {
|
||||||
|
events.push({
|
||||||
|
kind: 'permission_requested',
|
||||||
|
externalRequestId: stringify(properties.permissionID ?? properties.id),
|
||||||
|
title: 'Permission requested',
|
||||||
|
body: stringify(
|
||||||
|
properties.permission ?? properties.message ?? properties,
|
||||||
|
),
|
||||||
|
metadata: stringify(properties),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type.includes('question') && type.includes('asked')) {
|
||||||
|
events.push({
|
||||||
|
kind: 'question_requested',
|
||||||
|
externalRequestId: stringify(properties.requestID ?? properties.id),
|
||||||
|
title: 'Agent question',
|
||||||
|
body: stringify(properties.question ?? properties.message ?? properties),
|
||||||
|
metadata: stringify(properties),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type === 'session.idle') events.push({ kind: 'assistant_completed' });
|
||||||
|
if (type === 'session.error') {
|
||||||
|
events.push({
|
||||||
|
kind: 'error',
|
||||||
|
message: stringify(properties.error ?? properties.message ?? properties),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (events.length === 0 && type) {
|
||||||
|
events.push({
|
||||||
|
kind: 'status',
|
||||||
|
status: type,
|
||||||
|
metadata: stringify(properties),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { chmod, mkdir, stat } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export const codexContainerWorkspace = '/workspace';
|
||||||
|
export const codexContainerRepo = '/workspace/repo';
|
||||||
|
|
||||||
|
export const prepareCodexWorkspaceFiles = async (args: {
|
||||||
|
workdir: string;
|
||||||
|
repoDir: string;
|
||||||
|
}) => {
|
||||||
|
await mkdir(path.join(args.workdir, '.codex'), { recursive: true });
|
||||||
|
await mkdir(path.join(args.workdir, '.config'), { recursive: true });
|
||||||
|
await mkdir(path.join(args.workdir, '.local', 'share'), { recursive: true });
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
chmod(args.workdir, 0o755),
|
||||||
|
chmod(args.repoDir, 0o755),
|
||||||
|
chmod(path.join(args.workdir, '.codex'), 0o755),
|
||||||
|
chmod(path.join(args.workdir, '.config'), 0o755),
|
||||||
|
chmod(path.join(args.workdir, '.local'), 0o755),
|
||||||
|
chmod(path.join(args.workdir, '.local', 'share'), 0o755),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const projectCodexDir = path.join(args.repoDir, '.codex');
|
||||||
|
const projectConfig = path.join(projectCodexDir, 'config.toml');
|
||||||
|
try {
|
||||||
|
if ((await stat(projectCodexDir)).isDirectory()) {
|
||||||
|
await chmod(projectCodexDir, 0o755);
|
||||||
|
}
|
||||||
|
if ((await stat(projectConfig)).isFile()) {
|
||||||
|
await chmod(projectConfig, 0o644);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const code = error && typeof error === 'object' ? 'code' in error : false;
|
||||||
|
if (!code || (error as { code?: string }).code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -12,6 +12,8 @@ const requiredEnv = (name: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const env = {
|
export const env = {
|
||||||
|
buildSha: process.env.SPOON_BUILD_SHA?.trim() ?? 'development',
|
||||||
|
buildCreatedAt: process.env.SPOON_BUILD_CREATED_AT?.trim() ?? 'unknown',
|
||||||
convexUrl:
|
convexUrl:
|
||||||
process.env.NEXT_PUBLIC_CONVEX_URL?.trim() ??
|
process.env.NEXT_PUBLIC_CONVEX_URL?.trim() ??
|
||||||
process.env.CONVEX_SELF_HOSTED_URL?.trim() ??
|
process.env.CONVEX_SELF_HOSTED_URL?.trim() ??
|
||||||
@@ -19,9 +21,38 @@ export const env = {
|
|||||||
workerToken: requiredEnv('SPOON_WORKER_TOKEN'),
|
workerToken: requiredEnv('SPOON_WORKER_TOKEN'),
|
||||||
workerId: process.env.SPOON_AGENT_WORKER_ID?.trim() ?? 'local-worker',
|
workerId: process.env.SPOON_AGENT_WORKER_ID?.trim() ?? 'local-worker',
|
||||||
runtime: process.env.SPOON_AGENT_RUNTIME?.trim() ?? 'docker',
|
runtime: process.env.SPOON_AGENT_RUNTIME?.trim() ?? 'docker',
|
||||||
|
containerRuntime:
|
||||||
|
process.env.SPOON_AGENT_CONTAINER_RUNTIME?.trim() ??
|
||||||
|
process.env.SPOON_CONTAINER_RUNTIME?.trim() ??
|
||||||
|
'docker',
|
||||||
|
containerVolumeOptions:
|
||||||
|
process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS?.trim(),
|
||||||
|
containerAccess:
|
||||||
|
process.env.SPOON_AGENT_CONTAINER_ACCESS?.trim() === 'host_port'
|
||||||
|
? 'host_port'
|
||||||
|
: 'network',
|
||||||
jobImage:
|
jobImage:
|
||||||
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
|
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
|
||||||
|
// Interactive terminal: image for the persistent shell container (defaults to
|
||||||
|
// the job image), the secret shared with the Next app for verifying terminal
|
||||||
|
// tokens, and how long an idle terminal container survives before cleanup.
|
||||||
|
terminalImage:
|
||||||
|
process.env.SPOON_AGENT_TERMINAL_IMAGE?.trim() ??
|
||||||
|
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ??
|
||||||
|
'spoon-agent-job:latest',
|
||||||
|
terminalSecret:
|
||||||
|
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
|
||||||
|
// and holds port 3921 across restarts. Set by scripts/dev-agent-worker.
|
||||||
|
devWatchdog: process.env.SPOON_AGENT_DEV_WATCHDOG === '1',
|
||||||
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
|
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
|
||||||
|
hostWorkdir: process.env.SPOON_AGENT_HOST_WORKDIR?.trim(),
|
||||||
network: process.env.SPOON_AGENT_NETWORK?.trim(),
|
network: process.env.SPOON_AGENT_NETWORK?.trim(),
|
||||||
pollMs: intEnv('SPOON_AGENT_POLL_MS', 5_000),
|
pollMs: intEnv('SPOON_AGENT_POLL_MS', 5_000),
|
||||||
httpPort: intEnv('SPOON_AGENT_WORKER_HTTP_PORT', 3921),
|
httpPort: intEnv('SPOON_AGENT_WORKER_HTTP_PORT', 3921),
|
||||||
|
|||||||
@@ -36,12 +36,16 @@ export const cloneRepository = async (args: {
|
|||||||
workBranch: string;
|
workBranch: string;
|
||||||
redact: (value: string) => string;
|
redact: (value: string) => string;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
|
// Directory name to clone into under `workdir` (default "repo"). Used to lay
|
||||||
|
// out checkouts as ~/Code/{spoon}/{branch}.
|
||||||
|
dirName?: string;
|
||||||
}) => {
|
}) => {
|
||||||
await mkdir(args.workdir, { recursive: true });
|
await mkdir(args.workdir, { recursive: true });
|
||||||
|
const dirName = args.dirName ?? 'repo';
|
||||||
const repoUrl = `https://x-access-token:${args.token}@github.com/${args.owner}/${args.repo}.git`;
|
const repoUrl = `https://x-access-token:${args.token}@github.com/${args.owner}/${args.repo}.git`;
|
||||||
const clone = await run(
|
const clone = await run(
|
||||||
'git',
|
'git',
|
||||||
['clone', '--branch', args.baseBranch, '--single-branch', repoUrl, 'repo'],
|
['clone', '--branch', args.baseBranch, '--single-branch', repoUrl, dirName],
|
||||||
{
|
{
|
||||||
cwd: args.workdir,
|
cwd: args.workdir,
|
||||||
redact: args.redact,
|
redact: args.redact,
|
||||||
@@ -51,7 +55,7 @@ export const cloneRepository = async (args: {
|
|||||||
if (clone.exitCode !== 0) {
|
if (clone.exitCode !== 0) {
|
||||||
throw new Error(`git clone failed:\n${clone.output}`);
|
throw new Error(`git clone failed:\n${clone.output}`);
|
||||||
}
|
}
|
||||||
const repoDir = path.join(args.workdir, 'repo');
|
const repoDir = path.join(args.workdir, dirName);
|
||||||
const checkout = await run('git', ['checkout', '-b', args.workBranch], {
|
const checkout = await run('git', ['checkout', '-b', args.workBranch], {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
redact: args.redact,
|
redact: args.redact,
|
||||||
@@ -126,12 +130,41 @@ export const getDiff = async (
|
|||||||
export const getWorktreeDiff = async (
|
export const getWorktreeDiff = async (
|
||||||
repoDir: string,
|
repoDir: string,
|
||||||
redact: (value: string) => string,
|
redact: (value: string) => string,
|
||||||
) =>
|
) => {
|
||||||
await run('git', ['diff', '--', '.'], {
|
const trackedDiff = await run('git', ['diff', '--', '.'], {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
redact,
|
redact,
|
||||||
timeoutMs: 60_000,
|
timeoutMs: 60_000,
|
||||||
});
|
});
|
||||||
|
const untracked = await run(
|
||||||
|
'git',
|
||||||
|
['ls-files', '--others', '--exclude-standard'],
|
||||||
|
{
|
||||||
|
cwd: repoDir,
|
||||||
|
redact,
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const untrackedDiffs: string[] = [];
|
||||||
|
for (const filePath of untracked.output.split('\n').filter(Boolean)) {
|
||||||
|
const diff = await run(
|
||||||
|
'git',
|
||||||
|
['diff', '--no-index', '--', '/dev/null', filePath],
|
||||||
|
{
|
||||||
|
cwd: repoDir,
|
||||||
|
redact,
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (diff.output.trim()) untrackedDiffs.push(diff.output);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
exitCode: trackedDiff.exitCode === 0 && untracked.exitCode === 0 ? 0 : 1,
|
||||||
|
output: [trackedDiff.output, ...untrackedDiffs]
|
||||||
|
.filter((part) => part.trim())
|
||||||
|
.join('\n'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const getStatus = async (
|
export const getStatus = async (
|
||||||
repoDir: string,
|
repoDir: string,
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
|
import { env } from './env';
|
||||||
import { startWorkerServer } from './server';
|
import { startWorkerServer } from './server';
|
||||||
import { startWorker } from './worker';
|
import { startWorker } from './worker';
|
||||||
|
|
||||||
|
// Dev-only watchdog: the dev runner chain (turbo → with-env → dotenv → bash)
|
||||||
|
// doesn't always forward the stop signal to this leaf process, so on restart the
|
||||||
|
// worker can orphan and keep holding port 3921. Exit when our original parent
|
||||||
|
// goes away (we get reparented) or on a stop signal, so restarts stay clean.
|
||||||
|
// Never enabled in prod (gated on SPOON_AGENT_DEV_WATCHDOG).
|
||||||
|
if (env.devWatchdog) {
|
||||||
|
// Bun caches `process.ppid`, so poll whether the original parent still exists
|
||||||
|
// (signal 0 throws once it's gone) rather than comparing ppid.
|
||||||
|
const parentPid = process.ppid;
|
||||||
|
const watcher = setInterval(() => {
|
||||||
|
try {
|
||||||
|
process.kill(parentPid, 0);
|
||||||
|
} catch {
|
||||||
|
console.log('Dev parent exited; shutting down worker.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
watcher.unref();
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
|
||||||
|
process.on(signal, () => process.exit(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
startWorkerServer();
|
startWorkerServer();
|
||||||
await startWorker();
|
await startWorker();
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
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.');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,21 +1,112 @@
|
|||||||
|
import { mkdir } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { Readable } from 'node:stream';
|
||||||
import { execa } from 'execa';
|
import { execa } from 'execa';
|
||||||
|
|
||||||
import { env } from '../env';
|
import { env } from '../env';
|
||||||
|
|
||||||
|
type CommandResult = {
|
||||||
|
exitCode: number;
|
||||||
|
output: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const environmentArgs = (environment: Record<string, string>) =>
|
||||||
|
Object.entries(environment).flatMap(([name, value]) => [
|
||||||
|
'-e',
|
||||||
|
`${name}=${value}`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const networkArgs = () => (env.network ? ['--network', env.network] : []);
|
||||||
|
|
||||||
|
const containerRuntime = () => env.containerRuntime;
|
||||||
|
|
||||||
|
// `docker run` reuses a stale local `:latest` forever, so without an explicit
|
||||||
|
// pull the job image never updates in production. Pull once per worker process
|
||||||
|
// (i.e. once per deploy/restart) so a fresh worker always runs a fresh job
|
||||||
|
// image. Best-effort: if the registry is unreachable we fall back to whatever
|
||||||
|
// image is present locally rather than failing the job.
|
||||||
|
let jobImagePullPromise: Promise<void> | undefined;
|
||||||
|
export const ensureJobImagePulled = () => {
|
||||||
|
jobImagePullPromise ??= (async () => {
|
||||||
|
try {
|
||||||
|
await execa(containerRuntime(), ['pull', env.jobImage], {
|
||||||
|
reject: false,
|
||||||
|
stdin: 'ignore',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore: keep running with the locally cached image.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return jobImagePullPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
// execa with `reject: false` resolves (does not throw) even when the runtime
|
||||||
|
// binary is missing (ENOENT) — `exitCode` is then `undefined`. Coercing that to
|
||||||
|
// 0 makes a failed spawn look like a successful empty run, which is exactly how
|
||||||
|
// a worker image without a `docker` CLI silently produced empty agent
|
||||||
|
// responses. Normalize so any spawn failure is a non-zero exit carrying the
|
||||||
|
// real reason.
|
||||||
|
export const normalizeRunResult = (
|
||||||
|
// Declared nullable on purpose: execa's types claim these are always present,
|
||||||
|
// but on a spawn failure (e.g. missing `docker` binary) `exitCode`/`all` are
|
||||||
|
// actually undefined at runtime.
|
||||||
|
result: { exitCode?: number; shortMessage?: string },
|
||||||
|
output: string | undefined,
|
||||||
|
redact: (value: string) => string,
|
||||||
|
): CommandResult => {
|
||||||
|
const text = output ?? '';
|
||||||
|
if (result.exitCode == null) {
|
||||||
|
const reason = result.shortMessage ?? 'container runtime failed to start';
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
output: redact(`${text}${text ? '\n' : ''}${reason}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { exitCode: result.exitCode, output: redact(text) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const hostWorkspacePath = (workdir: string) => {
|
||||||
|
if (!env.hostWorkdir) return workdir;
|
||||||
|
const workerRoot = path.resolve(env.workdir);
|
||||||
|
const resolvedWorkdir = path.resolve(workdir);
|
||||||
|
const relative = path.relative(workerRoot, resolvedWorkdir);
|
||||||
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||||
|
return workdir;
|
||||||
|
}
|
||||||
|
return path.join(env.hostWorkdir, relative);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const containerVolumeSuffix = () =>
|
||||||
|
env.containerVolumeOptions ??
|
||||||
|
(containerRuntime().endsWith('podman') ? 'Z' : undefined);
|
||||||
|
|
||||||
|
export { hostWorkspacePath };
|
||||||
|
|
||||||
|
export const jobWorkspaceVolumeSpec = (
|
||||||
|
workdir: string,
|
||||||
|
containerHome = '/workspace',
|
||||||
|
) => {
|
||||||
|
const volumeOptions =
|
||||||
|
env.containerVolumeOptions ??
|
||||||
|
(containerRuntime().endsWith('podman') ? 'Z' : undefined);
|
||||||
|
const source = hostWorkspacePath(workdir);
|
||||||
|
return volumeOptions
|
||||||
|
? `${source}:${containerHome}:${volumeOptions}`
|
||||||
|
: `${source}:${containerHome}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const runInJobContainer = async (args: {
|
export const runInJobContainer = async (args: {
|
||||||
workdir: string;
|
workdir: string;
|
||||||
|
containerHome?: string;
|
||||||
|
containerCwd?: string;
|
||||||
command: string[];
|
command: string[];
|
||||||
environment: Record<string, string>;
|
environment: Record<string, string>;
|
||||||
redact: (value: string) => string;
|
redact: (value: string) => string;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
}) => {
|
}): Promise<CommandResult> => {
|
||||||
const envArgs = Object.entries(args.environment).flatMap(([name, value]) => [
|
await ensureJobImagePulled();
|
||||||
'-e',
|
|
||||||
`${name}=${value}`,
|
|
||||||
]);
|
|
||||||
const networkArgs = env.network ? ['--network', env.network] : [];
|
|
||||||
const result = await execa(
|
const result = await execa(
|
||||||
'docker',
|
containerRuntime(),
|
||||||
[
|
[
|
||||||
'run',
|
'run',
|
||||||
'--rm',
|
'--rm',
|
||||||
@@ -23,18 +114,110 @@ export const runInJobContainer = async (args: {
|
|||||||
'4g',
|
'4g',
|
||||||
'--cpus',
|
'--cpus',
|
||||||
'2',
|
'2',
|
||||||
...networkArgs,
|
...networkArgs(),
|
||||||
...envArgs,
|
...environmentArgs(args.environment),
|
||||||
'-v',
|
'-v',
|
||||||
`${args.workdir}:/workspace`,
|
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||||
'-w',
|
'-w',
|
||||||
'/workspace/repo',
|
args.containerCwd ?? '/workspace/repo',
|
||||||
env.jobImage,
|
env.jobImage,
|
||||||
...args.command,
|
...args.command,
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
all: true,
|
all: true,
|
||||||
reject: false,
|
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,
|
timeout: args.timeoutMs,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -43,3 +226,241 @@ export const runInJobContainer = async (args: {
|
|||||||
output: args.redact(result.all),
|
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 = {
|
||||||
|
stdout: Readable | null;
|
||||||
|
stderr: Readable | null;
|
||||||
|
} & Promise<{ exitCode?: number; shortMessage?: string; all?: string }>;
|
||||||
|
|
||||||
|
const streamSubprocess = async (
|
||||||
|
subprocess: StreamingSubprocess,
|
||||||
|
redact: (value: string) => string,
|
||||||
|
onStdoutLine?: (line: string) => Promise<void>,
|
||||||
|
onStderrLine?: (line: string) => Promise<void>,
|
||||||
|
): Promise<CommandResult> => {
|
||||||
|
let stdoutBuffer = '';
|
||||||
|
let stderrBuffer = '';
|
||||||
|
const output: string[] = [];
|
||||||
|
let lineHandlers = Promise.resolve();
|
||||||
|
const consume = async (
|
||||||
|
chunk: Buffer,
|
||||||
|
source: 'stdout' | 'stderr',
|
||||||
|
handler?: (line: string) => Promise<void>,
|
||||||
|
) => {
|
||||||
|
output.push(chunk.toString('utf8'));
|
||||||
|
const next = `${source === 'stdout' ? stdoutBuffer : stderrBuffer}${chunk.toString('utf8')}`;
|
||||||
|
const lines = next.split(/\r?\n/);
|
||||||
|
const remainder = lines.pop() ?? '';
|
||||||
|
if (source === 'stdout') stdoutBuffer = remainder;
|
||||||
|
else stderrBuffer = remainder;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (handler) await handler(redact(line));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
subprocess.stdout?.on('data', (chunk: Buffer) => {
|
||||||
|
lineHandlers = lineHandlers.then(() =>
|
||||||
|
consume(chunk, 'stdout', onStdoutLine),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
subprocess.stderr?.on('data', (chunk: Buffer) => {
|
||||||
|
lineHandlers = lineHandlers.then(() =>
|
||||||
|
consume(chunk, 'stderr', onStderrLine),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
let result: Awaited<StreamingSubprocess>;
|
||||||
|
try {
|
||||||
|
result = await subprocess;
|
||||||
|
} catch (error) {
|
||||||
|
await lineHandlers;
|
||||||
|
const outputText = output.join('');
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Container command failed.';
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
output: redact(`${outputText}${outputText ? '\n' : ''}${message}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await lineHandlers;
|
||||||
|
if (stdoutBuffer && onStdoutLine) await onStdoutLine(redact(stdoutBuffer));
|
||||||
|
if (stderrBuffer && onStderrLine) await onStderrLine(redact(stderrBuffer));
|
||||||
|
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) =>
|
||||||
|
`spoon-box-${username.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
|
||||||
|
|
||||||
|
export const ensureUserContainer = async (args: {
|
||||||
|
username: string;
|
||||||
|
workdir: string;
|
||||||
|
containerHome: string;
|
||||||
|
}): Promise<string> => {
|
||||||
|
await ensureJobImagePulled();
|
||||||
|
const name = userContainerName(args.username);
|
||||||
|
const inspect = await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
['inspect', '-f', '{{.State.Running}}', name],
|
||||||
|
{ reject: false, stdin: 'ignore' },
|
||||||
|
);
|
||||||
|
if (inspect.exitCode === 0 && inspect.stdout.trim() === 'true') return name;
|
||||||
|
// The box mounts the per-user home, but it's created before the thread's clone
|
||||||
|
// populates it — ensure it exists first, since podman (unlike docker) refuses to
|
||||||
|
// bind-mount a missing source directory (statfs: no such file or directory).
|
||||||
|
await mkdir(args.workdir, { recursive: true });
|
||||||
|
// Not running: remove any stale container, then start fresh.
|
||||||
|
await execa(containerRuntime(), ['rm', '-f', name], { reject: false });
|
||||||
|
await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
[
|
||||||
|
'run',
|
||||||
|
'-d',
|
||||||
|
'--name',
|
||||||
|
name,
|
||||||
|
'--memory',
|
||||||
|
'4g',
|
||||||
|
'--cpus',
|
||||||
|
'2',
|
||||||
|
...networkArgs(),
|
||||||
|
'-v',
|
||||||
|
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||||
|
'-w',
|
||||||
|
args.containerHome,
|
||||||
|
env.jobImage,
|
||||||
|
'sleep',
|
||||||
|
'infinity',
|
||||||
|
],
|
||||||
|
{ stdin: 'ignore' },
|
||||||
|
);
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const streamExecInContainer = async (args: {
|
||||||
|
containerName: string;
|
||||||
|
command: string[];
|
||||||
|
environment: Record<string, string>;
|
||||||
|
containerCwd: string;
|
||||||
|
redact: (value: string) => string;
|
||||||
|
timeoutMs: number;
|
||||||
|
onStdoutLine?: (line: string) => Promise<void>;
|
||||||
|
onStderrLine?: (line: string) => Promise<void>;
|
||||||
|
}): Promise<CommandResult> => {
|
||||||
|
const subprocess = execa(
|
||||||
|
containerRuntime(),
|
||||||
|
[
|
||||||
|
'exec',
|
||||||
|
...environmentArgs(args.environment),
|
||||||
|
'-w',
|
||||||
|
args.containerCwd,
|
||||||
|
args.containerName,
|
||||||
|
...args.command,
|
||||||
|
],
|
||||||
|
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
|
||||||
|
);
|
||||||
|
return streamSubprocess(
|
||||||
|
subprocess,
|
||||||
|
args.redact,
|
||||||
|
args.onStdoutLine,
|
||||||
|
args.onStderrLine,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runExecInContainer = async (args: {
|
||||||
|
containerName: string;
|
||||||
|
command: string[];
|
||||||
|
environment: Record<string, string>;
|
||||||
|
containerCwd: string;
|
||||||
|
redact: (value: string) => string;
|
||||||
|
timeoutMs: number;
|
||||||
|
}): Promise<CommandResult> => {
|
||||||
|
const result = await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
[
|
||||||
|
'exec',
|
||||||
|
...environmentArgs(args.environment),
|
||||||
|
'-w',
|
||||||
|
args.containerCwd,
|
||||||
|
args.containerName,
|
||||||
|
...args.command,
|
||||||
|
],
|
||||||
|
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
|
||||||
|
);
|
||||||
|
return normalizeRunResult(result, result.all, args.redact);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stopWorkspaceContainer = async (containerName: string) => {
|
||||||
|
await execa(containerRuntime(), ['rm', '-f', containerName], {
|
||||||
|
reject: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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(),
|
||||||
|
['ps', '-a', '--format', '{{.Names}}'],
|
||||||
|
{ all: true, reject: false },
|
||||||
|
);
|
||||||
|
if (result.exitCode !== 0) return [];
|
||||||
|
return result.all
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.startsWith(prefix));
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
|
import { attachTerminalServer } from './terminal';
|
||||||
import {
|
import {
|
||||||
|
abortWorkspaceAgent,
|
||||||
|
cleanupOrphanedWorkspaces,
|
||||||
|
getWorkerHealth,
|
||||||
|
getWorkspaceAgentStatus,
|
||||||
getWorkspaceDiff,
|
getWorkspaceDiff,
|
||||||
listWorkspaceTree,
|
listWorkspaceTree,
|
||||||
openWorkspacePullRequest,
|
openWorkspacePullRequest,
|
||||||
readWorkspaceFile,
|
readWorkspaceFile,
|
||||||
|
replyToInteraction,
|
||||||
runWorkspaceCommand,
|
runWorkspaceCommand,
|
||||||
sendWorkspaceMessage,
|
sendWorkspaceMessage,
|
||||||
stopWorkspace,
|
stopWorkspace,
|
||||||
@@ -43,7 +51,7 @@ const requireAuth = (request: IncomingMessage) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const jobRoute = (pathname: string) => {
|
const jobRoute = (pathname: string) => {
|
||||||
const match = /^\/jobs\/([^/]+)\/([^/]+)$/.exec(pathname);
|
const match = /^\/jobs\/([^/]+)\/(.+)$/.exec(pathname);
|
||||||
if (!match?.[1] || !match[2]) return null;
|
if (!match?.[1] || !match[2]) return null;
|
||||||
return { jobId: decodeURIComponent(match[1]), action: match[2] };
|
return { jobId: decodeURIComponent(match[1]), action: match[2] };
|
||||||
};
|
};
|
||||||
@@ -57,8 +65,12 @@ export const startWorkerServer = () => {
|
|||||||
request.url ?? '/',
|
request.url ?? '/',
|
||||||
`http://localhost:${env.httpPort}`,
|
`http://localhost:${env.httpPort}`,
|
||||||
);
|
);
|
||||||
if (url.pathname === '/health') {
|
if (url.pathname === '/health' && request.method === 'GET') {
|
||||||
sendJson(response, 200, { ok: true, workerId: env.workerId });
|
sendJson(response, 200, await getWorkerHealth());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/cleanup' && request.method === 'POST') {
|
||||||
|
sendJson(response, 200, await cleanupOrphanedWorkspaces());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const route = jobRoute(url.pathname);
|
const route = jobRoute(url.pathname);
|
||||||
@@ -108,6 +120,35 @@ export const startWorkerServer = () => {
|
|||||||
sendJson(response, 200, { success: true });
|
sendJson(response, 200, { success: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (request.method === 'GET' && route.action === 'agent/status') {
|
||||||
|
sendJson(response, 200, getWorkspaceAgentStatus(route.jobId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && route.action === 'agent/abort') {
|
||||||
|
sendJson(response, 200, await abortWorkspaceAgent(route.jobId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const interactionMatch = /^interactions\/([^/]+)\/reply$/.exec(
|
||||||
|
route.action,
|
||||||
|
);
|
||||||
|
if (request.method === 'POST' && interactionMatch?.[1]) {
|
||||||
|
const body = await parseJson<{
|
||||||
|
externalRequestId?: string;
|
||||||
|
response?: string;
|
||||||
|
}>(request);
|
||||||
|
sendJson(
|
||||||
|
response,
|
||||||
|
200,
|
||||||
|
await replyToInteraction(route.jobId, {
|
||||||
|
interactionId: decodeURIComponent(
|
||||||
|
interactionMatch[1],
|
||||||
|
) as Id<'agentInteractionRequests'>,
|
||||||
|
externalRequestId: body.externalRequestId ?? '',
|
||||||
|
response: body.response ?? 'once',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (request.method === 'POST' && route.action === 'run-command') {
|
if (request.method === 'POST' && route.action === 'run-command') {
|
||||||
const body = await parseJson<{ command?: string }>(request);
|
const body = await parseJson<{ command?: string }>(request);
|
||||||
sendJson(
|
sendJson(
|
||||||
@@ -128,12 +169,22 @@ export const startWorkerServer = () => {
|
|||||||
sendJson(response, 404, { error: 'Not found' });
|
sendJson(response, 404, { error: 'Not found' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
sendJson(response, message === 'Unauthorized' ? 401 : 500, {
|
console.error(
|
||||||
|
`Worker HTTP ${request.method ?? 'UNKNOWN'} ${request.url ?? '/'} failed: ${message}`,
|
||||||
|
);
|
||||||
|
const status =
|
||||||
|
message === 'Unauthorized'
|
||||||
|
? 401
|
||||||
|
: message.includes('not supported')
|
||||||
|
? 409
|
||||||
|
: 500;
|
||||||
|
sendJson(response, status, {
|
||||||
error: message,
|
error: message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
attachTerminalServer(server);
|
||||||
server.listen(env.httpPort, () => {
|
server.listen(env.httpPort, () => {
|
||||||
console.log(
|
console.log(
|
||||||
`Spoon agent worker HTTP server listening on port ${env.httpPort}`,
|
`Spoon agent worker HTTP server listening on port ${env.httpPort}`,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
// Short-lived, job-scoped token authorizing a browser terminal connection.
|
||||||
|
// Minted server-side by the Next app (which has verified job ownership) and
|
||||||
|
// verified here so the browser never sees the shared worker secret. Format:
|
||||||
|
// `${expiresAtMs}.${jobId}.${hmacSha256Hex}`
|
||||||
|
const signature = (payload: string, secret: string) =>
|
||||||
|
createHmac('sha256', secret).update(payload).digest('hex');
|
||||||
|
|
||||||
|
export const verifyTerminalToken = (
|
||||||
|
token: string,
|
||||||
|
jobId: string,
|
||||||
|
secret: string,
|
||||||
|
): boolean => {
|
||||||
|
if (!token || !secret) return false;
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return false;
|
||||||
|
const [expRaw, tokenJobId, provided] = parts;
|
||||||
|
if (tokenJobId !== jobId) return false;
|
||||||
|
const exp = Number.parseInt(expRaw ?? '', 10);
|
||||||
|
if (!Number.isFinite(exp) || Date.now() > exp) return false;
|
||||||
|
const expected = signature(`${expRaw}.${tokenJobId}`, secret);
|
||||||
|
const providedBuf = Buffer.from(provided ?? '', 'hex');
|
||||||
|
const expectedBuf = Buffer.from(expected, 'hex');
|
||||||
|
return (
|
||||||
|
providedBuf.length === expectedBuf.length &&
|
||||||
|
timingSafeEqual(providedBuf, expectedBuf)
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||||
|
import type { Server } from 'node:http';
|
||||||
|
import type { WebSocket } from 'ws';
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
import { env } from './env';
|
||||||
|
import { verifyTerminalToken } from './terminal-token';
|
||||||
|
import { acquireUserBox, releaseUserBox } from './user-container';
|
||||||
|
import { getTerminalWorkspace } from './worker';
|
||||||
|
|
||||||
|
const clampDimension = (value: unknown) => {
|
||||||
|
const n = Math.trunc(Number(value));
|
||||||
|
if (!Number.isFinite(n)) return undefined;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// bun can't load node-pty (native ABI mismatch) and dockerode can't attach to
|
||||||
|
// podman, so we drive the runtime CLI (`<runtime> exec -i`) and allocate the PTY
|
||||||
|
// *inside* the container with `script`, bridging the plain pipes to the socket.
|
||||||
|
//
|
||||||
|
// Register the message handler immediately and buffer input/size until the exec
|
||||||
|
// is ready (acquiring the box can take seconds on first connect), so the initial
|
||||||
|
// resize and early keystrokes aren't dropped.
|
||||||
|
const procHolder: { current?: ChildProcessWithoutNullStreams } = {};
|
||||||
|
const pendingInput: Buffer[] = [];
|
||||||
|
let cols = 80;
|
||||||
|
let rows = 24;
|
||||||
|
|
||||||
|
ws.on('message', (data: Buffer, isBinary: boolean) => {
|
||||||
|
if (!isBinary) {
|
||||||
|
// Text frames are control messages (resize); anything else is raw input.
|
||||||
|
try {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (procHolder.current) procHolder.current.stdin.write(data);
|
||||||
|
else pendingInput.push(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
let acquired = false;
|
||||||
|
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.
|
||||||
|
const isReleased = () => released;
|
||||||
|
const cleanup = () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
procHolder.current?.kill();
|
||||||
|
if (acquired) releaseUserBox(workspace.username);
|
||||||
|
};
|
||||||
|
ws.on('close', cleanup);
|
||||||
|
ws.on('error', cleanup);
|
||||||
|
|
||||||
|
// Hold the per-user box open while this terminal is connected; the agent and
|
||||||
|
// 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;
|
||||||
|
} catch (error) {
|
||||||
|
ws.close(
|
||||||
|
1011,
|
||||||
|
`Failed to start terminal: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReleased()) return; // client disconnected during startup; cleanup ran
|
||||||
|
|
||||||
|
// 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 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;
|
||||||
|
|
||||||
|
// Replay any keystrokes the client sent before the process was ready.
|
||||||
|
for (const buffered of pendingInput) proc.stdin.write(buffered);
|
||||||
|
pendingInput.length = 0;
|
||||||
|
|
||||||
|
const forward = (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', () => {
|
||||||
|
if (ws.readyState === ws.OPEN) ws.close();
|
||||||
|
});
|
||||||
|
proc.on('error', () => {
|
||||||
|
if (ws.readyState === ws.OPEN) ws.close();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
export const attachTerminalServer = (server: Server) => {
|
||||||
|
if (env.runtime !== 'docker') return;
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
server.on('upgrade', (request, socket, head) => {
|
||||||
|
const url = new URL(request.url ?? '', `http://localhost:${env.httpPort}`);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Spoon agent worker terminal WebSocket endpoint enabled.');
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { env } from './env';
|
||||||
|
import {
|
||||||
|
ensureUserContainer,
|
||||||
|
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>();
|
||||||
|
|
||||||
|
export const acquireUserBox = async (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;
|
||||||
|
}
|
||||||
|
box.refs += 1;
|
||||||
|
boxes.set(args.username, box);
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const releaseUserBox = (username: string) => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { ConvexHttpClient } from 'convex/browser';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { env } from './env';
|
||||||
|
import { runExecInContainer } from './runtime/docker';
|
||||||
|
|
||||||
|
const client = new ConvexHttpClient(env.convexUrl);
|
||||||
|
|
||||||
|
export type UserEnvironment = {
|
||||||
|
username: string;
|
||||||
|
enabled: boolean;
|
||||||
|
dotfilesRepoUrl?: string;
|
||||||
|
dotfilesRepoRef?: string;
|
||||||
|
setupCommand?: string;
|
||||||
|
files: { path: string; content: string; isExecutable: boolean }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The job owner's resolved environment (username + dotfiles, decrypted). */
|
||||||
|
export const fetchUserEnvironment = async (
|
||||||
|
jobId: Id<'agentJobs'>,
|
||||||
|
): Promise<UserEnvironment | null> =>
|
||||||
|
await client.action(api.userDotfilesNode.getEnvironmentForJob, {
|
||||||
|
workerToken: env.workerToken,
|
||||||
|
jobId,
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Materializes the persistent per-user home: a `.bash_profile` so login shells
|
||||||
|
* load `~/.bashrc`; (when configured and changed) a clone of the public dotfiles
|
||||||
|
* repo + the setup command, run inside the job image so the user's tools/paths
|
||||||
|
* apply; then the editable overlay files (which win over the repo). Idempotent
|
||||||
|
* via a hash marker so the repo/setup only re-runs when the config changes.
|
||||||
|
*/
|
||||||
|
export const materializeUserHome = async (args: {
|
||||||
|
homeDir: string;
|
||||||
|
containerHome: string;
|
||||||
|
boxName: string;
|
||||||
|
userEnv: UserEnvironment;
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userEnv.enabled) return;
|
||||||
|
|
||||||
|
// Public dotfiles repo + setup command, only re-run when the config changes.
|
||||||
|
if (userEnv.dotfilesRepoUrl) {
|
||||||
|
const configHash = createHash('sha256')
|
||||||
|
.update(
|
||||||
|
JSON.stringify({
|
||||||
|
repo: userEnv.dotfilesRepoUrl,
|
||||||
|
ref: userEnv.dotfilesRepoRef ?? '',
|
||||||
|
setup: userEnv.setupCommand ?? '',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.digest('hex');
|
||||||
|
const markerPath = path.join(homeDir, '.spoon', 'env-hash');
|
||||||
|
const previous = await readFile(markerPath, 'utf8').catch(() => '');
|
||||||
|
if (previous.trim() !== configHash) {
|
||||||
|
const branch = userEnv.dotfilesRepoRef
|
||||||
|
? `--branch ${shellQuote(userEnv.dotfilesRepoRef)} `
|
||||||
|
: '';
|
||||||
|
const script = [
|
||||||
|
'set -e',
|
||||||
|
'rm -rf ~/.dotfiles',
|
||||||
|
`git clone --depth 1 ${branch}${shellQuote(userEnv.dotfilesRepoUrl)} ~/.dotfiles`,
|
||||||
|
userEnv.setupCommand
|
||||||
|
? `cd ~/.dotfiles && bash ${shellQuote(userEnv.setupCommand)}`
|
||||||
|
: '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
await runExecInContainer({
|
||||||
|
containerName: boxName,
|
||||||
|
command: ['bash', '-lc', script],
|
||||||
|
containerCwd: containerHome,
|
||||||
|
environment: { HOME: containerHome },
|
||||||
|
redact,
|
||||||
|
timeoutMs: env.jobTimeoutMs,
|
||||||
|
});
|
||||||
|
await mkdir(path.dirname(markerPath), { recursive: true });
|
||||||
|
await writeFile(markerPath, configHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editable overlay tree (wins over the repo/setup output).
|
||||||
|
for (const file of userEnv.files) {
|
||||||
|
const target = safeHomeJoin(homeDir, file.path);
|
||||||
|
await mkdir(path.dirname(target), { recursive: true });
|
||||||
|
await writeFile(target, file.content);
|
||||||
|
if (file.isExecutable) await chmod(target, 0o755);
|
||||||
|
}
|
||||||
|
};
|
||||||
+984
-106
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,294 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeCodexJsonLine,
|
||||||
|
normalizeOpenCodeEvent,
|
||||||
|
} from '../../src/agent-events';
|
||||||
|
|
||||||
|
describe('agent event normalization', () => {
|
||||||
|
test('normalizes Codex assistant deltas and session ids', () => {
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'session.created',
|
||||||
|
session_id: 'codex-session-1',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'session', sessionId: 'codex-session-1' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'response.output_text.delta',
|
||||||
|
delta: 'hello',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'assistant_delta', content: 'hello' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes legacy codex-rs msg-wrapped events', () => {
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
id: '0',
|
||||||
|
msg: { type: 'agent_message', message: 'hello there' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'assistant_delta', content: 'hello there\n\n' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
id: '1',
|
||||||
|
msg: { type: 'error', message: 'usage limit reached' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'error', message: 'usage limit reached' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({ id: '2', msg: { type: 'task_complete' } }),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'assistant_completed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes Codex CLI thread lifecycle events', () => {
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'thread.started',
|
||||||
|
thread_id: '019ef701-f7d7-76a0-a96b-15c059631dd9',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'session',
|
||||||
|
sessionId: '019ef701-f7d7-76a0-a96b-15c059631dd9',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'turn.started',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'status', status: 'turn.started' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'turn.completed',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'assistant_completed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes Codex command and file events', () => {
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'command.completed',
|
||||||
|
command: 'bun test',
|
||||||
|
output: 'ok',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'command_executed',
|
||||||
|
command: 'bun test',
|
||||||
|
output: 'ok',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'file.edited',
|
||||||
|
path: 'src/app.ts',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'file_edited', path: 'src/app.ts' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes current Codex item events', () => {
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: {
|
||||||
|
id: 'item-1',
|
||||||
|
type: 'agent_message',
|
||||||
|
text: 'I updated the auth provider.',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: 'I updated the auth provider.\n\n',
|
||||||
|
externalMessageId: 'item-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: {
|
||||||
|
id: 'item-2',
|
||||||
|
type: 'error',
|
||||||
|
message: 'sandbox failed',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'error',
|
||||||
|
message: 'sandbox failed',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'turn.failed',
|
||||||
|
error: { message: 'request failed' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'error',
|
||||||
|
message: '{\n "message": "request failed"\n}',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: {
|
||||||
|
id: 'item-warning',
|
||||||
|
type: 'error',
|
||||||
|
message:
|
||||||
|
'`[features].codex_hooks` is deprecated. Use `[features].hooks` instead.',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'status',
|
||||||
|
status:
|
||||||
|
'`[features].codex_hooks` is deprecated. Use `[features].hooks` instead.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes Codex tool item lifecycle events', () => {
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.started',
|
||||||
|
item: {
|
||||||
|
id: 'tool-1',
|
||||||
|
type: 'local_shell_call',
|
||||||
|
command: ['bash', '-lc', 'rg Authentik'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'tool_started',
|
||||||
|
name: 'Command',
|
||||||
|
input: 'bash -lc rg Authentik',
|
||||||
|
externalMessageId: 'tool-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: {
|
||||||
|
id: 'tool-1',
|
||||||
|
type: 'local_shell_call',
|
||||||
|
command: ['bash', '-lc', 'rg Authentik'],
|
||||||
|
output: 'apps/web/auth.ts',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: 'Command',
|
||||||
|
output: 'apps/web/auth.ts',
|
||||||
|
externalMessageId: 'tool-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: {
|
||||||
|
id: 'tool-2',
|
||||||
|
type: 'exec_command',
|
||||||
|
command: 'cat package.json',
|
||||||
|
aggregated_output: '{"scripts":{"build":"turbo build"}}',
|
||||||
|
exit_code: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: 'Command',
|
||||||
|
output: '{"scripts":{"build":"turbo build"}}',
|
||||||
|
externalMessageId: 'tool-2',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes OpenCode assistant, tool, and permission events', () => {
|
||||||
|
expect(
|
||||||
|
normalizeOpenCodeEvent({
|
||||||
|
type: 'message.part.delta',
|
||||||
|
properties: {
|
||||||
|
part: { text: 'streamed' },
|
||||||
|
messageID: 'message-1',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: 'streamed',
|
||||||
|
externalMessageId: 'message-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeOpenCodeEvent({
|
||||||
|
type: 'tool.started',
|
||||||
|
properties: { tool: 'edit', input: { path: 'README.md' } },
|
||||||
|
}),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'tool_started',
|
||||||
|
name: 'edit',
|
||||||
|
input: '{\n "path": "README.md"\n}',
|
||||||
|
externalMessageId: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeOpenCodeEvent({
|
||||||
|
type: 'permission.asked',
|
||||||
|
properties: {
|
||||||
|
permissionID: 'perm-1',
|
||||||
|
message: 'Run bun test?',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'permission_requested',
|
||||||
|
externalRequestId: 'perm-1',
|
||||||
|
title: 'Permission requested',
|
||||||
|
body: 'Run bun test?',
|
||||||
|
metadata:
|
||||||
|
'{\n "permissionID": "perm-1",\n "message": "Run bun test?"\n}',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeOpenCodeEvent({
|
||||||
|
type: 'tool.output',
|
||||||
|
properties: {
|
||||||
|
tool: 'read',
|
||||||
|
output: 'apps/web/auth.ts',
|
||||||
|
messageID: 'message-2',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: 'read',
|
||||||
|
output: 'apps/web/auth.ts',
|
||||||
|
externalMessageId: 'message-2',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
mkdir,
|
||||||
|
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 { prepareCodexWorkspaceFiles } from '../../src/codex-runtime';
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
const mode = async (filePath: string) => (await stat(filePath)).mode & 0o777;
|
||||||
|
|
||||||
|
describe('Codex runtime preparation', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
tempDirs.map((dir) => rm(dir, { force: true, recursive: true })),
|
||||||
|
);
|
||||||
|
tempDirs.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepares writable Codex directories and preserves project config contents', async () => {
|
||||||
|
const workdir = await mkdtemp(path.join(os.tmpdir(), 'spoon-codex-'));
|
||||||
|
tempDirs.push(workdir);
|
||||||
|
const repoDir = path.join(workdir, 'repo');
|
||||||
|
await mkdir(path.join(repoDir, '.codex'), { recursive: true });
|
||||||
|
const projectConfig = path.join(repoDir, '.codex', 'config.toml');
|
||||||
|
await writeFile(projectConfig, '[features]\ncodex_hooks = true\n');
|
||||||
|
|
||||||
|
await prepareCodexWorkspaceFiles({ workdir, repoDir });
|
||||||
|
|
||||||
|
await expect(readFile(projectConfig, 'utf8')).resolves.toBe(
|
||||||
|
'[features]\ncodex_hooks = true\n',
|
||||||
|
);
|
||||||
|
await expect(mode(workdir)).resolves.toBe(0o755);
|
||||||
|
await expect(mode(repoDir)).resolves.toBe(0o755);
|
||||||
|
await expect(mode(path.join(workdir, '.codex'))).resolves.toBe(0o755);
|
||||||
|
await expect(mode(path.join(workdir, '.config'))).resolves.toBe(0o755);
|
||||||
|
await expect(mode(path.join(workdir, '.local', 'share'))).resolves.toBe(
|
||||||
|
0o755,
|
||||||
|
);
|
||||||
|
await expect(mode(path.join(repoDir, '.codex'))).resolves.toBe(0o755);
|
||||||
|
await expect(mode(projectConfig)).resolves.toBe(0o644);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
const loadVolumeSpec = 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/runtime/docker');
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Docker runtime', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.SPOON_AGENT_CONTAINER_RUNTIME;
|
||||||
|
delete process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS;
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds SELinux relabel option for Podman workspace mounts by default', async () => {
|
||||||
|
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'podman';
|
||||||
|
const { jobWorkspaceVolumeSpec } = await loadVolumeSpec();
|
||||||
|
|
||||||
|
expect(jobWorkspaceVolumeSpec('/tmp/spoon-job')).toBe(
|
||||||
|
'/tmp/spoon-job:/workspace:Z',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not add Podman volume options for Docker by default', async () => {
|
||||||
|
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'docker';
|
||||||
|
const { jobWorkspaceVolumeSpec } = await loadVolumeSpec();
|
||||||
|
|
||||||
|
expect(jobWorkspaceVolumeSpec('/tmp/spoon-job')).toBe(
|
||||||
|
'/tmp/spoon-job:/workspace',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows explicit workspace mount options', async () => {
|
||||||
|
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'podman';
|
||||||
|
process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS = 'z';
|
||||||
|
const { jobWorkspaceVolumeSpec } = await loadVolumeSpec();
|
||||||
|
|
||||||
|
expect(jobWorkspaceVolumeSpec('/tmp/spoon-job')).toBe(
|
||||||
|
'/tmp/spoon-job:/workspace:z',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('treats a spawn failure (no exitCode) as a non-zero exit, not empty success', async () => {
|
||||||
|
const { normalizeRunResult } = await loadVolumeSpec();
|
||||||
|
// This is what execa returns with `reject: false` when the runtime binary is
|
||||||
|
// missing (e.g. no `docker` CLI in the worker image): exitCode is undefined.
|
||||||
|
const result = normalizeRunResult(
|
||||||
|
{ exitCode: undefined, shortMessage: 'spawn docker ENOENT' },
|
||||||
|
undefined,
|
||||||
|
(value) => value,
|
||||||
|
);
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.output).toContain('spawn docker ENOENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passes through a normal command result unchanged', async () => {
|
||||||
|
const { normalizeRunResult } = await loadVolumeSpec();
|
||||||
|
const result = normalizeRunResult(
|
||||||
|
{ exitCode: 0, shortMessage: undefined },
|
||||||
|
'hello',
|
||||||
|
(value) => value,
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ exitCode: 0, output: 'hello' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,6 @@ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
type TestWorkspace = {
|
type TestWorkspace = {
|
||||||
@@ -53,7 +52,9 @@ const writeConfig = async (
|
|||||||
config: Record<string, unknown> | string,
|
config: Record<string, unknown> | string,
|
||||||
) => {
|
) => {
|
||||||
const content =
|
const content =
|
||||||
typeof config === 'string' ? config : `${JSON.stringify(config, null, 2)}\n`;
|
typeof config === 'string'
|
||||||
|
? config
|
||||||
|
: `${JSON.stringify(config, null, 2)}\n`;
|
||||||
await writeFile(configPath(workspace), content);
|
await writeFile(configPath(workspace), content);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { verifyTerminalToken } from '../../src/terminal-token';
|
||||||
|
|
||||||
|
const mint = (jobId: string, expiresAt: number, secret: string) => {
|
||||||
|
const payload = `${expiresAt}.${jobId}`;
|
||||||
|
const sig = createHmac('sha256', secret).update(payload).digest('hex');
|
||||||
|
return `${payload}.${sig}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('verifyTerminalToken', () => {
|
||||||
|
const secret = 'test-secret';
|
||||||
|
|
||||||
|
test('accepts a valid, unexpired, job-matched token', () => {
|
||||||
|
const token = mint('job1', Date.now() + 60_000, secret);
|
||||||
|
expect(verifyTerminalToken(token, 'job1', secret)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects an expired token', () => {
|
||||||
|
const token = mint('job1', Date.now() - 1, secret);
|
||||||
|
expect(verifyTerminalToken(token, 'job1', secret)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a token minted for another job', () => {
|
||||||
|
const token = mint('job1', Date.now() + 60_000, secret);
|
||||||
|
expect(verifyTerminalToken(token, 'job2', secret)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a token signed with a different secret', () => {
|
||||||
|
const token = mint('job1', Date.now() + 60_000, 'other-secret');
|
||||||
|
expect(verifyTerminalToken(token, 'job1', secret)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects malformed input and an empty secret', () => {
|
||||||
|
expect(verifyTerminalToken('garbage', 'job1', secret)).toBe(false);
|
||||||
|
expect(verifyTerminalToken('', 'job1', secret)).toBe(false);
|
||||||
|
expect(
|
||||||
|
verifyTerminalToken(mint('job1', Date.now() + 1000, ''), 'job1', ''),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://v2-8-20.turborepo.dev/schema.json",
|
"$schema": "https://v2-10-0.turborepo.dev/schema.json",
|
||||||
"extends": ["//"],
|
"extends": ["//"],
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"dev": {
|
"dev": {
|
||||||
|
|||||||
@@ -21,16 +21,21 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@convex-dev/auth": "catalog:convex",
|
"@convex-dev/auth": "catalog:convex",
|
||||||
|
"@git-diff-view/react": "^0.1.6",
|
||||||
"@monaco-editor/react": "latest",
|
"@monaco-editor/react": "latest",
|
||||||
"@sentry/nextjs": "^10.46.0",
|
"@sentry/nextjs": "^10.46.0",
|
||||||
"@spoon/backend": "workspace:*",
|
"@spoon/backend": "workspace:*",
|
||||||
"@spoon/ui": "workspace:*",
|
"@spoon/ui": "workspace:*",
|
||||||
"@t3-oss/env-nextjs": "^0.13.11",
|
"@t3-oss/env-nextjs": "^0.13.11",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"convex": "catalog:convex",
|
"convex": "catalog:convex",
|
||||||
"monaco-editor": "latest",
|
"monaco-editor": "latest",
|
||||||
"monaco-vim": "latest",
|
"monaco-vim": "latest",
|
||||||
"next": "^16.2.1",
|
"next": "^16.2.1",
|
||||||
"next-plausible": "^3.12.5",
|
"next-plausible": "^3.12.5",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "catalog:react19",
|
"react": "catalog:react19",
|
||||||
"react-dom": "catalog:react19",
|
"react-dom": "catalog:react19",
|
||||||
"require-in-the-middle": "^7.5.2",
|
"require-in-the-middle": "^7.5.2",
|
||||||
|
|||||||
Binary file not shown.
@@ -11,18 +11,20 @@ import { api } from '@spoon/backend/convex/_generated/api.js';
|
|||||||
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||||
|
|
||||||
const DashboardPage = () => {
|
const DashboardPage = () => {
|
||||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
||||||
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
|
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
|
||||||
const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
|
const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
|
||||||
const activeSpoons = spoons.filter(
|
const activeSpoons = spoons.filter(
|
||||||
(spoon) => spoon.status === 'active',
|
(spoon) => spoon.status === 'active',
|
||||||
).length;
|
).length;
|
||||||
const behind = spoons.filter((spoon) => spoon.syncStatus === 'behind').length;
|
const behind = spoons.filter(
|
||||||
|
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy === 0,
|
||||||
|
).length;
|
||||||
const diverged = spoons.filter(
|
const diverged = spoons.filter(
|
||||||
(spoon) => spoon.syncStatus === 'diverged',
|
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy > 0,
|
||||||
).length;
|
).length;
|
||||||
const openPullRequests = spoons.reduce(
|
const openPullRequests = spoons.reduce(
|
||||||
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
|
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -70,7 +72,7 @@ const DashboardPage = () => {
|
|||||||
<MetricCard
|
<MetricCard
|
||||||
label='Upstream commits'
|
label='Upstream commits'
|
||||||
value={openPullRequests}
|
value={openPullRequests}
|
||||||
note='Waiting across Spoons'
|
note='Actionable after ignores'
|
||||||
icon={ShieldCheck}
|
icon={ShieldCheck}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { DotfilesManager } from '@/components/settings/dotfiles/dotfiles-manager';
|
||||||
|
|
||||||
|
const SettingsDotfilesPage = () => {
|
||||||
|
return (
|
||||||
|
<section className='space-y-4'>
|
||||||
|
<div>
|
||||||
|
<h2 className='text-xl font-semibold'>Dotfiles</h2>
|
||||||
|
<p className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
Your personal shell, editor, and tool config — applied to the
|
||||||
|
workspace terminal in every thread. Files are placed relative to your
|
||||||
|
home directory (e.g. <code>.bashrc</code>,{' '}
|
||||||
|
<code>.config/nvim/init.lua</code>).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DotfilesManager />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsDotfilesPage;
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Brain, Github, Shield, User } from 'lucide-react';
|
import { Brain, FileCog, Github, ServerCog, Shield, User } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '@spoon/ui';
|
import { cn } from '@spoon/ui';
|
||||||
|
|
||||||
@@ -11,6 +11,8 @@ const settingsItems = [
|
|||||||
{ href: '/settings/profile', label: 'Profile', icon: User },
|
{ href: '/settings/profile', label: 'Profile', icon: User },
|
||||||
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
|
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
|
||||||
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
|
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
|
||||||
|
{ href: '/settings/dotfiles', label: 'Dotfiles', icon: FileCog },
|
||||||
|
{ href: '/settings/worker', label: 'Worker', icon: ServerCog },
|
||||||
{ href: '/settings/security', label: 'Security', icon: Shield },
|
{ href: '/settings/security', label: 'Security', icon: Shield },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { WorkerHealthPanel } from '@/components/settings/worker-health-panel';
|
||||||
|
|
||||||
|
const WorkerSettingsPage = () => (
|
||||||
|
<section className='max-w-5xl space-y-4'>
|
||||||
|
<div>
|
||||||
|
<h2 className='text-xl font-semibold'>Worker</h2>
|
||||||
|
<p className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
Monitor the agent worker and clean up old workspace state.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<WorkerHealthPanel />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default WorkerSettingsPage;
|
||||||
@@ -1,15 +1,33 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell';
|
import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell';
|
||||||
|
import { useQuery } from 'convex/react';
|
||||||
import { ArrowLeft } from 'lucide-react';
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
import { Button } from '@spoon/ui';
|
import { Button } from '@spoon/ui';
|
||||||
|
|
||||||
const AgentWorkspacePage = () => {
|
const AgentWorkspacePage = () => {
|
||||||
|
const router = useRouter();
|
||||||
const params = useParams<{ spoonId: string; jobId: string }>();
|
const params = useParams<{ spoonId: string; jobId: string }>();
|
||||||
|
const jobId = params.jobId as Id<'agentJobs'>;
|
||||||
|
const job = useQuery(api.agentJobs.get, { jobId });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (job?.threadId) router.replace(`/threads/${job.threadId}`);
|
||||||
|
}, [job?.threadId, router]);
|
||||||
|
|
||||||
|
if (job?.threadId) {
|
||||||
|
return (
|
||||||
|
<main className='text-muted-foreground p-6'>
|
||||||
|
Opening thread workspace...
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className='space-y-4'>
|
<main className='space-y-4'>
|
||||||
@@ -19,7 +37,7 @@ const AgentWorkspacePage = () => {
|
|||||||
Back to Spoon
|
Back to Spoon
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<AgentWorkspaceShell jobId={params.jobId as Id<'agentJobs'>} />
|
<AgentWorkspaceShell jobId={jobId} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { AgentJobList } from '@/components/agents/agent-job-list';
|
|
||||||
import { AgentRequestForm } from '@/components/agents/agent-request-form';
|
|
||||||
import { SpoonActivityTimeline } from '@/components/spoons/spoon-activity-timeline';
|
import { SpoonActivityTimeline } from '@/components/spoons/spoon-activity-timeline';
|
||||||
import { SpoonAgentSettingsForm } from '@/components/spoons/spoon-agent-settings-form';
|
import { SpoonAgentSettingsForm } from '@/components/spoons/spoon-agent-settings-form';
|
||||||
import { SpoonClonePanel } from '@/components/spoons/spoon-clone-panel';
|
import { SpoonClonePanel } from '@/components/spoons/spoon-clone-panel';
|
||||||
@@ -13,6 +11,8 @@ import { SpoonMetrics } from '@/components/spoons/spoon-metrics';
|
|||||||
import { SpoonPrList } from '@/components/spoons/spoon-pr-list';
|
import { SpoonPrList } from '@/components/spoons/spoon-pr-list';
|
||||||
import { SpoonSecretsForm } from '@/components/spoons/spoon-secrets-form';
|
import { SpoonSecretsForm } from '@/components/spoons/spoon-secrets-form';
|
||||||
import { SpoonSettingsForm } from '@/components/spoons/spoon-settings-form';
|
import { SpoonSettingsForm } from '@/components/spoons/spoon-settings-form';
|
||||||
|
import { DeleteThreadButton } from '@/components/threads/delete-thread-button';
|
||||||
|
import { ThreadWorkspaceForm } from '@/components/threads/thread-workspace-form';
|
||||||
import { useQuery } from 'convex/react';
|
import { useQuery } from 'convex/react';
|
||||||
|
|
||||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
@@ -55,6 +55,17 @@ const SpoonDetailPage = () => {
|
|||||||
});
|
});
|
||||||
const agentJobs =
|
const agentJobs =
|
||||||
useQuery(api.agentJobs.listForSpoon, { spoonId, limit: 25 }) ?? [];
|
useQuery(api.agentJobs.listForSpoon, { spoonId, limit: 25 }) ?? [];
|
||||||
|
const canDeleteThread = (thread: (typeof threads)[number]) => {
|
||||||
|
const latestJobStatus = thread.latestJobStatus;
|
||||||
|
const latestWorkspaceStatus = thread.latestJobWorkspaceStatus;
|
||||||
|
if (!latestJobStatus && !latestWorkspaceStatus) return true;
|
||||||
|
return (
|
||||||
|
['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes(
|
||||||
|
latestJobStatus ?? '',
|
||||||
|
) ||
|
||||||
|
['stopped', 'expired', 'failed'].includes(latestWorkspaceStatus ?? '')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (details === undefined) {
|
if (details === undefined) {
|
||||||
return <main className='text-muted-foreground p-6'>Loading Spoon...</main>;
|
return <main className='text-muted-foreground p-6'>Loading Spoon...</main>;
|
||||||
@@ -243,7 +254,7 @@ const SpoonDetailPage = () => {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value='threads' className='space-y-4'>
|
<TabsContent value='threads' className='space-y-4'>
|
||||||
<AgentRequestForm
|
<ThreadWorkspaceForm
|
||||||
spoon={details.spoon}
|
spoon={details.spoon}
|
||||||
agentSettings={agentSettings}
|
agentSettings={agentSettings}
|
||||||
/>
|
/>
|
||||||
@@ -254,17 +265,29 @@ const SpoonDetailPage = () => {
|
|||||||
<CardContent className='space-y-3'>
|
<CardContent className='space-y-3'>
|
||||||
{threads.length ? (
|
{threads.length ? (
|
||||||
threads.map((thread) => (
|
threads.map((thread) => (
|
||||||
<Link
|
<div
|
||||||
key={thread._id}
|
key={thread._id}
|
||||||
href={`/threads/${thread._id}`}
|
className='border-border hover:border-primary/50 grid gap-3 rounded-md border p-3 transition-colors md:grid-cols-[1fr_auto] md:items-center'
|
||||||
className='border-border hover:border-primary/50 block rounded-md border p-3 transition-colors'
|
|
||||||
>
|
>
|
||||||
<p className='font-medium'>{thread.title}</p>
|
<Link href={`/threads/${thread._id}`} className='min-w-0'>
|
||||||
<p className='text-muted-foreground mt-1 text-sm'>
|
<p className='truncate font-medium'>{thread.title}</p>
|
||||||
{thread.status.replaceAll('_', ' ')} ·{' '}
|
<p className='text-muted-foreground mt-1 text-sm'>
|
||||||
{thread.source.replaceAll('_', ' ')}
|
{thread.status.replaceAll('_', ' ')} ·{' '}
|
||||||
</p>
|
{thread.source.replaceAll('_', ' ')}
|
||||||
</Link>
|
{thread.latestJobWorkspaceStatus
|
||||||
|
? ` · workspace ${thread.latestJobWorkspaceStatus.replaceAll('_', ' ')}`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
<div className='flex justify-start md:justify-end'>
|
||||||
|
<DeleteThreadButton
|
||||||
|
threadId={thread._id}
|
||||||
|
disabled={!canDeleteThread(thread)}
|
||||||
|
label='Delete'
|
||||||
|
variant='outline'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className='text-muted-foreground text-sm'>
|
<p className='text-muted-foreground text-sm'>
|
||||||
@@ -273,7 +296,6 @@ const SpoonDetailPage = () => {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<AgentJobList jobs={agentJobs} />
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value='activity'>
|
<TabsContent value='activity'>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const formatDate = (value?: number) =>
|
|||||||
|
|
||||||
const SpoonsPage = () => {
|
const SpoonsPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
||||||
const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
|
const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
|
||||||
const active = spoons.filter((spoon) => spoon.status === 'active').length;
|
const active = spoons.filter((spoon) => spoon.status === 'active').length;
|
||||||
const needsReview = threads.filter(
|
const needsReview = threads.filter(
|
||||||
@@ -41,7 +41,7 @@ const SpoonsPage = () => {
|
|||||||
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
|
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
|
||||||
).length;
|
).length;
|
||||||
const upstreamWaiting = spoons.reduce(
|
const upstreamWaiting = spoons.reduce(
|
||||||
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
|
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -152,10 +152,16 @@ const SpoonsPage = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className='text-sm'>
|
<div className='text-sm'>
|
||||||
<p>{spoon.upstreamAheadBy ?? 0} upstream</p>
|
<p>{spoon.effectiveUpstreamAheadBy} actionable</p>
|
||||||
<p className='text-muted-foreground'>
|
<p className='text-muted-foreground'>
|
||||||
{spoon.forkAheadBy ?? 0} fork-only
|
{spoon.rawUpstreamAheadBy} raw upstream ·{' '}
|
||||||
|
{spoon.forkAheadBy} fork-only
|
||||||
</p>
|
</p>
|
||||||
|
{spoon.ignoredUpstreamCount ? (
|
||||||
|
<p className='text-muted-foreground'>
|
||||||
|
{spoon.ignoredUpstreamCount} ignored
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className='capitalize'>
|
<TableCell className='capitalize'>
|
||||||
@@ -197,7 +203,8 @@ const SpoonsPage = () => {
|
|||||||
|
|
||||||
{spoons.length ? (
|
{spoons.length ? (
|
||||||
<p className='text-muted-foreground text-sm'>
|
<p className='text-muted-foreground text-sm'>
|
||||||
Raw upstream commits waiting across all Spoons: {upstreamWaiting}
|
Actionable upstream commits waiting across all Spoons:{' '}
|
||||||
|
{upstreamWaiting}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,50 +1,96 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell';
|
||||||
|
import { DeleteThreadButton } from '@/components/threads/delete-thread-button';
|
||||||
import { useMutation, useQuery } from 'convex/react';
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
import { ArrowUpRight, CheckCircle2, XCircle } from 'lucide-react';
|
import { ArrowUpRight, CheckCircle2, Play, XCircle } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
import {
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
Textarea,
|
|
||||||
} from '@spoon/ui';
|
} from '@spoon/ui';
|
||||||
|
|
||||||
const ThreadDetailPage = () => {
|
const ThreadDetailPage = () => {
|
||||||
|
const router = useRouter();
|
||||||
const params = useParams<{ threadId: string }>();
|
const params = useParams<{ threadId: string }>();
|
||||||
const threadId = params.threadId as Id<'threads'>;
|
const threadId = params.threadId as Id<'threads'>;
|
||||||
const details = useQuery(api.threads.get, { threadId });
|
const details = useQuery(api.threads.get, { threadId });
|
||||||
const messages = useQuery(api.threads.listMessages, { threadId }) ?? [];
|
const createJob = useMutation(api.agentJobs.createForThread);
|
||||||
const appendMessage = useMutation(api.threads.appendUserMessage);
|
|
||||||
const markResolved = useMutation(api.threads.markResolved);
|
const markResolved = useMutation(api.threads.markResolved);
|
||||||
const cancel = useMutation(api.threads.cancel);
|
const cancel = useMutation(api.threads.cancel);
|
||||||
|
const [queueing, setQueueing] = useState(false);
|
||||||
|
|
||||||
if (details === undefined) {
|
if (details === undefined) {
|
||||||
return <main className='text-muted-foreground p-6'>Loading thread...</main>;
|
return <main className='text-muted-foreground p-6'>Loading thread...</main>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { thread, spoon, latestJob } = details;
|
const { thread, spoon, latestJob } = details;
|
||||||
|
if (latestJob && spoon) {
|
||||||
|
return (
|
||||||
|
<main className='space-y-4'>
|
||||||
|
<Button asChild variant='ghost' size='sm'>
|
||||||
|
<Link href={`/spoons/${spoon._id}`}>
|
||||||
|
<ArrowUpRight className='size-4 rotate-180' />
|
||||||
|
Back to Spoon
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<AgentWorkspaceShell jobId={latestJob._id} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const terminalThread = [
|
||||||
event.preventDefault();
|
'resolved',
|
||||||
const form = new FormData(event.currentTarget);
|
'ignored',
|
||||||
const value = form.get('message');
|
'failed',
|
||||||
const content = typeof value === 'string' ? value : '';
|
'cancelled',
|
||||||
|
].includes(thread.status);
|
||||||
|
const canQueueRun =
|
||||||
|
spoon &&
|
||||||
|
(!latestJob ||
|
||||||
|
['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes(
|
||||||
|
latestJob.status,
|
||||||
|
) ||
|
||||||
|
['stopped', 'expired', 'failed'].includes(
|
||||||
|
latestJob.workspaceStatus ?? '',
|
||||||
|
));
|
||||||
|
const jobType =
|
||||||
|
thread.source === 'merge_conflict'
|
||||||
|
? ('conflict_resolution' as const)
|
||||||
|
: thread.source === 'upstream_update'
|
||||||
|
? ('maintenance_review' as const)
|
||||||
|
: ('user_change' as const);
|
||||||
|
|
||||||
|
const startRun = async () => {
|
||||||
|
setQueueing(true);
|
||||||
try {
|
try {
|
||||||
await appendMessage({ threadId, content });
|
await createJob({ threadId, jobType });
|
||||||
event.currentTarget.reset();
|
toast.success('Workspace run queued.');
|
||||||
toast.success('Message added.');
|
router.replace(`/threads/${threadId}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error('Could not add message.');
|
toast.error('Could not queue workspace run.');
|
||||||
|
} finally {
|
||||||
|
setQueueing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -81,11 +127,7 @@ const ThreadDetailPage = () => {
|
|||||||
<div className='flex flex-wrap gap-2'>
|
<div className='flex flex-wrap gap-2'>
|
||||||
{latestJob ? (
|
{latestJob ? (
|
||||||
<Button variant='outline' asChild>
|
<Button variant='outline' asChild>
|
||||||
<Link
|
<Link href={`/threads/${threadId}`}>Open workspace</Link>
|
||||||
href={`/spoons/${latestJob.spoonId}/agent/${latestJob._id}`}
|
|
||||||
>
|
|
||||||
Open workspace
|
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{latestJob?.pullRequestUrl ? (
|
{latestJob?.pullRequestUrl ? (
|
||||||
@@ -99,60 +141,99 @@ const ThreadDetailPage = () => {
|
|||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
{canQueueRun ? (
|
||||||
variant='outline'
|
<Button disabled={queueing} onClick={() => void startRun()}>
|
||||||
onClick={() =>
|
<Play className='size-4' />
|
||||||
markResolved({ threadId }).then(() =>
|
{latestJob ? 'Rerun' : 'Start workspace run'}
|
||||||
toast.success('Thread resolved.'),
|
</Button>
|
||||||
)
|
) : null}
|
||||||
}
|
{!terminalThread ? (
|
||||||
>
|
<>
|
||||||
<CheckCircle2 className='size-4' />
|
<AlertDialog>
|
||||||
Resolve
|
<AlertDialogTrigger asChild>
|
||||||
</Button>
|
<Button variant='outline'>
|
||||||
<Button
|
<CheckCircle2 className='size-4' />
|
||||||
variant='outline'
|
Resolve
|
||||||
onClick={() =>
|
</Button>
|
||||||
cancel({ threadId }).then(() =>
|
</AlertDialogTrigger>
|
||||||
toast.success('Thread cancelled.'),
|
<AlertDialogContent>
|
||||||
)
|
<AlertDialogHeader>
|
||||||
}
|
<AlertDialogTitle>Mark thread resolved?</AlertDialogTitle>
|
||||||
>
|
<AlertDialogDescription>
|
||||||
<XCircle className='size-4' />
|
This closes the thread without deleting its history.
|
||||||
Cancel
|
</AlertDialogDescription>
|
||||||
</Button>
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Keep open</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
void markResolved({ threadId }).then(() =>
|
||||||
|
toast.success('Thread resolved.'),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Resolve thread
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant='outline'>
|
||||||
|
<XCircle className='size-4' />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Cancel this thread?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This marks the thread as cancelled. It does not delete
|
||||||
|
existing workspace history.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Keep open</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant='destructive'
|
||||||
|
onClick={() => {
|
||||||
|
void cancel({ threadId }).then(() =>
|
||||||
|
toast.success('Thread cancelled.'),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel thread
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<DeleteThreadButton threadId={threadId} redirectTo='/threads' />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid gap-6 xl:grid-cols-[1fr_320px]'>
|
<div className='grid gap-6 xl:grid-cols-[1fr_320px]'>
|
||||||
<Card className='shadow-none'>
|
<Card className='shadow-none'>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Conversation</CardTitle>
|
<CardTitle>Workspace</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4'>
|
<CardContent className='space-y-4 text-sm'>
|
||||||
{messages.map((message) => (
|
<p className='text-muted-foreground'>
|
||||||
<div
|
Threads open into a full workspace where you can review agent
|
||||||
key={message._id}
|
activity, edit files, inspect diffs, and reply to the agent.
|
||||||
className='border-border rounded-md border p-3'
|
</p>
|
||||||
>
|
{canQueueRun ? (
|
||||||
<div className='mb-2 flex items-center justify-between gap-2'>
|
<Button disabled={queueing} onClick={() => void startRun()}>
|
||||||
<Badge variant='outline'>{message.role}</Badge>
|
<Play className='size-4' />
|
||||||
<span className='text-muted-foreground text-xs'>
|
{latestJob ? 'Create new workspace run' : 'Start workspace run'}
|
||||||
{new Date(message.createdAt).toLocaleString()}
|
</Button>
|
||||||
</span>
|
) : (
|
||||||
</div>
|
<p className='text-muted-foreground'>
|
||||||
<p className='text-sm whitespace-pre-wrap'>{message.content}</p>
|
This thread does not currently have a workspace that can be
|
||||||
</div>
|
opened.
|
||||||
))}
|
</p>
|
||||||
<form onSubmit={submit} className='space-y-3'>
|
)}
|
||||||
<Textarea
|
|
||||||
name='message'
|
|
||||||
required
|
|
||||||
minLength={2}
|
|
||||||
placeholder='Add context or instructions for this thread.'
|
|
||||||
/>
|
|
||||||
<Button type='submit'>Add message</Button>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,53 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useQuery } from 'convex/react';
|
import { DeleteThreadButton } from '@/components/threads/delete-thread-button';
|
||||||
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
import { MessageSquare, Plus } from 'lucide-react';
|
import { MessageSquare, Plus } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
|
Switch,
|
||||||
|
Textarea,
|
||||||
} from '@spoon/ui';
|
} from '@spoon/ui';
|
||||||
|
|
||||||
const formatTime = (value: number) => new Date(value).toLocaleString();
|
const formatTime = (value: number) => new Date(value).toLocaleString();
|
||||||
|
|
||||||
const ThreadsPage = () => {
|
const ThreadsPage = () => {
|
||||||
|
const router = useRouter();
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
const source = params.get('source') ?? 'all';
|
const source = params.get('source') ?? 'all';
|
||||||
|
const status = params.get('status') ?? 'all';
|
||||||
|
const [spoonFilter, setSpoonFilter] = useState('all');
|
||||||
|
const [priorityFilter, setPriorityFilter] = useState('all');
|
||||||
|
const [outcomeFilter, setOutcomeFilter] = useState('all');
|
||||||
|
const [spoonId, setSpoonId] = useState('');
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [prompt, setPrompt] = useState('');
|
||||||
|
const [materializeEnvFile, setMaterializeEnvFile] = useState(false);
|
||||||
|
const [envFilePath, setEnvFilePath] = useState('.env.local');
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const createThread = useMutation(api.threads.createUserThread);
|
||||||
|
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
||||||
|
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||||
|
const defaultProfile = profiles.find((profile) => profile.isDefault);
|
||||||
const threads =
|
const threads =
|
||||||
useQuery(api.threads.listMine, {
|
useQuery(api.threads.listMine, {
|
||||||
source: source as
|
source: source as
|
||||||
@@ -32,8 +57,76 @@ const ThreadsPage = () => {
|
|||||||
| 'merge_conflict'
|
| 'merge_conflict'
|
||||||
| 'manual_review'
|
| 'manual_review'
|
||||||
| 'system',
|
| 'system',
|
||||||
|
status: status as
|
||||||
|
| 'all'
|
||||||
|
| 'open'
|
||||||
|
| 'queued'
|
||||||
|
| 'running'
|
||||||
|
| 'waiting_for_user'
|
||||||
|
| 'changes_ready'
|
||||||
|
| 'draft_pr_opened'
|
||||||
|
| 'resolved'
|
||||||
|
| 'ignored'
|
||||||
|
| 'failed'
|
||||||
|
| 'cancelled',
|
||||||
limit: 100,
|
limit: 100,
|
||||||
}) ?? [];
|
}) ?? [];
|
||||||
|
const visibleThreads = threads.filter((thread) => {
|
||||||
|
if (spoonFilter !== 'all' && thread.spoonId !== spoonFilter) return false;
|
||||||
|
if (priorityFilter !== 'all' && thread.priority !== priorityFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
outcomeFilter !== 'all' &&
|
||||||
|
(thread.maintenanceOutcome ?? 'none') !== outcomeFilter
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateFilter = (key: string, value: string) => {
|
||||||
|
const next = new URLSearchParams(params.toString());
|
||||||
|
if (value === 'all') next.delete(key);
|
||||||
|
else next.set(key, value);
|
||||||
|
router.push(next.size ? `/threads?${next.toString()}` : '/threads');
|
||||||
|
};
|
||||||
|
|
||||||
|
const threadTarget = (thread: (typeof visibleThreads)[number]) =>
|
||||||
|
`/threads/${thread._id}`;
|
||||||
|
const canDeleteThread = (thread: (typeof visibleThreads)[number]) => {
|
||||||
|
const latestJobStatus = thread.latestJobStatus;
|
||||||
|
const latestWorkspaceStatus = thread.latestJobWorkspaceStatus;
|
||||||
|
if (!latestJobStatus && !latestWorkspaceStatus) return true;
|
||||||
|
return (
|
||||||
|
['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes(
|
||||||
|
latestJobStatus ?? '',
|
||||||
|
) ||
|
||||||
|
['stopped', 'expired', 'failed'].includes(latestWorkspaceStatus ?? '')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitThread = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!spoonId || !prompt.trim()) return;
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const threadId = await createThread({
|
||||||
|
spoonId: spoonId as Id<'spoons'>,
|
||||||
|
title: title.trim() || undefined,
|
||||||
|
prompt,
|
||||||
|
materializeEnvFile,
|
||||||
|
envFilePath,
|
||||||
|
});
|
||||||
|
toast.success('Thread created.');
|
||||||
|
router.push(`/threads/${threadId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('Could not create thread.');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className='space-y-6'>
|
<main className='space-y-6'>
|
||||||
@@ -46,20 +139,97 @@ const ThreadsPage = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href='/spoons'>
|
<a href='#new-thread'>
|
||||||
<Plus className='size-4' />
|
<Plus className='size-4' />
|
||||||
New thread from Spoon
|
New thread
|
||||||
</Link>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex flex-col gap-3 md:flex-row'>
|
<Card id='new-thread' className='shadow-none'>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='text-base'>New thread</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={submitThread} className='grid gap-4 lg:grid-cols-2'>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label>Spoon</Label>
|
||||||
|
<Select value={spoonId} onValueChange={setSpoonId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Choose a Spoon' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{spoons.map((spoon) => (
|
||||||
|
<SelectItem key={spoon._id} value={spoon._id}>
|
||||||
|
{spoon.name} · {spoon.upstreamOwner}/{spoon.upstreamRepo}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label>Title</Label>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
placeholder='Optional'
|
||||||
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2 lg:col-span-2'>
|
||||||
|
<Label>Prompt</Label>
|
||||||
|
<Textarea
|
||||||
|
value={prompt}
|
||||||
|
placeholder='Describe the change, review, or maintenance task.'
|
||||||
|
required
|
||||||
|
minLength={4}
|
||||||
|
onChange={(event) => setPrompt(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
|
||||||
|
<div>
|
||||||
|
<Label>Write Spoon secrets to env file</Label>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
All Spoon secrets are always available as process env.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={materializeEnvFile}
|
||||||
|
onCheckedChange={setMaterializeEnvFile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label>Env file path</Label>
|
||||||
|
<Input
|
||||||
|
value={envFilePath}
|
||||||
|
onChange={(event) => setEnvFilePath(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='text-muted-foreground text-sm lg:col-span-2'>
|
||||||
|
Provider:{' '}
|
||||||
|
<span className='text-foreground font-medium'>
|
||||||
|
{defaultProfile
|
||||||
|
? `${defaultProfile.name} · ${defaultProfile.defaultModel}`
|
||||||
|
: 'Configure an AI provider in Settings'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='lg:col-span-2'>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
disabled={
|
||||||
|
creating || !spoonId || !prompt.trim() || !defaultProfile
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{creating ? 'Creating...' : 'Create thread'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-5'>
|
||||||
<Select
|
<Select
|
||||||
value={source}
|
value={source}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => updateFilter('source', value)}
|
||||||
window.location.href =
|
|
||||||
value === 'all' ? '/threads' : `/threads?source=${value}`;
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className='w-full md:w-56'>
|
<SelectTrigger className='w-full md:w-56'>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -73,43 +243,156 @@ const ThreadsPage = () => {
|
|||||||
<SelectItem value='system'>System</SelectItem>
|
<SelectItem value='system'>System</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={status}
|
||||||
|
onValueChange={(value) => updateFilter('status', value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='all'>All statuses</SelectItem>
|
||||||
|
<SelectItem value='open'>Open</SelectItem>
|
||||||
|
<SelectItem value='queued'>Queued</SelectItem>
|
||||||
|
<SelectItem value='running'>Running</SelectItem>
|
||||||
|
<SelectItem value='waiting_for_user'>Waiting</SelectItem>
|
||||||
|
<SelectItem value='changes_ready'>Changes ready</SelectItem>
|
||||||
|
<SelectItem value='draft_pr_opened'>Draft PR opened</SelectItem>
|
||||||
|
<SelectItem value='resolved'>Resolved</SelectItem>
|
||||||
|
<SelectItem value='ignored'>Ignored</SelectItem>
|
||||||
|
<SelectItem value='failed'>Failed</SelectItem>
|
||||||
|
<SelectItem value='cancelled'>Cancelled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={spoonFilter} onValueChange={setSpoonFilter}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='all'>All Spoons</SelectItem>
|
||||||
|
{spoons.map((spoon) => (
|
||||||
|
<SelectItem key={spoon._id} value={spoon._id}>
|
||||||
|
{spoon.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='all'>All priorities</SelectItem>
|
||||||
|
<SelectItem value='low'>Low</SelectItem>
|
||||||
|
<SelectItem value='normal'>Normal</SelectItem>
|
||||||
|
<SelectItem value='high'>High</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={outcomeFilter} onValueChange={setOutcomeFilter}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='all'>All outcomes</SelectItem>
|
||||||
|
<SelectItem value='none'>No outcome</SelectItem>
|
||||||
|
<SelectItem value='auto_synced'>Auto synced</SelectItem>
|
||||||
|
<SelectItem value='sync_recommended'>Sync recommended</SelectItem>
|
||||||
|
<SelectItem value='ignored'>Ignored</SelectItem>
|
||||||
|
<SelectItem value='review_pr_recommended'>Review PR</SelectItem>
|
||||||
|
<SelectItem value='manual_review_required'>
|
||||||
|
Manual review
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value='conflict_resolution_required'>
|
||||||
|
Conflict
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value='failed'>Failed</SelectItem>
|
||||||
|
<SelectItem value='unknown'>Unknown</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
{threads.length ? (
|
{visibleThreads.length ? (
|
||||||
threads.map((thread) => (
|
visibleThreads.map((thread) => (
|
||||||
<Link
|
<Card
|
||||||
key={thread._id}
|
key={thread._id}
|
||||||
href={`/threads/${thread._id}`}
|
role='link'
|
||||||
className='block'
|
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));
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Card className='hover:border-primary/50 shadow-none transition-colors'>
|
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
|
||||||
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
|
<div className='min-w-0'>
|
||||||
<div className='min-w-0'>
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
<div className='flex flex-wrap items-center gap-2'>
|
<h2 className='truncate font-medium'>{thread.title}</h2>
|
||||||
<h2 className='truncate font-medium'>{thread.title}</h2>
|
{thread.spoonName ? (
|
||||||
<Badge variant='outline'>
|
<Badge variant='outline'>{thread.spoonName}</Badge>
|
||||||
{thread.source.replaceAll('_', ' ')}
|
) : null}
|
||||||
|
<Badge variant='outline'>
|
||||||
|
{thread.source.replaceAll('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
|
||||||
|
{thread.maintenanceOutcome ? (
|
||||||
|
<Badge variant='secondary'>
|
||||||
|
{thread.maintenanceOutcome.replaceAll('_', ' ')}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
|
) : null}
|
||||||
{thread.maintenanceOutcome ? (
|
</div>
|
||||||
<Badge variant='secondary'>
|
<p className='text-muted-foreground mt-1 line-clamp-2 text-sm'>
|
||||||
{thread.maintenanceOutcome.replaceAll('_', ' ')}
|
{thread.summary ??
|
||||||
</Badge>
|
'No summary has been recorded for this thread yet.'}
|
||||||
) : null}
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className='text-muted-foreground mt-1 line-clamp-2 text-sm'>
|
<div className='text-muted-foreground text-xs md:text-right'>
|
||||||
{thread.summary ??
|
<p>{formatTime(thread.updatedAt)}</p>
|
||||||
'No summary has been recorded for this thread yet.'}
|
<p className='capitalize'>{thread.priority} priority</p>
|
||||||
|
{thread.latestJobStatus ? (
|
||||||
|
<p>{thread.latestJobStatus.replaceAll('_', ' ')}</p>
|
||||||
|
) : null}
|
||||||
|
{thread.latestJobWorkspaceStatus ? (
|
||||||
|
<p>
|
||||||
|
Workspace:{' '}
|
||||||
|
{thread.latestJobWorkspaceStatus.replaceAll('_', ' ')}
|
||||||
</p>
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className='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>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{thread.latestJobPullRequestUrl ? (
|
||||||
|
<Button size='sm' asChild>
|
||||||
|
<a
|
||||||
|
href={thread.latestJobPullRequestUrl}
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
PR
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<DeleteThreadButton
|
||||||
|
threadId={thread._id as Id<'threads'>}
|
||||||
|
disabled={!canDeleteThread(thread)}
|
||||||
|
label='Delete'
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='text-muted-foreground text-xs md:text-right'>
|
</div>
|
||||||
<p>{formatTime(thread.updatedAt)}</p>
|
</CardContent>
|
||||||
<p className='capitalize'>{thread.priority} priority</p>
|
</Card>
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<Card className='shadow-none'>
|
<Card className='shadow-none'>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
|
||||||
|
|
||||||
|
export const POST = async (
|
||||||
|
_request: Request,
|
||||||
|
context: { params: Promise<{ jobId: string }> },
|
||||||
|
) =>
|
||||||
|
await withOwnedJob(
|
||||||
|
context,
|
||||||
|
async (jobId) =>
|
||||||
|
await proxyWorker(jobId, 'agent/abort', { method: 'POST' }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
|
||||||
|
|
||||||
|
export const GET = async (
|
||||||
|
_request: Request,
|
||||||
|
context: { params: Promise<{ jobId: string }> },
|
||||||
|
) =>
|
||||||
|
await withOwnedJob(
|
||||||
|
context,
|
||||||
|
async (jobId) =>
|
||||||
|
await proxyWorker(jobId, 'agent/status', { method: 'GET' }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import {
|
||||||
|
proxyWorker,
|
||||||
|
requireOwnedJob,
|
||||||
|
routeJobId,
|
||||||
|
} from '@/lib/agent-worker-proxy';
|
||||||
|
|
||||||
|
export const POST = async (
|
||||||
|
request: Request,
|
||||||
|
context: { params: Promise<{ jobId: string; interactionId: string }> },
|
||||||
|
) => {
|
||||||
|
const params = await context.params;
|
||||||
|
const jobId = await routeJobId({ params });
|
||||||
|
const owned = await requireOwnedJob(jobId);
|
||||||
|
if (!owned.ok) return owned.response;
|
||||||
|
return await proxyWorker(
|
||||||
|
jobId,
|
||||||
|
`interactions/${encodeURIComponent(params.interactionId)}/reply`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: await request.text(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { mintTerminalToken, withOwnedJob } from '@/lib/agent-worker-proxy';
|
||||||
|
|
||||||
|
export const GET = async (
|
||||||
|
_request: Request,
|
||||||
|
context: { params: Promise<{ jobId: string }> },
|
||||||
|
) =>
|
||||||
|
await withOwnedJob(context, (jobId) => {
|
||||||
|
const minted = mintTerminalToken(jobId);
|
||||||
|
return Promise.resolve(
|
||||||
|
minted
|
||||||
|
? NextResponse.json(minted)
|
||||||
|
: NextResponse.json(
|
||||||
|
{ error: 'Terminal is not configured on this deployment.' },
|
||||||
|
{ status: 503 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import {
|
||||||
|
proxyWorkerRoot,
|
||||||
|
requireAuthenticatedUser,
|
||||||
|
} from '@/lib/agent-worker-proxy';
|
||||||
|
|
||||||
|
export const POST = async () => {
|
||||||
|
const authenticated = await requireAuthenticatedUser();
|
||||||
|
if (!authenticated.ok) return authenticated.response;
|
||||||
|
return await proxyWorkerRoot('/cleanup', { method: 'POST' });
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import {
|
||||||
|
proxyWorkerRoot,
|
||||||
|
requireAuthenticatedUser,
|
||||||
|
} from '@/lib/agent-worker-proxy';
|
||||||
|
|
||||||
|
export const GET = async () => {
|
||||||
|
const authenticated = await requireAuthenticatedUser();
|
||||||
|
if (!authenticated.ok) return authenticated.response;
|
||||||
|
return await proxyWorkerRoot('/health', { method: 'GET' });
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { proxyWorker } from '@/lib/agent-worker-proxy';
|
||||||
|
import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server';
|
||||||
|
import { fetchMutation, fetchQuery } from 'convex/nextjs';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
const activeJobStatuses = new Set([
|
||||||
|
'claimed',
|
||||||
|
'preparing',
|
||||||
|
'running',
|
||||||
|
'checks_running',
|
||||||
|
'changes_ready',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const activeWorkspaceStatuses = new Set(['active', 'idle']);
|
||||||
|
|
||||||
|
export const POST = async (
|
||||||
|
request: Request,
|
||||||
|
context: { params: Promise<{ threadId: string }> },
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const token = await convexAuthNextjsToken();
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const { threadId: rawThreadId } = await context.params;
|
||||||
|
const threadId = rawThreadId as Id<'threads'>;
|
||||||
|
const body = (await request.json()) as { content?: string };
|
||||||
|
const content = body.content?.trim() ?? '';
|
||||||
|
if (!content) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Message is required.' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const details = await fetchQuery(api.threads.get, { threadId }, { token });
|
||||||
|
const latestJob = details.latestJob;
|
||||||
|
const canSendToWorker =
|
||||||
|
latestJob &&
|
||||||
|
activeJobStatuses.has(latestJob.status) &&
|
||||||
|
activeWorkspaceStatuses.has(latestJob.workspaceStatus ?? '');
|
||||||
|
|
||||||
|
if (!canSendToWorker) {
|
||||||
|
await fetchMutation(
|
||||||
|
api.threads.appendUserMessage,
|
||||||
|
{ threadId, content },
|
||||||
|
{ token },
|
||||||
|
);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
mode: 'note',
|
||||||
|
message: latestJob
|
||||||
|
? 'Message was added as a thread note because the latest workspace is not active.'
|
||||||
|
: 'Message was added as a thread note.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxied = await proxyWorker(latestJob._id, 'message', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
});
|
||||||
|
if (!proxied.ok) {
|
||||||
|
const text = await proxied.text();
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: text,
|
||||||
|
recoverable:
|
||||||
|
text.includes('workspace is not active') ||
|
||||||
|
text.includes('not active on this worker'),
|
||||||
|
},
|
||||||
|
{ status: proxied.status === 500 ? 409 : proxied.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return proxied;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { Geist, Geist_Mono } from 'next/font/google';
|
import { Geist, Geist_Mono, Victor_Mono } from 'next/font/google';
|
||||||
import { env } from '@/env';
|
import { env } from '@/env';
|
||||||
|
|
||||||
import '@/app/styles.css';
|
import '@/app/styles.css';
|
||||||
@@ -30,6 +30,13 @@ const geistMono = Geist_Mono({
|
|||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
variable: '--font-geist-mono',
|
variable: '--font-geist-mono',
|
||||||
});
|
});
|
||||||
|
// Used by the workspace code editor (and, later, the terminal). Includes the
|
||||||
|
// italic cursive style for comments via Monaco's italic token styling.
|
||||||
|
const victorMono = Victor_Mono({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-victor-mono',
|
||||||
|
display: 'swap',
|
||||||
|
});
|
||||||
|
|
||||||
const RootLayout = ({
|
const RootLayout = ({
|
||||||
children,
|
children,
|
||||||
@@ -44,7 +51,7 @@ const RootLayout = ({
|
|||||||
>
|
>
|
||||||
<html lang='en' suppressHydrationWarning>
|
<html lang='en' suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} ${victorMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute='class'
|
attribute='class'
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
@import '@spoon/tailwind-config/theme';
|
@import '@spoon/tailwind-config/theme';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Nerd Font icons for the workspace terminal + editor. Scoped to the Nerd Font
|
||||||
|
* glyph ranges via unicode-range, so the ~1MB file is only fetched when an icon
|
||||||
|
* actually renders (latin text stays on Victor Mono). Used as a fallback in the
|
||||||
|
* terminal/editor font stacks.
|
||||||
|
*/
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Symbols Nerd Font Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/SymbolsNerdFontMono.woff2') format('woff2');
|
||||||
|
unicode-range:
|
||||||
|
U+23fb-23fe, U+2665, U+26a1, U+2b58, U+e000-f8ff, U+f0000-fffff;
|
||||||
|
}
|
||||||
|
|
||||||
@source '../../../../packages/ui/src/**/*.{ts,tsx}';
|
@source '../../../../packages/ui/src/**/*.{ts,tsx}';
|
||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|||||||
@@ -1,23 +1,135 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Send } from 'lucide-react';
|
import {
|
||||||
|
Ban,
|
||||||
|
FilePenLine,
|
||||||
|
MessagesSquare,
|
||||||
|
Send,
|
||||||
|
Terminal,
|
||||||
|
TriangleAlert,
|
||||||
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
import { Button, Textarea } from '@spoon/ui';
|
import { Badge, Button, Textarea } from '@spoon/ui';
|
||||||
|
|
||||||
|
import { DiffFileView, useDiffTheme } from './diff-file-view';
|
||||||
|
import { parseDiffFileForPath } from './diff-utils';
|
||||||
|
|
||||||
|
type ActivityFilter = 'all' | 'chat' | 'activity' | 'files' | 'errors';
|
||||||
|
|
||||||
|
const filters: { value: ActivityFilter; label: string }[] = [
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'chat', label: 'Chat' },
|
||||||
|
{ value: 'activity', label: 'Tools' },
|
||||||
|
{ value: 'files', label: 'Files' },
|
||||||
|
{ value: 'errors', label: 'Errors' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const formatEventTime = (value: number) =>
|
||||||
|
new Date(value).toLocaleTimeString([], {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventIcon = (event: Doc<'agentJobEvents'>) => {
|
||||||
|
if (event.level === 'error') return <TriangleAlert className='size-3' />;
|
||||||
|
if (event.phase === 'edit') return <FilePenLine className='size-3' />;
|
||||||
|
if (event.phase === 'check' || event.phase === 'test') {
|
||||||
|
return <Terminal className='size-3' />;
|
||||||
|
}
|
||||||
|
return <MessagesSquare className='size-3' />;
|
||||||
|
};
|
||||||
|
|
||||||
export const AgentThread = ({
|
export const AgentThread = ({
|
||||||
jobId,
|
jobId,
|
||||||
messages,
|
messages,
|
||||||
|
events,
|
||||||
|
interactions,
|
||||||
|
workspaceChanges,
|
||||||
disabled,
|
disabled,
|
||||||
|
agentTurnActive,
|
||||||
|
onOpenFile,
|
||||||
|
onOpenDiff,
|
||||||
}: {
|
}: {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
messages: Doc<'agentJobMessages'>[];
|
messages: Doc<'agentJobMessages'>[];
|
||||||
|
events: Doc<'agentJobEvents'>[];
|
||||||
|
interactions: Doc<'agentInteractionRequests'>[];
|
||||||
|
workspaceChanges: Doc<'agentWorkspaceChanges'>[];
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
agentTurnActive: boolean;
|
||||||
|
onOpenFile: (path: string) => void;
|
||||||
|
onOpenDiff: (path: string) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const [replying, setReplying] = useState<string>();
|
||||||
|
const [filter, setFilter] = useState<ActivityFilter>('all');
|
||||||
|
const diffTheme = useDiffTheme();
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const chatMessages = useMemo(
|
||||||
|
() =>
|
||||||
|
messages.filter((message) => {
|
||||||
|
if (message.role === 'system') return false;
|
||||||
|
if (message.role === 'tool') return false;
|
||||||
|
if (message.role === 'assistant' && !message.content.trim()) {
|
||||||
|
return message.status === 'streaming' && agentTurnActive;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
[agentTurnActive, messages],
|
||||||
|
);
|
||||||
|
const toolMessages = useMemo(
|
||||||
|
() =>
|
||||||
|
messages.filter(
|
||||||
|
(message) => message.role === 'tool' && message.content.trim(),
|
||||||
|
),
|
||||||
|
[messages],
|
||||||
|
);
|
||||||
|
const failedMessages = useMemo(
|
||||||
|
() => chatMessages.filter((message) => message.status === 'failed'),
|
||||||
|
[chatMessages],
|
||||||
|
);
|
||||||
|
const errorEvents = useMemo(
|
||||||
|
() => events.filter((event) => event.level === 'error'),
|
||||||
|
[events],
|
||||||
|
);
|
||||||
|
const visibleMessages =
|
||||||
|
filter === 'activity' || filter === 'files' || filter === 'errors'
|
||||||
|
? filter === 'errors'
|
||||||
|
? failedMessages
|
||||||
|
: []
|
||||||
|
: chatMessages;
|
||||||
|
const visibleToolMessages =
|
||||||
|
filter === 'all' || filter === 'activity' ? toolMessages : [];
|
||||||
|
const visibleEvents = filter === 'errors' ? errorEvents : [];
|
||||||
|
const visibleChanges =
|
||||||
|
filter === 'chat' || filter === 'activity' || filter === 'errors'
|
||||||
|
? []
|
||||||
|
: workspaceChanges;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const node = scrollRef.current;
|
||||||
|
if (!node) return;
|
||||||
|
const distanceFromBottom =
|
||||||
|
node.scrollHeight - node.scrollTop - node.clientHeight;
|
||||||
|
if (distanceFromBottom < 160 || agentTurnActive) {
|
||||||
|
if (typeof node.scrollTo === 'function') {
|
||||||
|
node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' });
|
||||||
|
} else {
|
||||||
|
node.scrollTop = node.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
agentTurnActive,
|
||||||
|
events.length,
|
||||||
|
interactions.length,
|
||||||
|
messages.length,
|
||||||
|
workspaceChanges.length,
|
||||||
|
]);
|
||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
if (!content.trim()) return;
|
if (!content.trim()) return;
|
||||||
@@ -37,36 +149,300 @@ export const AgentThread = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const abort = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/agent-jobs/${jobId}/agent/abort`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
toast.success('Agent turn aborted.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('Could not abort agent.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reply = async (
|
||||||
|
interaction: Doc<'agentInteractionRequests'>,
|
||||||
|
responseValue: string,
|
||||||
|
) => {
|
||||||
|
setReplying(interaction._id);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/agent-jobs/${jobId}/interactions/${interaction._id}/reply`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
externalRequestId: interaction.externalRequestId,
|
||||||
|
response: responseValue,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
toast.success('Response sent.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('Could not answer interaction.');
|
||||||
|
} finally {
|
||||||
|
setReplying(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex h-full min-h-[520px] flex-col'>
|
<div className='flex h-full min-h-0 flex-col overflow-hidden'>
|
||||||
<div className='border-border border-b p-3'>
|
<div className='border-border flex flex-none items-start justify-between gap-3 border-b p-3'>
|
||||||
<h2 className='text-sm font-semibold'>Agent thread</h2>
|
<div>
|
||||||
<p className='text-muted-foreground text-xs'>
|
<div className='flex items-center gap-2'>
|
||||||
Messages persist with this workspace.
|
<h2 className='text-sm font-semibold'>Agent thread</h2>
|
||||||
</p>
|
{agentTurnActive ? (
|
||||||
|
<Badge variant='secondary'>Working</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Messages, tool activity, and requests persist with this workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
disabled={disabled || !agentTurnActive}
|
||||||
|
onClick={abort}
|
||||||
|
>
|
||||||
|
<Ban className='size-3' />
|
||||||
|
Abort
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className='min-h-0 flex-1 space-y-3 overflow-auto p-3'>
|
<div className='border-border flex flex-none gap-1 overflow-x-auto border-b px-3 py-2'>
|
||||||
{messages.map((message) => (
|
{filters.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.value}
|
||||||
|
type='button'
|
||||||
|
variant={filter === item.value ? 'secondary' : 'ghost'}
|
||||||
|
size='sm'
|
||||||
|
className='h-7 flex-none text-xs'
|
||||||
|
onClick={() => setFilter(item.value)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className='min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain p-3'
|
||||||
|
>
|
||||||
|
{(filter === 'all' || filter === 'chat') && interactions.length > 0
|
||||||
|
? interactions.map((interaction) => (
|
||||||
|
<article
|
||||||
|
key={interaction._id}
|
||||||
|
className='border-primary/40 bg-primary/5 rounded-md border p-3 text-sm'
|
||||||
|
>
|
||||||
|
<div className='mb-2 flex items-center justify-between gap-2'>
|
||||||
|
<span className='font-medium'>{interaction.title}</span>
|
||||||
|
<Badge variant='outline' className='capitalize'>
|
||||||
|
{interaction.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className='text-sm whitespace-pre-wrap'>
|
||||||
|
{interaction.body}
|
||||||
|
</p>
|
||||||
|
{interaction.status === 'pending' ? (
|
||||||
|
<div className='mt-3 flex gap-2'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
size='sm'
|
||||||
|
disabled={replying === interaction._id}
|
||||||
|
onClick={() => void reply(interaction, 'once')}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
size='sm'
|
||||||
|
variant='outline'
|
||||||
|
disabled={replying === interaction._id}
|
||||||
|
onClick={() => void reply(interaction, 'reject')}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
{visibleMessages.map((message) => (
|
||||||
|
<article
|
||||||
|
key={message._id}
|
||||||
|
className={
|
||||||
|
message.role === 'user'
|
||||||
|
? 'border-border bg-muted ml-6 rounded-md border p-3 text-sm'
|
||||||
|
: message.status === 'failed'
|
||||||
|
? 'border-destructive/40 bg-destructive/5 rounded-md border p-3 text-sm'
|
||||||
|
: 'border-border bg-background rounded-md border p-3 text-sm'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className='mb-2 flex items-center justify-between gap-2'>
|
||||||
|
<span className='font-medium'>
|
||||||
|
{message.role === 'assistant' ? 'Agent' : 'You'}
|
||||||
|
</span>
|
||||||
|
{message.status === 'failed' || message.status === 'streaming' ? (
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
message.status === 'failed' ? 'destructive' : 'outline'
|
||||||
|
}
|
||||||
|
className='capitalize'
|
||||||
|
>
|
||||||
|
{message.status === 'streaming' ? 'Working' : 'Failed'}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className='whitespace-pre-wrap'>
|
||||||
|
{message.content ||
|
||||||
|
(message.status === 'streaming' ? 'Working...' : '')}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
{visibleToolMessages.map((message) => (
|
||||||
<article
|
<article
|
||||||
key={message._id}
|
key={message._id}
|
||||||
className='border-border bg-background rounded-md border p-3 text-sm'
|
className='border-border bg-background rounded-md border p-3 text-sm'
|
||||||
>
|
>
|
||||||
<div className='mb-2 flex items-center justify-between gap-2'>
|
<div className='mb-2 flex items-center gap-2'>
|
||||||
<span className='font-medium capitalize'>{message.role}</span>
|
<Terminal className='text-primary size-4' />
|
||||||
<span className='text-muted-foreground text-xs capitalize'>
|
<span className='font-medium'>Tool</span>
|
||||||
{message.status}
|
{message.status === 'streaming' ? (
|
||||||
</span>
|
<Badge variant='outline'>Running</Badge>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className='whitespace-pre-wrap'>{message.content}</p>
|
<pre className='text-muted-foreground max-h-56 overflow-auto text-xs whitespace-pre-wrap'>
|
||||||
|
{message.content}
|
||||||
|
</pre>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
{visibleChanges.map((change) => {
|
||||||
|
const changedFile = parseDiffFileForPath(change.diff, change.path);
|
||||||
|
const hasDiff = Boolean(changedFile && !changedFile.isBinary);
|
||||||
|
const hasRenderableHunk = Boolean(
|
||||||
|
changedFile && hasDiff && changedFile.hunkText.includes('@@'),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={change._id}
|
||||||
|
className='border-border bg-background rounded-md border p-3 text-sm'
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-between gap-3'>
|
||||||
|
<div className='min-w-0'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<FilePenLine className='text-primary size-4 flex-none' />
|
||||||
|
<span className='truncate font-mono text-xs'>
|
||||||
|
{change.path}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className='text-muted-foreground mt-1 text-xs capitalize'>
|
||||||
|
{change.source} {change.changeType}
|
||||||
|
{changedFile ? (
|
||||||
|
<span className='ml-2 font-mono normal-case'>
|
||||||
|
<span className='text-emerald-500'>
|
||||||
|
+{changedFile.additions}
|
||||||
|
</span>{' '}
|
||||||
|
<span className='text-red-500'>
|
||||||
|
−{changedFile.deletions}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-none items-center gap-2'>
|
||||||
|
{hasDiff ? (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => onOpenDiff(change.path)}
|
||||||
|
>
|
||||||
|
View diff
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{change.path !== '.' ? (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => onOpenFile(change.path)}
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasRenderableHunk && changedFile ? (
|
||||||
|
<details className='mt-3'>
|
||||||
|
<summary className='text-muted-foreground cursor-pointer text-xs'>
|
||||||
|
File diff
|
||||||
|
</summary>
|
||||||
|
<div className='border-border mt-2 max-h-72 overflow-auto rounded border'>
|
||||||
|
<DiffFileView
|
||||||
|
file={changedFile}
|
||||||
|
mode='unified'
|
||||||
|
theme={diffTheme}
|
||||||
|
fontSize={11}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{visibleEvents.slice(-80).map((event) => (
|
||||||
|
<article
|
||||||
|
key={event._id}
|
||||||
|
className={
|
||||||
|
event.level === 'error'
|
||||||
|
? 'border-destructive/40 bg-destructive/5 rounded-md border p-2 text-xs'
|
||||||
|
: 'border-border text-muted-foreground rounded-md border border-dashed p-2 text-xs'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-between gap-2'>
|
||||||
|
<span className='flex min-w-0 items-center gap-1 font-medium capitalize'>
|
||||||
|
{eventIcon(event)}
|
||||||
|
{event.phase} / {event.level}
|
||||||
|
</span>
|
||||||
|
<span>{formatEventTime(event.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<p className='mt-1 whitespace-pre-wrap'>{event.message}</p>
|
||||||
|
{event.metadata ? (
|
||||||
|
<details className='mt-2'>
|
||||||
|
<summary className='cursor-pointer'>Details</summary>
|
||||||
|
<pre className='bg-muted mt-1 max-h-40 overflow-auto rounded p-2 whitespace-pre-wrap'>
|
||||||
|
{event.metadata}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
{visibleMessages.length === 0 &&
|
||||||
|
visibleToolMessages.length === 0 &&
|
||||||
|
visibleEvents.length === 0 &&
|
||||||
|
visibleChanges.length === 0 &&
|
||||||
|
(filter !== 'chat' || interactions.length === 0) ? (
|
||||||
|
<p className='text-muted-foreground p-3 text-sm'>
|
||||||
|
No {filter === 'all' ? 'agent activity' : filter} has been recorded
|
||||||
|
yet.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className='border-border space-y-2 border-t p-3'>
|
<div className='border-border flex-none space-y-2 border-t p-3'>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={content}
|
value={content}
|
||||||
placeholder='Ask the agent to inspect, explain, or change this fork.'
|
placeholder='Ask the agent to inspect, explain, or change this fork.'
|
||||||
disabled={disabled || sending}
|
disabled={disabled || sending}
|
||||||
onChange={(event) => setContent(event.target.value)}
|
onChange={(event) => setContent(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
void send();
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type='button'
|
type='button'
|
||||||
|
|||||||
@@ -1,30 +1,99 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useQuery } from 'convex/react';
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
|
import {
|
||||||
|
FileCode,
|
||||||
|
GitCompare,
|
||||||
|
Loader2,
|
||||||
|
MessagesSquare,
|
||||||
|
SquareTerminal,
|
||||||
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
Button,
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from '@spoon/ui';
|
||||||
|
|
||||||
import type { DiffResponse, FileResponse, FileTreeNode } from './types';
|
import type { DiffResponse, FileResponse, FileTreeNode } from './types';
|
||||||
import { AgentThread } from './agent-thread';
|
import { AgentThread } from './agent-thread';
|
||||||
import { CodeEditor } from './code-editor';
|
import { CodeEditor } from './code-editor';
|
||||||
import { CommandPanel } from './command-panel';
|
import { CommandPanel } from './command-panel';
|
||||||
import { DiffViewer } from './diff-viewer';
|
import { DiffViewer } from './diff-viewer';
|
||||||
|
import { FileTabs } from './file-tabs';
|
||||||
import { FileTree } from './file-tree';
|
import { FileTree } from './file-tree';
|
||||||
import { JobStatusBar } from './job-status-bar';
|
import { JobStatusBar } from './job-status-bar';
|
||||||
import { WorkspaceActions } from './workspace-actions';
|
import { WorkspaceActions } from './workspace-actions';
|
||||||
|
import { WorkspaceTerminal } from './workspace-terminal';
|
||||||
|
|
||||||
|
type WorkspaceTab = 'editor' | 'diff' | 'thread' | 'terminal';
|
||||||
|
|
||||||
|
type OpenFileState = {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
savedContent: string;
|
||||||
|
loading: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingOverwrite = {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||||
const job = useQuery(api.agentJobs.get, { jobId });
|
const job = useQuery(api.agentJobs.get, { jobId });
|
||||||
const messages =
|
const messages =
|
||||||
useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? [];
|
useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? [];
|
||||||
|
const events =
|
||||||
|
useQuery(api.agentJobs.listEvents, { jobId, limit: 200 }) ?? [];
|
||||||
|
const workspaceChanges =
|
||||||
|
useQuery(api.agentJobs.listWorkspaceChanges, { jobId, limit: 200 }) ?? [];
|
||||||
|
const interactions =
|
||||||
|
useQuery(api.agentJobs.listInteractionRequests, {
|
||||||
|
jobId,
|
||||||
|
status: 'all',
|
||||||
|
}) ?? [];
|
||||||
|
const uiState = useQuery(api.agentJobs.getWorkspaceUiState, { jobId });
|
||||||
|
const patchUiState = useMutation(api.agentJobs.patchWorkspaceUiState);
|
||||||
|
const createJobForThread = useMutation(api.agentJobs.createForThread);
|
||||||
|
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
|
||||||
|
const markWorkspaceLost = useMutation(api.agentJobs.markWorkspaceLost);
|
||||||
const [tree, setTree] = useState<FileTreeNode | null>(null);
|
const [tree, setTree] = useState<FileTreeNode | null>(null);
|
||||||
const [selectedPath, setSelectedPath] = useState<string>();
|
const [files, setFiles] = useState<Record<string, OpenFileState>>({});
|
||||||
const [fileContent, setFileContent] = useState('');
|
const [openFilePaths, setOpenFilePaths] = useState<string[]>([]);
|
||||||
|
const [activeFilePath, setActiveFilePath] = useState<string>();
|
||||||
|
const [expandedDirectoryPaths, setExpandedDirectoryPaths] = useState<
|
||||||
|
string[]
|
||||||
|
>([]);
|
||||||
|
const [agentThreadWidth, setAgentThreadWidth] = useState(420);
|
||||||
|
const [vimEnabled, setVimEnabled] = useState(false);
|
||||||
|
const [hydratedUiState, setHydratedUiState] = useState(false);
|
||||||
const [diff, setDiff] = useState('');
|
const [diff, setDiff] = useState('');
|
||||||
|
const [focusedDiffPath, setFocusedDiffPath] = useState<string>();
|
||||||
|
const [workspaceError, setWorkspaceError] = useState<string>();
|
||||||
|
const [agentTurnActive, setAgentTurnActive] = useState(false);
|
||||||
|
const [activeWorkspaceTab, setActiveWorkspaceTab] =
|
||||||
|
useState<WorkspaceTab>('editor');
|
||||||
|
const [pendingOverwrite, setPendingOverwrite] = useState<PendingOverwrite>();
|
||||||
|
const [pendingClosePath, setPendingClosePath] = useState<string>();
|
||||||
|
|
||||||
const workspaceDisabled =
|
const workspaceDisabled =
|
||||||
!job ||
|
!job ||
|
||||||
@@ -33,10 +102,30 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
) ||
|
) ||
|
||||||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
|
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
|
||||||
|
|
||||||
|
// The worker only exposes the live runtime (tree/diff/file endpoints) once it
|
||||||
|
// has claimed the job and materialized the workspace (workspaceStatus
|
||||||
|
// 'active'/'idle'). Before that, hitting those endpoints returns "workspace is
|
||||||
|
// not active" — which is expected startup, not an error.
|
||||||
|
const workspaceReady = ['active', 'idle'].includes(
|
||||||
|
job?.workspaceStatus ?? '',
|
||||||
|
);
|
||||||
|
const workspaceFailed =
|
||||||
|
['failed', 'cancelled', 'timed_out'].includes(job?.status ?? '') ||
|
||||||
|
['stopped', 'expired', 'failed'].includes(job?.workspaceStatus ?? '');
|
||||||
|
// Waiting for a worker to pick up the job and start the runtime.
|
||||||
|
const workspacePending =
|
||||||
|
Boolean(job) &&
|
||||||
|
!workspaceReady &&
|
||||||
|
!workspaceFailed &&
|
||||||
|
['queued', 'claimed', 'preparing', 'running', 'checks_running'].includes(
|
||||||
|
job?.status ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
const loadTree = useCallback(async () => {
|
const loadTree = useCallback(async () => {
|
||||||
const response = await fetch(`/api/agent-jobs/${jobId}/tree`);
|
const response = await fetch(`/api/agent-jobs/${jobId}/tree`);
|
||||||
if (!response.ok) throw new Error(await response.text());
|
if (!response.ok) throw new Error(await response.text());
|
||||||
const data = (await response.json()) as { tree: FileTreeNode | null };
|
const data = (await response.json()) as { tree: FileTreeNode | null };
|
||||||
|
setWorkspaceError(undefined);
|
||||||
setTree(data.tree);
|
setTree(data.tree);
|
||||||
}, [jobId]);
|
}, [jobId]);
|
||||||
|
|
||||||
@@ -44,34 +133,189 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
const response = await fetch(`/api/agent-jobs/${jobId}/diff`);
|
const response = await fetch(`/api/agent-jobs/${jobId}/diff`);
|
||||||
if (!response.ok) throw new Error(await response.text());
|
if (!response.ok) throw new Error(await response.text());
|
||||||
const data = (await response.json()) as DiffResponse;
|
const data = (await response.json()) as DiffResponse;
|
||||||
|
setWorkspaceError(undefined);
|
||||||
setDiff(data.diff);
|
setDiff(data.diff);
|
||||||
}, [jobId]);
|
}, [jobId]);
|
||||||
|
|
||||||
|
const loadAgentStatus = useCallback(async () => {
|
||||||
|
const response = await fetch(`/api/agent-jobs/${jobId}/agent/status`);
|
||||||
|
if (!response.ok) {
|
||||||
|
setAgentTurnActive(false);
|
||||||
|
const body = await response.text();
|
||||||
|
if (body.includes('workspace is not active')) {
|
||||||
|
setWorkspaceError(body);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { active?: boolean };
|
||||||
|
setWorkspaceError(undefined);
|
||||||
|
setAgentTurnActive(Boolean(data.active));
|
||||||
|
}, [jobId]);
|
||||||
|
|
||||||
const loadFile = useCallback(
|
const loadFile = useCallback(
|
||||||
async (path: string) => {
|
async (path: string) => {
|
||||||
|
setFiles((current) => ({
|
||||||
|
...current,
|
||||||
|
[path]: current[path] ?? {
|
||||||
|
path,
|
||||||
|
content: '',
|
||||||
|
savedContent: '',
|
||||||
|
loading: true,
|
||||||
|
saving: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`,
|
`/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`,
|
||||||
);
|
);
|
||||||
if (!response.ok) throw new Error(await response.text());
|
if (!response.ok) throw new Error(await response.text());
|
||||||
const data = (await response.json()) as FileResponse;
|
const data = (await response.json()) as FileResponse;
|
||||||
setSelectedPath(data.path);
|
setFiles((current) => ({
|
||||||
setFileContent(data.content);
|
...current,
|
||||||
|
[data.path]: {
|
||||||
|
path: data.path,
|
||||||
|
content: data.content,
|
||||||
|
savedContent: data.content,
|
||||||
|
loading: false,
|
||||||
|
saving: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
[jobId],
|
[jobId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!job) return;
|
if (!workspaceReady) return;
|
||||||
|
const handleError = (error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
setWorkspaceError(error instanceof Error ? error.message : String(error));
|
||||||
|
};
|
||||||
const timeout = window.setTimeout(() => {
|
const timeout = window.setTimeout(() => {
|
||||||
void loadTree().catch((error: unknown) => {
|
void loadTree().catch(handleError);
|
||||||
console.error(error);
|
void loadDiff().catch(handleError);
|
||||||
});
|
void loadAgentStatus();
|
||||||
void loadDiff().catch((error: unknown) => {
|
|
||||||
console.error(error);
|
|
||||||
});
|
|
||||||
}, 0);
|
}, 0);
|
||||||
return () => window.clearTimeout(timeout);
|
return () => window.clearTimeout(timeout);
|
||||||
}, [job, loadDiff, loadTree]);
|
}, [workspaceReady, loadAgentStatus, loadDiff, loadTree]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspaceReady) return;
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
void loadAgentStatus();
|
||||||
|
}, 5_000);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [workspaceReady, loadAgentStatus]);
|
||||||
|
|
||||||
|
// Surface a gentle "taking longer than usual" hint if a worker never picks the
|
||||||
|
// job up (e.g. the worker is offline) instead of spinning forever.
|
||||||
|
const [pendingTooLong, setPendingTooLong] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspacePending) return;
|
||||||
|
const timer = window.setTimeout(() => setPendingTooLong(true), 90_000);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
setPendingTooLong(false);
|
||||||
|
};
|
||||||
|
}, [workspacePending]);
|
||||||
|
|
||||||
|
// Refresh the tree and diff whenever the agent records a workspace change
|
||||||
|
// (file edit / tool call that touched files) or a turn starts/ends, so the
|
||||||
|
// diff viewer stays current without a manual refresh. Rapid bursts of changes
|
||||||
|
// debounce into a single reload via the timeout cleanup.
|
||||||
|
const workspaceChangeSignature = workspaceChanges.reduce(
|
||||||
|
(latest, change) => Math.max(latest, change._creationTime),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspaceReady) return;
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
void loadDiff().catch(() => undefined);
|
||||||
|
void loadTree().catch(() => undefined);
|
||||||
|
}, 200);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [
|
||||||
|
workspaceChangeSignature,
|
||||||
|
agentTurnActive,
|
||||||
|
workspaceReady,
|
||||||
|
loadDiff,
|
||||||
|
loadTree,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!uiState || hydratedUiState) return;
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
setOpenFilePaths(uiState.openFilePaths);
|
||||||
|
setActiveFilePath(uiState.activeFilePath);
|
||||||
|
setExpandedDirectoryPaths(uiState.expandedDirectoryPaths);
|
||||||
|
setAgentThreadWidth(uiState.agentThreadWidth ?? 420);
|
||||||
|
setVimEnabled(uiState.vimEnabled);
|
||||||
|
setHydratedUiState(true);
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [hydratedUiState, uiState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydratedUiState) return;
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
void patchUiState({
|
||||||
|
jobId,
|
||||||
|
openFilePaths,
|
||||||
|
activeFilePath,
|
||||||
|
vimEnabled,
|
||||||
|
expandedDirectoryPaths,
|
||||||
|
agentThreadWidth,
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
}, 400);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [
|
||||||
|
activeFilePath,
|
||||||
|
expandedDirectoryPaths,
|
||||||
|
agentThreadWidth,
|
||||||
|
hydratedUiState,
|
||||||
|
jobId,
|
||||||
|
openFilePaths,
|
||||||
|
patchUiState,
|
||||||
|
vimEnabled,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hydratedUiState) return;
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
for (const path of openFilePaths) {
|
||||||
|
if (!files[path]) {
|
||||||
|
void loadFile(path).catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [files, hydratedUiState, loadFile, openFilePaths]);
|
||||||
|
|
||||||
if (job === undefined) {
|
if (job === undefined) {
|
||||||
return (
|
return (
|
||||||
@@ -79,28 +323,249 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveFile = async (content: string) => {
|
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
|
||||||
if (!selectedPath) return;
|
const recoverWorkspace = async () => {
|
||||||
|
if (!job.threadId) return;
|
||||||
|
await createJobForThread({
|
||||||
|
threadId: job.threadId,
|
||||||
|
jobType: job.jobType ?? 'user_change',
|
||||||
|
});
|
||||||
|
window.location.href = `/threads/${job.threadId}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteStaleWorkspace = async () => {
|
||||||
|
await markWorkspaceLost({ jobId });
|
||||||
|
await deleteWorkspace({ jobId });
|
||||||
|
window.location.href = job.threadId
|
||||||
|
? `/threads/${job.threadId}`
|
||||||
|
: `/spoons/${job.spoonId}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
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/agent-jobs/${jobId}/file`, {
|
const response = await fetch(`/api/agent-jobs/${jobId}/file`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ path: selectedPath, content }),
|
body: JSON.stringify({ path, content }),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error('Could not save file.');
|
toast.error('Could not save file.');
|
||||||
|
setFiles((current) => ({
|
||||||
|
...current,
|
||||||
|
[path]: {
|
||||||
|
...(current[path] ?? {
|
||||||
|
path,
|
||||||
|
content,
|
||||||
|
savedContent: '',
|
||||||
|
loading: false,
|
||||||
|
}),
|
||||||
|
saving: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
throw new Error(await response.text());
|
throw new Error(await response.text());
|
||||||
}
|
}
|
||||||
setFileContent(content);
|
setFiles((current) => ({
|
||||||
|
...current,
|
||||||
|
[path]: {
|
||||||
|
...(current[path] ?? {
|
||||||
|
path,
|
||||||
|
loading: false,
|
||||||
|
}),
|
||||||
|
content,
|
||||||
|
savedContent: content,
|
||||||
|
saving: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
await loadDiff();
|
await loadDiff();
|
||||||
toast.success('File saved.');
|
toast.success('File saved.');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveFile = async (content: string) => {
|
||||||
|
if (!activeFilePath) return;
|
||||||
|
const path = activeFilePath;
|
||||||
|
const activeFileBeforeSave = files[path];
|
||||||
|
if (activeFileBeforeSave) {
|
||||||
|
const latestResponse = await fetch(
|
||||||
|
`/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`,
|
||||||
|
);
|
||||||
|
if (latestResponse.ok) {
|
||||||
|
const latestData = (await latestResponse.json()) as FileResponse;
|
||||||
|
if (latestData.content !== activeFileBeforeSave.savedContent) {
|
||||||
|
setPendingOverwrite({
|
||||||
|
path,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeFileContent(path, content);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeFileUnchecked = (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 closeFile = (path: string) => {
|
||||||
|
const file = files[path];
|
||||||
|
if (file && file.content !== file.savedContent) {
|
||||||
|
setPendingClosePath(path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeFileUnchecked(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDirectory = (path: string) => {
|
||||||
|
setExpandedDirectoryPaths((current) =>
|
||||||
|
current.includes(path)
|
||||||
|
? current.filter((directoryPath) => directoryPath !== path)
|
||||||
|
: [...current, path],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFileFromActivity = (path: string) => {
|
||||||
|
openFile(path);
|
||||||
|
setActiveWorkspaceTab('editor');
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDiffFromActivity = (path: string) => {
|
||||||
|
setFocusedDiffPath(path);
|
||||||
|
setActiveWorkspaceTab('diff');
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeAgentThread = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const startX = event.clientX;
|
||||||
|
const startWidth = agentThreadWidth;
|
||||||
|
const move = (moveEvent: PointerEvent) => {
|
||||||
|
const nextWidth = Math.min(
|
||||||
|
Math.max(startWidth - (moveEvent.clientX - startX), 320),
|
||||||
|
720,
|
||||||
|
);
|
||||||
|
setAgentThreadWidth(Math.round(nextWidth));
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'>
|
<main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'>
|
||||||
<JobStatusBar job={job} />
|
<JobStatusBar job={job} />
|
||||||
|
{workspacePending && !workspaceError ? (
|
||||||
|
<div className='border-border bg-background border-b p-4'>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 rounded-md border p-4 ${
|
||||||
|
pendingTooLong
|
||||||
|
? 'border-amber-500/40 bg-amber-500/5'
|
||||||
|
: 'border-border bg-muted/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Loader2 className='text-muted-foreground size-5 flex-none animate-spin' />
|
||||||
|
<div>
|
||||||
|
<p className='font-medium'>
|
||||||
|
{pendingTooLong
|
||||||
|
? 'Still waiting for a worker…'
|
||||||
|
: 'Setting up your workspace…'}
|
||||||
|
</p>
|
||||||
|
<p className='text-muted-foreground text-sm'>
|
||||||
|
{pendingTooLong
|
||||||
|
? 'This is taking longer than usual — the worker may be busy or offline. It will start automatically once a worker is available.'
|
||||||
|
: 'Waiting for a worker to pick up this job. Files and diffs will appear automatically once the agent starts.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{workspaceError ? (
|
||||||
|
<div className='border-border bg-background border-b p-4'>
|
||||||
|
<div className='border-destructive/40 bg-destructive/5 rounded-md border p-4'>
|
||||||
|
<p className='font-medium'>Thread workspace needs recovery</p>
|
||||||
|
<p className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
The saved workspace record exists, but this worker cannot reach
|
||||||
|
its active runtime. This usually happens after a worker restart or
|
||||||
|
local container cleanup.
|
||||||
|
</p>
|
||||||
|
<p className='text-muted-foreground mt-2 text-xs break-all'>
|
||||||
|
{workspaceError}
|
||||||
|
</p>
|
||||||
|
<div className='mt-3 flex flex-wrap gap-2'>
|
||||||
|
{job.threadId ? (
|
||||||
|
<Button type='button' onClick={() => void recoverWorkspace()}>
|
||||||
|
Start a fresh run
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button type='button' variant='outline'>
|
||||||
|
Delete stale record
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Delete this stale workspace record?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This marks the unreachable workspace as failed and removes
|
||||||
|
its stored messages, events, artifacts, diffs, and UI
|
||||||
|
state. The thread itself is kept.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Keep record</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant='destructive'
|
||||||
|
onClick={() => void deleteStaleWorkspace()}
|
||||||
|
>
|
||||||
|
Delete stale record
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
{job.threadId ? (
|
||||||
|
<Button type='button' variant='outline' asChild>
|
||||||
|
<a href={`/threads/${job.threadId}`}>Open thread</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
|
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
|
||||||
<WorkspaceActions job={job} disabled={workspaceDisabled} />
|
<WorkspaceActions job={job} disabled={workspaceDisabled} />
|
||||||
</div>
|
</div>
|
||||||
<div className='grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)] 2xl:grid-cols-[300px_minmax(0,1fr)_420px]'>
|
<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={
|
||||||
|
{
|
||||||
|
'--agent-thread-width': `${agentThreadWidth}px`,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
<aside className='border-border bg-background min-h-0 border-r'>
|
<aside className='border-border bg-background min-h-0 border-r'>
|
||||||
<div className='border-border border-b p-3'>
|
<div className='border-border border-b p-3'>
|
||||||
<h2 className='text-sm font-semibold'>Files</h2>
|
<h2 className='text-sm font-semibold'>Files</h2>
|
||||||
@@ -108,59 +573,210 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
|||||||
</div>
|
</div>
|
||||||
<FileTree
|
<FileTree
|
||||||
tree={tree}
|
tree={tree}
|
||||||
selectedPath={selectedPath}
|
selectedPath={activeFilePath}
|
||||||
onSelect={(path) => {
|
expandedPaths={expandedDirectoryPaths}
|
||||||
void loadFile(path).catch((error) => {
|
onSelect={openFile}
|
||||||
console.error(error);
|
onToggleDirectory={toggleDirectory}
|
||||||
toast.error('Could not load file.');
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
<section className='bg-background flex min-w-0 flex-col'>
|
<section className='bg-background flex min-w-0 flex-col overflow-hidden'>
|
||||||
<Tabs defaultValue='editor' className='flex min-h-0 flex-1 flex-col'>
|
<Tabs
|
||||||
<TabsList
|
value={activeWorkspaceTab}
|
||||||
variant='line'
|
onValueChange={(value) =>
|
||||||
className='border-border h-11 flex-none justify-start rounded-none border-b px-3'
|
setActiveWorkspaceTab(value as WorkspaceTab)
|
||||||
>
|
}
|
||||||
<TabsTrigger value='editor'>Editor</TabsTrigger>
|
className='flex min-h-0 flex-1 flex-col'
|
||||||
<TabsTrigger value='diff'>Diff</TabsTrigger>
|
>
|
||||||
<TabsTrigger value='thread' className='2xl:hidden'>
|
<TabsList className='border-border bg-muted/30 h-12 flex-none justify-start rounded-none border-b px-3'>
|
||||||
|
<TabsTrigger
|
||||||
|
value='editor'
|
||||||
|
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
|
||||||
|
>
|
||||||
|
<FileCode className='size-4' />
|
||||||
|
Editor
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value='diff'
|
||||||
|
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
|
||||||
|
>
|
||||||
|
<GitCompare className='size-4' />
|
||||||
|
Diff viewer
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value='terminal'
|
||||||
|
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
|
||||||
|
>
|
||||||
|
<SquareTerminal className='size-4' />
|
||||||
|
Terminal
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value='thread'
|
||||||
|
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm 2xl:hidden'
|
||||||
|
>
|
||||||
|
<MessagesSquare className='size-4' />
|
||||||
Thread
|
Thread
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value='editor' className='m-0 min-h-0 flex-1'>
|
<TabsContent
|
||||||
|
value='editor'
|
||||||
|
className='m-0 flex min-h-0 flex-1 flex-col'
|
||||||
|
>
|
||||||
|
<FileTabs
|
||||||
|
tabs={openFilePaths.map((path) => ({
|
||||||
|
path,
|
||||||
|
dirty: files[path]
|
||||||
|
? files[path].content !== files[path].savedContent
|
||||||
|
: false,
|
||||||
|
}))}
|
||||||
|
activePath={activeFilePath}
|
||||||
|
onActivate={setActiveFilePath}
|
||||||
|
onClose={closeFile}
|
||||||
|
/>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
path={selectedPath}
|
path={activeFilePath}
|
||||||
content={fileContent}
|
content={activeFile?.content ?? ''}
|
||||||
|
savedContent={activeFile?.savedContent ?? ''}
|
||||||
readOnly={workspaceDisabled}
|
readOnly={workspaceDisabled}
|
||||||
|
vimEnabled={vimEnabled}
|
||||||
onSave={saveFile}
|
onSave={saveFile}
|
||||||
|
onVimEnabledChange={setVimEnabled}
|
||||||
|
onChange={(content) => {
|
||||||
|
if (!activeFilePath) return;
|
||||||
|
setFiles((current) => ({
|
||||||
|
...current,
|
||||||
|
[activeFilePath]: {
|
||||||
|
...(current[activeFilePath] ?? {
|
||||||
|
path: activeFilePath,
|
||||||
|
savedContent: '',
|
||||||
|
loading: false,
|
||||||
|
saving: false,
|
||||||
|
}),
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent
|
||||||
|
value='terminal'
|
||||||
|
className='m-0 min-h-0 flex-1 overflow-hidden'
|
||||||
|
>
|
||||||
|
<WorkspaceTerminal
|
||||||
|
jobId={jobId}
|
||||||
|
active={activeWorkspaceTab === 'terminal' && workspaceReady}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value='diff' className='m-0 min-h-0 flex-1'>
|
<TabsContent value='diff' className='m-0 min-h-0 flex-1'>
|
||||||
<DiffViewer diff={diff} onRefresh={loadDiff} />
|
<DiffViewer
|
||||||
|
diff={diff}
|
||||||
|
focusedPath={focusedDiffPath}
|
||||||
|
onRefresh={loadDiff}
|
||||||
|
onClearFocusedPath={() => setFocusedDiffPath(undefined)}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value='thread'
|
value='thread'
|
||||||
className='m-0 min-h-0 flex-1 2xl:hidden'
|
className='m-0 min-h-0 flex-1 overflow-hidden 2xl:hidden'
|
||||||
>
|
>
|
||||||
<AgentThread
|
<AgentThread
|
||||||
jobId={jobId}
|
jobId={jobId}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
|
events={events}
|
||||||
|
interactions={interactions}
|
||||||
|
workspaceChanges={workspaceChanges}
|
||||||
disabled={workspaceDisabled}
|
disabled={workspaceDisabled}
|
||||||
|
agentTurnActive={agentTurnActive}
|
||||||
|
onOpenFile={openFileFromActivity}
|
||||||
|
onOpenDiff={openDiffFromActivity}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<CommandPanel jobId={jobId} disabled={workspaceDisabled} />
|
<CommandPanel jobId={jobId} disabled={workspaceDisabled} />
|
||||||
</section>
|
</section>
|
||||||
<aside className='border-border bg-muted/20 hidden min-w-0 border-l 2xl:block'>
|
<div
|
||||||
|
role='separator'
|
||||||
|
aria-label='Resize agent thread'
|
||||||
|
aria-orientation='vertical'
|
||||||
|
className='bg-border hover:bg-primary/50 hidden cursor-col-resize transition-colors 2xl:block'
|
||||||
|
onPointerDown={resizeAgentThread}
|
||||||
|
/>
|
||||||
|
<aside className='border-border bg-muted/20 hidden min-h-0 min-w-0 overflow-hidden border-l 2xl:block'>
|
||||||
<AgentThread
|
<AgentThread
|
||||||
jobId={jobId}
|
jobId={jobId}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
|
events={events}
|
||||||
|
interactions={interactions}
|
||||||
|
workspaceChanges={workspaceChanges}
|
||||||
disabled={workspaceDisabled}
|
disabled={workspaceDisabled}
|
||||||
|
agentTurnActive={agentTurnActive}
|
||||||
|
onOpenFile={openFileFromActivity}
|
||||||
|
onOpenDiff={openDiffFromActivity}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
<AlertDialog
|
||||||
|
open={Boolean(pendingOverwrite)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setPendingOverwrite(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Overwrite newer workspace changes?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{pendingOverwrite?.path} changed after you opened it. Overwriting
|
||||||
|
will replace the newer workspace contents with your editor
|
||||||
|
contents.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Keep editing</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant='destructive'
|
||||||
|
onClick={() => {
|
||||||
|
const pending = pendingOverwrite;
|
||||||
|
setPendingOverwrite(undefined);
|
||||||
|
if (pending) {
|
||||||
|
void writeFileContent(pending.path, pending.content);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Overwrite file
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
<AlertDialog
|
||||||
|
open={Boolean(pendingClosePath)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setPendingClosePath(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Discard unsaved changes?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{pendingClosePath} has unsaved changes. Closing this tab will
|
||||||
|
discard the editor contents that have not been saved.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Keep tab open</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant='destructive'
|
||||||
|
onClick={() => {
|
||||||
|
const path = pendingClosePath;
|
||||||
|
setPendingClosePath(undefined);
|
||||||
|
if (path) closeFileUnchecked(path);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Discard and close
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,13 +2,26 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
import { Button, Switch } from '@spoon/ui';
|
import { Button, Switch } from '@spoon/ui';
|
||||||
|
|
||||||
|
import type { MonacoLike } from './monaco-theme';
|
||||||
|
import { languageForPath } from './languages';
|
||||||
|
import {
|
||||||
|
configureSpoonMonaco,
|
||||||
|
remeasureFontsWhenReady,
|
||||||
|
SPOON_DARK,
|
||||||
|
SPOON_LIGHT,
|
||||||
|
} from './monaco-theme';
|
||||||
|
|
||||||
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const EDITOR_FONT_FAMILY =
|
||||||
|
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, SFMono-Regular, monospace";
|
||||||
|
|
||||||
type MonacoEditorInstance = {
|
type MonacoEditorInstance = {
|
||||||
getModel?: () => unknown;
|
getModel?: () => unknown;
|
||||||
};
|
};
|
||||||
@@ -20,26 +33,28 @@ type VimMode = {
|
|||||||
export const CodeEditor = ({
|
export const CodeEditor = ({
|
||||||
path,
|
path,
|
||||||
content,
|
content,
|
||||||
|
savedContent,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
vimEnabled,
|
||||||
onSave,
|
onSave,
|
||||||
|
onChange,
|
||||||
|
onVimEnabledChange,
|
||||||
}: {
|
}: {
|
||||||
path?: string;
|
path?: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
savedContent: string;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
|
vimEnabled: boolean;
|
||||||
onSave: (content: string) => Promise<void>;
|
onSave: (content: string) => Promise<void>;
|
||||||
|
onChange: (content: string) => void;
|
||||||
|
onVimEnabledChange: (enabled: boolean) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [value, setValue] = useState(content);
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [vimEnabled, setVimEnabled] = useState(false);
|
|
||||||
const [dirty, setDirty] = useState(false);
|
|
||||||
const editorRef = useRef<MonacoEditorInstance | null>(null);
|
const editorRef = useRef<MonacoEditorInstance | null>(null);
|
||||||
const vimRef = useRef<VimMode | null>(null);
|
const vimRef = useRef<VimMode | null>(null);
|
||||||
const statusRef = useRef<HTMLDivElement | null>(null);
|
const statusRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
useEffect(() => {
|
const editorTheme = resolvedTheme === 'light' ? SPOON_LIGHT : SPOON_DARK;
|
||||||
setValue(content);
|
|
||||||
setDirty(false);
|
|
||||||
}, [content, path]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const editor = editorRef.current;
|
const editor = editorRef.current;
|
||||||
@@ -71,17 +86,21 @@ export const CodeEditor = ({
|
|||||||
const save = async () => {
|
const save = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await onSave(value);
|
await onSave(content);
|
||||||
setDirty(false);
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dirty = content !== savedContent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex h-full min-h-0 flex-col'>
|
<div className='flex h-full min-h-0 flex-col'>
|
||||||
<div className='border-border flex h-11 items-center justify-between gap-3 border-b px-3'>
|
<div className='border-border flex h-14 items-center justify-between gap-3 border-b px-3'>
|
||||||
<div className='min-w-0'>
|
<div className='min-w-0'>
|
||||||
|
<p className='text-muted-foreground text-[11px] font-medium tracking-wide uppercase'>
|
||||||
|
Editor
|
||||||
|
</p>
|
||||||
<p className='truncate font-mono text-xs'>{path}</p>
|
<p className='truncate font-mono text-xs'>{path}</p>
|
||||||
{dirty ? (
|
{dirty ? (
|
||||||
<p className='text-muted-foreground text-xs'>Unsaved changes</p>
|
<p className='text-muted-foreground text-xs'>Unsaved changes</p>
|
||||||
@@ -90,7 +109,7 @@ export const CodeEditor = ({
|
|||||||
<div className='flex items-center gap-3'>
|
<div className='flex items-center gap-3'>
|
||||||
<label className='flex items-center gap-2 text-xs'>
|
<label className='flex items-center gap-2 text-xs'>
|
||||||
Vim
|
Vim
|
||||||
<Switch checked={vimEnabled} onCheckedChange={setVimEnabled} />
|
<Switch checked={vimEnabled} onCheckedChange={onVimEnabledChange} />
|
||||||
</label>
|
</label>
|
||||||
<Button
|
<Button
|
||||||
type='button'
|
type='button'
|
||||||
@@ -107,22 +126,40 @@ export const CodeEditor = ({
|
|||||||
height='100%'
|
height='100%'
|
||||||
width='100%'
|
width='100%'
|
||||||
path={path}
|
path={path}
|
||||||
value={value}
|
language={languageForPath(path)}
|
||||||
theme='vs-dark'
|
value={content}
|
||||||
|
theme={editorTheme}
|
||||||
|
beforeMount={(monaco) => {
|
||||||
|
configureSpoonMonaco(monaco as unknown as MonacoLike);
|
||||||
|
}}
|
||||||
options={{
|
options={{
|
||||||
readOnly,
|
readOnly,
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: false },
|
||||||
|
fontFamily: EDITOR_FONT_FAMILY,
|
||||||
|
fontLigatures: true,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
lineHeight: 1.6,
|
||||||
scrollBeyondLastLine: false,
|
scrollBeyondLastLine: false,
|
||||||
wordWrap: 'on',
|
wordWrap: 'on',
|
||||||
automaticLayout: true,
|
automaticLayout: true,
|
||||||
|
smoothScrolling: true,
|
||||||
|
cursorSmoothCaretAnimation: 'on',
|
||||||
|
padding: { top: 12, bottom: 12 },
|
||||||
|
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||||
|
quickSuggestions: true,
|
||||||
|
suggestOnTriggerCharacters: true,
|
||||||
|
tabCompletion: 'on',
|
||||||
|
wordBasedSuggestions: 'matchingDocuments',
|
||||||
|
bracketPairColorization: { enabled: true },
|
||||||
|
renderWhitespace: 'selection',
|
||||||
}}
|
}}
|
||||||
onMount={(editor) => {
|
onMount={(editor, monaco) => {
|
||||||
editorRef.current = editor as MonacoEditorInstance;
|
editorRef.current = editor as MonacoEditorInstance;
|
||||||
|
remeasureFontsWhenReady(monaco as unknown as MonacoLike);
|
||||||
}}
|
}}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
setValue(next ?? '');
|
const nextValue = next ?? '';
|
||||||
setDirty((next ?? '') !== content);
|
onChange(nextValue);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { DiffModeEnum, DiffView } from '@git-diff-view/react';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
|
||||||
|
import '@git-diff-view/react/styles/diff-view.css';
|
||||||
|
|
||||||
|
import type { ParsedDiffFile } from './diff-utils';
|
||||||
|
|
||||||
|
export type DiffMode = 'unified' | 'split';
|
||||||
|
|
||||||
|
/** Resolves the git-diff-view theme from next-themes, defaulting to dark. */
|
||||||
|
export const useDiffTheme = (): 'light' | 'dark' => {
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
return resolvedTheme === 'light' ? 'light' : 'dark';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders one file's diff with syntax highlighting. Falls back to a short note
|
||||||
|
* for binary files and metadata-only changes (pure renames / mode changes) that
|
||||||
|
* have no hunks to display.
|
||||||
|
*/
|
||||||
|
export const DiffFileView = ({
|
||||||
|
file,
|
||||||
|
mode,
|
||||||
|
theme,
|
||||||
|
fontSize = 12,
|
||||||
|
wrap = false,
|
||||||
|
}: {
|
||||||
|
file: ParsedDiffFile;
|
||||||
|
mode: DiffMode;
|
||||||
|
theme: 'light' | 'dark';
|
||||||
|
fontSize?: number;
|
||||||
|
wrap?: boolean;
|
||||||
|
}) => {
|
||||||
|
if (file.isBinary) {
|
||||||
|
return (
|
||||||
|
<div className='text-muted-foreground px-3 py-2 text-xs'>
|
||||||
|
Binary file not shown.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!file.hunkText.includes('@@')) {
|
||||||
|
return (
|
||||||
|
<div className='text-muted-foreground px-3 py-2 text-xs'>
|
||||||
|
{file.status === 'renamed'
|
||||||
|
? 'Renamed with no content changes.'
|
||||||
|
: 'No content changes.'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<DiffView
|
||||||
|
data={{
|
||||||
|
oldFile: { fileName: file.oldPath || file.displayPath },
|
||||||
|
newFile: { fileName: file.newPath || file.displayPath },
|
||||||
|
hunks: [file.hunkText],
|
||||||
|
}}
|
||||||
|
diffViewMode={
|
||||||
|
mode === 'split' ? DiffModeEnum.Split : DiffModeEnum.Unified
|
||||||
|
}
|
||||||
|
diffViewTheme={theme}
|
||||||
|
diffViewHighlight
|
||||||
|
diffViewWrap={wrap}
|
||||||
|
diffViewFontSize={fontSize}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
export type DiffFileStatus = 'added' | 'deleted' | 'modified' | 'renamed';
|
||||||
|
|
||||||
|
export type ParsedDiffFile = {
|
||||||
|
id: string;
|
||||||
|
oldPath: string;
|
||||||
|
newPath: string;
|
||||||
|
/** Path to show in the UI (new path, or old path for deletions). */
|
||||||
|
displayPath: string;
|
||||||
|
status: DiffFileStatus;
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
isBinary: boolean;
|
||||||
|
/** The full per-file unified diff section, fed as-is to the diff renderer. */
|
||||||
|
hunkText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripABPrefix = (value: string) => value.replace(/^[ab]\//, '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a raw unified git diff into structured, per-file entries. Replaces the
|
||||||
|
* "one giant blob" rendering: each file can be shown, counted, and highlighted
|
||||||
|
* independently.
|
||||||
|
*/
|
||||||
|
export const parseDiffFiles = (diff: string | undefined): ParsedDiffFile[] => {
|
||||||
|
if (!diff?.trim()) return [];
|
||||||
|
const sections: string[][] = [];
|
||||||
|
let current: string[] | null = null;
|
||||||
|
for (const line of diff.split('\n')) {
|
||||||
|
if (line.startsWith('diff --git ')) {
|
||||||
|
if (current) sections.push(current);
|
||||||
|
current = [line];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current?.push(line);
|
||||||
|
}
|
||||||
|
if (current) sections.push(current);
|
||||||
|
|
||||||
|
return sections.map((sectionLines, index) => {
|
||||||
|
const header = sectionLines[0] ?? '';
|
||||||
|
const gitMatch = /^diff --git a\/(.+?) b\/(.+)$/.exec(header);
|
||||||
|
let oldPath = gitMatch?.[1] ?? '';
|
||||||
|
let newPath = gitMatch?.[2] ?? oldPath;
|
||||||
|
let status: DiffFileStatus = 'modified';
|
||||||
|
let isBinary = false;
|
||||||
|
let additions = 0;
|
||||||
|
let deletions = 0;
|
||||||
|
let renameFrom = '';
|
||||||
|
let renameTo = '';
|
||||||
|
|
||||||
|
for (const line of sectionLines) {
|
||||||
|
if (line.startsWith('new file mode')) status = 'added';
|
||||||
|
else if (line.startsWith('deleted file mode')) status = 'deleted';
|
||||||
|
else if (line.startsWith('rename from ')) {
|
||||||
|
renameFrom = line.slice('rename from '.length);
|
||||||
|
} else if (line.startsWith('rename to ')) {
|
||||||
|
renameTo = line.slice('rename to '.length);
|
||||||
|
} else if (
|
||||||
|
line.startsWith('Binary files') ||
|
||||||
|
line.startsWith('GIT binary patch')
|
||||||
|
) {
|
||||||
|
isBinary = true;
|
||||||
|
} else if (line.startsWith('--- ')) {
|
||||||
|
const value = line.slice(4).trim();
|
||||||
|
if (value !== '/dev/null') oldPath = stripABPrefix(value);
|
||||||
|
} else if (line.startsWith('+++ ')) {
|
||||||
|
const value = line.slice(4).trim();
|
||||||
|
if (value !== '/dev/null') newPath = stripABPrefix(value);
|
||||||
|
} else if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||||
|
additions += 1;
|
||||||
|
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
||||||
|
deletions += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renameFrom || renameTo) {
|
||||||
|
status = 'renamed';
|
||||||
|
oldPath = renameFrom || oldPath;
|
||||||
|
newPath = renameTo || newPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayPath = status === 'deleted' ? oldPath : newPath;
|
||||||
|
return {
|
||||||
|
id: `${index}-${displayPath}`,
|
||||||
|
oldPath,
|
||||||
|
newPath,
|
||||||
|
displayPath,
|
||||||
|
status,
|
||||||
|
additions,
|
||||||
|
deletions,
|
||||||
|
isBinary,
|
||||||
|
hunkText: sectionLines.join('\n'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Returns the single parsed file matching a path, if present in the diff. */
|
||||||
|
export const parseDiffFileForPath = (
|
||||||
|
diff: string | undefined,
|
||||||
|
filePath: string,
|
||||||
|
): ParsedDiffFile | undefined => {
|
||||||
|
const normalized = filePath.replace(/^\.\/+/, '');
|
||||||
|
return parseDiffFiles(diff).find(
|
||||||
|
(file) =>
|
||||||
|
file.displayPath === normalized ||
|
||||||
|
file.newPath === normalized ||
|
||||||
|
file.oldPath === normalized,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractFileDiff = (diff: string | undefined, filePath: string) => {
|
||||||
|
if (!diff?.trim() || filePath === '.') return '';
|
||||||
|
const lines = diff.split('\n');
|
||||||
|
const sections: string[][] = [];
|
||||||
|
let current: string[] | null = null;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('diff --git ')) {
|
||||||
|
if (current) sections.push(current);
|
||||||
|
current = [line];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current?.push(line);
|
||||||
|
}
|
||||||
|
if (current) sections.push(current);
|
||||||
|
const normalizedPath = filePath.replace(/^\.\/+/, '');
|
||||||
|
const section = sections.find((item) => {
|
||||||
|
const header = item[0] ?? '';
|
||||||
|
return (
|
||||||
|
header.includes(` a/${normalizedPath} `) ||
|
||||||
|
header.endsWith(` a/${normalizedPath}`) ||
|
||||||
|
header.includes(` b/${normalizedPath}`) ||
|
||||||
|
header.endsWith(` b/${normalizedPath}`)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return section?.join('\n') ?? '';
|
||||||
|
};
|
||||||
@@ -1,49 +1,196 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@spoon/ui';
|
import { Button } from '@spoon/ui';
|
||||||
|
|
||||||
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
import type { DiffMode } from './diff-file-view';
|
||||||
ssr: false,
|
import type { DiffFileStatus, ParsedDiffFile } from './diff-utils';
|
||||||
});
|
import { DiffFileView, useDiffTheme } from './diff-file-view';
|
||||||
|
import { parseDiffFiles } from './diff-utils';
|
||||||
|
|
||||||
|
const statusBadge: Record<
|
||||||
|
DiffFileStatus,
|
||||||
|
{ label: string; className: string }
|
||||||
|
> = {
|
||||||
|
added: { label: 'Added', className: 'bg-emerald-500/15 text-emerald-500' },
|
||||||
|
deleted: { label: 'Deleted', className: 'bg-red-500/15 text-red-500' },
|
||||||
|
modified: { label: 'Modified', className: 'bg-amber-500/15 text-amber-500' },
|
||||||
|
renamed: { label: 'Renamed', className: 'bg-sky-500/15 text-sky-500' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const totals = (files: ParsedDiffFile[]) =>
|
||||||
|
files.reduce(
|
||||||
|
(acc, file) => ({
|
||||||
|
additions: acc.additions + file.additions,
|
||||||
|
deletions: acc.deletions + file.deletions,
|
||||||
|
}),
|
||||||
|
{ additions: 0, deletions: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const FileCard = ({
|
||||||
|
file,
|
||||||
|
mode,
|
||||||
|
theme,
|
||||||
|
defaultOpen,
|
||||||
|
}: {
|
||||||
|
file: ParsedDiffFile;
|
||||||
|
mode: DiffMode;
|
||||||
|
theme: 'light' | 'dark';
|
||||||
|
defaultOpen: boolean;
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
const badge = statusBadge[file.status];
|
||||||
|
return (
|
||||||
|
<div className='border-border overflow-hidden rounded-md border'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
className='bg-muted/40 hover:bg-muted/70 flex w-full items-center gap-2 px-3 py-2 text-left transition-colors'
|
||||||
|
>
|
||||||
|
{open ? (
|
||||||
|
<ChevronDown className='text-muted-foreground size-4 flex-none' />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className='text-muted-foreground size-4 flex-none' />
|
||||||
|
)}
|
||||||
|
<span className='min-w-0 flex-1 truncate font-mono text-xs'>
|
||||||
|
{file.status === 'renamed' && file.oldPath !== file.newPath ? (
|
||||||
|
<>
|
||||||
|
<span className='text-muted-foreground'>{file.oldPath} → </span>
|
||||||
|
{file.newPath}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
file.displayPath
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`flex-none rounded px-1.5 py-0.5 text-[10px] font-medium ${badge.className}`}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
<span className='flex-none font-mono text-xs'>
|
||||||
|
{file.additions > 0 ? (
|
||||||
|
<span className='text-emerald-500'>+{file.additions}</span>
|
||||||
|
) : null}{' '}
|
||||||
|
{file.deletions > 0 ? (
|
||||||
|
<span className='text-red-500'>−{file.deletions}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div className='overflow-x-auto'>
|
||||||
|
<DiffFileView file={file} mode={mode} theme={theme} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const DiffViewer = ({
|
export const DiffViewer = ({
|
||||||
diff,
|
diff,
|
||||||
|
focusedPath,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
onClearFocusedPath,
|
||||||
}: {
|
}: {
|
||||||
diff: string;
|
diff: string;
|
||||||
|
focusedPath?: string;
|
||||||
onRefresh: () => Promise<void>;
|
onRefresh: () => Promise<void>;
|
||||||
}) => (
|
onClearFocusedPath?: () => void;
|
||||||
<div className='flex h-full min-h-0 flex-col'>
|
}) => {
|
||||||
<div className='border-border flex h-11 items-center justify-between border-b px-3'>
|
const [mode, setMode] = useState<DiffMode>('unified');
|
||||||
<div>
|
const theme = useDiffTheme();
|
||||||
<p className='text-sm font-medium'>Workspace diff</p>
|
|
||||||
<p className='text-muted-foreground text-xs'>Current git diff</p>
|
const files = useMemo(() => parseDiffFiles(diff), [diff]);
|
||||||
|
const normalizedFocus = focusedPath?.replace(/^\.\/+/, '');
|
||||||
|
const visibleFiles = useMemo(
|
||||||
|
() =>
|
||||||
|
normalizedFocus
|
||||||
|
? files.filter(
|
||||||
|
(file) =>
|
||||||
|
file.displayPath === normalizedFocus ||
|
||||||
|
file.newPath === normalizedFocus ||
|
||||||
|
file.oldPath === normalizedFocus,
|
||||||
|
)
|
||||||
|
: files,
|
||||||
|
[files, normalizedFocus],
|
||||||
|
);
|
||||||
|
const stats = totals(visibleFiles);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex h-full min-h-0 flex-col'>
|
||||||
|
<div className='border-border flex h-12 items-center justify-between gap-3 border-b px-3'>
|
||||||
|
<div className='min-w-0'>
|
||||||
|
<p className='truncate text-sm font-medium'>
|
||||||
|
{focusedPath ? `Diff: ${focusedPath}` : 'Diff viewer'}
|
||||||
|
</p>
|
||||||
|
<p className='text-muted-foreground truncate text-xs'>
|
||||||
|
{visibleFiles.length > 0
|
||||||
|
? `${visibleFiles.length} ${visibleFiles.length === 1 ? 'file' : 'files'}, `
|
||||||
|
: ''}
|
||||||
|
<span className='text-emerald-500'>+{stats.additions}</span>{' '}
|
||||||
|
<span className='text-red-500'>−{stats.deletions}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-none items-center gap-2'>
|
||||||
|
<div className='border-border flex items-center rounded-md border p-0.5'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setMode('unified')}
|
||||||
|
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||||
|
mode === 'unified'
|
||||||
|
? 'bg-muted text-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Unified
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setMode('split')}
|
||||||
|
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||||
|
mode === 'split'
|
||||||
|
? 'bg-muted text-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Split
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{focusedPath ? (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
onClick={onClearFocusedPath}
|
||||||
|
>
|
||||||
|
Show all
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
|
{visibleFiles.length > 0 ? (
|
||||||
Refresh
|
<div className='flex flex-1 flex-col gap-3 overflow-y-auto p-3'>
|
||||||
</Button>
|
{visibleFiles.map((file, index) => (
|
||||||
|
<FileCard
|
||||||
|
key={file.id}
|
||||||
|
file={file}
|
||||||
|
mode={mode}
|
||||||
|
theme={theme}
|
||||||
|
defaultOpen={visibleFiles.length <= 10 || index < 5}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'>
|
||||||
|
{focusedPath
|
||||||
|
? 'No diff is recorded for this file yet.'
|
||||||
|
: 'No workspace diff yet.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{diff.trim() ? (
|
);
|
||||||
<MonacoEditor
|
};
|
||||||
height='100%'
|
|
||||||
width='100%'
|
|
||||||
language='diff'
|
|
||||||
theme='vs-dark'
|
|
||||||
value={diff}
|
|
||||||
options={{
|
|
||||||
readOnly: true,
|
|
||||||
minimap: { enabled: false },
|
|
||||||
fontSize: 13,
|
|
||||||
scrollBeyondLastLine: false,
|
|
||||||
automaticLayout: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'>
|
|
||||||
No workspace diff yet.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Circle, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@spoon/ui';
|
||||||
|
|
||||||
|
import { basename } from './languages';
|
||||||
|
|
||||||
|
export type OpenFileTab = {
|
||||||
|
path: string;
|
||||||
|
dirty: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FileTabs = ({
|
||||||
|
tabs,
|
||||||
|
activePath,
|
||||||
|
onActivate,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
tabs: OpenFileTab[];
|
||||||
|
activePath?: string;
|
||||||
|
onActivate: (path: string) => void;
|
||||||
|
onClose: (path: string) => void;
|
||||||
|
}) => {
|
||||||
|
if (tabs.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className='border-border bg-muted/30 flex h-10 flex-none items-stretch overflow-x-auto border-b'>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const active = tab.path === activePath;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tab.path}
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? 'border-primary bg-background flex max-w-56 min-w-0 items-center border-t-2 border-r'
|
||||||
|
: 'border-border flex max-w-56 min-w-0 items-center border-r'
|
||||||
|
}
|
||||||
|
title={tab.path}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='flex h-full min-w-0 flex-1 items-center gap-2 px-3 text-left text-xs'
|
||||||
|
onClick={() => onActivate(tab.path)}
|
||||||
|
>
|
||||||
|
{tab.dirty ? (
|
||||||
|
<Circle className='fill-primary text-primary size-2 flex-none' />
|
||||||
|
) : null}
|
||||||
|
<span className='truncate font-mono'>{basename(tab.path)}</span>
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
className='mr-1 size-6 flex-none'
|
||||||
|
aria-label={`Close ${tab.path}`}
|
||||||
|
onClick={() => onClose(tab.path)}
|
||||||
|
>
|
||||||
|
<X className='size-3' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ChevronRight, FileCode, Folder } from 'lucide-react';
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
FileCode,
|
||||||
|
Folder,
|
||||||
|
FolderOpen,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@spoon/ui';
|
import { Button } from '@spoon/ui';
|
||||||
|
|
||||||
@@ -9,38 +15,59 @@ import type { FileTreeNode } from './types';
|
|||||||
const TreeNode = ({
|
const TreeNode = ({
|
||||||
node,
|
node,
|
||||||
selectedPath,
|
selectedPath,
|
||||||
|
expandedPaths,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onToggle,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
}: {
|
}: {
|
||||||
node: FileTreeNode;
|
node: FileTreeNode;
|
||||||
selectedPath?: string;
|
selectedPath?: string;
|
||||||
|
expandedPaths: Set<string>;
|
||||||
onSelect: (path: string) => void;
|
onSelect: (path: string) => void;
|
||||||
|
onToggle: (path: string) => void;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
}) => {
|
}) => {
|
||||||
if (node.type === 'directory') {
|
if (node.type === 'directory') {
|
||||||
|
const isRoot = !node.path;
|
||||||
|
const expanded = isRoot || expandedPaths.has(node.path);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{node.path ? (
|
{!isRoot ? (
|
||||||
<div
|
<button
|
||||||
className='text-muted-foreground flex h-7 items-center gap-1 px-2 text-xs font-medium'
|
type='button'
|
||||||
|
aria-expanded={expanded}
|
||||||
|
className='text-muted-foreground hover:bg-muted flex h-7 w-full items-center gap-1 px-2 text-left text-xs font-medium'
|
||||||
style={{ paddingLeft: depth * 12 + 8 }}
|
style={{ paddingLeft: depth * 12 + 8 }}
|
||||||
|
onClick={() => onToggle(node.path)}
|
||||||
>
|
>
|
||||||
<ChevronRight className='size-3' />
|
{expanded ? (
|
||||||
<Folder className='size-3' />
|
<ChevronDown className='size-3 flex-none' />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className='size-3 flex-none' />
|
||||||
|
)}
|
||||||
|
{expanded ? (
|
||||||
|
<FolderOpen className='size-3 flex-none' />
|
||||||
|
) : (
|
||||||
|
<Folder className='size-3 flex-none' />
|
||||||
|
)}
|
||||||
<span className='truncate'>{node.name}</span>
|
<span className='truncate'>{node.name}</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{expanded ? (
|
||||||
|
<div>
|
||||||
|
{node.children?.map((child) => (
|
||||||
|
<TreeNode
|
||||||
|
key={`${child.type}:${child.path}`}
|
||||||
|
node={child}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
expandedPaths={expandedPaths}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onToggle={onToggle}
|
||||||
|
depth={node.path ? depth + 1 : depth}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div>
|
|
||||||
{node.children?.map((child) => (
|
|
||||||
<TreeNode
|
|
||||||
key={`${child.type}:${child.path}`}
|
|
||||||
node={child}
|
|
||||||
selectedPath={selectedPath}
|
|
||||||
onSelect={onSelect}
|
|
||||||
depth={node.path ? depth + 1 : depth}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -62,11 +89,15 @@ const TreeNode = ({
|
|||||||
export const FileTree = ({
|
export const FileTree = ({
|
||||||
tree,
|
tree,
|
||||||
selectedPath,
|
selectedPath,
|
||||||
|
expandedPaths,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onToggleDirectory,
|
||||||
}: {
|
}: {
|
||||||
tree: FileTreeNode | null;
|
tree: FileTreeNode | null;
|
||||||
selectedPath?: string;
|
selectedPath?: string;
|
||||||
|
expandedPaths: string[];
|
||||||
onSelect: (path: string) => void;
|
onSelect: (path: string) => void;
|
||||||
|
onToggleDirectory: (path: string) => void;
|
||||||
}) => {
|
}) => {
|
||||||
if (!tree) {
|
if (!tree) {
|
||||||
return (
|
return (
|
||||||
@@ -76,8 +107,14 @@ export const FileTree = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className='overflow-auto py-2'>
|
<div className='h-full overflow-auto py-2'>
|
||||||
<TreeNode node={tree} selectedPath={selectedPath} onSelect={onSelect} />
|
<TreeNode
|
||||||
|
node={tree}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
expandedPaths={new Set(expandedPaths)}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onToggle={onToggleDirectory}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export const languageForPath = (path?: string) => {
|
||||||
|
if (!path) return undefined;
|
||||||
|
const name = path.toLowerCase().split('/').at(-1) ?? path.toLowerCase();
|
||||||
|
if (name === '.env' || name.startsWith('.env.')) return 'plaintext';
|
||||||
|
if (name.endsWith('.tsx') || name.endsWith('.ts')) return 'typescript';
|
||||||
|
if (
|
||||||
|
name.endsWith('.jsx') ||
|
||||||
|
name.endsWith('.js') ||
|
||||||
|
name.endsWith('.mjs') ||
|
||||||
|
name.endsWith('.cjs')
|
||||||
|
) {
|
||||||
|
return 'javascript';
|
||||||
|
}
|
||||||
|
if (name.endsWith('.json')) return 'json';
|
||||||
|
if (name.endsWith('.css')) return 'css';
|
||||||
|
if (name.endsWith('.scss')) return 'scss';
|
||||||
|
if (name.endsWith('.html')) return 'html';
|
||||||
|
if (name.endsWith('.md') || name.endsWith('.mdx')) return 'markdown';
|
||||||
|
if (name.endsWith('.yml') || name.endsWith('.yaml')) return 'yaml';
|
||||||
|
if (name.endsWith('.sh') || name.endsWith('.bash')) return 'shell';
|
||||||
|
if (name.endsWith('.py')) return 'python';
|
||||||
|
if (name.endsWith('.rs')) return 'rust';
|
||||||
|
if (name.endsWith('.go')) return 'go';
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const basename = (path: string) => path.split('/').at(-1) ?? path;
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
export const SPOON_DARK = 'spoon-dark';
|
||||||
|
export const SPOON_LIGHT = 'spoon-light';
|
||||||
|
|
||||||
|
type ThemeRule = { token: string; foreground?: string; fontStyle?: string };
|
||||||
|
type ThemeData = {
|
||||||
|
base: 'vs' | 'vs-dark';
|
||||||
|
inherit: boolean;
|
||||||
|
rules: ThemeRule[];
|
||||||
|
colors: Record<string, string>;
|
||||||
|
};
|
||||||
|
type DiagnosticsDefaults = {
|
||||||
|
setDiagnosticsOptions: (options: {
|
||||||
|
noSemanticValidation?: boolean;
|
||||||
|
noSyntaxValidation?: boolean;
|
||||||
|
noSuggestionDiagnostics?: boolean;
|
||||||
|
}) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Minimal typed surface of the bits of the Monaco namespace we touch. Avoids
|
||||||
|
// depending on monaco-editor's full (and, under our eslint program, unresolved)
|
||||||
|
// type graph while keeping these calls fully type-checked.
|
||||||
|
export type MonacoLike = {
|
||||||
|
editor: {
|
||||||
|
defineTheme: (name: string, data: ThemeData) => void;
|
||||||
|
remeasureFonts: () => void;
|
||||||
|
};
|
||||||
|
languages: {
|
||||||
|
typescript: {
|
||||||
|
typescriptDefaults: DiagnosticsDefaults;
|
||||||
|
javascriptDefaults: DiagnosticsDefaults;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hex equivalents of the site's oklch design tokens (tools/tailwind/theme.css),
|
||||||
|
// so the editor matches the rest of the app. Brand accent is the teal --primary.
|
||||||
|
const dark = {
|
||||||
|
bg: '#080e14', // --background
|
||||||
|
surface: '#10171e', // --card
|
||||||
|
surfaceAlt: '#192028', // --muted
|
||||||
|
border: '#29313a', // --border
|
||||||
|
fg: '#eef3f5', // --foreground
|
||||||
|
fgDim: '#cdd6dc',
|
||||||
|
muted: '#93a1a9', // --muted-foreground
|
||||||
|
comment: '#6b7d88',
|
||||||
|
teal: '#1fb895', // --primary
|
||||||
|
mint: '#8fd6b4',
|
||||||
|
cyan: '#5fd0e0',
|
||||||
|
blue: '#6aa6ff',
|
||||||
|
amber: '#e3b341',
|
||||||
|
red: '#f3625d', // --destructive
|
||||||
|
};
|
||||||
|
|
||||||
|
const light = {
|
||||||
|
bg: '#f7fbfa', // --background
|
||||||
|
surface: '#ffffff', // --card
|
||||||
|
surfaceAlt: '#eaeff3', // --muted
|
||||||
|
border: '#d4dce2', // --border
|
||||||
|
fg: '#0d1218', // --foreground
|
||||||
|
fgDim: '#26323c',
|
||||||
|
muted: '#555f68', // --muted-foreground
|
||||||
|
comment: '#6b7680',
|
||||||
|
teal: '#007560', // --primary
|
||||||
|
mint: '#2f8f6e',
|
||||||
|
cyan: '#0f7d92',
|
||||||
|
blue: '#2f6bd8',
|
||||||
|
amber: '#9a6b00',
|
||||||
|
red: '#d73337', // --destructive
|
||||||
|
};
|
||||||
|
|
||||||
|
const hex = (value: string) => value.slice(1);
|
||||||
|
|
||||||
|
const themeData = (p: typeof dark, base: 'vs' | 'vs-dark'): ThemeData => ({
|
||||||
|
base,
|
||||||
|
inherit: true,
|
||||||
|
rules: [
|
||||||
|
{ token: '', foreground: hex(p.fg) },
|
||||||
|
{ token: 'comment', foreground: hex(p.comment), fontStyle: 'italic' },
|
||||||
|
{ token: 'keyword', foreground: hex(p.teal) },
|
||||||
|
{ token: 'keyword.control', foreground: hex(p.teal) },
|
||||||
|
{ token: 'storage', foreground: hex(p.teal) },
|
||||||
|
{ token: 'string', foreground: hex(p.mint) },
|
||||||
|
{ token: 'string.key.json', foreground: hex(p.cyan) },
|
||||||
|
{ token: 'string.value.json', foreground: hex(p.mint) },
|
||||||
|
{ token: 'number', foreground: hex(p.amber) },
|
||||||
|
{ token: 'constant', foreground: hex(p.amber) },
|
||||||
|
{ token: 'regexp', foreground: hex(p.amber) },
|
||||||
|
{ token: 'type', foreground: hex(p.cyan) },
|
||||||
|
{ token: 'type.identifier', foreground: hex(p.cyan) },
|
||||||
|
{ token: 'interface', foreground: hex(p.cyan) },
|
||||||
|
{ token: 'namespace', foreground: hex(p.cyan) },
|
||||||
|
{ token: 'function', foreground: hex(p.blue) },
|
||||||
|
{ token: 'variable', foreground: hex(p.fgDim) },
|
||||||
|
{ token: 'variable.parameter', foreground: hex(p.fgDim) },
|
||||||
|
{ token: 'property', foreground: hex(p.fgDim) },
|
||||||
|
{ token: 'operator', foreground: hex(p.muted) },
|
||||||
|
{ token: 'delimiter', foreground: hex(p.muted) },
|
||||||
|
{ token: 'tag', foreground: hex(p.teal) },
|
||||||
|
{ token: 'attribute.name', foreground: hex(p.amber) },
|
||||||
|
{ token: 'attribute.value', foreground: hex(p.mint) },
|
||||||
|
{ token: 'metatag', foreground: hex(p.teal) },
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
'editor.background': p.bg,
|
||||||
|
'editor.foreground': p.fg,
|
||||||
|
'editorCursor.foreground': p.teal,
|
||||||
|
'editorLineNumber.foreground': p.border,
|
||||||
|
'editorLineNumber.activeForeground': p.muted,
|
||||||
|
'editor.lineHighlightBackground': p.surface,
|
||||||
|
'editor.selectionBackground': `${p.teal}33`,
|
||||||
|
'editor.inactiveSelectionBackground': `${p.teal}22`,
|
||||||
|
'editor.findMatchBackground': `${p.teal}55`,
|
||||||
|
'editor.findMatchHighlightBackground': `${p.teal}33`,
|
||||||
|
'editorWhitespace.foreground': p.border,
|
||||||
|
'editorIndentGuide.background1': p.surfaceAlt,
|
||||||
|
'editorIndentGuide.activeBackground1': p.border,
|
||||||
|
'editorGutter.background': p.bg,
|
||||||
|
'editorWidget.background': p.surface,
|
||||||
|
'editorWidget.border': p.border,
|
||||||
|
'editorHoverWidget.background': p.surface,
|
||||||
|
'editorHoverWidget.border': p.border,
|
||||||
|
'editorSuggestWidget.background': p.surface,
|
||||||
|
'editorSuggestWidget.border': p.border,
|
||||||
|
'editorSuggestWidget.selectedBackground': p.surfaceAlt,
|
||||||
|
'editorBracketMatch.background': `${p.teal}22`,
|
||||||
|
'editorBracketMatch.border': p.teal,
|
||||||
|
'editorError.foreground': p.red,
|
||||||
|
'scrollbarSlider.background': `${p.border}aa`,
|
||||||
|
'scrollbarSlider.hoverBackground': p.border,
|
||||||
|
'scrollbarSlider.activeBackground': p.muted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let configured = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the site-matched editor themes and quiets the in-browser TypeScript
|
||||||
|
* service. Monaco's TS worker has no access to the project's node_modules or the
|
||||||
|
* `~`/`@` path aliases, so its semantic diagnostics (e.g. "Cannot find module
|
||||||
|
* '~/server/auth'") are always false positives here. We keep real syntax errors
|
||||||
|
* and disable the unresolvable semantic noise. Runs once per page load.
|
||||||
|
*/
|
||||||
|
export const configureSpoonMonaco = (monaco: MonacoLike) => {
|
||||||
|
monaco.editor.defineTheme(SPOON_DARK, themeData(dark, 'vs-dark'));
|
||||||
|
monaco.editor.defineTheme(SPOON_LIGHT, themeData(light, 'vs'));
|
||||||
|
if (configured) return;
|
||||||
|
configured = true;
|
||||||
|
for (const defaults of [
|
||||||
|
monaco.languages.typescript.typescriptDefaults,
|
||||||
|
monaco.languages.typescript.javascriptDefaults,
|
||||||
|
]) {
|
||||||
|
defaults.setDiagnosticsOptions({
|
||||||
|
noSemanticValidation: true,
|
||||||
|
noSuggestionDiagnostics: true,
|
||||||
|
noSyntaxValidation: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Re-measures glyph widths once the web font finishes loading so they align. */
|
||||||
|
export const remeasureFontsWhenReady = (monaco: MonacoLike) => {
|
||||||
|
void document.fonts.ready.then(() => monaco.editor.remeasureFonts());
|
||||||
|
};
|
||||||
@@ -1,10 +1,29 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ExternalLink, GitPullRequestDraft, Square } from 'lucide-react';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useMutation } from 'convex/react';
|
||||||
|
import {
|
||||||
|
ExternalLink,
|
||||||
|
GitPullRequestDraft,
|
||||||
|
Square,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
import { Button } from '@spoon/ui';
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
Button,
|
||||||
|
} from '@spoon/ui';
|
||||||
|
|
||||||
export const WorkspaceActions = ({
|
export const WorkspaceActions = ({
|
||||||
job,
|
job,
|
||||||
@@ -13,6 +32,13 @@ export const WorkspaceActions = ({
|
|||||||
job: Doc<'agentJobs'>;
|
job: Doc<'agentJobs'>;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
|
||||||
|
const deleteThread = useMutation(api.threads.deleteThread);
|
||||||
|
const canDelete =
|
||||||
|
['failed', 'cancelled', 'timed_out'].includes(job.status) ||
|
||||||
|
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
|
||||||
|
|
||||||
const openPr = async () => {
|
const openPr = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/agent-jobs/${job._id}/open-pr`, {
|
const response = await fetch(`/api/agent-jobs/${job._id}/open-pr`, {
|
||||||
@@ -26,6 +52,31 @@ export const WorkspaceActions = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
try {
|
||||||
|
await deleteWorkspace({ jobId: job._id });
|
||||||
|
toast.success('Workspace deleted.');
|
||||||
|
router.push(`/spoons/${job.spoonId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('Could not delete workspace.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeThread = async () => {
|
||||||
|
if (!job.threadId) return;
|
||||||
|
try {
|
||||||
|
await deleteThread({ threadId: job.threadId });
|
||||||
|
toast.success('Thread deleted.');
|
||||||
|
router.push('/threads');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Could not delete thread.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const stop = async () => {
|
const stop = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/agent-jobs/${job._id}/stop`, {
|
const response = await fetch(`/api/agent-jobs/${job._id}/stop`, {
|
||||||
@@ -63,6 +114,66 @@ export const WorkspaceActions = ({
|
|||||||
<Square className='size-4' />
|
<Square className='size-4' />
|
||||||
Stop
|
Stop
|
||||||
</Button>
|
</Button>
|
||||||
|
{canDelete ? (
|
||||||
|
<>
|
||||||
|
{job.threadId ? (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button type='button' variant='destructive' size='sm'>
|
||||||
|
<Trash2 className='size-4' />
|
||||||
|
Delete thread
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete this thread?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This removes the thread and any terminal workspace records,
|
||||||
|
messages, events, artifacts, diffs, and UI state attached to
|
||||||
|
it. This cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Keep thread</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant='destructive'
|
||||||
|
onClick={() => void removeThread()}
|
||||||
|
>
|
||||||
|
Delete thread
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
) : null}
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button type='button' variant='outline' size='sm'>
|
||||||
|
<Trash2 className='size-4' />
|
||||||
|
Delete workspace
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete this workspace?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This removes the workspace record, messages, events,
|
||||||
|
artifacts, diffs, and UI state. The thread is kept unless you
|
||||||
|
delete it separately.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Keep workspace</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant='destructive'
|
||||||
|
onClick={() => void remove()}
|
||||||
|
>
|
||||||
|
Delete workspace
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
'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',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkspaceTerminal = ({
|
||||||
|
jobId,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Copy } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
|
||||||
import { Button } from '@spoon/ui';
|
|
||||||
|
|
||||||
export const AgentArtifactViewer = ({
|
|
||||||
artifacts,
|
|
||||||
}: {
|
|
||||||
artifacts: Doc<'agentJobArtifacts'>[];
|
|
||||||
}) => {
|
|
||||||
if (!artifacts.length) {
|
|
||||||
return (
|
|
||||||
<p className='text-muted-foreground text-sm'>
|
|
||||||
No artifacts captured yet.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='space-y-3'>
|
|
||||||
{artifacts.map((artifact) => (
|
|
||||||
<section key={artifact._id} className='border-border rounded-md border'>
|
|
||||||
<div className='flex items-center justify-between gap-3 border-b p-3'>
|
|
||||||
<div>
|
|
||||||
<h3 className='text-sm font-semibold'>{artifact.title}</h3>
|
|
||||||
<p className='text-muted-foreground text-xs'>{artifact.kind}</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
variant='outline'
|
|
||||||
size='icon'
|
|
||||||
aria-label='Copy artifact'
|
|
||||||
onClick={async () => {
|
|
||||||
await navigator.clipboard.writeText(artifact.content);
|
|
||||||
toast.success('Artifact copied.');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Copy className='size-4' />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<pre className='bg-muted/40 max-h-96 overflow-auto p-3 text-xs whitespace-pre-wrap'>
|
|
||||||
{artifact.content}
|
|
||||||
</pre>
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
|
||||||
|
|
||||||
const formatTime = (value: number) =>
|
|
||||||
new Intl.DateTimeFormat(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
}).format(value);
|
|
||||||
|
|
||||||
export const AgentEventLog = ({
|
|
||||||
events,
|
|
||||||
}: {
|
|
||||||
events: Doc<'agentJobEvents'>[];
|
|
||||||
}) => {
|
|
||||||
if (!events.length) {
|
|
||||||
return (
|
|
||||||
<p className='text-muted-foreground text-sm'>No worker events yet.</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='divide-border overflow-hidden rounded-md border'>
|
|
||||||
{events.map((event) => (
|
|
||||||
<div key={event._id} className='grid gap-1 border-b p-3 text-sm'>
|
|
||||||
<div className='flex flex-wrap items-center gap-2'>
|
|
||||||
<span className='font-mono text-xs uppercase'>{event.phase}</span>
|
|
||||||
<span className='text-muted-foreground text-xs'>
|
|
||||||
{formatTime(event.createdAt)}
|
|
||||||
</span>
|
|
||||||
<span className='text-muted-foreground text-xs capitalize'>
|
|
||||||
{event.level}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className='whitespace-pre-wrap'>{event.message}</p>
|
|
||||||
{event.metadata ? (
|
|
||||||
<pre className='bg-muted overflow-auto rounded p-2 text-xs'>
|
|
||||||
{event.metadata}
|
|
||||||
</pre>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useQuery } from 'convex/react';
|
|
||||||
|
|
||||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
|
||||||
|
|
||||||
import { AgentArtifactViewer } from './agent-artifact-viewer';
|
|
||||||
import { AgentEventLog } from './agent-event-log';
|
|
||||||
|
|
||||||
export const AgentJobDetail = ({ job }: { job: Doc<'agentJobs'> }) => {
|
|
||||||
const events =
|
|
||||||
useQuery(api.agentJobs.listEvents, { jobId: job._id, limit: 200 }) ?? [];
|
|
||||||
const artifacts =
|
|
||||||
useQuery(api.agentJobs.listArtifacts, { jobId: job._id }) ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className='shadow-none'>
|
|
||||||
<CardHeader className='pb-3'>
|
|
||||||
<CardTitle className='text-base'>Job details</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className='space-y-5'>
|
|
||||||
<div className='grid gap-3 text-sm md:grid-cols-3'>
|
|
||||||
<div>
|
|
||||||
<p className='text-muted-foreground text-xs'>Status</p>
|
|
||||||
<p className='font-medium capitalize'>
|
|
||||||
{job.status.replaceAll('_', ' ')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className='text-muted-foreground text-xs'>Branch</p>
|
|
||||||
<p className='font-mono text-xs'>{job.workBranch}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className='text-muted-foreground text-xs'>Model</p>
|
|
||||||
<p className='font-medium'>{job.model}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{job.pullRequestUrl ? (
|
|
||||||
<a
|
|
||||||
href={job.pullRequestUrl}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
className='text-primary text-sm font-medium underline-offset-4 hover:underline'
|
|
||||||
>
|
|
||||||
Open draft PR #{job.pullRequestNumber}
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
{job.error ? (
|
|
||||||
<pre className='border-destructive bg-destructive/5 text-destructive overflow-auto rounded-md border p-3 text-xs whitespace-pre-wrap'>
|
|
||||||
{job.error}
|
|
||||||
</pre>
|
|
||||||
) : null}
|
|
||||||
<section className='space-y-2'>
|
|
||||||
<h3 className='text-sm font-semibold'>Events</h3>
|
|
||||||
<AgentEventLog events={events} />
|
|
||||||
</section>
|
|
||||||
<section className='space-y-2'>
|
|
||||||
<h3 className='text-sm font-semibold'>Artifacts</h3>
|
|
||||||
<AgentArtifactViewer artifacts={artifacts} />
|
|
||||||
</section>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useMutation } from 'convex/react';
|
|
||||||
import { ExternalLink, MonitorUp, XCircle } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
|
||||||
import { Badge, Button } from '@spoon/ui';
|
|
||||||
|
|
||||||
import { AgentJobDetail } from './agent-job-detail';
|
|
||||||
|
|
||||||
const formatTime = (value: number) =>
|
|
||||||
new Intl.DateTimeFormat(undefined, {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
}).format(value);
|
|
||||||
|
|
||||||
export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
|
|
||||||
const cancel = useMutation(api.agentJobs.cancel);
|
|
||||||
const [selectedJobId, setSelectedJobId] = useState<string | null>(
|
|
||||||
jobs[0]?._id ?? null,
|
|
||||||
);
|
|
||||||
const selectedJob = jobs.find((job) => job._id === selectedJobId) ?? jobs[0];
|
|
||||||
|
|
||||||
if (!jobs.length) {
|
|
||||||
return (
|
|
||||||
<div className='border-border rounded-md border p-5'>
|
|
||||||
<h3 className='text-sm font-semibold'>No agent jobs yet</h3>
|
|
||||||
<p className='text-muted-foreground mt-1 text-sm'>
|
|
||||||
Queue a job to have Spoon open a draft PR against this fork.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='grid gap-4 xl:grid-cols-[0.85fr_1.15fr]'>
|
|
||||||
<div className='divide-border overflow-hidden rounded-md border'>
|
|
||||||
{jobs.map((job) => (
|
|
||||||
<button
|
|
||||||
key={job._id}
|
|
||||||
type='button'
|
|
||||||
className='hover:bg-muted/40 data-[selected=true]:bg-muted/60 block w-full border-b p-3 text-left'
|
|
||||||
data-selected={job._id === selectedJob?._id}
|
|
||||||
onClick={() => setSelectedJobId(job._id)}
|
|
||||||
>
|
|
||||||
<div className='flex items-start justify-between gap-3'>
|
|
||||||
<div className='min-w-0'>
|
|
||||||
<p className='truncate text-sm font-medium'>{job.prompt}</p>
|
|
||||||
<p className='text-muted-foreground mt-1 font-mono text-xs'>
|
|
||||||
{job.workBranch}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge variant='outline' className='capitalize'>
|
|
||||||
{job.status.replaceAll('_', ' ')}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className='text-muted-foreground mt-2 flex flex-wrap gap-2 text-xs'>
|
|
||||||
<span>{formatTime(job.createdAt)}</span>
|
|
||||||
{job.pullRequestUrl ? (
|
|
||||||
<a
|
|
||||||
href={job.pullRequestUrl}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
className='text-primary inline-flex items-center gap-1'
|
|
||||||
>
|
|
||||||
PR <ExternalLink className='size-3' />
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{selectedJob ? (
|
|
||||||
<div className='space-y-3'>
|
|
||||||
{[
|
|
||||||
'queued',
|
|
||||||
'claimed',
|
|
||||||
'preparing',
|
|
||||||
'running',
|
|
||||||
'checks_running',
|
|
||||||
].includes(selectedJob.status) ? (
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
variant='outline'
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await cancel({ jobId: selectedJob._id });
|
|
||||||
toast.success('Agent job cancelled.');
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
toast.error('Could not cancel job.');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XCircle className='size-4' />
|
|
||||||
Cancel job
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button asChild>
|
|
||||||
<Link
|
|
||||||
href={`/spoons/${selectedJob.spoonId}/agent/${selectedJob._id}`}
|
|
||||||
>
|
|
||||||
<MonitorUp className='size-4' />
|
|
||||||
Open workspace
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<AgentJobDetail job={selectedJob} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -5,7 +5,9 @@ import { usePathname } from 'next/navigation';
|
|||||||
|
|
||||||
export const AppShell = ({ children }: { children: ReactNode }) => {
|
export const AppShell = ({ children }: { children: ReactNode }) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isWorkspace = /\/spoons\/[^/]+\/agent\/[^/]+/.test(pathname);
|
const isWorkspace =
|
||||||
|
/\/spoons\/[^/]+\/agent\/[^/]+/.test(pathname) ||
|
||||||
|
/^\/threads\/[^/]+/.test(pathname);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='bg-muted/20 flex-1 border-t'>
|
<div className='bg-muted/20 flex-1 border-t'>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ProviderModelOption } from '@/lib/models-dev';
|
import type { ProviderModelOption } from '@/lib/provider-model-options';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { loadModelsDevOptions } from '@/lib/models-dev';
|
import {
|
||||||
|
modelOptionsFromIds,
|
||||||
|
suggestedModelOptions,
|
||||||
|
supportsCustomModelOptions,
|
||||||
|
} from '@/lib/provider-model-options';
|
||||||
import { useAction, useMutation, useQuery } from 'convex/react';
|
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||||
import { makeFunctionReference } from 'convex/server';
|
import { makeFunctionReference } from 'convex/server';
|
||||||
import { KeyRound, Trash2 } from 'lucide-react';
|
import { KeyRound, Trash2 } from 'lucide-react';
|
||||||
@@ -11,6 +15,7 @@ import { toast } from 'sonner';
|
|||||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -50,6 +55,7 @@ const saveProfileRef = makeFunctionReference<
|
|||||||
secret?: string;
|
secret?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
defaultModel: string;
|
defaultModel: string;
|
||||||
|
modelOptions?: string[];
|
||||||
reasoningEffort: ReasoningEffort;
|
reasoningEffort: ReasoningEffort;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
},
|
},
|
||||||
@@ -119,33 +125,24 @@ export const AiProviderProfilesPanel = () => {
|
|||||||
);
|
);
|
||||||
const [secret, setSecret] = useState('');
|
const [secret, setSecret] = useState('');
|
||||||
const [baseUrl, setBaseUrl] = useState('');
|
const [baseUrl, setBaseUrl] = useState('');
|
||||||
const [defaultModelValue, setDefaultModelValue] = useState('');
|
const [defaultModelValue, setDefaultModelValue] = useState(
|
||||||
const [modelOptions, setModelOptions] = useState<ProviderModelOption[]>([]);
|
suggestedModelOptions('openai')[0]?.id ?? '',
|
||||||
|
);
|
||||||
|
const [modelOptions, setModelOptions] = useState<ProviderModelOption[]>(
|
||||||
|
suggestedModelOptions('openai'),
|
||||||
|
);
|
||||||
|
const [customModelId, setCustomModelId] = useState('');
|
||||||
const [reasoningEffort, setReasoningEffort] =
|
const [reasoningEffort, setReasoningEffort] =
|
||||||
useState<ReasoningEffort>('medium');
|
useState<ReasoningEffort>('medium');
|
||||||
const [enabled, setEnabled] = useState(true);
|
const [enabled, setEnabled] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const resetModelOptions = (nextProvider: Provider) => {
|
||||||
let cancelled = false;
|
const options = suggestedModelOptions(nextProvider);
|
||||||
loadModelsDevOptions(provider)
|
setModelOptions(options);
|
||||||
.then((options) => {
|
setDefaultModelValue(options[0]?.id ?? '');
|
||||||
if (cancelled) return;
|
setCustomModelId('');
|
||||||
setModelOptions(options);
|
};
|
||||||
setDefaultModelValue((current) =>
|
|
||||||
current && options.some((option) => option.id === current)
|
|
||||||
? current
|
|
||||||
: (options[0]?.id ?? ''),
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
console.error(error);
|
|
||||||
if (!cancelled) setModelOptions([]);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [provider]);
|
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setProfileId(undefined);
|
setProfileId(undefined);
|
||||||
@@ -153,6 +150,8 @@ export const AiProviderProfilesPanel = () => {
|
|||||||
setSecret('');
|
setSecret('');
|
||||||
setBaseUrl('');
|
setBaseUrl('');
|
||||||
setDefaultModelValue('');
|
setDefaultModelValue('');
|
||||||
|
setModelOptions(suggestedModelOptions('openai'));
|
||||||
|
setCustomModelId('');
|
||||||
setReasoningEffort('medium');
|
setReasoningEffort('medium');
|
||||||
setEnabled(true);
|
setEnabled(true);
|
||||||
setName('OpenAI');
|
setName('OpenAI');
|
||||||
@@ -165,6 +164,14 @@ export const AiProviderProfilesPanel = () => {
|
|||||||
setSecret('');
|
setSecret('');
|
||||||
setBaseUrl(profile.baseUrl ?? '');
|
setBaseUrl(profile.baseUrl ?? '');
|
||||||
setDefaultModelValue(profile.defaultModel);
|
setDefaultModelValue(profile.defaultModel);
|
||||||
|
setModelOptions(
|
||||||
|
modelOptionsFromIds(
|
||||||
|
profile.modelOptions?.length
|
||||||
|
? profile.modelOptions
|
||||||
|
: [profile.defaultModel],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setCustomModelId('');
|
||||||
setReasoningEffort(profile.reasoningEffort as ReasoningEffort);
|
setReasoningEffort(profile.reasoningEffort as ReasoningEffort);
|
||||||
setEnabled(profile.enabled);
|
setEnabled(profile.enabled);
|
||||||
};
|
};
|
||||||
@@ -181,6 +188,7 @@ export const AiProviderProfilesPanel = () => {
|
|||||||
secret: secret.trim() ? secret : undefined,
|
secret: secret.trim() ? secret : undefined,
|
||||||
baseUrl: baseUrl.trim() || undefined,
|
baseUrl: baseUrl.trim() || undefined,
|
||||||
defaultModel: defaultModelValue,
|
defaultModel: defaultModelValue,
|
||||||
|
modelOptions: modelOptions.map((model) => model.id),
|
||||||
reasoningEffort,
|
reasoningEffort,
|
||||||
enabled,
|
enabled,
|
||||||
});
|
});
|
||||||
@@ -310,6 +318,7 @@ export const AiProviderProfilesPanel = () => {
|
|||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
const nextProvider = value as Provider;
|
const nextProvider = value as Provider;
|
||||||
setProvider(nextProvider);
|
setProvider(nextProvider);
|
||||||
|
resetModelOptions(nextProvider);
|
||||||
setName(
|
setName(
|
||||||
providerOptions
|
providerOptions
|
||||||
.find((option) => option.value === nextProvider)
|
.find((option) => option.value === nextProvider)
|
||||||
@@ -397,9 +406,47 @@ export const AiProviderProfilesPanel = () => {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className='text-muted-foreground text-xs'>
|
<p className='text-muted-foreground text-xs'>
|
||||||
Models are loaded from Models.dev, the catalog OpenCode uses
|
Saved model options are used by Spoons. Add custom model IDs
|
||||||
for provider/model metadata.
|
for compatible provider gateways.
|
||||||
</p>
|
</p>
|
||||||
|
<div className='rounded-md border p-2'>
|
||||||
|
<p className='text-muted-foreground mb-2 text-xs'>
|
||||||
|
Available model options
|
||||||
|
</p>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
{modelOptions.map((model) => (
|
||||||
|
<Badge key={model.id} variant='outline'>
|
||||||
|
{model.id}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{supportsCustomModelOptions(provider) ? (
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Input
|
||||||
|
value={customModelId}
|
||||||
|
placeholder='provider/model-id'
|
||||||
|
onChange={(event) => setCustomModelId(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
onClick={() => {
|
||||||
|
const id = customModelId.trim();
|
||||||
|
if (!id) return;
|
||||||
|
setModelOptions((current) =>
|
||||||
|
current.some((model) => model.id === id)
|
||||||
|
? current
|
||||||
|
: [...current, ...modelOptionsFromIds([id])],
|
||||||
|
);
|
||||||
|
setDefaultModelValue((current) => current || id);
|
||||||
|
setCustomModelId('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className='grid gap-2'>
|
<div className='grid gap-2'>
|
||||||
<Label>Thinking</Label>
|
<Label>Thinking</Label>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const features = [
|
|||||||
{
|
{
|
||||||
title: 'Provider-owned AI',
|
title: 'Provider-owned AI',
|
||||||
description:
|
description:
|
||||||
'Use encrypted provider profiles, OpenCode auth, or user-owned API keys rather than a shared application key.',
|
'Use encrypted provider profiles: API-key providers run through OpenCode, and Codex login profiles run through the Codex CLI.',
|
||||||
icon: KeyRound,
|
icon: KeyRound,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -119,7 +119,7 @@ const ownership = [
|
|||||||
{
|
{
|
||||||
title: 'Your providers',
|
title: 'Your providers',
|
||||||
description:
|
description:
|
||||||
'AI provider profiles and Codex/OpenCode auth stay encrypted and selected by you.',
|
'AI provider profiles, API keys, and Codex auth JSON stay encrypted and selected by you.',
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ export default function Footer() {
|
|||||||
Integrations
|
Integrations
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href='/settings/worker'
|
||||||
|
className='text-muted-foreground hover:text-foreground transition-colors'
|
||||||
|
>
|
||||||
|
Worker
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
href='https://git.gbrown.org/gib/spoon'
|
href='https://git.gbrown.org/gib/spoon'
|
||||||
|
|||||||
@@ -0,0 +1,453 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { FileTreeNode } from '@/components/agent-workspace/types';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { CodeEditor } from '@/components/agent-workspace/code-editor';
|
||||||
|
import { FileTree } from '@/components/agent-workspace/file-tree';
|
||||||
|
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||||
|
import { FilePlus, FolderUp, Trash2, Upload } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
import { Button, Card, Input, Label } from '@spoon/ui';
|
||||||
|
|
||||||
|
type DotfileMeta = {
|
||||||
|
_id: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
isExecutable: boolean;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UploadFile = { path: string; content: string; isExecutable?: boolean };
|
||||||
|
|
||||||
|
// Minimal typed surface of the drag-and-drop FileSystem entry API.
|
||||||
|
type FsEntry = {
|
||||||
|
isFile: boolean;
|
||||||
|
isDirectory: boolean;
|
||||||
|
name: string;
|
||||||
|
file?: (cb: (f: File) => void, err: (e: unknown) => void) => void;
|
||||||
|
createReader?: () => {
|
||||||
|
readEntries: (
|
||||||
|
cb: (e: FsEntry[]) => void,
|
||||||
|
err: (e: unknown) => void,
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTree = (files: DotfileMeta[], rootLabel: string): FileTreeNode => {
|
||||||
|
const root: FileTreeNode = {
|
||||||
|
name: rootLabel,
|
||||||
|
path: '',
|
||||||
|
type: 'directory',
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
|
||||||
|
const segments = file.path.split('/');
|
||||||
|
let node = root;
|
||||||
|
segments.forEach((segment, index) => {
|
||||||
|
const isLeaf = index === segments.length - 1;
|
||||||
|
const childPath = segments.slice(0, index + 1).join('/');
|
||||||
|
node.children ??= [];
|
||||||
|
let child = node.children.find((c) => c.path === childPath);
|
||||||
|
if (!child) {
|
||||||
|
child = {
|
||||||
|
name: segment,
|
||||||
|
path: childPath,
|
||||||
|
type: isLeaf ? 'file' : 'directory',
|
||||||
|
children: isLeaf ? undefined : [],
|
||||||
|
};
|
||||||
|
node.children.push(child);
|
||||||
|
}
|
||||||
|
node = child;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readAllEntries = (reader: {
|
||||||
|
readEntries: (cb: (e: FsEntry[]) => void, err: (e: unknown) => void) => void;
|
||||||
|
}) =>
|
||||||
|
new Promise<FsEntry[]>((resolve, reject) => {
|
||||||
|
const all: FsEntry[] = [];
|
||||||
|
const next = () =>
|
||||||
|
reader.readEntries((batch) => {
|
||||||
|
if (batch.length === 0) resolve(all);
|
||||||
|
else {
|
||||||
|
all.push(...batch);
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}, reject);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
const collectEntry = async (
|
||||||
|
entry: FsEntry,
|
||||||
|
prefix: string,
|
||||||
|
out: UploadFile[],
|
||||||
|
) => {
|
||||||
|
if (entry.isFile && entry.file) {
|
||||||
|
const file = await new Promise<File>((res, rej) => entry.file?.(res, rej));
|
||||||
|
out.push({ path: `${prefix}${entry.name}`, content: await file.text() });
|
||||||
|
} else if (entry.isDirectory && entry.createReader) {
|
||||||
|
const entries = await readAllEntries(entry.createReader());
|
||||||
|
for (const child of entries) {
|
||||||
|
await collectEntry(child, `${prefix}${entry.name}/`, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DotfilesManager = () => {
|
||||||
|
const settings = useQuery(api.userEnvironment.getMine);
|
||||||
|
const filesQuery = useQuery(api.userDotfiles.listMine);
|
||||||
|
const files = useMemo(
|
||||||
|
() => (filesQuery ?? []) as DotfileMeta[],
|
||||||
|
[filesQuery],
|
||||||
|
);
|
||||||
|
const getFileContent = useAction(api.userDotfilesNode.getFileContent);
|
||||||
|
const putFile = useAction(api.userDotfilesNode.putFile);
|
||||||
|
const importFiles = useAction(api.userDotfilesNode.importFiles);
|
||||||
|
const removeFile = useMutation(api.userDotfiles.remove);
|
||||||
|
const updateEnv = useMutation(api.userEnvironment.updateMine);
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState<DotfileMeta>();
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [savedContent, setSavedContent] = useState('');
|
||||||
|
const [expandedOverride, setExpandedOverride] = useState<string[] | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const filesInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const firstName = settings?.firstName ?? 'you';
|
||||||
|
const tree = useMemo(
|
||||||
|
() => buildTree(files, `home/${firstName}`),
|
||||||
|
[files, firstName],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Directories default to expanded; once the user toggles, their choice wins.
|
||||||
|
const allDirs = useMemo(
|
||||||
|
() =>
|
||||||
|
files
|
||||||
|
.flatMap((f) => {
|
||||||
|
const segs = f.path.split('/');
|
||||||
|
return segs
|
||||||
|
.slice(0, -1)
|
||||||
|
.map((_, i) => segs.slice(0, i + 1).join('/'));
|
||||||
|
})
|
||||||
|
.filter((v, i, a) => a.indexOf(v) === i),
|
||||||
|
[files],
|
||||||
|
);
|
||||||
|
const expanded = expandedOverride ?? allDirs;
|
||||||
|
|
||||||
|
const openFile = async (path: string) => {
|
||||||
|
const file = files.find((f) => f.path === path);
|
||||||
|
if (!file) return; // directory
|
||||||
|
setSelected(file);
|
||||||
|
setContent('');
|
||||||
|
setSavedContent('');
|
||||||
|
try {
|
||||||
|
const { content: text } = await getFileContent({
|
||||||
|
fileId: file._id as never,
|
||||||
|
});
|
||||||
|
setContent(text);
|
||||||
|
setSavedContent(text);
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not open file.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSelected = async (next: string) => {
|
||||||
|
if (!selected) return;
|
||||||
|
await putFile({
|
||||||
|
path: selected.path,
|
||||||
|
content: next,
|
||||||
|
isExecutable: selected.isExecutable,
|
||||||
|
});
|
||||||
|
setSavedContent(next);
|
||||||
|
toast.success('Saved.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const importAll = async (incoming: UploadFile[]) => {
|
||||||
|
const valid = incoming.filter((f) => f.path.trim());
|
||||||
|
if (valid.length === 0) return;
|
||||||
|
try {
|
||||||
|
await importFiles({ files: valid });
|
||||||
|
toast.success(`Imported ${valid.length} file(s).`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Import failed.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrop = async (event: React.DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const out: UploadFile[] = [];
|
||||||
|
const entries: FsEntry[] = [];
|
||||||
|
for (const item of Array.from(event.dataTransfer.items)) {
|
||||||
|
const entry = item.webkitGetAsEntry() as FsEntry | null;
|
||||||
|
if (entry) entries.push(entry);
|
||||||
|
}
|
||||||
|
if (entries.length > 0) {
|
||||||
|
for (const entry of entries) await collectEntry(entry, '', out);
|
||||||
|
} else {
|
||||||
|
for (const file of Array.from(event.dataTransfer.files)) {
|
||||||
|
out.push({ path: file.name, content: await file.text() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await importAll(out);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPickFiles = async (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
stripFirstSegment: boolean,
|
||||||
|
) => {
|
||||||
|
const picked = Array.from(event.target.files ?? []);
|
||||||
|
const out: UploadFile[] = [];
|
||||||
|
for (const file of picked) {
|
||||||
|
const relative =
|
||||||
|
(file as File & { webkitRelativePath?: string }).webkitRelativePath ||
|
||||||
|
file.name;
|
||||||
|
const path = stripFirstSegment
|
||||||
|
? relative.split('/').slice(1).join('/')
|
||||||
|
: relative;
|
||||||
|
out.push({ path, content: await file.text() });
|
||||||
|
}
|
||||||
|
event.target.value = '';
|
||||||
|
await importAll(out);
|
||||||
|
};
|
||||||
|
|
||||||
|
const newFile = async () => {
|
||||||
|
const path = window.prompt('New file path (relative to home):', '.bashrc');
|
||||||
|
if (!path?.trim()) return;
|
||||||
|
try {
|
||||||
|
await putFile({ path: path.trim(), content: '' });
|
||||||
|
toast.success('Created.');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Could not create.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSelected = async () => {
|
||||||
|
if (!selected) return;
|
||||||
|
await removeFile({ fileId: selected._id as never });
|
||||||
|
setSelected(undefined);
|
||||||
|
setContent('');
|
||||||
|
setSavedContent('');
|
||||||
|
toast.success('Deleted.');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<Card className='gap-0 overflow-hidden p-0 shadow-none'>
|
||||||
|
<div className='border-border flex flex-wrap items-center gap-2 border-b p-2'>
|
||||||
|
<Button type='button' variant='outline' size='sm' onClick={newFile}>
|
||||||
|
<FilePlus className='size-4' /> New file
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => folderInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<FolderUp className='size-4' /> Upload folder
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => filesInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className='size-4' /> Upload files
|
||||||
|
</Button>
|
||||||
|
{selected ? (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
className='text-destructive ml-auto'
|
||||||
|
onClick={() => void deleteSelected()}
|
||||||
|
>
|
||||||
|
<Trash2 className='size-4' /> Delete
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<input
|
||||||
|
ref={folderInputRef}
|
||||||
|
type='file'
|
||||||
|
// @ts-expect-error non-standard but widely supported folder picker
|
||||||
|
webkitdirectory=''
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={(e) => void onPickFiles(e, true)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={filesInputRef}
|
||||||
|
type='file'
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={(e) => void onPickFiles(e, false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='grid min-h-[28rem] grid-cols-1 md:grid-cols-[16rem_1fr]'>
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={(e) => void onDrop(e)}
|
||||||
|
className={`border-border min-h-0 overflow-auto border-b md:border-r md:border-b-0 ${
|
||||||
|
dragOver ? 'bg-primary/10' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileTree
|
||||||
|
tree={tree}
|
||||||
|
selectedPath={selected?.path}
|
||||||
|
expandedPaths={expanded}
|
||||||
|
onSelect={(path) => void openFile(path)}
|
||||||
|
onToggleDirectory={(path) =>
|
||||||
|
setExpandedOverride(
|
||||||
|
expanded.includes(path)
|
||||||
|
? expanded.filter((p) => p !== path)
|
||||||
|
: [...expanded, path],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<p className='text-muted-foreground p-4 text-center text-xs'>
|
||||||
|
Drag files or folders here, or use the buttons above. They land
|
||||||
|
relative to your home directory.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className='min-h-0'>
|
||||||
|
{selected ? (
|
||||||
|
<CodeEditor
|
||||||
|
path={selected.path}
|
||||||
|
content={content}
|
||||||
|
savedContent={savedContent}
|
||||||
|
readOnly={false}
|
||||||
|
vimEnabled={false}
|
||||||
|
onSave={saveSelected}
|
||||||
|
onChange={setContent}
|
||||||
|
onVimEnabledChange={() => undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='text-muted-foreground flex h-full items-center justify-center p-6 text-sm'>
|
||||||
|
Select a file to edit, or add files to get started.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<RepoPanel
|
||||||
|
settings={settings}
|
||||||
|
onSave={async (values) => {
|
||||||
|
await updateEnv(values);
|
||||||
|
toast.success('Saved.');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Dotfiles are encrypted at rest. For real API keys or tokens, use the
|
||||||
|
Secrets feature on a Spoon instead — those are injected as environment
|
||||||
|
variables.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RepoPanel = ({
|
||||||
|
settings,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
settings:
|
||||||
|
| {
|
||||||
|
dotfilesRepoUrl?: string;
|
||||||
|
dotfilesRepoRef?: string;
|
||||||
|
setupCommand?: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
onSave: (values: {
|
||||||
|
dotfilesRepoUrl?: string;
|
||||||
|
dotfilesRepoRef?: string;
|
||||||
|
setupCommand?: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
const [repoUrl, setRepoUrl] = useState('');
|
||||||
|
const [repoRef, setRepoRef] = useState('');
|
||||||
|
const [setupCommand, setSetupCommand] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settings || hydrated) return;
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
setRepoUrl(settings.dotfilesRepoUrl ?? '');
|
||||||
|
setRepoRef(settings.dotfilesRepoRef ?? '');
|
||||||
|
setSetupCommand(settings.setupCommand ?? '');
|
||||||
|
setHydrated(true);
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [settings, hydrated]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className='space-y-3 p-4 shadow-none'>
|
||||||
|
<div>
|
||||||
|
<h3 className='font-medium'>Dotfiles repo (optional)</h3>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
A public git repo cloned to <code>~/.dotfiles</code> on start. The
|
||||||
|
setup command runs in the container afterwards (e.g.{' '}
|
||||||
|
<code>install</code> to symlink, like a dotfiles bootstrap). Your
|
||||||
|
edited files above are applied on top.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-3 sm:grid-cols-2'>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<Label htmlFor='repoUrl'>Public repo URL</Label>
|
||||||
|
<Input
|
||||||
|
id='repoUrl'
|
||||||
|
placeholder='https://github.com/you/dotfiles'
|
||||||
|
value={repoUrl}
|
||||||
|
onChange={(e) => setRepoUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<Label htmlFor='repoRef'>Branch / ref (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id='repoRef'
|
||||||
|
placeholder='main'
|
||||||
|
value={repoRef}
|
||||||
|
onChange={(e) => setRepoRef(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<Label htmlFor='setupCommand'>Setup script path (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id='setupCommand'
|
||||||
|
placeholder='install.sh'
|
||||||
|
value={setupCommand}
|
||||||
|
onChange={(e) => setSetupCommand(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
size='sm'
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => {
|
||||||
|
setSaving(true);
|
||||||
|
void onSave({
|
||||||
|
dotfilesRepoUrl: repoUrl,
|
||||||
|
dotfilesRepoRef: repoRef,
|
||||||
|
setupCommand,
|
||||||
|
}).finally(() => setSaving(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save repo settings'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
|
import { Copy, RefreshCw, Trash2, Wrench } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Input,
|
||||||
|
} from '@spoon/ui';
|
||||||
|
|
||||||
|
type WorkerHealth = {
|
||||||
|
ok: boolean;
|
||||||
|
workerId: string;
|
||||||
|
convexUrl: string;
|
||||||
|
runtime: string;
|
||||||
|
containerRuntime: string;
|
||||||
|
containerAccess: string;
|
||||||
|
jobImage: string;
|
||||||
|
workdir: string;
|
||||||
|
network?: string;
|
||||||
|
httpPort: number;
|
||||||
|
activeWorkspaceCount: number;
|
||||||
|
workspaceContainers: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type CleanupResult = {
|
||||||
|
removedContainers: string[];
|
||||||
|
removedWorkdirs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkerHealthPanel = () => {
|
||||||
|
const [health, setHealth] = useState<WorkerHealth | null>(null);
|
||||||
|
const [healthError, setHealthError] = useState<string>();
|
||||||
|
const [loadingHealth, setLoadingHealth] = useState(false);
|
||||||
|
const [cleaning, setCleaning] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [olderThanDays, setOlderThanDays] = useState(7);
|
||||||
|
const deletableCount =
|
||||||
|
useQuery(api.agentJobs.countOldWorkspaces, { olderThanDays }) ?? 0;
|
||||||
|
const deleteOldWorkspaces = useMutation(api.agentJobs.deleteOldWorkspaces);
|
||||||
|
|
||||||
|
const copy = async (value: string) => {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
toast.success('Copied.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const DiagnosticValue = ({ value }: { value: string }) => (
|
||||||
|
<dd className='flex items-center gap-2 font-mono break-all'>
|
||||||
|
<span>{value}</span>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
onClick={() => void copy(value)}
|
||||||
|
>
|
||||||
|
<Copy className='size-3' />
|
||||||
|
</Button>
|
||||||
|
</dd>
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshHealth = async () => {
|
||||||
|
setLoadingHealth(true);
|
||||||
|
setHealthError(undefined);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/agent-worker/health');
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
setHealth((await response.json()) as WorkerHealth);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setHealthError(message);
|
||||||
|
setHealth(null);
|
||||||
|
} finally {
|
||||||
|
setLoadingHealth(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshHealth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cleanupOrphans = async () => {
|
||||||
|
setCleaning(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/agent-worker/cleanup', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
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.`,
|
||||||
|
);
|
||||||
|
await refreshHealth();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('Could not clean worker resources.');
|
||||||
|
} finally {
|
||||||
|
setCleaning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteOld = async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const result = await deleteOldWorkspaces({
|
||||||
|
olderThanDays,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
toast.success(`Deleted ${result.deleted} workspaces.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('Could not delete old workspaces.');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<Card className='shadow-none'>
|
||||||
|
<CardHeader className='flex flex-row items-start justify-between gap-4'>
|
||||||
|
<div>
|
||||||
|
<CardTitle>Worker health</CardTitle>
|
||||||
|
<p className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
Runtime status for the server-side agent worker.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
disabled={loadingHealth}
|
||||||
|
onClick={() => void refreshHealth()}
|
||||||
|
>
|
||||||
|
<RefreshCw className='size-4' />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
{healthError ? (
|
||||||
|
<div className='border-destructive/40 bg-destructive/10 text-destructive rounded-md border p-3 text-sm'>
|
||||||
|
{healthError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{health ? (
|
||||||
|
<>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
<Badge variant={health.ok ? 'secondary' : 'destructive'}>
|
||||||
|
{health.ok ? 'healthy' : 'unhealthy'}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant='outline'>{health.workerId}</Badge>
|
||||||
|
<Badge variant='outline'>
|
||||||
|
{health.containerRuntime} / {health.containerAccess}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<dl className='grid gap-3 text-sm md:grid-cols-2'>
|
||||||
|
<div>
|
||||||
|
<dt className='text-muted-foreground'>Convex</dt>
|
||||||
|
<DiagnosticValue value={health.convexUrl} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className='text-muted-foreground'>Job image</dt>
|
||||||
|
<DiagnosticValue value={health.jobImage} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className='text-muted-foreground'>Workdir</dt>
|
||||||
|
<DiagnosticValue value={health.workdir} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className='text-muted-foreground'>Network</dt>
|
||||||
|
<dd className='font-mono break-all'>
|
||||||
|
{health.network ?? 'none'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className='text-muted-foreground'>HTTP port</dt>
|
||||||
|
<dd>{health.httpPort}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className='text-muted-foreground'>Active workspaces</dt>
|
||||||
|
<dd>{health.activeWorkspaceCount}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div>
|
||||||
|
<p className='text-muted-foreground text-sm'>
|
||||||
|
Workspace containers
|
||||||
|
</p>
|
||||||
|
<p className='mt-1 font-mono text-sm'>
|
||||||
|
{health.workspaceContainers.length
|
||||||
|
? health.workspaceContainers.join(', ')
|
||||||
|
: 'none'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : !healthError ? (
|
||||||
|
<p className='text-muted-foreground text-sm'>
|
||||||
|
{loadingHealth ? 'Checking worker...' : 'No worker response yet.'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className='shadow-none'>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Cleanup</CardTitle>
|
||||||
|
<p className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
Remove stopped workspace records and orphaned local worker
|
||||||
|
resources.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
<div className='grid gap-3 md:grid-cols-[12rem_1fr_auto] md:items-end'>
|
||||||
|
<label className='space-y-1'>
|
||||||
|
<span className='text-sm font-medium'>Older than days</span>
|
||||||
|
<Input
|
||||||
|
type='number'
|
||||||
|
min={0}
|
||||||
|
value={olderThanDays}
|
||||||
|
onChange={(event) =>
|
||||||
|
setOlderThanDays(
|
||||||
|
Math.max(Number.parseInt(event.target.value, 10) || 0, 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className='text-muted-foreground text-sm'>
|
||||||
|
{deletableCount} stopped, cancelled, failed, timed out, or expired
|
||||||
|
workspaces match this age filter.
|
||||||
|
</p>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='destructive'
|
||||||
|
disabled={deleting || deletableCount === 0}
|
||||||
|
>
|
||||||
|
<Trash2 className='size-4' />
|
||||||
|
Delete old
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Delete old workspace records?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This deletes up to 100 stopped, cancelled, failed, timed
|
||||||
|
out, or expired workspaces older than {olderThanDays} days.
|
||||||
|
Active workspaces are not eligible.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Keep records</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant='destructive'
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={() => void deleteOld()}
|
||||||
|
>
|
||||||
|
Delete old workspaces
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='border-border flex flex-col justify-between gap-3 rounded-md border p-3 md:flex-row md:items-center'>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
disabled={cleaning}
|
||||||
|
onClick={() => void cleanupOrphans()}
|
||||||
|
>
|
||||||
|
<Wrench className='size-4' />
|
||||||
|
Clean orphans
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ProviderModelOption } from '@/lib/models-dev';
|
import { useState } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { loadModelsDevOptions } from '@/lib/models-dev';
|
|
||||||
import { useMutation, useQuery } from 'convex/react';
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
import { Bot } from 'lucide-react';
|
import { Bot } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -53,6 +51,7 @@ export const SpoonAgentSettingsForm = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const update = useMutation(api.spoonAgentSettings.update);
|
const update = useMutation(api.spoonAgentSettings.update);
|
||||||
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||||
|
const modelCatalog = useQuery(api.aiProviderModels.listAvailableForUser);
|
||||||
const configuredProfiles = profiles.filter(
|
const configuredProfiles = profiles.filter(
|
||||||
(profile) => profile.enabled && profile.configured,
|
(profile) => profile.enabled && profile.configured,
|
||||||
);
|
);
|
||||||
@@ -99,8 +98,12 @@ export const SpoonAgentSettingsForm = ({
|
|||||||
? defaultProfile?._id
|
? defaultProfile?._id
|
||||||
: aiProviderProfileId),
|
: aiProviderProfileId),
|
||||||
);
|
);
|
||||||
const [availableModels, setAvailableModels] = useState<ProviderModelOption[]>(
|
const selectedModelProfile = modelCatalog?.profiles.find(
|
||||||
[],
|
(profile) =>
|
||||||
|
profile.profileId ===
|
||||||
|
(aiProviderProfileId === '__default'
|
||||||
|
? defaultProfile?._id
|
||||||
|
: aiProviderProfileId),
|
||||||
);
|
);
|
||||||
const [agentModel, setAgentModel] = useState(
|
const [agentModel, setAgentModel] = useState(
|
||||||
settings?.aiProviderProfileId ? settings.agentModel : '',
|
settings?.aiProviderProfileId ? settings.agentModel : '',
|
||||||
@@ -115,42 +118,17 @@ export const SpoonAgentSettingsForm = ({
|
|||||||
: settings.reasoningEffort,
|
: settings.reasoningEffort,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const selectableModels = selectedModelProfile?.configured
|
||||||
if (!selectedProfile?.configured) {
|
? selectedModelProfile.models
|
||||||
return;
|
: [];
|
||||||
}
|
const selectedAgentModel =
|
||||||
let cancelled = false;
|
agentModel && selectableModels.some((model) => model.id === agentModel)
|
||||||
loadModelsDevOptions(selectedProfile.provider)
|
? agentModel
|
||||||
.then((models) => {
|
: selectableModels.some(
|
||||||
if (cancelled) return;
|
(model) => model.id === selectedModelProfile?.defaultModel,
|
||||||
setAvailableModels(models);
|
)
|
||||||
setAgentModel((current) =>
|
? (selectedModelProfile?.defaultModel ?? '')
|
||||||
current && models.some((model) => model.id === current)
|
: (selectableModels[0]?.id ?? '');
|
||||||
? current
|
|
||||||
: models.some((model) => model.id === selectedProfile.defaultModel)
|
|
||||||
? selectedProfile.defaultModel
|
|
||||||
: (models[0]?.id ?? ''),
|
|
||||||
);
|
|
||||||
setReasoningEffort(
|
|
||||||
selectedProfile.reasoningEffort === 'none'
|
|
||||||
? 'minimal'
|
|
||||||
: selectedProfile.reasoningEffort,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
console.error(error);
|
|
||||||
if (!cancelled) setAvailableModels([]);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
selectedProfile?.configured,
|
|
||||||
selectedProfile?.defaultModel,
|
|
||||||
selectedProfile?.provider,
|
|
||||||
selectedProfile?.reasoningEffort,
|
|
||||||
]);
|
|
||||||
const selectableModels = selectedProfile?.configured ? availableModels : [];
|
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -163,9 +141,7 @@ export const SpoonAgentSettingsForm = ({
|
|||||||
installCommand: installCommand || undefined,
|
installCommand: installCommand || undefined,
|
||||||
checkCommand: checkCommand || undefined,
|
checkCommand: checkCommand || undefined,
|
||||||
testCommand: testCommand || undefined,
|
testCommand: testCommand || undefined,
|
||||||
agentModel: agentModel.trim()
|
agentModel: selectedAgentModel || undefined,
|
||||||
? agentModel
|
|
||||||
: (selectableModels[0]?.id ?? undefined),
|
|
||||||
reasoningEffort,
|
reasoningEffort,
|
||||||
envFilePath: envFilePath as
|
envFilePath: envFilePath as
|
||||||
| '.env'
|
| '.env'
|
||||||
@@ -200,7 +176,7 @@ export const SpoonAgentSettingsForm = ({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4'>
|
<CardContent className='space-y-4'>
|
||||||
<div className='flex items-center justify-between gap-4'>
|
<div className='flex items-center justify-between gap-4'>
|
||||||
<Label htmlFor='agentEnabled'>Enable agent jobs</Label>
|
<Label htmlFor='agentEnabled'>Enable thread workspaces</Label>
|
||||||
<Switch
|
<Switch
|
||||||
id='agentEnabled'
|
id='agentEnabled'
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
@@ -249,7 +225,8 @@ export const SpoonAgentSettingsForm = ({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className='text-muted-foreground text-xs'>
|
<p className='text-muted-foreground text-xs'>
|
||||||
OpenCode jobs and maintenance review threads use this profile.
|
Workspaces use this profile. Use default resolves to your account
|
||||||
|
default provider.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='grid gap-2'>
|
<div className='grid gap-2'>
|
||||||
@@ -271,7 +248,7 @@ export const SpoonAgentSettingsForm = ({
|
|||||||
<div className='grid gap-2'>
|
<div className='grid gap-2'>
|
||||||
<Label htmlFor='agentModel'>Model</Label>
|
<Label htmlFor='agentModel'>Model</Label>
|
||||||
<Select
|
<Select
|
||||||
value={agentModel}
|
value={selectedAgentModel}
|
||||||
onValueChange={setAgentModel}
|
onValueChange={setAgentModel}
|
||||||
disabled={!selectableModels.length}
|
disabled={!selectableModels.length}
|
||||||
>
|
>
|
||||||
@@ -288,8 +265,8 @@ export const SpoonAgentSettingsForm = ({
|
|||||||
</Select>
|
</Select>
|
||||||
{!selectableModels.length ? (
|
{!selectableModels.length ? (
|
||||||
<p className='text-muted-foreground text-xs'>
|
<p className='text-muted-foreground text-xs'>
|
||||||
Configure an enabled AI provider profile in Settings before
|
Configure an enabled AI provider profile with saved model
|
||||||
choosing a model.
|
options in Settings before choosing a model.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -423,7 +400,7 @@ export const SpoonAgentSettingsForm = ({
|
|||||||
onClick={save}
|
onClick={save}
|
||||||
disabled={
|
disabled={
|
||||||
!selectedProfile?.configured ||
|
!selectedProfile?.configured ||
|
||||||
!selectableModels.some((model) => model.id === agentModel)
|
!selectableModels.some((model) => model.id === selectedAgentModel)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Save agent settings
|
Save agent settings
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ const formatDate = (value?: number) =>
|
|||||||
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
|
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
|
||||||
: 'Never';
|
: 'Never';
|
||||||
|
|
||||||
export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
|
type SpoonCardData = Doc<'spoons'> & {
|
||||||
|
rawUpstreamAheadBy?: number;
|
||||||
|
effectiveUpstreamAheadBy?: number;
|
||||||
|
ignoredUpstreamCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SpoonCard = ({ spoon }: { spoon: SpoonCardData }) => (
|
||||||
<Link href={`/spoons/${spoon._id}`} className='group/spoon-card block'>
|
<Link href={`/spoons/${spoon._id}`} className='group/spoon-card block'>
|
||||||
<Card className='group-hover/spoon-card:border-primary/50 group-hover/spoon-card:bg-muted/20 shadow-none transition-colors'>
|
<Card className='group-hover/spoon-card:border-primary/50 group-hover/spoon-card:bg-muted/20 shadow-none transition-colors'>
|
||||||
<CardHeader className='flex-row items-start justify-between gap-4'>
|
<CardHeader className='flex-row items-start justify-between gap-4'>
|
||||||
@@ -45,8 +51,15 @@ export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
|
|||||||
<p className='font-medium'>{formatDate(spoon.lastCheckedAt)}</p>
|
<p className='font-medium'>{formatDate(spoon.lastCheckedAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-muted-foreground'>Upstream waiting</p>
|
<p className='text-muted-foreground'>Actionable upstream</p>
|
||||||
<p className='font-medium'>{spoon.upstreamAheadBy ?? 0}</p>
|
<p className='font-medium'>
|
||||||
|
{spoon.effectiveUpstreamAheadBy ?? spoon.upstreamAheadBy ?? 0}
|
||||||
|
</p>
|
||||||
|
{spoon.ignoredUpstreamCount ? (
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
{spoon.ignoredUpstreamCount} ignored
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-muted-foreground'>Fork-only commits</p>
|
<p className='text-muted-foreground'>Fork-only commits</p>
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useMutation } from 'convex/react';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
Button,
|
||||||
|
} from '@spoon/ui';
|
||||||
|
|
||||||
|
export const DeleteThreadButton = ({
|
||||||
|
threadId,
|
||||||
|
disabled,
|
||||||
|
redirectTo,
|
||||||
|
onDeleted,
|
||||||
|
label = 'Delete',
|
||||||
|
size = 'sm',
|
||||||
|
variant = 'destructive',
|
||||||
|
}: {
|
||||||
|
threadId: Id<'threads'>;
|
||||||
|
disabled?: boolean;
|
||||||
|
redirectTo?: string;
|
||||||
|
onDeleted?: () => void;
|
||||||
|
label?: string;
|
||||||
|
size?: 'sm' | 'default';
|
||||||
|
variant?: 'destructive' | 'outline';
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const deleteThread = useMutation(api.threads.deleteThread);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteThread({ threadId });
|
||||||
|
toast.success('Thread deleted.');
|
||||||
|
onDeleted?.();
|
||||||
|
if (redirectTo) router.push(redirectTo);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Could not delete thread.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
size={size}
|
||||||
|
variant={variant}
|
||||||
|
disabled={(disabled ?? false) || deleting}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Trash2 className='size-4' />
|
||||||
|
{deleting ? 'Deleting...' : label}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent onClick={(event) => event.stopPropagation()}>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete this thread?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This removes the thread and any terminal workspace records,
|
||||||
|
messages, events, artifacts, diffs, and UI state attached to it.
|
||||||
|
This cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Keep thread</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant='destructive'
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={() => void remove()}
|
||||||
|
>
|
||||||
|
Delete thread
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
+11
-8
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import { useMutation, useQuery } from 'convex/react';
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
import { Bot } from 'lucide-react';
|
import { MessageSquarePlus } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
@@ -35,13 +36,14 @@ type AgentSettings = {
|
|||||||
aiProviderProfileId?: Id<'aiProviderProfiles'>;
|
aiProviderProfileId?: Id<'aiProviderProfiles'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AgentRequestForm = ({
|
export const ThreadWorkspaceForm = ({
|
||||||
spoon,
|
spoon,
|
||||||
agentSettings,
|
agentSettings,
|
||||||
}: {
|
}: {
|
||||||
spoon: Doc<'spoons'>;
|
spoon: Doc<'spoons'>;
|
||||||
agentSettings?: AgentSettings | null;
|
agentSettings?: AgentSettings | null;
|
||||||
}) => {
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
const secrets =
|
const secrets =
|
||||||
useQuery(api.spoonSecrets.listForSpoon, {
|
useQuery(api.spoonSecrets.listForSpoon, {
|
||||||
spoonId: spoon._id,
|
spoonId: spoon._id,
|
||||||
@@ -90,7 +92,7 @@ export const AgentRequestForm = ({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await createThread({
|
const threadId = await createThread({
|
||||||
spoonId: spoon._id,
|
spoonId: spoon._id,
|
||||||
prompt,
|
prompt,
|
||||||
baseBranch,
|
baseBranch,
|
||||||
@@ -105,9 +107,10 @@ export const AgentRequestForm = ({
|
|||||||
setPrompt('');
|
setPrompt('');
|
||||||
setRequestedBranchName('');
|
setRequestedBranchName('');
|
||||||
toast.success('Thread created.');
|
toast.success('Thread created.');
|
||||||
|
router.push(`/threads/${threadId}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error('Could not queue agent job.');
|
toast.error('Could not create thread workspace.');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -117,16 +120,16 @@ export const AgentRequestForm = ({
|
|||||||
<Card className='shadow-none'>
|
<Card className='shadow-none'>
|
||||||
<CardHeader className='pb-3'>
|
<CardHeader className='pb-3'>
|
||||||
<CardTitle className='flex items-center gap-2 text-base'>
|
<CardTitle className='flex items-center gap-2 text-base'>
|
||||||
<Bot className='size-4' />
|
<MessageSquarePlus className='size-4' />
|
||||||
Request agent work
|
Create thread workspace
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={submit} className='space-y-4'>
|
<form onSubmit={submit} className='space-y-4'>
|
||||||
<div className='grid gap-2'>
|
<div className='grid gap-2'>
|
||||||
<Label htmlFor='agentPrompt'>Prompt</Label>
|
<Label htmlFor='threadPrompt'>Prompt</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id='agentPrompt'
|
id='threadPrompt'
|
||||||
required
|
required
|
||||||
minLength={12}
|
minLength={12}
|
||||||
value={prompt}
|
value={prompt}
|
||||||
@@ -22,6 +22,9 @@ export const env = createEnv({
|
|||||||
SPOON_AGENT_WORKER_URL: z.url().default('http://localhost:3921'),
|
SPOON_AGENT_WORKER_URL: z.url().default('http://localhost:3921'),
|
||||||
SPOON_AGENT_WORKER_INTERNAL_TOKEN: z.string().optional(),
|
SPOON_AGENT_WORKER_INTERNAL_TOKEN: z.string().optional(),
|
||||||
SPOON_WORKER_TOKEN: z.string().optional(),
|
SPOON_WORKER_TOKEN: z.string().optional(),
|
||||||
|
// Secret shared with the worker for signing short-lived terminal tokens.
|
||||||
|
// Falls back (in code) to the worker internal token.
|
||||||
|
SPOON_AGENT_TERMINAL_SECRET: z.string().optional(),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +39,10 @@ export const env = createEnv({
|
|||||||
NEXT_PUBLIC_SENTRY_URL: z.string(),
|
NEXT_PUBLIC_SENTRY_URL: z.string(),
|
||||||
NEXT_PUBLIC_SENTRY_ORG: z.string(),
|
NEXT_PUBLIC_SENTRY_ORG: z.string(),
|
||||||
NEXT_PUBLIC_SENTRY_PROJECT_NAME: z.string(),
|
NEXT_PUBLIC_SENTRY_PROJECT_NAME: z.string(),
|
||||||
|
// Browser-facing WebSocket base URL of the agent worker, e.g.
|
||||||
|
// `wss://worker.spoon.gbrown.org` (prod) or `ws://localhost:3921` (dev).
|
||||||
|
// When unset, the workspace Terminal tab is disabled.
|
||||||
|
NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL: z.string().optional(),
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
||||||
@@ -59,6 +66,9 @@ export const env = createEnv({
|
|||||||
SPOON_AGENT_WORKER_INTERNAL_TOKEN:
|
SPOON_AGENT_WORKER_INTERNAL_TOKEN:
|
||||||
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN,
|
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN,
|
||||||
SPOON_WORKER_TOKEN: process.env.SPOON_WORKER_TOKEN,
|
SPOON_WORKER_TOKEN: process.env.SPOON_WORKER_TOKEN,
|
||||||
|
SPOON_AGENT_TERMINAL_SECRET: process.env.SPOON_AGENT_TERMINAL_SECRET,
|
||||||
|
NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL:
|
||||||
|
process.env.NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL,
|
||||||
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
|
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
|
||||||
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
|
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
|
||||||
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
|
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'server-only';
|
import 'server-only';
|
||||||
|
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { env } from '@/env';
|
import { env } from '@/env';
|
||||||
import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server';
|
import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server';
|
||||||
@@ -8,6 +9,26 @@ import { fetchQuery } from 'convex/nextjs';
|
|||||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
const terminalSecret = () =>
|
||||||
|
env.SPOON_AGENT_TERMINAL_SECRET ??
|
||||||
|
env.SPOON_AGENT_WORKER_INTERNAL_TOKEN ??
|
||||||
|
env.SPOON_WORKER_TOKEN;
|
||||||
|
|
||||||
|
// Mints a short-lived, job-scoped terminal token + the worker WS URL. Returns
|
||||||
|
// null when the terminal feature is not configured. The 2-minute expiry is a
|
||||||
|
// connect window only; an established PTY session persists past it.
|
||||||
|
export const mintTerminalToken = (jobId: Id<'agentJobs'>) => {
|
||||||
|
const secret = terminalSecret();
|
||||||
|
const base = env.NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL;
|
||||||
|
if (!secret || !base) return null;
|
||||||
|
const expiresAt = Date.now() + 2 * 60 * 1000;
|
||||||
|
const payload = `${expiresAt}.${jobId}`;
|
||||||
|
const signature = createHmac('sha256', secret).update(payload).digest('hex');
|
||||||
|
const token = `${payload}.${signature}`;
|
||||||
|
const url = `${base.replace(/\/$/, '')}/jobs/${encodeURIComponent(jobId)}/terminal?token=${encodeURIComponent(token)}`;
|
||||||
|
return { url, expiresAt };
|
||||||
|
};
|
||||||
|
|
||||||
type RouteContext = {
|
type RouteContext = {
|
||||||
params: Promise<{ jobId: string }> | { jobId: string };
|
params: Promise<{ jobId: string }> | { jobId: string };
|
||||||
};
|
};
|
||||||
@@ -32,6 +53,45 @@ export const requireOwnedJob = async (jobId: Id<'agentJobs'>) => {
|
|||||||
return { ok: true as const };
|
return { ok: true as const };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const requireAuthenticatedUser = async () => {
|
||||||
|
const token = await convexAuthNextjsToken();
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await fetchQuery(api.auth.getUser, {}, { token });
|
||||||
|
return { ok: true as const };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const proxyWorkerRoot = async (path: string, init?: RequestInit) => {
|
||||||
|
const token = workerToken();
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SPOON_AGENT_WORKER_INTERNAL_TOKEN is not configured.' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const url = new URL(path, env.SPOON_AGENT_WORKER_URL);
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const proxyWorker = async (
|
export const proxyWorker = async (
|
||||||
jobId: Id<'agentJobs'>,
|
jobId: Id<'agentJobs'>,
|
||||||
action: string,
|
action: string,
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
type ModelsDevModel = {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
tool_call?: boolean;
|
|
||||||
reasoning?: boolean;
|
|
||||||
limit?: { context?: number };
|
|
||||||
};
|
|
||||||
|
|
||||||
type ModelsDevProvider = {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
models?: Record<string, ModelsDevModel>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const providerMap = {
|
|
||||||
openai: 'openai',
|
|
||||||
anthropic: 'anthropic',
|
|
||||||
google: 'google',
|
|
||||||
openrouter: 'openrouter',
|
|
||||||
requesty: 'requesty',
|
|
||||||
litellm: 'litellm',
|
|
||||||
cloudflare_ai_gateway: 'cloudflare',
|
|
||||||
custom_openai_compatible: '',
|
|
||||||
opencode_openai_login: 'openai',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type ProviderModelOption = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
reasoning: boolean;
|
|
||||||
toolCall: boolean;
|
|
||||||
context?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const loadModelsDevOptions = async (provider: string) => {
|
|
||||||
const mapped = providerMap[provider as keyof typeof providerMap];
|
|
||||||
if (!mapped) return [];
|
|
||||||
const response = await fetch('https://models.dev/api.json', {
|
|
||||||
cache: 'force-cache',
|
|
||||||
});
|
|
||||||
if (!response.ok) return [];
|
|
||||||
const catalog = (await response.json()) as Record<string, ModelsDevProvider>;
|
|
||||||
const providerCatalog = catalog[mapped];
|
|
||||||
return Object.entries(providerCatalog?.models ?? {})
|
|
||||||
.map(
|
|
||||||
([id, model]): ProviderModelOption => ({
|
|
||||||
id: model.id ?? id,
|
|
||||||
label: model.name ?? model.id ?? id,
|
|
||||||
reasoning: Boolean(model.reasoning),
|
|
||||||
toolCall: Boolean(model.tool_call),
|
|
||||||
context: model.limit?.context,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.filter((model) => model.toolCall)
|
|
||||||
.sort((a, b) => a.label.localeCompare(b.label));
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
export type ProviderModelOption = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
reasoning: boolean;
|
||||||
|
toolCall: boolean;
|
||||||
|
context?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
openai: ['gpt-5.1-codex', 'gpt-5.1', 'gpt-5', 'gpt-5-mini'],
|
||||||
|
opencode_openai_login: ['gpt-5.1-codex', 'gpt-5.1', 'gpt-5'],
|
||||||
|
anthropic: ['claude-sonnet-4-5', 'claude-opus-4-5', 'claude-haiku-4-5'],
|
||||||
|
google: ['gemini-3-pro', 'gemini-2.5-pro', 'gemini-2.5-flash'],
|
||||||
|
openrouter: ['openai/gpt-5.1-codex', 'anthropic/claude-sonnet-4-5'],
|
||||||
|
requesty: ['openai/gpt-5.1-codex', 'anthropic/claude-sonnet-4-5'],
|
||||||
|
litellm: ['openai/gpt-5.1-codex', 'anthropic/claude-sonnet-4-5'],
|
||||||
|
cloudflare_ai_gateway: ['openai/gpt-5.1-codex'],
|
||||||
|
custom_openai_compatible: ['gpt-5.1-codex'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ProviderModelKey = keyof typeof options;
|
||||||
|
const modelOptionsByProvider: Record<string, readonly string[]> = options;
|
||||||
|
|
||||||
|
const labelForModel = (id: string) => {
|
||||||
|
const label = id
|
||||||
|
.split('/')
|
||||||
|
.at(-1)
|
||||||
|
?.replaceAll('-', ' ')
|
||||||
|
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
|
return label ?? id;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const suggestedModelOptions = (
|
||||||
|
provider: string,
|
||||||
|
): ProviderModelOption[] =>
|
||||||
|
(modelOptionsByProvider[provider] ?? []).map((id) => ({
|
||||||
|
id,
|
||||||
|
label: labelForModel(id),
|
||||||
|
reasoning: true,
|
||||||
|
toolCall: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const modelOptionsFromIds = (
|
||||||
|
ids: string[] | undefined,
|
||||||
|
): ProviderModelOption[] =>
|
||||||
|
(ids ?? [])
|
||||||
|
.map((id) => id.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((id, index, all) => all.indexOf(id) === index)
|
||||||
|
.map((id) => ({
|
||||||
|
id,
|
||||||
|
label: labelForModel(id),
|
||||||
|
reasoning: true,
|
||||||
|
toolCall: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const modelIdsForProfile = (profile?: {
|
||||||
|
defaultModel?: string;
|
||||||
|
modelOptions?: string[];
|
||||||
|
}) =>
|
||||||
|
[profile?.defaultModel, ...(profile?.modelOptions ?? [])]
|
||||||
|
.filter((model): model is string => Boolean(model?.trim()))
|
||||||
|
.filter((model, index, all) => all.indexOf(model) === index);
|
||||||
|
|
||||||
|
export const supportsCustomModelOptions = (provider: string) =>
|
||||||
|
[
|
||||||
|
'openrouter',
|
||||||
|
'requesty',
|
||||||
|
'litellm',
|
||||||
|
'cloudflare_ai_gateway',
|
||||||
|
'custom_openai_compatible',
|
||||||
|
].includes(provider);
|
||||||
@@ -1,16 +1,27 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import ThreadDetailPage from '../../src/app/(app)/threads/[threadId]/page';
|
||||||
|
import { AgentThread } from '../../src/components/agent-workspace/agent-thread';
|
||||||
|
import { extractFileDiff } from '../../src/components/agent-workspace/diff-utils';
|
||||||
import { Hero } from '../../src/components/landing';
|
import { Hero } from '../../src/components/landing';
|
||||||
import { NewSpoonForm } from '../../src/components/spoons/new-spoon-form';
|
import { NewSpoonForm } from '../../src/components/spoons/new-spoon-form';
|
||||||
|
|
||||||
|
const { mockUseMutation, mockUseParams, mockUseQuery } = vi.hoisted(() => ({
|
||||||
|
mockUseMutation: vi.fn(),
|
||||||
|
mockUseParams: vi.fn(),
|
||||||
|
mockUseQuery: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('convex/react', () => ({
|
vi.mock('convex/react', () => ({
|
||||||
useConvexAuth: () => ({ isAuthenticated: false }),
|
useConvexAuth: () => ({ isAuthenticated: false }),
|
||||||
useMutation: () => vi.fn(),
|
useMutation: mockUseMutation,
|
||||||
|
useQuery: mockUseQuery,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('next/navigation', () => ({
|
vi.mock('next/navigation', () => ({
|
||||||
useRouter: () => ({ push: vi.fn() }),
|
useParams: mockUseParams,
|
||||||
|
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('sonner', () => ({
|
vi.mock('sonner', () => ({
|
||||||
@@ -20,6 +31,12 @@ vi.mock('sonner', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/agent-workspace/agent-workspace-shell', () => ({
|
||||||
|
AgentWorkspaceShell: ({ jobId }: { jobId: string }) => (
|
||||||
|
<div>workspace shell {jobId}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('component test harness', () => {
|
describe('component test harness', () => {
|
||||||
it('renders the Spoon landing headline', () => {
|
it('renders the Spoon landing headline', () => {
|
||||||
render(<Hero />);
|
render(<Hero />);
|
||||||
@@ -36,4 +53,194 @@ describe('component test harness', () => {
|
|||||||
expect(screen.getByLabelText(/upstream owner/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/upstream owner/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/upstream repository/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/upstream repository/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('extracts a single file diff from a workspace diff', () => {
|
||||||
|
const diff = [
|
||||||
|
'diff --git a/apps/web/auth.ts b/apps/web/auth.ts',
|
||||||
|
'index 123..456 100644',
|
||||||
|
'--- a/apps/web/auth.ts',
|
||||||
|
'+++ b/apps/web/auth.ts',
|
||||||
|
'@@ -1 +1 @@',
|
||||||
|
'-github',
|
||||||
|
'+authentik',
|
||||||
|
'diff --git a/README.md b/README.md',
|
||||||
|
'--- a/README.md',
|
||||||
|
'+++ b/README.md',
|
||||||
|
'@@ -1 +1 @@',
|
||||||
|
'-old',
|
||||||
|
'+new',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
expect(extractFileDiff(diff, 'apps/web/auth.ts')).toContain('+authentik');
|
||||||
|
expect(extractFileDiff(diff, 'apps/web/auth.ts')).not.toContain(
|
||||||
|
'README.md',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders workspace file activity and opens changed files', () => {
|
||||||
|
const onOpenFile = vi.fn();
|
||||||
|
const onOpenDiff = vi.fn();
|
||||||
|
render(
|
||||||
|
<AgentThread
|
||||||
|
jobId='job-1'
|
||||||
|
messages={[]}
|
||||||
|
events={[]}
|
||||||
|
interactions={[]}
|
||||||
|
workspaceChanges={[
|
||||||
|
{
|
||||||
|
_id: 'change-1',
|
||||||
|
_creationTime: 1,
|
||||||
|
jobId: 'job-1',
|
||||||
|
spoonId: 'spoon-1',
|
||||||
|
ownerId: 'user-1',
|
||||||
|
path: 'apps/web/auth.ts',
|
||||||
|
source: 'agent',
|
||||||
|
changeType: 'modified',
|
||||||
|
diff: 'diff --git a/apps/web/auth.ts b/apps/web/auth.ts\n+authentik',
|
||||||
|
createdAt: 1,
|
||||||
|
} as never,
|
||||||
|
]}
|
||||||
|
disabled={false}
|
||||||
|
agentTurnActive={false}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
|
onOpenDiff={onOpenDiff}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Files' }));
|
||||||
|
expect(screen.getByText('apps/web/auth.ts')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'View diff' }));
|
||||||
|
expect(onOpenDiff).toHaveBeenCalledWith('apps/web/auth.ts');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
|
||||||
|
expect(onOpenFile).toHaveBeenCalledWith('apps/web/auth.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the workspace thread focused on user, agent, and tool content', () => {
|
||||||
|
render(
|
||||||
|
<AgentThread
|
||||||
|
jobId='job-1'
|
||||||
|
messages={[
|
||||||
|
{
|
||||||
|
_id: 'message-system',
|
||||||
|
_creationTime: 1,
|
||||||
|
jobId: 'job-1',
|
||||||
|
spoonId: 'spoon-1',
|
||||||
|
ownerId: 'user-1',
|
||||||
|
role: 'system',
|
||||||
|
content: 'Workspace is ready.',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
_id: 'message-empty-assistant',
|
||||||
|
_creationTime: 2,
|
||||||
|
jobId: 'job-1',
|
||||||
|
spoonId: 'spoon-1',
|
||||||
|
ownerId: 'user-1',
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: 2,
|
||||||
|
updatedAt: 2,
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
_id: 'message-user',
|
||||||
|
_creationTime: 3,
|
||||||
|
jobId: 'job-1',
|
||||||
|
spoonId: 'spoon-1',
|
||||||
|
ownerId: 'user-1',
|
||||||
|
role: 'user',
|
||||||
|
content: 'Use Authentik as the only provider.',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: 3,
|
||||||
|
updatedAt: 3,
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
_id: 'message-assistant',
|
||||||
|
_creationTime: 4,
|
||||||
|
jobId: 'job-1',
|
||||||
|
spoonId: 'spoon-1',
|
||||||
|
ownerId: 'user-1',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'I found the Auth.js provider configuration.',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: 4,
|
||||||
|
updatedAt: 4,
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
_id: 'message-tool',
|
||||||
|
_creationTime: 5,
|
||||||
|
jobId: 'job-1',
|
||||||
|
spoonId: 'spoon-1',
|
||||||
|
ownerId: 'user-1',
|
||||||
|
role: 'tool',
|
||||||
|
content: 'rg Authentik',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: 5,
|
||||||
|
updatedAt: 5,
|
||||||
|
} as never,
|
||||||
|
]}
|
||||||
|
events={[
|
||||||
|
{
|
||||||
|
_id: 'event-info',
|
||||||
|
_creationTime: 1,
|
||||||
|
jobId: 'job-1',
|
||||||
|
level: 'info',
|
||||||
|
phase: 'plan',
|
||||||
|
message: 'Sending message to agent.',
|
||||||
|
createdAt: 1,
|
||||||
|
} as never,
|
||||||
|
]}
|
||||||
|
interactions={[]}
|
||||||
|
workspaceChanges={[]}
|
||||||
|
disabled={false}
|
||||||
|
agentTurnActive={false}
|
||||||
|
onOpenFile={vi.fn()}
|
||||||
|
onOpenDiff={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Workspace is ready.')).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText('Sending message to agent.'),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Assistant')).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('Use Authentik as the only provider.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('I found the Auth.js provider configuration.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('rg Authentik')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders thread workspaces on the canonical thread route', () => {
|
||||||
|
mockUseParams.mockReturnValue({ threadId: 'thread-1' });
|
||||||
|
mockUseQuery.mockReturnValue({
|
||||||
|
thread: {
|
||||||
|
_id: 'thread-1',
|
||||||
|
title: 'Update auth',
|
||||||
|
status: 'running',
|
||||||
|
source: 'user_request',
|
||||||
|
priority: 'normal',
|
||||||
|
summary: 'Use Authentik',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
},
|
||||||
|
spoon: { _id: 'spoon-1', name: 'useSend' },
|
||||||
|
latestJob: {
|
||||||
|
_id: 'job-1',
|
||||||
|
spoonId: 'spoon-1',
|
||||||
|
status: 'running',
|
||||||
|
workspaceStatus: 'active',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockUseMutation.mockReturnValue(vi.fn());
|
||||||
|
|
||||||
|
render(<ThreadDetailPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('workspace shell job-1')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Thread state')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
modelIdsForProfile,
|
||||||
|
modelOptionsFromIds,
|
||||||
|
suggestedModelOptions,
|
||||||
|
supportsCustomModelOptions,
|
||||||
|
} from '../../src/lib/provider-model-options';
|
||||||
|
|
||||||
|
describe('provider model options', () => {
|
||||||
|
it('returns stored profile model ids without duplicates', () => {
|
||||||
|
expect(
|
||||||
|
modelIdsForProfile({
|
||||||
|
defaultModel: 'gpt-5.1-codex',
|
||||||
|
modelOptions: ['gpt-5.1-codex', 'gpt-5'],
|
||||||
|
}),
|
||||||
|
).toEqual(['gpt-5.1-codex', 'gpt-5']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides local suggestions for built-in providers', () => {
|
||||||
|
expect(
|
||||||
|
suggestedModelOptions('openai').some(
|
||||||
|
(model) => model.id === 'gpt-5.1-codex',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports custom model ids only for gateway-style providers', () => {
|
||||||
|
expect(supportsCustomModelOptions('openrouter')).toBe(true);
|
||||||
|
expect(supportsCustomModelOptions('openai')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes model ids into select options', () => {
|
||||||
|
expect(modelOptionsFromIds(['openai/gpt-5.1-codex'])[0]).toMatchObject({
|
||||||
|
id: 'openai/gpt-5.1-codex',
|
||||||
|
label: 'Gpt 5.1 Codex',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
basename,
|
||||||
|
languageForPath,
|
||||||
|
} from '../../src/components/agent-workspace/languages';
|
||||||
|
|
||||||
|
describe('workspace language helpers', () => {
|
||||||
|
it('maps common code file extensions to Monaco languages', () => {
|
||||||
|
expect(languageForPath('src/app.ts')).toBe('typescript');
|
||||||
|
expect(languageForPath('src/app.tsx')).toBe('typescript');
|
||||||
|
expect(languageForPath('src/app.js')).toBe('javascript');
|
||||||
|
expect(languageForPath('package.json')).toBe('json');
|
||||||
|
expect(languageForPath('README.md')).toBe('markdown');
|
||||||
|
expect(languageForPath('.env.local')).toBe('plaintext');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets Monaco fall back for unknown paths', () => {
|
||||||
|
expect(languageForPath('Gemfile')).toBeUndefined();
|
||||||
|
expect(languageForPath()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a useful basename for file tabs', () => {
|
||||||
|
expect(basename('src/components/button.tsx')).toBe('button.tsx');
|
||||||
|
expect(basename('README.md')).toBe('README.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,35 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
import { jsdomProject, nodeProject } from '@spoon/vitest-config';
|
import { jsdomProject, nodeProject } from '@spoon/vitest-config';
|
||||||
|
|
||||||
|
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const srcAlias = path.join(dirname, 'src');
|
||||||
|
const withNextAlias = <T extends object>(project: T) => ({
|
||||||
|
...project,
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': srcAlias,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': srcAlias,
|
||||||
|
},
|
||||||
|
},
|
||||||
test: {
|
test: {
|
||||||
projects: [
|
projects: [
|
||||||
nodeProject('unit', ['tests/unit/**/*.test.{ts,tsx}']),
|
withNextAlias(nodeProject('unit', ['tests/unit/**/*.test.{ts,tsx}'])),
|
||||||
nodeProject('integration', ['tests/integration/**/*.test.{ts,tsx}']),
|
withNextAlias(
|
||||||
jsdomProject('component', ['tests/component/**/*.test.{ts,tsx}']),
|
nodeProject('integration', ['tests/integration/**/*.test.{ts,tsx}']),
|
||||||
|
),
|
||||||
|
withNextAlias(
|
||||||
|
jsdomProject('component', ['tests/component/**/*.test.{ts,tsx}']),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,14 +23,18 @@
|
|||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
"@opencode-ai/sdk": "latest",
|
"@opencode-ai/sdk": "latest",
|
||||||
"convex": "catalog:convex",
|
"convex": "catalog:convex",
|
||||||
|
"dockerode": "^4.0.7",
|
||||||
"execa": "latest",
|
"execa": "latest",
|
||||||
|
"ws": "catalog:",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@spoon/eslint-config": "workspace:*",
|
"@spoon/eslint-config": "workspace:*",
|
||||||
"@spoon/prettier-config": "workspace:*",
|
"@spoon/prettier-config": "workspace:*",
|
||||||
"@spoon/tsconfig": "workspace:*",
|
"@spoon/tsconfig": "workspace:*",
|
||||||
|
"@types/dockerode": "^3.3.42",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
@@ -97,16 +101,21 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@convex-dev/auth": "catalog:convex",
|
"@convex-dev/auth": "catalog:convex",
|
||||||
|
"@git-diff-view/react": "^0.1.6",
|
||||||
"@monaco-editor/react": "latest",
|
"@monaco-editor/react": "latest",
|
||||||
"@sentry/nextjs": "^10.46.0",
|
"@sentry/nextjs": "^10.46.0",
|
||||||
"@spoon/backend": "workspace:*",
|
"@spoon/backend": "workspace:*",
|
||||||
"@spoon/ui": "workspace:*",
|
"@spoon/ui": "workspace:*",
|
||||||
"@t3-oss/env-nextjs": "^0.13.11",
|
"@t3-oss/env-nextjs": "^0.13.11",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"convex": "catalog:convex",
|
"convex": "catalog:convex",
|
||||||
"monaco-editor": "latest",
|
"monaco-editor": "latest",
|
||||||
"monaco-vim": "latest",
|
"monaco-vim": "latest",
|
||||||
"next": "^16.2.1",
|
"next": "^16.2.1",
|
||||||
"next-plausible": "^3.12.5",
|
"next-plausible": "^3.12.5",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "catalog:react19",
|
"react": "catalog:react19",
|
||||||
"react-dom": "catalog:react19",
|
"react-dom": "catalog:react19",
|
||||||
"require-in-the-middle": "^7.5.2",
|
"require-in-the-middle": "^7.5.2",
|
||||||
@@ -537,6 +546,8 @@
|
|||||||
|
|
||||||
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
|
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
|
||||||
|
|
||||||
|
"@balena/dockerignore": ["@balena/dockerignore@1.0.2", "", {}, "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="],
|
||||||
|
|
||||||
"@base-ui/react": ["@base-ui/react@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@base-ui/utils": "0.2.6", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "tabbable": "^6.4.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA=="],
|
"@base-ui/react": ["@base-ui/react@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@base-ui/utils": "0.2.6", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "tabbable": "^6.4.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA=="],
|
||||||
|
|
||||||
"@base-ui/utils": ["@base-ui/utils@0.2.6", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw=="],
|
"@base-ui/utils": ["@base-ui/utils@0.2.6", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw=="],
|
||||||
@@ -571,57 +582,57 @@
|
|||||||
|
|
||||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.11", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg=="],
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||||
|
|
||||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.11", "", { "os": "android", "cpu": "arm" }, "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg=="],
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||||
|
|
||||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.11", "", { "os": "android", "cpu": "arm64" }, "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ=="],
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||||
|
|
||||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.11", "", { "os": "android", "cpu": "x64" }, "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g=="],
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||||
|
|
||||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w=="],
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||||
|
|
||||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ=="],
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||||
|
|
||||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.11", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA=="],
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||||
|
|
||||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw=="],
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.11", "", { "os": "linux", "cpu": "arm" }, "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw=="],
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA=="],
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||||
|
|
||||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.11", "", { "os": "linux", "cpu": "ia32" }, "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw=="],
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||||
|
|
||||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw=="],
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||||
|
|
||||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ=="],
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw=="],
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.11", "", { "os": "linux", "cpu": "none" }, "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww=="],
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.11", "", { "os": "linux", "cpu": "s390x" }, "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw=="],
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||||
|
|
||||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.11", "", { "os": "linux", "cpu": "x64" }, "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ=="],
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||||
|
|
||||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.11", "", { "os": "none", "cpu": "arm64" }, "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg=="],
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||||
|
|
||||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.11", "", { "os": "none", "cpu": "x64" }, "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A=="],
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||||
|
|
||||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.11", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg=="],
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||||
|
|
||||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.11", "", { "os": "openbsd", "cpu": "x64" }, "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw=="],
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||||
|
|
||||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.11", "", { "os": "none", "cpu": "arm64" }, "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ=="],
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||||
|
|
||||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.11", "", { "os": "sunos", "cpu": "x64" }, "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA=="],
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||||
|
|
||||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q=="],
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||||
|
|
||||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA=="],
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.11", "", { "os": "win32", "cpu": "x64" }, "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA=="],
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||||
|
|
||||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||||
|
|
||||||
@@ -705,6 +716,16 @@
|
|||||||
|
|
||||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||||
|
|
||||||
|
"@git-diff-view/core": ["@git-diff-view/core@0.1.6", "", { "dependencies": { "@git-diff-view/lowlight": "^0.1.6", "fast-diff": "^1.3.0", "highlight.js": "^11.11.0", "lowlight": "^3.3.0" } }, "sha512-q2Ch8jURF6pL7VeNpOgHBRVY9gsGLXCOYpKXHG3BqpXe0kv6GNSUux8SmAYsDrakBzfgDClODxDtsM2rfiWpnA=="],
|
||||||
|
|
||||||
|
"@git-diff-view/lowlight": ["@git-diff-view/lowlight@0.1.6", "", { "dependencies": { "@types/hast": "^3.0.0", "highlight.js": "^11.11.0", "lowlight": "^3.3.0" } }, "sha512-YIsiAc2aWAePWaDNi3k8xI0Vs/ZItt5J6nrftTIFbMFN3GwDOsyJFm2L7o8XWKTJkV2yItaz28KUI9CWj0MVZA=="],
|
||||||
|
|
||||||
|
"@git-diff-view/react": ["@git-diff-view/react@0.1.6", "", { "dependencies": { "@git-diff-view/core": "^0.1.6", "@types/hast": "^3.0.0", "fast-diff": "^1.3.0", "highlight.js": "^11.11.0", "lowlight": "^3.3.0", "reactivity-store": "^0.4.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-koABBon5bNKh6/WnWSxggK9ojw+cvWAPnY2/ciOkwlR+8dm0h6A7Qa5kP2HFDxqYHwZ2imkGMcSLgXMOnWHRFA=="],
|
||||||
|
|
||||||
|
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
|
||||||
|
|
||||||
|
"@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="],
|
||||||
|
|
||||||
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
|
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
|
||||||
|
|
||||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||||
@@ -831,6 +852,8 @@
|
|||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
|
||||||
|
|
||||||
"@legendapp/list": ["@legendapp/list@2.0.19", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-zDWg8yg0smKxxk+M7gwAbZAnf5uczohPA+IjqLSkImz7+e9ytxeT0Mq35RBO9RTKODOXfV/aIgm1uqUHLBEdmg=="],
|
"@legendapp/list": ["@legendapp/list@2.0.19", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-zDWg8yg0smKxxk+M7gwAbZAnf5uczohPA+IjqLSkImz7+e9ytxeT0Mq35RBO9RTKODOXfV/aIgm1uqUHLBEdmg=="],
|
||||||
|
|
||||||
"@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="],
|
"@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="],
|
||||||
@@ -961,7 +984,7 @@
|
|||||||
|
|
||||||
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||||
|
|
||||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.9", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-MHmXEpGPHkg14v1p+cUlIOUxd6DQdSElfau9nqY7tcDI0x5r4Y8D0dKXcyAh0Gc73ptaGW67Vg84nkcV6O27Pw=="],
|
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.10", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-s9OcS7pubNCimS98B9ERJ/59veOj1SSGHD0qGBxGIx+164wSspUlHsAWhQIihvF8eZe16F5VY1XUQIEXGBTm2Q=="],
|
||||||
|
|
||||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
@@ -1041,6 +1064,24 @@
|
|||||||
|
|
||||||
"@prisma/instrumentation": ["@prisma/instrumentation@7.4.2", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-r9JfchJF1Ae6yAxcaLu/V1TGqBhAuSDe3mRNOssBfx1rMzfZ4fdNvrgUBwyb/TNTGXFxlH9AZix5P257x07nrg=="],
|
"@prisma/instrumentation": ["@prisma/instrumentation@7.4.2", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-r9JfchJF1Ae6yAxcaLu/V1TGqBhAuSDe3mRNOssBfx1rMzfZ4fdNvrgUBwyb/TNTGXFxlH9AZix5P257x07nrg=="],
|
||||||
|
|
||||||
|
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||||
|
|
||||||
|
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||||
|
|
||||||
|
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
|
||||||
|
|
||||||
|
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="],
|
||||||
|
|
||||||
|
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="],
|
||||||
|
|
||||||
|
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
||||||
|
|
||||||
|
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
||||||
|
|
||||||
|
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||||
|
|
||||||
|
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
|
||||||
|
|
||||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||||
|
|
||||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||||
@@ -1461,19 +1502,19 @@
|
|||||||
|
|
||||||
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
|
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
|
||||||
|
|
||||||
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-9f27peFu16ur8c0v9nUFUEyBnbKuuFsUTjHFWfmwGfzySBXbHwzU44QhZon6Mznz0cHsIr3984NQj/bVrnGSRw=="],
|
"@turbo/darwin-64": ["@turbo/darwin-64@2.10.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-EwvHThXzpY0KGd1/NAmuewI5D+aVa3Rl/OlxE36yfjUKb/+ySrfJrSlEFt8aD1OXwnnaHnQnPKHFndor0Zxlsg=="],
|
||||||
|
|
||||||
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9A6TMRq/Ib+QnbhLlgkhOm+624wO4pzSQ/yQviQfWHOlFvaYxdnIAYmu2H6TS6y7kSVL0DvzNe04NbESTOzFVQ=="],
|
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9d2fTyyG0lf5Wq1bwJA9qUaeecViMkLcdctWaMMmCkxZ/JqypmqOwK3W6vmejeKVgkr06gSoiX8bD+xN5Jpxcg=="],
|
||||||
|
|
||||||
"@turbo/gen": ["@turbo/gen@2.9.18", "", { "dependencies": { "@inquirer/prompts": "^7.10.1", "esbuild": "^0.25.0" }, "bin": { "gen": "dist/cli.js" } }, "sha512-9Ry3V2eqFANYI7A5dyjehq2EOuLTY30XQSg4aDR7F3cJOuiP/Ad2KXwrxD3AnwNDkuSDVbJjlbES7yfJ0y7dhw=="],
|
"@turbo/gen": ["@turbo/gen@2.10.0", "", { "dependencies": { "@inquirer/prompts": "^7.10.1", "esbuild": "^0.28.1" }, "bin": { "gen": "dist/cli.js" } }, "sha512-QrnFiSKpKjijnQhde4VgEsg+WA8dQRc6EzO4iLy1+n7R8QZ3BCeVR7NePVOhhYcewoD8GZHnSPwrzu9cOvTdOA=="],
|
||||||
|
|
||||||
"@turbo/linux-64": ["@turbo/linux-64@2.9.18", "", { "os": "linux", "cpu": "x64" }, "sha512-zCdIDtz69AnbYh913elJRRoF3QY5aa2HNnf+4rAkc7bQ+tWujiDkCNV7stazOUPggaDvhKIf2Z87qHftTeXSkw=="],
|
"@turbo/linux-64": ["@turbo/linux-64@2.10.0", "", { "os": "linux", "cpu": "x64" }, "sha512-sZBtjMuufitanjzi6UssoUpJMnnPlLMcdcJj3m3ptNsSq31Xh7MnjhwA5nWvLDTfEFg8GPcbYFXMo8vSdKRfqQ=="],
|
||||||
|
|
||||||
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-Va1kXI04naMgYwqv/5Dfa36dTDx8015U7oaQAjrXa45ua9OoFjSV4OmvkML4EmXvUclQHCiBRbY8bvd0jV7eAg=="],
|
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.10.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vkq/Z8R+1DQ+kifWFa810IjRy2NNBVvha3cg9sWA3nFh6nnGrHSMnnJKrzH7c/No9kq4Jb55Ru44YKsCSBgrKg=="],
|
||||||
|
|
||||||
"@turbo/windows-64": ["@turbo/windows-64@2.9.18", "", { "os": "win32", "cpu": "x64" }, "sha512-m0kDhZANxSNz9ck1ybogFscHabriAsp4eDFNrN/1H5WrgTF7b3VlcPZnhuO3v2+E2KnCbeAc+UUT10BZZHdDKw=="],
|
"@turbo/windows-64": ["@turbo/windows-64@2.10.0", "", { "os": "win32", "cpu": "x64" }, "sha512-CRUEguLWxFQHptYZS7HjPhNhAFawfea07iR+xAQ5e4klgLrPCMdexBkXwSCwOxqTFknJ7RZFN3gOaADsw+Gttg=="],
|
||||||
|
|
||||||
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-nUdR8WqoomUys9iIQmG45TMiizJ+5BV8egSeLLZba/AWblyp3fVBcIH1kSE58OtK4g2YzbMJEth6Ttv9w5rqMA=="],
|
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.10.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-dVHGaf9F8twzgibcBqKoADT/LLqf9++jDb+hq/LPWWaOmRpp4M+/pVOm7vy4z9D++xg8eaxWLT0+wQxFwhYu9A=="],
|
||||||
|
|
||||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.8.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Z96T/L6dUFFxgFJ+pQtkPpne9q7i6kIPYCFnQBHSgSPV9idTsKfIhCss0h5iM9irweZCatkrdeP8yi5uM1eX6Q=="],
|
"@tybys/wasm-util": ["@tybys/wasm-util@0.8.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Z96T/L6dUFFxgFJ+pQtkPpne9q7i6kIPYCFnQBHSgSPV9idTsKfIhCss0h5iM9irweZCatkrdeP8yi5uM1eX6Q=="],
|
||||||
|
|
||||||
@@ -1513,6 +1554,10 @@
|
|||||||
|
|
||||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||||
|
|
||||||
|
"@types/docker-modem": ["@types/docker-modem@3.0.6", "", { "dependencies": { "@types/node": "*", "@types/ssh2": "*" } }, "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg=="],
|
||||||
|
|
||||||
|
"@types/dockerode": ["@types/dockerode@3.3.47", "", { "dependencies": { "@types/docker-modem": "*", "@types/node": "*", "@types/ssh2": "*" } }, "sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw=="],
|
||||||
|
|
||||||
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
|
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
|
||||||
|
|
||||||
"@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="],
|
"@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="],
|
||||||
@@ -1525,6 +1570,8 @@
|
|||||||
|
|
||||||
"@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="],
|
"@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="],
|
||||||
|
|
||||||
|
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||||
|
|
||||||
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
|
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
|
||||||
|
|
||||||
"@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="],
|
"@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="],
|
||||||
@@ -1547,12 +1594,16 @@
|
|||||||
|
|
||||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||||
|
|
||||||
|
"@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
|
||||||
|
|
||||||
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
|
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
|
||||||
|
|
||||||
"@types/tedious": ["@types/tedious@4.0.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw=="],
|
"@types/tedious": ["@types/tedious@4.0.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw=="],
|
||||||
|
|
||||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||||
|
|
||||||
|
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||||
|
|
||||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||||
|
|
||||||
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
||||||
@@ -1605,6 +1656,10 @@
|
|||||||
|
|
||||||
"@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="],
|
"@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="],
|
||||||
|
|
||||||
|
"@vue/reactivity": ["@vue/reactivity@3.5.38", "", { "dependencies": { "@vue/shared": "3.5.38" } }, "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ=="],
|
||||||
|
|
||||||
|
"@vue/shared": ["@vue/shared@3.5.38", "", {}, "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug=="],
|
||||||
|
|
||||||
"@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="],
|
"@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="],
|
||||||
|
|
||||||
"@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="],
|
"@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="],
|
||||||
@@ -1637,6 +1692,12 @@
|
|||||||
|
|
||||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
|
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
|
||||||
|
|
||||||
|
"@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
|
||||||
|
|
||||||
|
"@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="],
|
||||||
|
|
||||||
|
"@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
|
||||||
|
|
||||||
"@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="],
|
"@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="],
|
||||||
|
|
||||||
"@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="],
|
"@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="],
|
||||||
@@ -1701,6 +1762,8 @@
|
|||||||
|
|
||||||
"asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
|
"asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
|
||||||
|
|
||||||
|
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
|
||||||
|
|
||||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||||
|
|
||||||
"ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
|
"ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
|
||||||
@@ -1751,6 +1814,8 @@
|
|||||||
|
|
||||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg=="],
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg=="],
|
||||||
|
|
||||||
|
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
|
||||||
|
|
||||||
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
||||||
|
|
||||||
"better-opn": ["better-opn@3.0.2", "", { "dependencies": { "open": "^8.0.4" } }, "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ=="],
|
"better-opn": ["better-opn@3.0.2", "", { "dependencies": { "open": "^8.0.4" } }, "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ=="],
|
||||||
@@ -1761,6 +1826,8 @@
|
|||||||
|
|
||||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||||
|
|
||||||
|
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||||
|
|
||||||
"bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="],
|
"bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="],
|
||||||
|
|
||||||
"bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="],
|
"bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="],
|
||||||
@@ -1777,6 +1844,8 @@
|
|||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
|
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
|
||||||
|
|
||||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||||
|
|
||||||
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
||||||
@@ -1799,7 +1868,7 @@
|
|||||||
|
|
||||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||||
|
|
||||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
||||||
|
|
||||||
"chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="],
|
"chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="],
|
||||||
|
|
||||||
@@ -1881,6 +1950,8 @@
|
|||||||
|
|
||||||
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
|
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
|
||||||
|
|
||||||
|
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
|
||||||
|
|
||||||
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
|
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
|
||||||
|
|
||||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
@@ -1965,6 +2036,12 @@
|
|||||||
|
|
||||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||||
|
|
||||||
|
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||||
|
|
||||||
|
"docker-modem": ["docker-modem@5.0.7", "", { "dependencies": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", "ssh2": "^1.15.0" } }, "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA=="],
|
||||||
|
|
||||||
|
"dockerode": ["dockerode@4.0.12", "", { "dependencies": { "@balena/dockerignore": "^1.0.2", "@grpc/grpc-js": "^1.11.1", "@grpc/proto-loader": "^0.7.13", "docker-modem": "^5.0.7", "protobufjs": "^7.3.2", "tar-fs": "^2.1.4", "uuid": "^10.0.0" } }, "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw=="],
|
||||||
|
|
||||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||||
|
|
||||||
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
|
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
|
||||||
@@ -2003,6 +2080,8 @@
|
|||||||
|
|
||||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||||
|
|
||||||
|
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||||
|
|
||||||
"engine.io": ["engine.io@6.6.4", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1" } }, "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g=="],
|
"engine.io": ["engine.io@6.6.4", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1" } }, "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g=="],
|
||||||
|
|
||||||
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
|
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
|
||||||
@@ -2039,7 +2118,7 @@
|
|||||||
|
|
||||||
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
|
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
|
||||||
|
|
||||||
"esbuild": ["esbuild@0.25.11", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.11", "@esbuild/android-arm": "0.25.11", "@esbuild/android-arm64": "0.25.11", "@esbuild/android-x64": "0.25.11", "@esbuild/darwin-arm64": "0.25.11", "@esbuild/darwin-x64": "0.25.11", "@esbuild/freebsd-arm64": "0.25.11", "@esbuild/freebsd-x64": "0.25.11", "@esbuild/linux-arm": "0.25.11", "@esbuild/linux-arm64": "0.25.11", "@esbuild/linux-ia32": "0.25.11", "@esbuild/linux-loong64": "0.25.11", "@esbuild/linux-mips64el": "0.25.11", "@esbuild/linux-ppc64": "0.25.11", "@esbuild/linux-riscv64": "0.25.11", "@esbuild/linux-s390x": "0.25.11", "@esbuild/linux-x64": "0.25.11", "@esbuild/netbsd-arm64": "0.25.11", "@esbuild/netbsd-x64": "0.25.11", "@esbuild/openbsd-arm64": "0.25.11", "@esbuild/openbsd-x64": "0.25.11", "@esbuild/openharmony-arm64": "0.25.11", "@esbuild/sunos-x64": "0.25.11", "@esbuild/win32-arm64": "0.25.11", "@esbuild/win32-ia32": "0.25.11", "@esbuild/win32-x64": "0.25.11" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q=="],
|
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||||
|
|
||||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
@@ -2157,6 +2236,8 @@
|
|||||||
|
|
||||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
||||||
|
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
|
||||||
|
|
||||||
"fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
|
"fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
|
||||||
|
|
||||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||||
@@ -2205,6 +2286,8 @@
|
|||||||
|
|
||||||
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
|
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
|
||||||
|
|
||||||
|
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
||||||
|
|
||||||
"fs-monkey": ["fs-monkey@1.1.0", "", {}, "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw=="],
|
"fs-monkey": ["fs-monkey@1.1.0", "", {}, "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw=="],
|
||||||
|
|
||||||
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||||
@@ -2277,6 +2360,8 @@
|
|||||||
|
|
||||||
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||||
|
|
||||||
|
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||||
|
|
||||||
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
|
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
|
||||||
|
|
||||||
"hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="],
|
"hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="],
|
||||||
@@ -2523,6 +2608,8 @@
|
|||||||
|
|
||||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||||
|
|
||||||
|
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
|
||||||
|
|
||||||
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
|
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
|
||||||
|
|
||||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||||
@@ -2533,8 +2620,12 @@
|
|||||||
|
|
||||||
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
|
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
|
||||||
|
|
||||||
|
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||||
|
|
||||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||||
|
|
||||||
|
"lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="],
|
||||||
|
|
||||||
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
|
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
|
||||||
|
|
||||||
"lucia": ["lucia@3.2.2", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0" } }, "sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA=="],
|
"lucia": ["lucia@3.2.2", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0" } }, "sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA=="],
|
||||||
@@ -2617,6 +2708,8 @@
|
|||||||
|
|
||||||
"mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
|
"mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
|
||||||
|
|
||||||
|
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
||||||
|
|
||||||
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
|
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
|
||||||
|
|
||||||
"monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="],
|
"monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="],
|
||||||
@@ -2635,6 +2728,8 @@
|
|||||||
|
|
||||||
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||||
|
|
||||||
|
"nan": ["nan@2.27.0", "", {}, "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ=="],
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
"nativewind": ["nativewind@5.0.0-preview.2", "", { "dependencies": { "tailwindcss-safe-area": "^1.1.0" }, "peerDependencies": { "react-native-css": "^3.0.1", "tailwindcss": ">4.1.11" } }, "sha512-rTNrwFIwl/n2VH7KPvsZj/NdvKf+uGHF4NYtPamr5qG2eTYGT8B8VeyCPfYf/xUskpWOLJVqVEXaFO/vuIDEdw=="],
|
"nativewind": ["nativewind@5.0.0-preview.2", "", { "dependencies": { "tailwindcss-safe-area": "^1.1.0" }, "peerDependencies": { "react-native-css": "^3.0.1", "tailwindcss": ">4.1.11" } }, "sha512-rTNrwFIwl/n2VH7KPvsZj/NdvKf+uGHF4NYtPamr5qG2eTYGT8B8VeyCPfYf/xUskpWOLJVqVEXaFO/vuIDEdw=="],
|
||||||
@@ -2805,8 +2900,12 @@
|
|||||||
|
|
||||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||||
|
|
||||||
|
"protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="],
|
||||||
|
|
||||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||||
|
|
||||||
|
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
|
||||||
|
|
||||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
"qrcode-terminal": ["qrcode-terminal@0.11.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ=="],
|
"qrcode-terminal": ["qrcode-terminal@0.11.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ=="],
|
||||||
@@ -2877,6 +2976,10 @@
|
|||||||
|
|
||||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||||
|
|
||||||
|
"reactivity-store": ["reactivity-store@0.4.0", "", { "dependencies": { "@vue/reactivity": "~3.5.30", "@vue/shared": "~3.5.30", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-uL9uoREOBg2o4zUa8vMU0AbvAOk0osPloizscmyZqMvJzcuuKX3ELFYYr1DX8gAcfvlhPduz4QuLZn1eChCu4Q=="],
|
||||||
|
|
||||||
|
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||||
|
|
||||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||||
|
|
||||||
"recharts": ["recharts@3.8.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="],
|
"recharts": ["recharts@3.8.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="],
|
||||||
@@ -3031,10 +3134,14 @@
|
|||||||
|
|
||||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||||
|
|
||||||
|
"split-ca": ["split-ca@1.0.1", "", {}, "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ=="],
|
||||||
|
|
||||||
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
|
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
|
||||||
|
|
||||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||||
|
|
||||||
|
"ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
|
||||||
|
|
||||||
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
|
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
|
||||||
|
|
||||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||||
@@ -3073,6 +3180,8 @@
|
|||||||
|
|
||||||
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
|
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
|
||||||
|
|
||||||
|
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||||
|
|
||||||
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||||
|
|
||||||
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
||||||
@@ -3119,6 +3228,10 @@
|
|||||||
|
|
||||||
"tar": ["tar@7.5.12", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw=="],
|
"tar": ["tar@7.5.12", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw=="],
|
||||||
|
|
||||||
|
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
|
||||||
|
|
||||||
|
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
|
||||||
|
|
||||||
"terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="],
|
"terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="],
|
||||||
|
|
||||||
"terser": ["terser@5.44.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w=="],
|
"terser": ["terser@5.44.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w=="],
|
||||||
@@ -3169,10 +3282,12 @@
|
|||||||
|
|
||||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||||
|
|
||||||
"turbo": ["turbo@2.9.18", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.18", "@turbo/darwin-arm64": "2.9.18", "@turbo/linux-64": "2.9.18", "@turbo/linux-arm64": "2.9.18", "@turbo/windows-64": "2.9.18", "@turbo/windows-arm64": "2.9.18" }, "bin": { "turbo": "bin/turbo" } }, "sha512-bwabv6PupzeavybzEoArBAkwq5fnzwf8OFnRtpHwnviFWuwJPFxtyH+aVp36TmIqK3aYYgtTJ3J0m2ysxxSzQg=="],
|
"turbo": ["turbo@2.10.0", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.0", "@turbo/darwin-arm64": "2.10.0", "@turbo/linux-64": "2.10.0", "@turbo/linux-arm64": "2.10.0", "@turbo/windows-64": "2.10.0", "@turbo/windows-arm64": "2.10.0" }, "bin": { "turbo": "bin/turbo" } }, "sha512-o016H9PPtuH2deb3mh3Vci3Avfi9UYgM/RONQisY7HnloupP0IFSbFS3gFYJgFJP8nwBrByHWFQIDa8T2zIXPw=="],
|
||||||
|
|
||||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||||
|
|
||||||
|
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
|
||||||
|
|
||||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||||
|
|
||||||
"type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="],
|
"type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="],
|
||||||
@@ -3231,9 +3346,11 @@
|
|||||||
|
|
||||||
"usesend-js": ["usesend-js@1.6.3", "", { "dependencies": { "@react-email/render": "^1.0.6", "react": "^19.1.0" } }, "sha512-HKhW4F+RbAnp6izWxo2sjISmxhYQvxAjAsBFvdn0P25oVnZ8kXTMjvEqKyvkhgRrzXALu0N6NUyQjVOdOsjnoA=="],
|
"usesend-js": ["usesend-js@1.6.3", "", { "dependencies": { "@react-email/render": "^1.0.6", "react": "^19.1.0" } }, "sha512-HKhW4F+RbAnp6izWxo2sjISmxhYQvxAjAsBFvdn0P25oVnZ8kXTMjvEqKyvkhgRrzXALu0N6NUyQjVOdOsjnoA=="],
|
||||||
|
|
||||||
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
|
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
|
||||||
|
|
||||||
"uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
"uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
|
||||||
|
|
||||||
"validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="],
|
"validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="],
|
||||||
|
|
||||||
@@ -3297,7 +3414,7 @@
|
|||||||
|
|
||||||
"write-file-atomic": ["write-file-atomic@4.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" } }, "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg=="],
|
"write-file-atomic": ["write-file-atomic@4.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" } }, "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg=="],
|
||||||
|
|
||||||
"ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||||
|
|
||||||
"xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="],
|
"xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="],
|
||||||
|
|
||||||
@@ -3399,8 +3516,6 @@
|
|||||||
|
|
||||||
"@expo/cli/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
"@expo/cli/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||||
|
|
||||||
"@expo/cli/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
|
||||||
|
|
||||||
"@expo/config/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
|
"@expo/config/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
|
||||||
|
|
||||||
"@expo/config/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
"@expo/config/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||||
@@ -3455,6 +3570,8 @@
|
|||||||
|
|
||||||
"@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="],
|
"@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="],
|
||||||
|
|
||||||
|
"@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
|
||||||
|
|
||||||
"@ianvs/prettier-plugin-sort-imports/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
|
"@ianvs/prettier-plugin-sort-imports/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
|
||||||
|
|
||||||
"@ianvs/prettier-plugin-sort-imports/@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
|
"@ianvs/prettier-plugin-sort-imports/@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
|
||||||
@@ -3707,6 +3824,8 @@
|
|||||||
|
|
||||||
"@sentry/vercel-edge/@sentry/core": ["@sentry/core@10.46.0", "", {}, "sha512-N3fj4zqBQOhXliS1Ne9euqIKuciHCGOJfPGQLwBoW9DNz03jF+NB8+dUKtrJ79YLoftjVgf8nbgwtADK7NR+2Q=="],
|
"@sentry/vercel-edge/@sentry/core": ["@sentry/core@10.46.0", "", {}, "sha512-N3fj4zqBQOhXliS1Ne9euqIKuciHCGOJfPGQLwBoW9DNz03jF+NB8+dUKtrJ79YLoftjVgf8nbgwtADK7NR+2Q=="],
|
||||||
|
|
||||||
|
"@sentry/webpack-plugin/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
||||||
|
|
||||||
"@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
"@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||||
|
|
||||||
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||||
@@ -3745,6 +3864,8 @@
|
|||||||
|
|
||||||
"@types/pg/@types/node": ["@types/node@22.18.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A=="],
|
"@types/pg/@types/node": ["@types/node@22.18.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A=="],
|
||||||
|
|
||||||
|
"@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||||
|
|
||||||
"@types/tedious/@types/node": ["@types/node@22.18.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A=="],
|
"@types/tedious/@types/node": ["@types/node@22.18.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A=="],
|
||||||
|
|
||||||
"@types/ws/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
|
"@types/ws/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
|
||||||
@@ -3823,6 +3944,8 @@
|
|||||||
|
|
||||||
"convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
|
"convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
|
||||||
|
|
||||||
|
"convex/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||||
|
|
||||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||||
|
|
||||||
"dot-prop/type-fest": ["type-fest@5.5.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g=="],
|
"dot-prop/type-fest": ["type-fest@5.5.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g=="],
|
||||||
@@ -3899,8 +4022,6 @@
|
|||||||
|
|
||||||
"happy-dom/whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
"happy-dom/whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
||||||
|
|
||||||
"happy-dom/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
|
||||||
|
|
||||||
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||||
|
|
||||||
"hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
"hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||||
@@ -4079,6 +4200,8 @@
|
|||||||
|
|
||||||
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||||
|
|
||||||
|
"tar/chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||||
|
|
||||||
"terminal-link/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
|
"terminal-link/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
|
||||||
|
|
||||||
"terser/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
"terser/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||||
@@ -4427,6 +4550,8 @@
|
|||||||
|
|
||||||
"@types/pg/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
"@types/pg/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|
||||||
|
"@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||||
|
|
||||||
"@types/tedious/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
"@types/tedious/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|
||||||
"@types/ws/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
"@types/ws/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ ARG NEXT_PUBLIC_SENTRY_DSN
|
|||||||
ARG NEXT_PUBLIC_SENTRY_URL
|
ARG NEXT_PUBLIC_SENTRY_URL
|
||||||
ARG NEXT_PUBLIC_SENTRY_ORG
|
ARG NEXT_PUBLIC_SENTRY_ORG
|
||||||
ARG NEXT_PUBLIC_SENTRY_PROJECT_NAME
|
ARG NEXT_PUBLIC_SENTRY_PROJECT_NAME
|
||||||
|
ARG NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL
|
||||||
|
|
||||||
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
|
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
|
||||||
ENV SENTRY_DISABLE_AUTO_UPLOAD=$SENTRY_DISABLE_AUTO_UPLOAD
|
ENV SENTRY_DISABLE_AUTO_UPLOAD=$SENTRY_DISABLE_AUTO_UPLOAD
|
||||||
@@ -25,6 +26,7 @@ ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN
|
|||||||
ENV NEXT_PUBLIC_SENTRY_URL=$NEXT_PUBLIC_SENTRY_URL
|
ENV NEXT_PUBLIC_SENTRY_URL=$NEXT_PUBLIC_SENTRY_URL
|
||||||
ENV NEXT_PUBLIC_SENTRY_ORG=$NEXT_PUBLIC_SENTRY_ORG
|
ENV NEXT_PUBLIC_SENTRY_ORG=$NEXT_PUBLIC_SENTRY_ORG
|
||||||
ENV NEXT_PUBLIC_SENTRY_PROJECT_NAME=$NEXT_PUBLIC_SENTRY_PROJECT_NAME
|
ENV NEXT_PUBLIC_SENTRY_PROJECT_NAME=$NEXT_PUBLIC_SENTRY_PROJECT_NAME
|
||||||
|
ENV NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL=$NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL
|
||||||
|
|
||||||
# Copy source code (node_modules excluded via .dockerignore)
|
# Copy source code (node_modules excluded via .dockerignore)
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Spoon container — neutral interactive shell defaults (system-wide).
|
||||||
|
# Tools here benefit everyone; a user's ~/.bashrc (loaded via ~/.bash_profile,
|
||||||
|
# which the worker ensures) layers on top and can override any of this.
|
||||||
|
|
||||||
|
# Interactive shells only.
|
||||||
|
case $- in
|
||||||
|
*i*) ;;
|
||||||
|
*) return ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
export EDITOR="${EDITOR:-nvim}"
|
||||||
|
export PAGER="${PAGER:-less}"
|
||||||
|
# User-local + bun install locations.
|
||||||
|
export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH"
|
||||||
|
|
||||||
|
if command -v zoxide >/dev/null 2>&1; then
|
||||||
|
eval "$(zoxide init bash)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v eza >/dev/null 2>&1; then
|
||||||
|
alias ls='eza --group-directories-first --icons'
|
||||||
|
alias ll='eza -lh --group-directories-first --icons --git'
|
||||||
|
alias la='eza -lha --group-directories-first --icons --git'
|
||||||
|
alias lt='eza --tree --level=2 --icons --git'
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v bat >/dev/null 2>&1 && alias cat='bat --paging=never --style=plain'
|
||||||
|
alias n='nvim'
|
||||||
|
alias g='git'
|
||||||
|
alias cl='clear'
|
||||||
|
|
||||||
|
# fzf keybindings + completion when present.
|
||||||
|
for f in /usr/share/fzf/shell/key-bindings.bash \
|
||||||
|
/usr/share/bash-completion/completions/fzf; do
|
||||||
|
[ -f "$f" ] && . "$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
if command -v oh-my-posh >/dev/null 2>&1 && [ -f /etc/spoon/omp.json ]; then
|
||||||
|
eval "$(oh-my-posh init bash --config /etc/spoon/omp.json)"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/schema.json",
|
||||||
|
"version": 3,
|
||||||
|
"final_space": true,
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"type": "prompt",
|
||||||
|
"alignment": "left",
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"type": "path",
|
||||||
|
"style": "plain",
|
||||||
|
"foreground": "#5fd0e0",
|
||||||
|
"template": " {{ .Path }} ",
|
||||||
|
"properties": { "style": "agnoster_short", "max_depth": 3 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "git",
|
||||||
|
"style": "plain",
|
||||||
|
"foreground": "#8fd6b4",
|
||||||
|
"template": "{{ .HEAD }}{{ if or (.Working.Changed) (.Staging.Changed) }}*{{ end }} ",
|
||||||
|
"properties": {
|
||||||
|
"fetch_status": true,
|
||||||
|
"branch_icon": " "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "prompt",
|
||||||
|
"alignment": "left",
|
||||||
|
"newline": true,
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"style": "plain",
|
||||||
|
"foreground": "#1fb895",
|
||||||
|
"foreground_templates": ["{{ if gt .Code 0 }}#f3625d{{ end }}"],
|
||||||
|
"template": "❯ "
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Spoon container — system tmux defaults. A user's ~/.config/tmux/tmux.conf (or
|
||||||
|
# ~/.tmux.conf) is read after this and overrides it.
|
||||||
|
|
||||||
|
# Login shells so /etc/profile.d/spoon.sh (tools) and ~/.bash_profile load.
|
||||||
|
set -g default-command "exec bash -l"
|
||||||
|
set -g default-terminal "tmux-256color"
|
||||||
|
set -ag terminal-overrides ",xterm-256color:RGB"
|
||||||
|
set -g mouse on
|
||||||
|
set -g history-limit 50000
|
||||||
|
set -g escape-time 10
|
||||||
|
set -g focus-events on
|
||||||
|
setw -g mode-keys vi
|
||||||
@@ -1,22 +1,61 @@
|
|||||||
FROM docker.io/library/node:22-bookworm
|
FROM registry.fedoraproject.org/fedora:41
|
||||||
|
|
||||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||||
|
ENV LANG=en_US.UTF-8
|
||||||
|
|
||||||
RUN apt-get update \
|
# Core toolchain + interactive/QoL CLI tooling. Everything below is in the
|
||||||
&& apt-get install -y --no-install-recommends \
|
# default Fedora repos (no COPR needed). The QoL set mirrors the user's Panama
|
||||||
|
# workstation setup so the terminal feels like a real dev box for everyone.
|
||||||
|
RUN dnf install -y --setopt=install_weak_deps=False --nodocs \
|
||||||
bash \
|
bash \
|
||||||
|
bash-completion \
|
||||||
|
bat \
|
||||||
bubblewrap \
|
bubblewrap \
|
||||||
build-essential \
|
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
|
eza \
|
||||||
|
fd-find \
|
||||||
|
findutils \
|
||||||
|
fzf \
|
||||||
|
gcc \
|
||||||
|
gcc-c++ \
|
||||||
|
gh \
|
||||||
git \
|
git \
|
||||||
|
glibc-langpack-en \
|
||||||
|
gum \
|
||||||
|
gzip \
|
||||||
jq \
|
jq \
|
||||||
openssh-client \
|
less \
|
||||||
|
make \
|
||||||
|
ncurses \
|
||||||
|
neovim \
|
||||||
|
nodejs \
|
||||||
|
nodejs-npm \
|
||||||
|
openssh-clients \
|
||||||
|
procps-ng \
|
||||||
python3 \
|
python3 \
|
||||||
|
python3-pip \
|
||||||
ripgrep \
|
ripgrep \
|
||||||
&& corepack enable \
|
tar \
|
||||||
&& npm install -g bun@1.3.10 opencode-ai@latest @openai/codex@latest \
|
tmux \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
unzip \
|
||||||
|
wget \
|
||||||
|
which \
|
||||||
|
zoxide \
|
||||||
|
&& dnf clean all \
|
||||||
|
&& rm -rf /var/cache/dnf
|
||||||
|
|
||||||
|
# Package managers + pinned agent CLIs (kept identical to the prior image).
|
||||||
|
# Fedora's nodejs-npm doesn't ship corepack, so install pnpm/yarn via npm.
|
||||||
|
RUN npm install -g pnpm yarn bun@1.3.10 opencode-ai@1.17.9 @openai/codex@0.142.0 \
|
||||||
|
&& npm cache clean --force
|
||||||
|
|
||||||
|
# oh-my-posh prompt (binary only; we ship our own /etc/spoon/omp.json theme).
|
||||||
|
RUN curl -fsSL https://ohmyposh.dev/install.sh | bash -s -- -d /usr/local/bin \
|
||||||
|
&& oh-my-posh version
|
||||||
|
|
||||||
|
# Neutral system-wide defaults: /etc/profile.d/spoon.sh, /etc/tmux.conf, theme.
|
||||||
|
COPY docker/agent-job-rootfs/ /
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
FROM docker.io/oven/bun:1.3.10
|
FROM docker.io/oven/bun:1.3.10
|
||||||
|
|
||||||
|
ARG SPOON_BUILD_SHA=development
|
||||||
|
ARG SPOON_BUILD_CREATED_AT=unknown
|
||||||
|
|
||||||
|
ENV SPOON_BUILD_SHA=${SPOON_BUILD_SHA}
|
||||||
|
ENV SPOON_BUILD_CREATED_AT=${SPOON_BUILD_CREATED_AT}
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
bash \
|
bash \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
docker.io \
|
|
||||||
git \
|
git \
|
||||||
jq \
|
jq \
|
||||||
openssh-client \
|
openssh-client \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Docker CLI client only — the daemon is the host's, reached via the bind-mounted
|
||||||
|
# /var/run/docker.sock. The Debian `docker.io` package does NOT install the
|
||||||
|
# client under `--no-install-recommends` (trixie split it into `docker-cli`),
|
||||||
|
# which left the worker with no `docker` binary and silently broke every job.
|
||||||
|
# Install the official static client pinned to the host daemon's version.
|
||||||
|
ARG DOCKER_CLI_VERSION=29.5.3
|
||||||
|
RUN arch="$(uname -m)" \
|
||||||
|
&& curl -fsSL "https://download.docker.com/linux/static/stable/${arch}/docker-${DOCKER_CLI_VERSION}.tgz" -o /tmp/docker.tgz \
|
||||||
|
&& tar -xzf /tmp/docker.tgz -C /tmp \
|
||||||
|
&& install -m0755 /tmp/docker/docker /usr/local/bin/docker \
|
||||||
|
&& rm -rf /tmp/docker /tmp/docker.tgz \
|
||||||
|
&& docker --version
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json bun.lock* turbo.json ./
|
COPY package.json bun.lock* turbo.json ./
|
||||||
|
|||||||
@@ -71,15 +71,20 @@ services:
|
|||||||
- SPOON_AGENT_WORKER_ID=${SPOON_AGENT_WORKER_ID:-local-worker}
|
- SPOON_AGENT_WORKER_ID=${SPOON_AGENT_WORKER_ID:-local-worker}
|
||||||
- SPOON_AGENT_JOB_IMAGE=${SPOON_AGENT_JOB_IMAGE:-spoon-agent-job:latest}
|
- SPOON_AGENT_JOB_IMAGE=${SPOON_AGENT_JOB_IMAGE:-spoon-agent-job:latest}
|
||||||
- SPOON_AGENT_RUNTIME=${SPOON_AGENT_RUNTIME:-docker}
|
- SPOON_AGENT_RUNTIME=${SPOON_AGENT_RUNTIME:-docker}
|
||||||
|
- SPOON_AGENT_CONTAINER_RUNTIME=${SPOON_AGENT_CONTAINER_RUNTIME:-docker}
|
||||||
|
- SPOON_AGENT_CONTAINER_ACCESS=${SPOON_AGENT_CONTAINER_ACCESS:-network}
|
||||||
- SPOON_AGENT_NETWORK=${SPOON_AGENT_NETWORK:-spoon-local_default}
|
- SPOON_AGENT_NETWORK=${SPOON_AGENT_NETWORK:-spoon-local_default}
|
||||||
- SPOON_AGENT_MAX_CONCURRENT_JOBS=${SPOON_AGENT_MAX_CONCURRENT_JOBS:-1}
|
- SPOON_AGENT_MAX_CONCURRENT_JOBS=${SPOON_AGENT_MAX_CONCURRENT_JOBS:-1}
|
||||||
- SPOON_AGENT_JOB_TIMEOUT_MS=${SPOON_AGENT_JOB_TIMEOUT_MS:-1800000}
|
- SPOON_AGENT_JOB_TIMEOUT_MS=${SPOON_AGENT_JOB_TIMEOUT_MS:-1800000}
|
||||||
- SPOON_AGENT_WORKDIR=${SPOON_AGENT_WORKDIR:-/var/lib/spoon-agent/work}
|
- SPOON_AGENT_WORKDIR=${SPOON_AGENT_WORKDIR:-/var/lib/spoon-agent/work}
|
||||||
|
# See compose.yml: the host-side path must match SPOON_AGENT_WORKDIR so the
|
||||||
|
# sibling job containers' bind mounts resolve on the host Docker daemon.
|
||||||
|
- SPOON_AGENT_HOST_WORKDIR=${SPOON_AGENT_HOST_WORKDIR:-/var/lib/spoon-agent/work}
|
||||||
- GITHUB_APP_ID=${GITHUB_APP_ID}
|
- GITHUB_APP_ID=${GITHUB_APP_ID}
|
||||||
- GITHUB_APP_PRIVATE_KEY=${GITHUB_APP_PRIVATE_KEY}
|
- GITHUB_APP_PRIVATE_KEY=${GITHUB_APP_PRIVATE_KEY}
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- agent-work:/var/lib/spoon-agent/work
|
- ${SPOON_AGENT_HOST_WORKDIR:-/var/lib/spoon-agent/work}:/var/lib/spoon-agent/work
|
||||||
depends_on:
|
depends_on:
|
||||||
convex-backend:
|
convex-backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -88,4 +93,3 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
convex-data:
|
convex-data:
|
||||||
agent-work:
|
|
||||||
|
|||||||
+14
-4
@@ -17,9 +17,11 @@ services:
|
|||||||
NEXT_PUBLIC_SENTRY_URL: ${NEXT_PUBLIC_SENTRY_URL}
|
NEXT_PUBLIC_SENTRY_URL: ${NEXT_PUBLIC_SENTRY_URL}
|
||||||
NEXT_PUBLIC_SENTRY_ORG: ${NEXT_PUBLIC_SENTRY_ORG}
|
NEXT_PUBLIC_SENTRY_ORG: ${NEXT_PUBLIC_SENTRY_ORG}
|
||||||
NEXT_PUBLIC_SENTRY_PROJECT_NAME: ${NEXT_PUBLIC_SENTRY_PROJECT_NAME}
|
NEXT_PUBLIC_SENTRY_PROJECT_NAME: ${NEXT_PUBLIC_SENTRY_PROJECT_NAME}
|
||||||
|
NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL: ${NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL:-}
|
||||||
image: spoon-next:latest
|
image: spoon-next:latest
|
||||||
#image: git.gbrown.org/gib/spoon-next:latest
|
#image: git.gbrown.org/gib/spoon-next:latest
|
||||||
container_name: ${NEXT_CONTAINER_NAME}
|
container_name: ${NEXT_CONTAINER_NAME}
|
||||||
|
labels: ['com.centurylinklabs.watchtower.enable=true']
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=${NODE_ENV}
|
- NODE_ENV=${NODE_ENV}
|
||||||
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
|
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
|
||||||
@@ -95,6 +97,7 @@ services:
|
|||||||
image: spoon-agent-worker:latest
|
image: spoon-agent-worker:latest
|
||||||
container_name: ${AGENT_WORKER_CONTAINER_NAME:-spoon-agent-worker}
|
container_name: ${AGENT_WORKER_CONTAINER_NAME:-spoon-agent-worker}
|
||||||
hostname: ${AGENT_WORKER_CONTAINER_NAME:-spoon-agent-worker}
|
hostname: ${AGENT_WORKER_CONTAINER_NAME:-spoon-agent-worker}
|
||||||
|
labels: ['com.centurylinklabs.watchtower.enable=true']
|
||||||
networks: ['${NETWORK:-nginx-bridge}']
|
networks: ['${NETWORK:-nginx-bridge}']
|
||||||
environment:
|
environment:
|
||||||
- NEXT_PUBLIC_CONVEX_URL=${CONVEX_SELF_HOSTED_URL:-http://${BACKEND_CONTAINER_NAME:-spoon-backend}:${BACKEND_PORT:-3210}}
|
- NEXT_PUBLIC_CONVEX_URL=${CONVEX_SELF_HOSTED_URL:-http://${BACKEND_CONTAINER_NAME:-spoon-backend}:${BACKEND_PORT:-3210}}
|
||||||
@@ -102,19 +105,26 @@ services:
|
|||||||
- SPOON_AGENT_WORKER_ID=${SPOON_AGENT_WORKER_ID:-production-worker}
|
- SPOON_AGENT_WORKER_ID=${SPOON_AGENT_WORKER_ID:-production-worker}
|
||||||
- SPOON_AGENT_JOB_IMAGE=${SPOON_AGENT_JOB_IMAGE:-spoon-agent-job:latest}
|
- SPOON_AGENT_JOB_IMAGE=${SPOON_AGENT_JOB_IMAGE:-spoon-agent-job:latest}
|
||||||
- SPOON_AGENT_RUNTIME=${SPOON_AGENT_RUNTIME:-docker}
|
- SPOON_AGENT_RUNTIME=${SPOON_AGENT_RUNTIME:-docker}
|
||||||
|
- SPOON_AGENT_CONTAINER_RUNTIME=${SPOON_AGENT_CONTAINER_RUNTIME:-docker}
|
||||||
|
- SPOON_AGENT_CONTAINER_ACCESS=${SPOON_AGENT_CONTAINER_ACCESS:-network}
|
||||||
- SPOON_AGENT_NETWORK=${SPOON_AGENT_NETWORK:-nginx-bridge}
|
- SPOON_AGENT_NETWORK=${SPOON_AGENT_NETWORK:-nginx-bridge}
|
||||||
- SPOON_AGENT_MAX_CONCURRENT_JOBS=${SPOON_AGENT_MAX_CONCURRENT_JOBS:-1}
|
- SPOON_AGENT_MAX_CONCURRENT_JOBS=${SPOON_AGENT_MAX_CONCURRENT_JOBS:-1}
|
||||||
- SPOON_AGENT_JOB_TIMEOUT_MS=${SPOON_AGENT_JOB_TIMEOUT_MS:-1800000}
|
- SPOON_AGENT_JOB_TIMEOUT_MS=${SPOON_AGENT_JOB_TIMEOUT_MS:-1800000}
|
||||||
- SPOON_AGENT_WORKDIR=${SPOON_AGENT_WORKDIR:-/var/lib/spoon-agent/work}
|
- SPOON_AGENT_WORKDIR=${SPOON_AGENT_WORKDIR:-/var/lib/spoon-agent/work}
|
||||||
|
# Required when the worker controls the host Docker socket: bind-mount
|
||||||
|
# source paths are resolved on the host, not inside this container, so the
|
||||||
|
# worker must know the host-side path backing SPOON_AGENT_WORKDIR. We bind
|
||||||
|
# the same host path at the same location below so they are identical.
|
||||||
|
- SPOON_AGENT_HOST_WORKDIR=${SPOON_AGENT_HOST_WORKDIR:-/var/lib/spoon-agent/work}
|
||||||
- GITHUB_APP_ID=${GITHUB_APP_ID}
|
- GITHUB_APP_ID=${GITHUB_APP_ID}
|
||||||
- GITHUB_APP_PRIVATE_KEY=${GITHUB_APP_PRIVATE_KEY}
|
- GITHUB_APP_PRIVATE_KEY=${GITHUB_APP_PRIVATE_KEY}
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- spoon-agent-work:/var/lib/spoon-agent/work
|
# Host bind mount (not a named volume) so the path is identical on the
|
||||||
|
# host and inside the worker, which is what the sibling job containers
|
||||||
|
# need for their `-v <path>:/workspace` mounts to resolve correctly.
|
||||||
|
- ${SPOON_AGENT_HOST_WORKDIR:-/var/lib/spoon-agent/work}:/var/lib/spoon-agent/work
|
||||||
depends_on:
|
depends_on:
|
||||||
spoon-backend:
|
spoon-backend:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
|
||||||
spoon-agent-work:
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Workspace interactive terminal
|
||||||
|
|
||||||
|
A real shell inside the agent workspace, shown as the **Terminal** tab in the
|
||||||
|
workspace UI. It's an xterm.js front end bridged to a bash/tmux PTY running in a
|
||||||
|
persistent per-job container (the agent job image), mounting the same workspace
|
||||||
|
the editor and agent use.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
browser (xterm.js)
|
||||||
|
│ 1. GET /api/agent-jobs/:id/terminal-token (Convex-auth'd, owner only)
|
||||||
|
│ → { url: "wss://worker…/jobs/:id/terminal?token=…", expiresAt }
|
||||||
|
│
|
||||||
|
│ 2. WebSocket wss://worker.<domain>/jobs/:id/terminal?token=…
|
||||||
|
▼
|
||||||
|
nginx ── upgrade ──► spoon-agent-worker :3921
|
||||||
|
│ verifyTerminalToken(token, jobId, secret)
|
||||||
|
│ dockerode exec -t → bash/tmux PTY
|
||||||
|
▼
|
||||||
|
spoon-agent-term-<jobId> (job image, mounts the workspace)
|
||||||
|
```
|
||||||
|
|
||||||
|
- The browser **never** holds the worker secret. The Next app (which has already
|
||||||
|
verified job ownership) mints a short-lived HMAC token; the worker verifies it.
|
||||||
|
- Frames: **binary** = stdin/stdout bytes; **text JSON** `{type:"resize",cols,rows}`
|
||||||
|
= resize. The token's 2-minute expiry is a _connect_ window; an established
|
||||||
|
session persists.
|
||||||
|
- The shell runs `tmux new-session -A -s spoon` (falls back to `bash -l`), so
|
||||||
|
reconnecting reattaches the same session. Idle containers are removed after
|
||||||
|
`SPOON_AGENT_TERMINAL_IDLE_MS` (default 30m).
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Where | Variable | Required? | Notes |
|
||||||
|
| -------- | --------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| Next app | `NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL` | **Yes** | Browser-facing worker WS base, e.g. `wss://worker.spoon.gbrown.org` (prod) or `ws://localhost:3921` (dev). **Build-time** (`NEXT_PUBLIC`): for the Docker image it must be passed as a build arg (wired in `docker/Dockerfile` + `docker/compose.yml`, sourced from the build env file), not a runtime env. Unset → the Terminal tab shows "not configured". |
|
||||||
|
| Next app | `SPOON_AGENT_TERMINAL_SECRET` | No | HMAC secret for signing tokens. Falls back to `SPOON_AGENT_WORKER_INTERNAL_TOKEN`. |
|
||||||
|
| Worker | `SPOON_AGENT_TERMINAL_SECRET` | No | Must match the Next app's. Falls back to `SPOON_AGENT_WORKER_INTERNAL_TOKEN` (already shared), so by default **no new secret is needed**. |
|
||||||
|
| Worker | `SPOON_AGENT_TERMINAL_IMAGE` | No | Shell container image. Defaults to `SPOON_AGENT_JOB_IMAGE`. |
|
||||||
|
| Worker | `SPOON_AGENT_TERMINAL_IDLE_MS` | No | Idle-container reap delay (default `1800000`). |
|
||||||
|
|
||||||
|
Because the secret defaults to the already-shared worker token, the **only**
|
||||||
|
required step is setting `NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL` and exposing the
|
||||||
|
worker over nginx (prod).
|
||||||
|
|
||||||
|
## Exposing the worker (prod, nginx)
|
||||||
|
|
||||||
|
The worker and nginx are on the same `nginx-bridge` network, so nginx can reach
|
||||||
|
`spoon-agent-worker:3921` directly — no published port needed. Add a server block
|
||||||
|
that upgrades WebSockets:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name worker.spoon.gbrown.org;
|
||||||
|
# ssl_certificate ... ; ssl_certificate_key ... ;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://spoon-agent-worker:3921;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 86400s; # keep idle terminals open
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then set on the Next app: `NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL=wss://worker.spoon.gbrown.org`.
|
||||||
|
|
||||||
|
> The worker's HTTP routes (`/jobs/:id/tree` etc.) require the internal bearer
|
||||||
|
> token, so exposing the worker host only usefully exposes the token-gated
|
||||||
|
> `/jobs/:id/terminal` upgrade. Still, restrict the server block to TLS.
|
||||||
|
|
||||||
|
## Dev testing (no nginx)
|
||||||
|
|
||||||
|
The dev worker runs on the host at `localhost:3921` (`bun dev:next:worker`), so
|
||||||
|
the browser can hit it directly:
|
||||||
|
|
||||||
|
```
|
||||||
|
NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL=ws://localhost:3921
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: the terminal uses **dockerode** against the Docker socket. In dev with
|
||||||
|
Podman, point it at the Podman socket (run `podman system service` and set
|
||||||
|
`DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock`), or run the worker in
|
||||||
|
Docker mode. Prod (Docker socket mounted) works as-is.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Owner-only: the token route uses Convex auth + `assertOwned`.
|
||||||
|
- Tokens are short-lived (2m connect window), job-scoped, HMAC-signed.
|
||||||
|
- A shell in the workspace can reach the network and the repo's git credentials.
|
||||||
|
This is intended for the single-user self-hosted deployment; do not expose the
|
||||||
|
worker domain without TLS, and keep the deployment single-tenant.
|
||||||
|
|
||||||
|
## Tools in the shell
|
||||||
|
|
||||||
|
The job image ships `bash`, `tmux`, `neovim`, `git`, `ripgrep`, `jq`, `python3`,
|
||||||
|
`node`, `bun`, `pnpm`, `yarn`, `curl`/`wget`, `unzip`. Bring your own dotfiles by
|
||||||
|
cloning them in-session (e.g. `git clone <dotfiles> ~/.config/...`); persistent
|
||||||
|
auto-cloning of a dotfiles repo is a planned follow-up.
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Production Compose for Spoon
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Reference deployment for the production host. Copy this to the server and run
|
||||||
|
# with `docker compose -f compose.prod.yml up -d` (alongside your prod `.env`).
|
||||||
|
#
|
||||||
|
# Two things in here are load-bearing for the agent ("run a thread") to work.
|
||||||
|
# If you change them, read the comments first:
|
||||||
|
#
|
||||||
|
# 1. AGENT WORKDIR (spoon-agent-worker): the worker is containerized but
|
||||||
|
# launches the Codex job container by talking to the HOST Docker daemon.
|
||||||
|
# The host can only bind-mount real HOST paths, so the work directory MUST
|
||||||
|
# be a bind mount whose path is IDENTICAL inside and outside the container,
|
||||||
|
# and SPOON_AGENT_HOST_WORKDIR must match it. A named volume does NOT work
|
||||||
|
# here because its real host path is hidden from the worker. All three
|
||||||
|
# references to /var/lib/spoon-agent/work below must stay in sync; change
|
||||||
|
# them together if you want the data somewhere else.
|
||||||
|
#
|
||||||
|
# 2. IMAGE FRESHNESS: services use `pull_policy: always` + Watchtower labels so
|
||||||
|
# a redeploy / new push always lands. The Codex *job* image is pulled by the
|
||||||
|
# worker itself on startup (see SPOON_AGENT_JOB_IMAGE); restarting the worker
|
||||||
|
# (which Watchtower does on a new image) re-pulls a fresh job image.
|
||||||
|
|
||||||
|
networks:
|
||||||
|
nginx-bridge: # Change to network you plan to use
|
||||||
|
external: true
|
||||||
|
|
||||||
|
services:
|
||||||
|
spoon-next:
|
||||||
|
image: git.gbrown.org/gib/${NEXT_CONTAINER_NAME}:latest
|
||||||
|
container_name: ${NEXT_CONTAINER_NAME}
|
||||||
|
hostname: ${NEXT_CONTAINER_NAME}
|
||||||
|
domainname: ${NEXT_DOMAIN}
|
||||||
|
networks: ['${NETWORK:-nginx-bridge}']
|
||||||
|
#ports: ['${NEXT_PORT}:${NEXT_PORT}']
|
||||||
|
pull_policy: always
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=${NODE_ENV}
|
||||||
|
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
|
||||||
|
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-http://localhost:${NEXT_PORT:-3000}}
|
||||||
|
- NEXT_PUBLIC_CONVEX_URL=${NEXT_PUBLIC_CONVEX_URL:-http://${BACKEND_CONTAINER_NAME:-spoon-backend}:${BACKEND_PORT:-3210}}
|
||||||
|
- NEXT_PUBLIC_PLAUSIBLE_URL=${NEXT_PUBLIC_PLAUSIBLE_URL:-https://plausible.gbrown.org}
|
||||||
|
- NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
|
||||||
|
- NEXT_PUBLIC_SENTRY_URL=${NEXT_PUBLIC_SENTRY_URL}
|
||||||
|
- NEXT_PUBLIC_SENTRY_ORG=${NEXT_PUBLIC_SENTRY_ORG:-sentry}
|
||||||
|
- NEXT_PUBLIC_SENTRY_PROJECT_NAME=${NEXT_PUBLIC_SENTRY_PROJECT_NAME}
|
||||||
|
- SPOON_AGENT_WORKER_URL=${SPOON_AGENT_WORKER_URL:-http://spoon-agent-worker:3921}
|
||||||
|
- SPOON_AGENT_WORKER_INTERNAL_TOKEN=${SPOON_AGENT_WORKER_INTERNAL_TOKEN}
|
||||||
|
- SPOON_WORKER_TOKEN=${SPOON_WORKER_TOKEN}
|
||||||
|
# NOTE: the Terminal tab needs NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL, which is
|
||||||
|
# a NEXT_PUBLIC (build-time) var — it must be baked into the spoon-next image
|
||||||
|
# at build (via the build env file / CI), NOT set as a runtime env here. Also
|
||||||
|
# requires nginx to proxy worker.<domain> → spoon-agent-worker:3921 with WS
|
||||||
|
# upgrade. See docs/agent-terminal.md.
|
||||||
|
depends_on: ['spoon-backend', 'spoon-postgres']
|
||||||
|
labels: ['com.centurylinklabs.watchtower.enable=true']
|
||||||
|
tty: true
|
||||||
|
stdin_open: true
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
spoon-agent-worker:
|
||||||
|
image: git.gbrown.org/gib/spoon-agent-worker:latest
|
||||||
|
container_name: spoon-agent-worker
|
||||||
|
hostname: spoon-agent-worker
|
||||||
|
domainname: worker.${NEXT_DOMAIN:-spoon.gbrown.org}
|
||||||
|
networks: ['${NETWORK:-nginx-bridge}']
|
||||||
|
pull_policy: always
|
||||||
|
environment:
|
||||||
|
- GITHUB_APP_ID=${GITHUB_APP_ID}
|
||||||
|
- GITHUB_APP_PRIVATE_KEY=${GITHUB_APP_PRIVATE_KEY}
|
||||||
|
- NEXT_PUBLIC_CONVEX_URL=https://api.spoon.gbrown.org
|
||||||
|
- SPOON_AGENT_WORKER_ID=${SPOON_AGENT_WORKER_ID:-production-worker}
|
||||||
|
- SPOON_AGENT_JOB_IMAGE=${SPOON_AGENT_JOB_IMAGE:-git.gbrown.org/gib/spoon-agent-job:latest}
|
||||||
|
- SPOON_AGENT_RUNTIME=docker
|
||||||
|
- SPOON_AGENT_NETWORK=${NETWORK:-nginx-bridge}
|
||||||
|
# The work directory MUST be the same absolute path here, in the bind mount
|
||||||
|
# below, and in SPOON_AGENT_HOST_WORKDIR. See header note (1).
|
||||||
|
- SPOON_AGENT_WORKDIR=/var/lib/spoon-agent/work
|
||||||
|
- SPOON_AGENT_HOST_WORKDIR=/var/lib/spoon-agent/work
|
||||||
|
- SPOON_AGENT_WORKER_HTTP_PORT=${SPOON_AGENT_WORKER_HTTP_PORT:-3921}
|
||||||
|
- SPOON_AGENT_WORKER_INTERNAL_TOKEN=${SPOON_AGENT_WORKER_INTERNAL_TOKEN}
|
||||||
|
- SPOON_AGENT_MAX_CONCURRENT_JOBS=${SPOON_AGENT_MAX_CONCURRENT_JOBS:-1}
|
||||||
|
- SPOON_AGENT_JOB_TIMEOUT_MS=${SPOON_AGENT_JOB_TIMEOUT_MS:-1800000}
|
||||||
|
- SPOON_WORKER_TOKEN=${SPOON_WORKER_TOKEN}
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
# Identical host:container path so the sibling Codex job containers can
|
||||||
|
# bind-mount the workspace via the host daemon. Do NOT switch this to a
|
||||||
|
# named volume. See header note (1).
|
||||||
|
- /var/lib/spoon-agent/work:/var/lib/spoon-agent/work
|
||||||
|
labels: ['com.centurylinklabs.watchtower.enable=true']
|
||||||
|
tty: true
|
||||||
|
stdin_open: true
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
spoon-backend:
|
||||||
|
image: ghcr.io/get-convex/convex-backend:${BACKEND_TAG:-latest}
|
||||||
|
container_name: ${BACKEND_CONTAINER_NAME:-spoon-backend}
|
||||||
|
hostname: ${BACKEND_CONTAINER_NAME:-spoon-backend}
|
||||||
|
domainname: ${BACKEND_DOMAIN:-convex.spoon.gbrown.org}
|
||||||
|
networks: ['${NETWORK:-nginx-bridge}']
|
||||||
|
#user: '1000:1000'
|
||||||
|
#ports: ['${BACKEND_PORT:-3210}:3210','${SITE_PROXY_PORT:-3211}:3211']
|
||||||
|
volumes: [./volumes/convex:/convex/data]
|
||||||
|
pull_policy: always
|
||||||
|
environment:
|
||||||
|
- INSTANCE_NAME=${INSTANCE_NAME}
|
||||||
|
- CONVEX_CLOUD_ORIGIN=${CONVEX_CLOUD_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-spoon-backend}:${BACKEND_PORT:-3210}}
|
||||||
|
- CONVEX_SITE_ORIGIN=${CONVEX_SITE_ORIGIN:-http://${BACKEND_CONTAINER_NAME:-spoon-backend}:${SITE_PROXY_PORT:-3211}}
|
||||||
|
- DISABLE_BEACON=${DISABLE_BEACON:-true}
|
||||||
|
- REDACT_LOGS_TO_CLIENT=${REDACT_LOGS_TO_CLIENT:-true}
|
||||||
|
- DO_NOT_REQUIRE_SSL=${DO_NOT_REQUIRE_SSL:-false}
|
||||||
|
- POSTGRES_URL=${POSTGRES_URL}
|
||||||
|
depends_on: ['spoon-postgres']
|
||||||
|
labels: ['com.centurylinklabs.watchtower.enable=true']
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: curl -f http://localhost:3210/version
|
||||||
|
interval: 5s
|
||||||
|
start_period: 10s
|
||||||
|
stop_grace_period: 10s
|
||||||
|
stop_signal: SIGINT
|
||||||
|
|
||||||
|
spoon-dashboard:
|
||||||
|
image: ghcr.io/get-convex/convex-dashboard:${DASHBOARD_TAG:-latest}
|
||||||
|
container_name: ${DASHBOARD_CONTAINER_NAME:-spoon-dashboard}
|
||||||
|
hostname: ${DASHBOARD_CONTAINER_NAME:-spoon-dashboard}
|
||||||
|
domainname: ${DASHBOARD_DOMAIN:-dashboard.${BACKEND_DOMAIN:-spoon.gbrown.org}}
|
||||||
|
networks: ['${NETWORK:-nginx-bridge}']
|
||||||
|
#user: 1000:1000
|
||||||
|
#ports: ['${DASHBOARD_PORT:-6791}:6791']
|
||||||
|
pull_policy: always
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://${BACKEND_CONTAINER_NAME:-spoon-backend}:${PORT:-3210}}
|
||||||
|
depends_on:
|
||||||
|
spoon-backend:
|
||||||
|
condition: service_healthy
|
||||||
|
labels: ['com.centurylinklabs.watchtower.enable=true']
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 10s
|
||||||
|
stop_signal: SIGINT
|
||||||
|
|
||||||
|
spoon-postgres:
|
||||||
|
image: postgres:17
|
||||||
|
container_name: ${POSTGRES_CONTAINER_NAME:-spoon-postgres}
|
||||||
|
hostname: ${POSTGRES_CONTAINER_NAME:-spoon-postgres}
|
||||||
|
domainname: postgres.${NEXT_DOMAIN:-spoon.gbrown.org}
|
||||||
|
networks: ['${NETWORK:-nginx-bridge}']
|
||||||
|
# ports: ["5434:5432"]
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=${POSTGRES_USER:-spoon}
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
- POSTGRES_DB=${POSTGRES_DB:-spoon_convex}
|
||||||
|
volumes: ['./volumes/postgres:/var/lib/postgresql/data']
|
||||||
|
labels: ['com.centurylinklabs.watchtower.enable=true']
|
||||||
|
tty: true
|
||||||
|
stdin_open: true
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}']
|
||||||
|
start_period: 20s
|
||||||
|
interval: 30s
|
||||||
|
retries: 5
|
||||||
|
timeout: 5s
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Personalized dev environment (dotfiles + persistent home)
|
||||||
|
|
||||||
|
Makes the workspace terminal feel like the user's own machine: a Fedora image
|
||||||
|
preloaded with QoL CLI tooling, a persistent per-user home, and user dotfiles.
|
||||||
|
|
||||||
|
## The model
|
||||||
|
|
||||||
|
- **Persistent per-user home.** Each user gets a home directory on the worker
|
||||||
|
host at `${SPOON_AGENT_WORKDIR}/homes/{username}`, bind-mounted into every
|
||||||
|
job/terminal container at `/home/{username}` (`HOME`). It survives across
|
||||||
|
sessions, so dotfiles, installed tools, nvim plugins, and shell history persist.
|
||||||
|
`username` is derived from the user's profile first name (sanitized).
|
||||||
|
- **Threads as folders.** Each thread's checkout lives at
|
||||||
|
`~/Code/{spoon}/{branch}` inside that home, so every thread shows up as a
|
||||||
|
folder in one home. The agent (`codex --cd …`) and the terminal both open there.
|
||||||
|
- **Neutral defaults (everyone).** The Fedora job image
|
||||||
|
(`docker/agent-job.Dockerfile`) ships zoxide, eza, bat, fzf, fd, ripgrep, gh,
|
||||||
|
gum, neovim, tmux, oh-my-posh, etc., plus system-wide defaults that work even
|
||||||
|
with an empty home: `/etc/profile.d/spoon.sh` (tool init + aliases),
|
||||||
|
`/etc/tmux.conf` (login-shell panes), `/etc/spoon/omp.json` (prompt theme).
|
||||||
|
- **User dotfiles (per-user).** Configured in **Settings → Dotfiles**, applied on
|
||||||
|
top of the neutral defaults.
|
||||||
|
|
||||||
|
## Settings → Dotfiles
|
||||||
|
|
||||||
|
A mini file-browser workspace rooted at `home/{firstName}`:
|
||||||
|
|
||||||
|
- **Editable overlay tree** — drag in files/folders (or use Upload folder/files),
|
||||||
|
edit them in the Monaco editor, add/delete. Files are placed **relative to
|
||||||
|
`$HOME`** (`.bashrc` → `~/.bashrc`, `.config/nvim/…` → `~/.config/nvim/…`).
|
||||||
|
Stored encrypted at rest (`userDotfiles`, AES-256-GCM via `secretCrypto`).
|
||||||
|
- **Dotfiles repo (optional)** — a **public** git repo URL + optional ref + a
|
||||||
|
setup script path. On start the container clones it to `~/.dotfiles` and runs
|
||||||
|
`bash ~/.dotfiles/<setup>` (e.g. a bootstrap that symlinks configs, like the
|
||||||
|
user's Panama `install`).
|
||||||
|
- **Precedence (hybrid):** repo clone + setup runs first; then the editable
|
||||||
|
overlay is written on top — **overlay wins**.
|
||||||
|
|
||||||
|
Secrets: dotfiles are encrypted, but real API keys/tokens belong in a Spoon's
|
||||||
|
**Secrets** feature (injected as env vars), not in dotfiles. The UI nudges this.
|
||||||
|
|
||||||
|
## Materialization (worker)
|
||||||
|
|
||||||
|
`apps/agent-worker/src/user-environment.ts`:
|
||||||
|
|
||||||
|
1. `fetchUserEnvironment(jobId)` — a worker-token Convex action
|
||||||
|
(`userDotfilesNode.getEnvironmentForJob`) returns the owner's decrypted
|
||||||
|
dotfiles + repo/setup config.
|
||||||
|
2. `materializeUserHome` — ensures `~/.bash_profile` (so login shells source
|
||||||
|
`~/.bashrc` in a mounted home with no `/etc/skel`); clones the repo + runs the
|
||||||
|
setup command **inside the job image** (so the user's tools/paths apply), only
|
||||||
|
when the config hash changes (`~/.spoon/env-hash`); writes the overlay files.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Default | Notes |
|
||||||
|
| ------------------------------------------ | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||||
|
| `SPOON_AGENT_WORKDIR` | `.local/agent-work` (dev) / `/var/lib/spoon-agent/work` (prod) | Per-user homes live under `homes/{username}`; reuses the existing host-path translation. |
|
||||||
|
| `SPOON_ENCRYPTION_KEY` / `INSTANCE_SECRET` | — | Already required; encrypts dotfiles like other secrets. |
|
||||||
|
|
||||||
|
No new required env. The home is a host directory under the existing workdir, so
|
||||||
|
the prod bind-mount + `SPOON_AGENT_HOST_WORKDIR` translation already covers it.
|
||||||
|
|
||||||
|
## Notes / limits (Phase 1)
|
||||||
|
|
||||||
|
- **Repo auth:** public repos only. Private/self-hosted (e.g. Gitea) dotfiles
|
||||||
|
repos are a follow-up (store a token/deploy key).
|
||||||
|
- **Binary files:** the overlay is text-first.
|
||||||
|
- **Cleanup:** `~/Code/{spoon}/{branch}` checkouts persist (threads as folders);
|
||||||
|
a per-thread "delete checkout" action is a follow-up.
|
||||||
|
- **Concurrency:** jobs share one home; fine at the default
|
||||||
|
`SPOON_AGENT_MAX_CONCURRENT_JOBS=1`.
|
||||||
|
|
||||||
|
## Phase 2 north star
|
||||||
|
|
||||||
|
A single long-running per-user container that every thread `exec`s into (agent
|
||||||
|
via `docker exec`, not `docker run --rm`). The per-user home + `~/Code/{spoon}/
|
||||||
|
{branch}` layout built here is its foundation.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Server deploy changes (terminal + dotfiles + Fedora + Phase 2)
|
||||||
|
|
||||||
|
Everything the production host / compose / `.env` needs for the workspace
|
||||||
|
terminal, personalized dev environment, Nerd Font, and the per-user container.
|
||||||
|
Most items have safe defaults; the **Required** ones are the only must-dos.
|
||||||
|
|
||||||
|
## Required
|
||||||
|
|
||||||
|
1. **Build-time env for the Next image** — add to the build env file (the one CI /
|
||||||
|
`scripts/build-next-app` passes as build args; e.g. `DOTENV_PROD`):
|
||||||
|
|
||||||
|
```
|
||||||
|
NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL=wss://worker.spoon.gbrown.org
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a `NEXT_PUBLIC` (build-time) var — it must be present **when the
|
||||||
|
`spoon-next` image is built**, not just at runtime. Already wired into
|
||||||
|
`docker/Dockerfile` + `docker/compose.yml` build args. Without it, the
|
||||||
|
workspace **Terminal** tab shows "not configured".
|
||||||
|
|
||||||
|
2. **nginx: expose the worker for the terminal WebSocket.** Add a TLS server
|
||||||
|
block proxying the worker domain to the worker on the shared network, with WS
|
||||||
|
upgrade:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name worker.spoon.gbrown.org; # + your ssl_certificate lines
|
||||||
|
location / {
|
||||||
|
proxy_pass http://spoon-agent-worker:3921;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
proxy_send_timeout 86400s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Rebuild + redeploy all three images** (CI does this on push to `main`):
|
||||||
|
`spoon-next`, `spoon-agent-worker`, and `spoon-agent-job` (now **Fedora**).
|
||||||
|
The worker auto-`docker pull`s the job image once per process, so a worker
|
||||||
|
restart picks up the new Fedora job image. Make sure the prod registry has the
|
||||||
|
new `spoon-agent-job:latest`.
|
||||||
|
|
||||||
|
4. **Deploy Convex functions** (new tables `userDotfiles`, `userEnvironment`).
|
||||||
|
`SPOON_ENCRYPTION_KEY` (or `INSTANCE_SECRET`) is already required and is what
|
||||||
|
encrypts dotfiles at rest — no change, just confirm it's set.
|
||||||
|
|
||||||
|
5. **Confirm `SPOON_AGENT_HOST_WORKDIR`** on the `spoon-agent-worker` service is
|
||||||
|
the absolute host path backing `SPOON_AGENT_WORKDIR` (the fix from the terminal
|
||||||
|
work). The per-user homes live under `${SPOON_AGENT_WORKDIR}/homes/{username}`
|
||||||
|
and are bind-mounted into the box via the host daemon — this only resolves if
|
||||||
|
the host-workdir translation is correct. (No new var; just verify.)
|
||||||
|
|
||||||
|
## Optional (safe defaults — only set to override)
|
||||||
|
|
||||||
|
On the `spoon-agent-worker` service:
|
||||||
|
|
||||||
|
| Var | Default | Purpose |
|
||||||
|
| ------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `SPOON_AGENT_TERMINAL_SECRET` | falls back to `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | HMAC secret for terminal tokens (must match the Next app's, which also falls back). Leave unset to use the shared token. |
|
||||||
|
| `SPOON_AGENT_BOX_IDLE_MS` | `1800000` (30m) | How long a per-user box survives idle before being reaped. |
|
||||||
|
| `SPOON_AGENT_TERMINAL_IDLE_MS` | `1800000` | (Legacy; box idle now governs cleanup.) |
|
||||||
|
|
||||||
|
No new env is needed for dotfiles, the per-user home, or the Nerd Font.
|
||||||
|
|
||||||
|
## Notes / one-time cleanup
|
||||||
|
|
||||||
|
- **Layout change:** thread checkouts moved from `${WORKDIR}/{jobId}/repo` to
|
||||||
|
`${WORKDIR}/homes/{username}/Code/{spoon}/{branch}` (persistent). Old per-job
|
||||||
|
dirs are orphaned and safe to delete.
|
||||||
|
- **Containers:** per-thread agent containers (`docker run --rm`) and per-job
|
||||||
|
terminal containers (`spoon-agent-term-*`) are gone; everything runs in one
|
||||||
|
`spoon-box-{username}` per user. Any lingering `spoon-agent-term-*` containers
|
||||||
|
can be removed.
|
||||||
|
- **Resources:** each active user holds one box (4 GB mem cap, `sleep infinity`)
|
||||||
|
until 30m idle. Single-user = one box.
|
||||||
|
- Compose already mounts `/var/run/docker.sock` into the worker (unchanged) — the
|
||||||
|
box is created/exec'd through it.
|
||||||
|
|
||||||
|
## Quick post-deploy checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec spoon-agent-worker docker --version # CLI present (29.x)
|
||||||
|
docker run --rm git.gbrown.org/gib/spoon-agent-job:latest codex --version # 0.142
|
||||||
|
docker run --rm git.gbrown.org/gib/spoon-agent-job:latest bash -lc 'eza --version; zoxide --version; oh-my-posh --version'
|
||||||
|
# then: open a thread → Terminal tab; Settings → Dotfiles add a .bashrc alias.
|
||||||
|
```
|
||||||
+9
-2
@@ -53,8 +53,10 @@
|
|||||||
"dev:tunnel": "turbo run dev:tunnel",
|
"dev:tunnel": "turbo run dev:tunnel",
|
||||||
"dev:next": "turbo run dev -F @spoon/next -F @spoon/backend",
|
"dev:next": "turbo run dev -F @spoon/next -F @spoon/backend",
|
||||||
"dev:next:staging": "INFISICAL_ENV=staging turbo run dev -F @spoon/next -F @spoon/backend",
|
"dev:next:staging": "INFISICAL_ENV=staging turbo run dev -F @spoon/next -F @spoon/backend",
|
||||||
"dev:agent": "turbo run dev -F @spoon/agent-worker",
|
"dev:agent": "SPOON_AGENT_WORKER_URL=http://localhost:3921 SPOON_AGENT_CONTAINER_ACCESS=host_port turbo run dev -F @spoon/agent-worker",
|
||||||
"dev:agent:staging": "INFISICAL_ENV=staging turbo run dev -F @spoon/agent-worker",
|
"dev:agent:staging": "INFISICAL_ENV=staging SPOON_AGENT_WORKER_URL=http://localhost:3921 SPOON_AGENT_CONTAINER_ACCESS=host_port turbo run dev -F @spoon/agent-worker",
|
||||||
|
"dev:next:worker": "SPOON_AGENT_WORKER_URL=http://localhost:3921 SPOON_AGENT_CONTAINER_ACCESS=host_port turbo run dev -F @spoon/next -F @spoon/backend -F @spoon/agent-worker",
|
||||||
|
"dev:next:worker:staging": "INFISICAL_ENV=staging SPOON_AGENT_WORKER_URL=http://localhost:3921 SPOON_AGENT_CONTAINER_ACCESS=host_port turbo run dev -F @spoon/next -F @spoon/backend -F @spoon/agent-worker",
|
||||||
"dev:next:web": "turbo run dev:web -F @spoon/next -F @spoon/backend",
|
"dev:next:web": "turbo run dev:web -F @spoon/next -F @spoon/backend",
|
||||||
"dev:next:web:staging": "INFISICAL_ENV=staging turbo run dev:web -F @spoon/next -F @spoon/backend",
|
"dev:next:web:staging": "INFISICAL_ENV=staging turbo run dev:web -F @spoon/next -F @spoon/backend",
|
||||||
"dev:expo": "turbo run dev -F @spoon/expo -F @spoon/backend",
|
"dev:expo": "turbo run dev -F @spoon/expo -F @spoon/backend",
|
||||||
@@ -73,6 +75,7 @@
|
|||||||
"sync:convex:production": "scripts/sync-convex-env production",
|
"sync:convex:production": "scripts/sync-convex-env production",
|
||||||
"sync:convex:prod": "scripts/sync-convex-env prod",
|
"sync:convex:prod": "scripts/sync-convex-env prod",
|
||||||
"auth:keys": "node scripts/generate-convex-auth-keys.mjs",
|
"auth:keys": "node scripts/generate-convex-auth-keys.mjs",
|
||||||
|
"smoke:agent-container": "scripts/smoke-agent-container",
|
||||||
"db:up": "bash scripts/db/up",
|
"db:up": "bash scripts/db/up",
|
||||||
"db:down": "bash scripts/db/down",
|
"db:down": "bash scripts/db/down",
|
||||||
"db:down:wipe": "bash scripts/db/down --wipe",
|
"db:down:wipe": "bash scripts/db/down --wipe",
|
||||||
@@ -116,6 +119,10 @@
|
|||||||
"eslint --flag unstable_native_nodejs_ts_config --fix --no-warn-ignored --config apps/expo/eslint.config.mts",
|
"eslint --flag unstable_native_nodejs_ts_config --fix --no-warn-ignored --config apps/expo/eslint.config.mts",
|
||||||
"prettier --write"
|
"prettier --write"
|
||||||
],
|
],
|
||||||
|
"apps/agent-worker/**/*.{ts,tsx}": [
|
||||||
|
"eslint --flag unstable_native_nodejs_ts_config --fix --no-warn-ignored --config apps/expo/eslint.config.mts",
|
||||||
|
"prettier --write"
|
||||||
|
],
|
||||||
"packages/backend/**/*.{ts,tsx}": [
|
"packages/backend/**/*.{ts,tsx}": [
|
||||||
"eslint --flag unstable_native_nodejs_ts_config --fix --no-warn-ignored --config packages/backend/eslint.config.ts",
|
"eslint --flag unstable_native_nodejs_ts_config --fix --no-warn-ignored --config packages/backend/eslint.config.ts",
|
||||||
"prettier --write"
|
"prettier --write"
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ const workspaceStatus = v.union(
|
|||||||
v.literal('failed'),
|
v.literal('failed'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const agentRuntimeMode = v.union(
|
||||||
|
v.literal('opencode_server'),
|
||||||
|
v.literal('codex_exec'),
|
||||||
|
v.literal('legacy_cli'),
|
||||||
|
);
|
||||||
|
|
||||||
const messageRole = v.union(
|
const messageRole = v.union(
|
||||||
v.literal('user'),
|
v.literal('user'),
|
||||||
v.literal('assistant'),
|
v.literal('assistant'),
|
||||||
@@ -100,6 +106,22 @@ const artifactContentType = v.union(
|
|||||||
v.literal('text/x-diff'),
|
v.literal('text/x-diff'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const interactionRuntime = v.union(v.literal('opencode'), v.literal('codex'));
|
||||||
|
|
||||||
|
const interactionKind = v.union(
|
||||||
|
v.literal('question'),
|
||||||
|
v.literal('permission'),
|
||||||
|
v.literal('tool_confirmation'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const interactionStatus = v.union(
|
||||||
|
v.literal('pending'),
|
||||||
|
v.literal('answered'),
|
||||||
|
v.literal('approved'),
|
||||||
|
v.literal('rejected'),
|
||||||
|
v.literal('expired'),
|
||||||
|
);
|
||||||
|
|
||||||
const maintenanceDecision = v.union(
|
const maintenanceDecision = v.union(
|
||||||
v.literal('sync'),
|
v.literal('sync'),
|
||||||
v.literal('ignore'),
|
v.literal('ignore'),
|
||||||
@@ -138,6 +160,27 @@ const requireWorkerToken = (workerToken: string) => {
|
|||||||
if (workerToken !== expected) throw new ConvexError('Invalid worker token.');
|
if (workerToken !== expected) throw new ConvexError('Invalid worker token.');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mergeMessageMetadata = (
|
||||||
|
metadata: string | undefined,
|
||||||
|
patch: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
if (!metadata) return JSON.stringify(patch);
|
||||||
|
try {
|
||||||
|
return JSON.stringify({ ...(JSON.parse(metadata) as object), ...patch });
|
||||||
|
} catch {
|
||||||
|
return JSON.stringify({ note: metadata, ...patch });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseMessageMetadata = (metadata: string | undefined) => {
|
||||||
|
if (!metadata) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(metadata) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const slugify = (value: string) =>
|
const slugify = (value: string) =>
|
||||||
value
|
value
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -172,6 +215,84 @@ const normalizeEnvFilePath = (value?: string) => {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeWorkspacePath = (value: string) => {
|
||||||
|
const trimmed = optionalText(value);
|
||||||
|
if (!trimmed) throw new ConvexError('Workspace path is required.');
|
||||||
|
if (
|
||||||
|
trimmed.startsWith('/') ||
|
||||||
|
trimmed.includes('\0') ||
|
||||||
|
trimmed.split('/').includes('..') ||
|
||||||
|
trimmed === '.git' ||
|
||||||
|
trimmed.startsWith('.git/')
|
||||||
|
) {
|
||||||
|
throw new ConvexError('Workspace path must stay inside the repository.');
|
||||||
|
}
|
||||||
|
return trimmed.replace(/^\.\/+/, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeWorkspacePaths = (values: string[] | undefined, max: number) =>
|
||||||
|
values
|
||||||
|
?.map(normalizeWorkspacePath)
|
||||||
|
.filter((value, index, all) => all.indexOf(value) === index)
|
||||||
|
.slice(0, max);
|
||||||
|
|
||||||
|
const isDeletableWorkspace = (job: Doc<'agentJobs'>) =>
|
||||||
|
['failed', 'cancelled', 'timed_out'].includes(job.status) ||
|
||||||
|
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
|
||||||
|
|
||||||
|
const isTerminalJob = (job: Doc<'agentJobs'>) =>
|
||||||
|
['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes(
|
||||||
|
job.status,
|
||||||
|
) || ['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
|
||||||
|
|
||||||
|
const deleteWorkspaceRows = async (ctx: MutationCtx, job: Doc<'agentJobs'>) => {
|
||||||
|
const messages = await ctx.db
|
||||||
|
.query('agentJobMessages')
|
||||||
|
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
||||||
|
.collect();
|
||||||
|
const events = await ctx.db
|
||||||
|
.query('agentJobEvents')
|
||||||
|
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
||||||
|
.collect();
|
||||||
|
const artifacts = await ctx.db
|
||||||
|
.query('agentJobArtifacts')
|
||||||
|
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
||||||
|
.collect();
|
||||||
|
const changes = await ctx.db
|
||||||
|
.query('agentWorkspaceChanges')
|
||||||
|
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
||||||
|
.collect();
|
||||||
|
const uiStates = await ctx.db
|
||||||
|
.query('agentWorkspaceUiStates')
|
||||||
|
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
||||||
|
.collect();
|
||||||
|
const interactions = await ctx.db
|
||||||
|
.query('agentInteractionRequests')
|
||||||
|
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (const row of [
|
||||||
|
...messages,
|
||||||
|
...events,
|
||||||
|
...artifacts,
|
||||||
|
...changes,
|
||||||
|
...uiStates,
|
||||||
|
...interactions,
|
||||||
|
]) {
|
||||||
|
await ctx.db.delete(row._id);
|
||||||
|
}
|
||||||
|
if (job.threadId) {
|
||||||
|
const thread = await ctx.db.get(job.threadId);
|
||||||
|
if (thread?.latestAgentJobId === job._id) {
|
||||||
|
await ctx.db.patch(job.threadId, {
|
||||||
|
latestAgentJobId: undefined,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await ctx.db.delete(job._id);
|
||||||
|
};
|
||||||
|
|
||||||
const getAgentSettings = async (ctx: MutationCtx, spoon: Doc<'spoons'>) => {
|
const getAgentSettings = async (ctx: MutationCtx, spoon: Doc<'spoons'>) => {
|
||||||
const settings = await ctx.db
|
const settings = await ctx.db
|
||||||
.query('spoonAgentSettings')
|
.query('spoonAgentSettings')
|
||||||
@@ -451,7 +572,10 @@ export const createForThread = mutation({
|
|||||||
throw new ConvexError('Thread not found.');
|
throw new ConvexError('Thread not found.');
|
||||||
}
|
}
|
||||||
if (thread.latestAgentJobId) {
|
if (thread.latestAgentJobId) {
|
||||||
throw new ConvexError('This thread already has an agent job.');
|
const latestJob = await ctx.db.get(thread.latestAgentJobId);
|
||||||
|
if (latestJob && !isTerminalJob(latestJob)) {
|
||||||
|
throw new ConvexError('This thread already has an active agent job.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const spoon = await getOwnedSpoon(ctx, thread.spoonId, ownerId);
|
const spoon = await getOwnedSpoon(ctx, thread.spoonId, ownerId);
|
||||||
const promptMessage = await ctx.db
|
const promptMessage = await ctx.db
|
||||||
@@ -514,7 +638,12 @@ export const createForThreadInternal = internalMutation({
|
|||||||
if (thread?.ownerId !== args.ownerId || !thread.spoonId) {
|
if (thread?.ownerId !== args.ownerId || !thread.spoonId) {
|
||||||
throw new ConvexError('Thread not found.');
|
throw new ConvexError('Thread not found.');
|
||||||
}
|
}
|
||||||
if (thread.latestAgentJobId) return thread.latestAgentJobId;
|
if (thread.latestAgentJobId) {
|
||||||
|
const latestJob = await ctx.db.get(thread.latestAgentJobId);
|
||||||
|
if (latestJob && !isTerminalJob(latestJob)) {
|
||||||
|
return thread.latestAgentJobId;
|
||||||
|
}
|
||||||
|
}
|
||||||
const spoon = await ctx.db.get(thread.spoonId);
|
const spoon = await ctx.db.get(thread.spoonId);
|
||||||
if (spoon?.ownerId !== args.ownerId) {
|
if (spoon?.ownerId !== args.ownerId) {
|
||||||
throw new ConvexError('Spoon not found.');
|
throw new ConvexError('Spoon not found.');
|
||||||
@@ -609,6 +738,126 @@ export const listMessages = query({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getWorkspaceUiState = query({
|
||||||
|
args: { jobId: v.id('agentJobs') },
|
||||||
|
handler: async (ctx, { jobId }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const job = await ctx.db.get(jobId);
|
||||||
|
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
|
||||||
|
const state = await ctx.db
|
||||||
|
.query('agentWorkspaceUiStates')
|
||||||
|
.withIndex('by_job', (q) => q.eq('jobId', jobId))
|
||||||
|
.first();
|
||||||
|
return (
|
||||||
|
state ?? {
|
||||||
|
jobId,
|
||||||
|
spoonId: job.spoonId,
|
||||||
|
ownerId,
|
||||||
|
openFilePaths: [],
|
||||||
|
activeFilePath: undefined,
|
||||||
|
vimEnabled: false,
|
||||||
|
expandedDirectoryPaths: [],
|
||||||
|
agentThreadWidth: 420,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const patchWorkspaceUiState = mutation({
|
||||||
|
args: {
|
||||||
|
jobId: v.id('agentJobs'),
|
||||||
|
openFilePaths: v.optional(v.array(v.string())),
|
||||||
|
activeFilePath: v.optional(v.string()),
|
||||||
|
vimEnabled: v.optional(v.boolean()),
|
||||||
|
expandedDirectoryPaths: v.optional(v.array(v.string())),
|
||||||
|
agentThreadWidth: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const job = await ctx.db.get(args.jobId);
|
||||||
|
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
|
||||||
|
const now = Date.now();
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query('agentWorkspaceUiStates')
|
||||||
|
.withIndex('by_job', (q) => q.eq('jobId', args.jobId))
|
||||||
|
.first();
|
||||||
|
const patch = {
|
||||||
|
...(args.openFilePaths !== undefined
|
||||||
|
? { openFilePaths: normalizeWorkspacePaths(args.openFilePaths, 40) }
|
||||||
|
: {}),
|
||||||
|
...(args.activeFilePath !== undefined
|
||||||
|
? {
|
||||||
|
activeFilePath: args.activeFilePath
|
||||||
|
? normalizeWorkspacePath(args.activeFilePath)
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(args.vimEnabled !== undefined ? { vimEnabled: args.vimEnabled } : {}),
|
||||||
|
...(args.expandedDirectoryPaths !== undefined
|
||||||
|
? {
|
||||||
|
expandedDirectoryPaths: normalizeWorkspacePaths(
|
||||||
|
args.expandedDirectoryPaths,
|
||||||
|
500,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(args.agentThreadWidth !== undefined
|
||||||
|
? {
|
||||||
|
agentThreadWidth: Math.min(
|
||||||
|
Math.max(Math.round(args.agentThreadWidth), 320),
|
||||||
|
720,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, patch);
|
||||||
|
return existing._id;
|
||||||
|
}
|
||||||
|
return await ctx.db.insert('agentWorkspaceUiStates', {
|
||||||
|
jobId: args.jobId,
|
||||||
|
spoonId: job.spoonId,
|
||||||
|
ownerId,
|
||||||
|
openFilePaths: patch.openFilePaths ?? [],
|
||||||
|
activeFilePath: patch.activeFilePath,
|
||||||
|
vimEnabled: patch.vimEnabled ?? false,
|
||||||
|
expandedDirectoryPaths: patch.expandedDirectoryPaths ?? [],
|
||||||
|
agentThreadWidth: patch.agentThreadWidth ?? 420,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listInteractionRequests = query({
|
||||||
|
args: {
|
||||||
|
jobId: v.id('agentJobs'),
|
||||||
|
status: v.optional(v.union(v.literal('pending'), v.literal('all'))),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { jobId, status }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const job = await ctx.db.get(jobId);
|
||||||
|
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
|
||||||
|
if (status === 'pending') {
|
||||||
|
return await ctx.db
|
||||||
|
.query('agentInteractionRequests')
|
||||||
|
.withIndex('by_job_status', (q) =>
|
||||||
|
q.eq('jobId', jobId).eq('status', 'pending'),
|
||||||
|
)
|
||||||
|
.order('asc')
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
return await ctx.db
|
||||||
|
.query('agentInteractionRequests')
|
||||||
|
.withIndex('by_job', (q) => q.eq('jobId', jobId))
|
||||||
|
.order('asc')
|
||||||
|
.collect();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const appendUserMessage = mutation({
|
export const appendUserMessage = mutation({
|
||||||
args: { jobId: v.id('agentJobs'), content: v.string() },
|
args: { jobId: v.id('agentJobs'), content: v.string() },
|
||||||
handler: async (ctx, { jobId, content }) => {
|
handler: async (ctx, { jobId, content }) => {
|
||||||
@@ -709,6 +958,91 @@ export const cancel = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const deleteWorkspace = mutation({
|
||||||
|
args: { jobId: v.id('agentJobs') },
|
||||||
|
handler: async (ctx, { jobId }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const job = await ctx.db.get(jobId);
|
||||||
|
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
|
||||||
|
if (!isDeletableWorkspace(job)) {
|
||||||
|
throw new ConvexError(
|
||||||
|
'Only stopped, cancelled, failed, or expired workspaces can be deleted.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await deleteWorkspaceRows(ctx, job);
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const markWorkspaceLost = mutation({
|
||||||
|
args: { jobId: v.id('agentJobs') },
|
||||||
|
handler: async (ctx, { jobId }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const job = await ctx.db.get(jobId);
|
||||||
|
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
|
||||||
|
const now = Date.now();
|
||||||
|
await ctx.db.patch(jobId, {
|
||||||
|
status: 'failed',
|
||||||
|
workspaceStatus: 'failed',
|
||||||
|
error: 'Workspace is not active on the configured worker.',
|
||||||
|
completedAt: job.completedAt ?? now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
if (job.threadId) {
|
||||||
|
await ctx.db.patch(job.threadId, {
|
||||||
|
status: 'failed',
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const countOldWorkspaces = query({
|
||||||
|
args: { olderThanDays: v.optional(v.number()) },
|
||||||
|
handler: async (ctx, { olderThanDays }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const cutoff =
|
||||||
|
olderThanDays && olderThanDays > 0
|
||||||
|
? Date.now() - olderThanDays * 24 * 60 * 60 * 1000
|
||||||
|
: Number.POSITIVE_INFINITY;
|
||||||
|
const jobs = await ctx.db
|
||||||
|
.query('agentJobs')
|
||||||
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||||
|
.collect();
|
||||||
|
return jobs.filter(
|
||||||
|
(job) => isDeletableWorkspace(job) && job.updatedAt <= cutoff,
|
||||||
|
).length;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteOldWorkspaces = mutation({
|
||||||
|
args: {
|
||||||
|
olderThanDays: v.optional(v.number()),
|
||||||
|
limit: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { olderThanDays, limit }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const cutoff =
|
||||||
|
olderThanDays && olderThanDays > 0
|
||||||
|
? Date.now() - olderThanDays * 24 * 60 * 60 * 1000
|
||||||
|
: Number.POSITIVE_INFINITY;
|
||||||
|
const max = Math.min(Math.max(limit ?? 50, 1), 100);
|
||||||
|
const jobs = await ctx.db
|
||||||
|
.query('agentJobs')
|
||||||
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||||
|
.collect();
|
||||||
|
const deletable = jobs
|
||||||
|
.filter((job) => isDeletableWorkspace(job) && job.updatedAt <= cutoff)
|
||||||
|
.sort((a, b) => a.updatedAt - b.updatedAt)
|
||||||
|
.slice(0, max);
|
||||||
|
for (const job of deletable) {
|
||||||
|
await deleteWorkspaceRows(ctx, job);
|
||||||
|
}
|
||||||
|
return { deleted: deletable.length };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const claimNextInternal = internalMutation({
|
export const claimNextInternal = internalMutation({
|
||||||
args: { workerId: v.string() },
|
args: { workerId: v.string() },
|
||||||
handler: async (ctx, { workerId }) => {
|
handler: async (ctx, { workerId }) => {
|
||||||
@@ -867,6 +1201,138 @@ export const markWorkspaceActive = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const setRuntimeSession = mutation({
|
||||||
|
args: {
|
||||||
|
workerToken: v.string(),
|
||||||
|
workerId: v.string(),
|
||||||
|
jobId: v.id('agentJobs'),
|
||||||
|
agentRuntimeMode,
|
||||||
|
opencodeSessionId: v.optional(v.string()),
|
||||||
|
codexSessionId: v.optional(v.string()),
|
||||||
|
containerId: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
requireWorkerToken(args.workerToken);
|
||||||
|
const job = await ctx.db.get(args.jobId);
|
||||||
|
if (job?.claimedBy !== args.workerId) {
|
||||||
|
throw new ConvexError('Agent job not claimed by this worker.');
|
||||||
|
}
|
||||||
|
await ctx.db.patch(args.jobId, {
|
||||||
|
agentRuntimeMode: args.agentRuntimeMode,
|
||||||
|
opencodeSessionId: optionalText(args.opencodeSessionId),
|
||||||
|
codexSessionId: optionalText(args.codexSessionId),
|
||||||
|
containerId: optionalText(args.containerId),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setCodexSessionId = mutation({
|
||||||
|
args: {
|
||||||
|
workerToken: v.string(),
|
||||||
|
workerId: v.string(),
|
||||||
|
jobId: v.id('agentJobs'),
|
||||||
|
codexSessionId: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
requireWorkerToken(args.workerToken);
|
||||||
|
const job = await ctx.db.get(args.jobId);
|
||||||
|
if (job?.claimedBy !== args.workerId) {
|
||||||
|
throw new ConvexError('Agent job not claimed by this worker.');
|
||||||
|
}
|
||||||
|
await ctx.db.patch(args.jobId, {
|
||||||
|
codexSessionId: optionalText(args.codexSessionId),
|
||||||
|
agentRuntimeMode: 'codex_exec',
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createInteractionRequest = mutation({
|
||||||
|
args: {
|
||||||
|
workerToken: v.string(),
|
||||||
|
workerId: v.string(),
|
||||||
|
jobId: v.id('agentJobs'),
|
||||||
|
runtime: interactionRuntime,
|
||||||
|
externalRequestId: v.string(),
|
||||||
|
kind: interactionKind,
|
||||||
|
title: v.string(),
|
||||||
|
body: v.string(),
|
||||||
|
options: v.optional(v.array(v.string())),
|
||||||
|
metadata: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
requireWorkerToken(args.workerToken);
|
||||||
|
const job = await ctx.db.get(args.jobId);
|
||||||
|
if (job?.claimedBy !== args.workerId) {
|
||||||
|
throw new ConvexError('Agent job not claimed by this worker.');
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const existing = (
|
||||||
|
await ctx.db
|
||||||
|
.query('agentInteractionRequests')
|
||||||
|
.withIndex('by_job', (q) => q.eq('jobId', args.jobId))
|
||||||
|
.collect()
|
||||||
|
).find((request) => request.externalRequestId === args.externalRequestId);
|
||||||
|
const record = {
|
||||||
|
runtime: args.runtime,
|
||||||
|
externalRequestId: args.externalRequestId,
|
||||||
|
kind: args.kind,
|
||||||
|
title: args.title,
|
||||||
|
body: args.body,
|
||||||
|
options: args.options,
|
||||||
|
metadata: args.metadata,
|
||||||
|
status: 'pending' as const,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, record);
|
||||||
|
return existing._id;
|
||||||
|
}
|
||||||
|
const requestId = await ctx.db.insert('agentInteractionRequests', {
|
||||||
|
jobId: args.jobId,
|
||||||
|
spoonId: job.spoonId,
|
||||||
|
ownerId: job.ownerId,
|
||||||
|
...record,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
await ctx.db.patch(args.jobId, {
|
||||||
|
status: 'running',
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
return requestId;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const patchInteractionRequest = mutation({
|
||||||
|
args: {
|
||||||
|
workerToken: v.string(),
|
||||||
|
workerId: v.string(),
|
||||||
|
interactionId: v.id('agentInteractionRequests'),
|
||||||
|
status: interactionStatus,
|
||||||
|
response: v.optional(v.string()),
|
||||||
|
metadata: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
requireWorkerToken(args.workerToken);
|
||||||
|
const interaction = await ctx.db.get(args.interactionId);
|
||||||
|
if (!interaction) throw new ConvexError('Interaction request not found.');
|
||||||
|
const job = await ctx.db.get(interaction.jobId);
|
||||||
|
if (job?.claimedBy !== args.workerId) {
|
||||||
|
throw new ConvexError('Agent job not claimed by this worker.');
|
||||||
|
}
|
||||||
|
await ctx.db.patch(args.interactionId, {
|
||||||
|
status: args.status,
|
||||||
|
response: optionalText(args.response),
|
||||||
|
metadata: args.metadata,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const markWorkspaceStopped = mutation({
|
export const markWorkspaceStopped = mutation({
|
||||||
args: {
|
args: {
|
||||||
workerToken: v.string(),
|
workerToken: v.string(),
|
||||||
@@ -1103,7 +1569,9 @@ export const appendMessage = mutation({
|
|||||||
role: args.role,
|
role: args.role,
|
||||||
content: args.content,
|
content: args.content,
|
||||||
status: args.status,
|
status: args.status,
|
||||||
metadata: args.metadata,
|
metadata: mergeMessageMetadata(args.metadata, {
|
||||||
|
agentJobMessageId: messageId,
|
||||||
|
}),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
@@ -1136,6 +1604,32 @@ export const updateMessage = mutation({
|
|||||||
if (args.status !== undefined) patch.status = args.status;
|
if (args.status !== undefined) patch.status = args.status;
|
||||||
if (args.metadata !== undefined) patch.metadata = args.metadata;
|
if (args.metadata !== undefined) patch.metadata = args.metadata;
|
||||||
await ctx.db.patch(args.messageId, patch);
|
await ctx.db.patch(args.messageId, patch);
|
||||||
|
const threadId = job.threadId;
|
||||||
|
if (threadId) {
|
||||||
|
const threadMessages = await ctx.db
|
||||||
|
.query('threadMessages')
|
||||||
|
.withIndex('by_thread', (q) => q.eq('threadId', threadId))
|
||||||
|
.order('desc')
|
||||||
|
.take(300);
|
||||||
|
const mirrored = threadMessages.find(
|
||||||
|
(threadMessage) =>
|
||||||
|
parseMessageMetadata(threadMessage.metadata)?.agentJobMessageId ===
|
||||||
|
args.messageId,
|
||||||
|
);
|
||||||
|
if (mirrored) {
|
||||||
|
const threadPatch: Partial<Doc<'threadMessages'>> = {
|
||||||
|
updatedAt: patch.updatedAt,
|
||||||
|
};
|
||||||
|
if (args.content !== undefined) threadPatch.content = args.content;
|
||||||
|
if (args.status !== undefined) threadPatch.status = args.status;
|
||||||
|
if (args.metadata !== undefined) {
|
||||||
|
threadPatch.metadata = mergeMessageMetadata(args.metadata, {
|
||||||
|
agentJobMessageId: args.messageId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await ctx.db.patch(mirrored._id, threadPatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { Doc } from './_generated/dataModel';
|
||||||
|
import { query } from './_generated/server';
|
||||||
|
import { getRequiredUserId } from './model';
|
||||||
|
|
||||||
|
type AiProviderProfileWithDefault = Doc<'aiProviderProfiles'> & {
|
||||||
|
isDefault?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelForModel = (model: string): string => {
|
||||||
|
const parts = model.split('/');
|
||||||
|
const raw = parts[parts.length - 1] ?? model;
|
||||||
|
return raw
|
||||||
|
.replaceAll('-', ' ')
|
||||||
|
.replace(/\b\w/g, (letter: string) => letter.toUpperCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
const recommendedFor = (model: string) => {
|
||||||
|
const lower = model.toLowerCase();
|
||||||
|
const tags: ('coding' | 'review' | 'fast' | 'large_context')[] = [];
|
||||||
|
if (
|
||||||
|
lower.includes('codex') ||
|
||||||
|
lower.includes('claude') ||
|
||||||
|
lower.includes('sonnet')
|
||||||
|
) {
|
||||||
|
tags.push('coding');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
lower.includes('mini') ||
|
||||||
|
lower.includes('haiku') ||
|
||||||
|
lower.includes('flash')
|
||||||
|
) {
|
||||||
|
tags.push('fast');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
lower.includes('200k') ||
|
||||||
|
lower.includes('1m') ||
|
||||||
|
lower.includes('large')
|
||||||
|
) {
|
||||||
|
tags.push('large_context');
|
||||||
|
}
|
||||||
|
if (!tags.length) tags.push('review');
|
||||||
|
return tags;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listAvailableForUser = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const profiles = await ctx.db
|
||||||
|
.query('aiProviderProfiles')
|
||||||
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||||
|
.order('desc')
|
||||||
|
.collect();
|
||||||
|
const configuredProfiles = profiles.filter(
|
||||||
|
(profile) =>
|
||||||
|
profile.enabled &&
|
||||||
|
(profile.authType === 'none' || Boolean(profile.encryptedSecret)),
|
||||||
|
);
|
||||||
|
const explicitDefault = configuredProfiles.find(
|
||||||
|
(profile) => (profile as AiProviderProfileWithDefault).isDefault,
|
||||||
|
);
|
||||||
|
const defaultProfileId =
|
||||||
|
explicitDefault?._id ??
|
||||||
|
(configuredProfiles.length === 1
|
||||||
|
? configuredProfiles[0]?._id
|
||||||
|
: undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
profiles: profiles
|
||||||
|
.filter((profile) => profile.enabled)
|
||||||
|
.map((profile) => {
|
||||||
|
const configured =
|
||||||
|
profile.authType === 'none' || Boolean(profile.encryptedSecret);
|
||||||
|
const modelIds = [
|
||||||
|
profile.defaultModel,
|
||||||
|
...(profile.modelOptions ?? []),
|
||||||
|
]
|
||||||
|
.map((model) => model.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((model, index, all) => all.indexOf(model) === index);
|
||||||
|
return {
|
||||||
|
profileId: profile._id,
|
||||||
|
profileName: profile.name,
|
||||||
|
provider: profile.provider,
|
||||||
|
configured,
|
||||||
|
enabled: profile.enabled,
|
||||||
|
isDefault: profile._id === defaultProfileId,
|
||||||
|
defaultModel: profile.defaultModel,
|
||||||
|
reasoningEffort: profile.reasoningEffort,
|
||||||
|
models: modelIds.map((id) => ({
|
||||||
|
id,
|
||||||
|
label: labelForModel(id),
|
||||||
|
recommendedFor: recommendedFor(id),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -35,3 +35,28 @@ export const optionalText = (value: string | undefined) => {
|
|||||||
if (!trimmed) return undefined;
|
if (!trimmed) return undefined;
|
||||||
return trimmed;
|
return trimmed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Linux username for the per-user container home (/home/<username>). Derived
|
||||||
|
// from the first token of the profile name, sanitized; falls back to "user".
|
||||||
|
export const deriveHomeUsername = (name?: string): string => {
|
||||||
|
const first = (name ?? '').trim().split(/\s+/)[0] ?? '';
|
||||||
|
const sanitized = first.toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
||||||
|
return sanitized || 'user';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Normalizes a dotfile path to a safe HOME-relative path (no leading slash, no
|
||||||
|
// "..", no empty segments). Throws on anything that would escape HOME.
|
||||||
|
export const normalizeDotfilePath = (rawPath: string): string => {
|
||||||
|
const cleaned = rawPath
|
||||||
|
.trim()
|
||||||
|
.replace(/^\.\/+/, '')
|
||||||
|
.replace(/^\/+/, '');
|
||||||
|
const segments = cleaned.split('/').filter((s) => s.length > 0);
|
||||||
|
if (segments.length === 0) {
|
||||||
|
throw new ConvexError('A dotfile path is required.');
|
||||||
|
}
|
||||||
|
if (segments.some((s) => s === '..' || s === '.')) {
|
||||||
|
throw new ConvexError(`Invalid dotfile path: ${rawPath}`);
|
||||||
|
}
|
||||||
|
return segments.join('/');
|
||||||
|
};
|
||||||
|
|||||||
@@ -348,6 +348,30 @@ const applicationTables = {
|
|||||||
})
|
})
|
||||||
.index('by_user', ['userId'])
|
.index('by_user', ['userId'])
|
||||||
.index('by_user_provider', ['userId', 'provider']),
|
.index('by_user_provider', ['userId', 'provider']),
|
||||||
|
// Per-user dotfiles: one row per file, materialized into the workspace
|
||||||
|
// container's HOME. Content is encrypted at rest (reuses secretCrypto).
|
||||||
|
// `path` is relative to HOME, e.g. ".bashrc" or ".config/nvim/init.lua".
|
||||||
|
userDotfiles: defineTable({
|
||||||
|
ownerId: v.id('users'),
|
||||||
|
path: v.string(),
|
||||||
|
encryptedContent: v.string(),
|
||||||
|
size: v.number(),
|
||||||
|
isExecutable: v.optional(v.boolean()),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index('by_owner', ['ownerId'])
|
||||||
|
.index('by_owner_path', ['ownerId', 'path']),
|
||||||
|
// Per-user environment config: the persistent home username + an optional
|
||||||
|
// public dotfiles repo and setup command run in the container.
|
||||||
|
userEnvironment: defineTable({
|
||||||
|
ownerId: v.id('users'),
|
||||||
|
enabled: v.boolean(),
|
||||||
|
homeUsername: v.optional(v.string()),
|
||||||
|
dotfilesRepoUrl: v.optional(v.string()),
|
||||||
|
dotfilesRepoRef: v.optional(v.string()),
|
||||||
|
setupCommand: v.optional(v.string()),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
}).index('by_owner', ['ownerId']),
|
||||||
aiProviderProfiles: defineTable({
|
aiProviderProfiles: defineTable({
|
||||||
ownerId: v.id('users'),
|
ownerId: v.id('users'),
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
@@ -444,6 +468,7 @@ const applicationTables = {
|
|||||||
spoonId: v.id('spoons'),
|
spoonId: v.id('spoons'),
|
||||||
ownerId: v.id('users'),
|
ownerId: v.id('users'),
|
||||||
enabled: v.boolean(),
|
enabled: v.boolean(),
|
||||||
|
// Legacy records may contain openai_direct. New writes use opencode only.
|
||||||
runtime: v.optional(
|
runtime: v.optional(
|
||||||
v.union(v.literal('opencode'), v.literal('openai_direct')),
|
v.union(v.literal('opencode'), v.literal('openai_direct')),
|
||||||
),
|
),
|
||||||
@@ -507,6 +532,7 @@ const applicationTables = {
|
|||||||
v.literal('timed_out'),
|
v.literal('timed_out'),
|
||||||
),
|
),
|
||||||
prompt: v.string(),
|
prompt: v.string(),
|
||||||
|
// Legacy jobs may contain openai_direct. New jobs use opencode only.
|
||||||
runtime: v.optional(
|
runtime: v.optional(
|
||||||
v.union(v.literal('openai_direct'), v.literal('opencode')),
|
v.union(v.literal('openai_direct'), v.literal('opencode')),
|
||||||
),
|
),
|
||||||
@@ -524,6 +550,14 @@ const applicationTables = {
|
|||||||
baseBranch: v.string(),
|
baseBranch: v.string(),
|
||||||
workBranch: v.string(),
|
workBranch: v.string(),
|
||||||
opencodeSessionId: v.optional(v.string()),
|
opencodeSessionId: v.optional(v.string()),
|
||||||
|
codexSessionId: v.optional(v.string()),
|
||||||
|
agentRuntimeMode: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal('opencode_server'),
|
||||||
|
v.literal('codex_exec'),
|
||||||
|
v.literal('legacy_cli'),
|
||||||
|
),
|
||||||
|
),
|
||||||
containerId: v.optional(v.string()),
|
containerId: v.optional(v.string()),
|
||||||
workspaceUrl: v.optional(v.string()),
|
workspaceUrl: v.optional(v.string()),
|
||||||
workspaceExpiresAt: v.optional(v.number()),
|
workspaceExpiresAt: v.optional(v.number()),
|
||||||
@@ -587,6 +621,49 @@ const applicationTables = {
|
|||||||
})
|
})
|
||||||
.index('by_job', ['jobId'])
|
.index('by_job', ['jobId'])
|
||||||
.index('by_owner', ['ownerId']),
|
.index('by_owner', ['ownerId']),
|
||||||
|
agentWorkspaceUiStates: defineTable({
|
||||||
|
jobId: v.id('agentJobs'),
|
||||||
|
spoonId: v.id('spoons'),
|
||||||
|
ownerId: v.id('users'),
|
||||||
|
openFilePaths: v.array(v.string()),
|
||||||
|
activeFilePath: v.optional(v.string()),
|
||||||
|
vimEnabled: v.boolean(),
|
||||||
|
expandedDirectoryPaths: v.array(v.string()),
|
||||||
|
agentThreadWidth: v.optional(v.number()),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index('by_job', ['jobId'])
|
||||||
|
.index('by_owner', ['ownerId']),
|
||||||
|
agentInteractionRequests: defineTable({
|
||||||
|
jobId: v.id('agentJobs'),
|
||||||
|
spoonId: v.id('spoons'),
|
||||||
|
ownerId: v.id('users'),
|
||||||
|
runtime: v.union(v.literal('opencode'), v.literal('codex')),
|
||||||
|
externalRequestId: v.string(),
|
||||||
|
kind: v.union(
|
||||||
|
v.literal('question'),
|
||||||
|
v.literal('permission'),
|
||||||
|
v.literal('tool_confirmation'),
|
||||||
|
),
|
||||||
|
title: v.string(),
|
||||||
|
body: v.string(),
|
||||||
|
options: v.optional(v.array(v.string())),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('pending'),
|
||||||
|
v.literal('answered'),
|
||||||
|
v.literal('approved'),
|
||||||
|
v.literal('rejected'),
|
||||||
|
v.literal('expired'),
|
||||||
|
),
|
||||||
|
response: v.optional(v.string()),
|
||||||
|
metadata: v.optional(v.string()),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index('by_job', ['jobId'])
|
||||||
|
.index('by_job_status', ['jobId', 'status'])
|
||||||
|
.index('by_owner', ['ownerId']),
|
||||||
agentWorkspaceChanges: defineTable({
|
agentWorkspaceChanges: defineTable({
|
||||||
jobId: v.id('agentJobs'),
|
jobId: v.id('agentJobs'),
|
||||||
spoonId: v.id('spoons'),
|
spoonId: v.id('spoons'),
|
||||||
|
|||||||
@@ -87,6 +87,64 @@ export const listMine = query({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const listMineWithState = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const spoons = (
|
||||||
|
await ctx.db
|
||||||
|
.query('spoons')
|
||||||
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||||
|
.order('desc')
|
||||||
|
.collect()
|
||||||
|
).filter((spoon) => spoon.status !== 'archived');
|
||||||
|
|
||||||
|
return await Promise.all(
|
||||||
|
spoons.map(async (spoon) => {
|
||||||
|
const [state, ignoredChanges, threads] = await Promise.all([
|
||||||
|
ctx.db
|
||||||
|
.query('spoonRepositoryStates')
|
||||||
|
.withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
|
||||||
|
.first(),
|
||||||
|
ctx.db
|
||||||
|
.query('ignoredUpstreamChanges')
|
||||||
|
.withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
|
||||||
|
.collect(),
|
||||||
|
ctx.db
|
||||||
|
.query('threads')
|
||||||
|
.withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
|
||||||
|
.order('desc')
|
||||||
|
.collect(),
|
||||||
|
]);
|
||||||
|
const ignoredShas = new Set(
|
||||||
|
ignoredChanges.flatMap((change) => change.commitShas),
|
||||||
|
);
|
||||||
|
const rawUpstreamAheadBy =
|
||||||
|
state?.upstreamAheadBy ?? spoon.upstreamAheadBy ?? 0;
|
||||||
|
const effectiveUpstreamAheadBy = Math.max(
|
||||||
|
0,
|
||||||
|
rawUpstreamAheadBy - ignoredShas.size,
|
||||||
|
);
|
||||||
|
const openThreads = threads.filter(
|
||||||
|
(thread) =>
|
||||||
|
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
|
||||||
|
thread.status,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...spoon,
|
||||||
|
rawUpstreamAheadBy,
|
||||||
|
effectiveUpstreamAheadBy,
|
||||||
|
ignoredUpstreamCount: ignoredShas.size,
|
||||||
|
forkAheadBy: state?.forkAheadBy ?? spoon.forkAheadBy ?? 0,
|
||||||
|
openThreadCount: openThreads.length,
|
||||||
|
latestThreadStatus: threads[0]?.status,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const get = query({
|
export const get = query({
|
||||||
args: { spoonId: v.id('spoons') },
|
args: { spoonId: v.id('spoons') },
|
||||||
handler: async (ctx, { spoonId }) => {
|
handler: async (ctx, { spoonId }) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ConvexError, v } from 'convex/values';
|
import { ConvexError, v } from 'convex/values';
|
||||||
|
|
||||||
import type { Doc } from './_generated/dataModel';
|
import type { Doc } from './_generated/dataModel';
|
||||||
|
import type { MutationCtx } from './_generated/server';
|
||||||
import { internal } from './_generated/api';
|
import { internal } from './_generated/api';
|
||||||
import {
|
import {
|
||||||
internalMutation,
|
internalMutation,
|
||||||
@@ -68,6 +69,53 @@ const titleFromPrompt = (prompt: string) => {
|
|||||||
|
|
||||||
const publicThread = (thread: Doc<'threads'>) => thread;
|
const publicThread = (thread: Doc<'threads'>) => thread;
|
||||||
|
|
||||||
|
const isDeletableThreadJob = (job: Doc<'agentJobs'>) =>
|
||||||
|
['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes(
|
||||||
|
job.status,
|
||||||
|
) || ['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
|
||||||
|
|
||||||
|
const deleteThreadJobRows = async (ctx: MutationCtx, job: Doc<'agentJobs'>) => {
|
||||||
|
const [messages, events, artifacts, changes, uiStates, interactions] =
|
||||||
|
await Promise.all([
|
||||||
|
ctx.db
|
||||||
|
.query('agentJobMessages')
|
||||||
|
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
||||||
|
.collect(),
|
||||||
|
ctx.db
|
||||||
|
.query('agentJobEvents')
|
||||||
|
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
||||||
|
.collect(),
|
||||||
|
ctx.db
|
||||||
|
.query('agentJobArtifacts')
|
||||||
|
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
||||||
|
.collect(),
|
||||||
|
ctx.db
|
||||||
|
.query('agentWorkspaceChanges')
|
||||||
|
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
||||||
|
.collect(),
|
||||||
|
ctx.db
|
||||||
|
.query('agentWorkspaceUiStates')
|
||||||
|
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
||||||
|
.collect(),
|
||||||
|
ctx.db
|
||||||
|
.query('agentInteractionRequests')
|
||||||
|
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
||||||
|
.collect(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const row of [
|
||||||
|
...messages,
|
||||||
|
...events,
|
||||||
|
...artifacts,
|
||||||
|
...changes,
|
||||||
|
...uiStates,
|
||||||
|
...interactions,
|
||||||
|
]) {
|
||||||
|
await ctx.db.delete(row._id);
|
||||||
|
}
|
||||||
|
await ctx.db.delete(job._id);
|
||||||
|
};
|
||||||
|
|
||||||
export const listMine = query({
|
export const listMine = query({
|
||||||
args: {
|
args: {
|
||||||
status: v.optional(v.union(threadStatus, v.literal('all'))),
|
status: v.optional(v.union(threadStatus, v.literal('all'))),
|
||||||
@@ -82,7 +130,7 @@ export const listMine = query({
|
|||||||
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||||
.order('desc')
|
.order('desc')
|
||||||
.take(args.limit ?? 50);
|
.take(args.limit ?? 50);
|
||||||
return threads.filter((thread) => {
|
const filtered = threads.filter((thread) => {
|
||||||
if (
|
if (
|
||||||
args.status &&
|
args.status &&
|
||||||
args.status !== 'all' &&
|
args.status !== 'all' &&
|
||||||
@@ -100,6 +148,28 @@ export const listMine = query({
|
|||||||
if (args.spoonId && thread.spoonId !== args.spoonId) return false;
|
if (args.spoonId && thread.spoonId !== args.spoonId) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
return await Promise.all(
|
||||||
|
filtered.map(async (thread) => {
|
||||||
|
const [spoon, latestJob] = await Promise.all([
|
||||||
|
thread.spoonId ? ctx.db.get(thread.spoonId) : null,
|
||||||
|
thread.latestAgentJobId ? ctx.db.get(thread.latestAgentJobId) : null,
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
...publicThread(thread),
|
||||||
|
spoonName: spoon?.ownerId === ownerId ? spoon.name : undefined,
|
||||||
|
latestJobStatus:
|
||||||
|
latestJob?.ownerId === ownerId ? latestJob.status : undefined,
|
||||||
|
latestJobWorkspaceStatus:
|
||||||
|
latestJob?.ownerId === ownerId
|
||||||
|
? latestJob.workspaceStatus
|
||||||
|
: undefined,
|
||||||
|
latestJobPullRequestUrl:
|
||||||
|
latestJob?.ownerId === ownerId
|
||||||
|
? latestJob.pullRequestUrl
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,11 +178,31 @@ export const listForSpoon = query({
|
|||||||
handler: async (ctx, { spoonId, limit }) => {
|
handler: async (ctx, { spoonId, limit }) => {
|
||||||
const ownerId = await getRequiredUserId(ctx);
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
await getOwnedSpoon(ctx, spoonId, ownerId);
|
await getOwnedSpoon(ctx, spoonId, ownerId);
|
||||||
return await ctx.db
|
const threads = await ctx.db
|
||||||
.query('threads')
|
.query('threads')
|
||||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||||
.order('desc')
|
.order('desc')
|
||||||
.take(limit ?? 25);
|
.take(limit ?? 25);
|
||||||
|
return await Promise.all(
|
||||||
|
threads.map(async (thread) => {
|
||||||
|
const latestJob = thread.latestAgentJobId
|
||||||
|
? await ctx.db.get(thread.latestAgentJobId)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
...publicThread(thread),
|
||||||
|
latestJobStatus:
|
||||||
|
latestJob?.ownerId === ownerId ? latestJob.status : undefined,
|
||||||
|
latestJobWorkspaceStatus:
|
||||||
|
latestJob?.ownerId === ownerId
|
||||||
|
? latestJob.workspaceStatus
|
||||||
|
: undefined,
|
||||||
|
latestJobPullRequestUrl:
|
||||||
|
latestJob?.ownerId === ownerId
|
||||||
|
? latestJob.pullRequestUrl
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,7 +306,7 @@ export const appendUserMessage = mutation({
|
|||||||
spoonId: thread.spoonId,
|
spoonId: thread.spoonId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: requireText(content, 'Message'),
|
content: requireText(content, 'Message'),
|
||||||
status: 'queued',
|
status: 'completed',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
@@ -253,6 +343,42 @@ export const markResolved = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const deleteThread = mutation({
|
||||||
|
args: { threadId: v.id('threads') },
|
||||||
|
handler: async (ctx, { threadId }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const thread = await ctx.db.get(threadId);
|
||||||
|
if (thread?.ownerId !== ownerId) throw new ConvexError('Thread not found.');
|
||||||
|
|
||||||
|
const jobs = (
|
||||||
|
await ctx.db
|
||||||
|
.query('agentJobs')
|
||||||
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||||
|
.collect()
|
||||||
|
).filter((job) => job.threadId === threadId);
|
||||||
|
|
||||||
|
const activeJob = jobs.find((job) => !isDeletableThreadJob(job));
|
||||||
|
if (activeJob) {
|
||||||
|
throw new ConvexError(
|
||||||
|
'Stop or cancel active workspace runs before deleting this thread.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await ctx.db
|
||||||
|
.query('threadMessages')
|
||||||
|
.withIndex('by_thread', (q) => q.eq('threadId', threadId))
|
||||||
|
.collect();
|
||||||
|
for (const job of jobs) {
|
||||||
|
await deleteThreadJobRows(ctx, job);
|
||||||
|
}
|
||||||
|
for (const message of messages) {
|
||||||
|
await ctx.db.delete(message._id);
|
||||||
|
}
|
||||||
|
await ctx.db.delete(threadId);
|
||||||
|
return { deletedJobs: jobs.length, deletedMessages: messages.length };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const findOpenMaintenanceThread = internalQuery({
|
export const findOpenMaintenanceThread = internalQuery({
|
||||||
args: {
|
args: {
|
||||||
spoonId: v.id('spoons'),
|
spoonId: v.id('spoons'),
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { ConvexError, v } from 'convex/values';
|
||||||
|
|
||||||
|
import type { Doc } from './_generated/dataModel';
|
||||||
|
import {
|
||||||
|
internalMutation,
|
||||||
|
internalQuery,
|
||||||
|
mutation,
|
||||||
|
query,
|
||||||
|
} from './_generated/server';
|
||||||
|
import { getRequiredUserId, normalizeDotfilePath } from './model';
|
||||||
|
|
||||||
|
const fileMeta = (file: Doc<'userDotfiles'>) => ({
|
||||||
|
_id: file._id,
|
||||||
|
path: file.path,
|
||||||
|
size: file.size,
|
||||||
|
isExecutable: file.isExecutable ?? false,
|
||||||
|
updatedAt: file.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Lists the user's dotfile tree (metadata only; content is fetched per-file). */
|
||||||
|
export const listMine = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const files = await ctx.db
|
||||||
|
.query('userDotfiles')
|
||||||
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||||
|
.collect();
|
||||||
|
return files.map(fileMeta).sort((a, b) => a.path.localeCompare(b.path));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const remove = mutation({
|
||||||
|
args: { fileId: v.id('userDotfiles') },
|
||||||
|
handler: async (ctx, { fileId }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const file = await ctx.db.get(fileId);
|
||||||
|
if (file?.ownerId !== ownerId) throw new ConvexError('Dotfile not found.');
|
||||||
|
await ctx.db.delete(fileId);
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Removes every file under a directory prefix (e.g. deleting ".config/nvim"). */
|
||||||
|
export const removeDirectory = mutation({
|
||||||
|
args: { prefix: v.string() },
|
||||||
|
handler: async (ctx, { prefix }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const normalized = normalizeDotfilePath(prefix);
|
||||||
|
const files = await ctx.db
|
||||||
|
.query('userDotfiles')
|
||||||
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||||
|
.collect();
|
||||||
|
const matches = files.filter(
|
||||||
|
(f) => f.path === normalized || f.path.startsWith(`${normalized}/`),
|
||||||
|
);
|
||||||
|
await Promise.all(matches.map((f) => ctx.db.delete(f._id)));
|
||||||
|
return { removed: matches.length };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rename = mutation({
|
||||||
|
args: { fileId: v.id('userDotfiles'), path: v.string() },
|
||||||
|
handler: async (ctx, { fileId, path }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const file = await ctx.db.get(fileId);
|
||||||
|
if (file?.ownerId !== ownerId) throw new ConvexError('Dotfile not found.');
|
||||||
|
const normalized = normalizeDotfilePath(path);
|
||||||
|
const clash = await ctx.db
|
||||||
|
.query('userDotfiles')
|
||||||
|
.withIndex('by_owner_path', (q) =>
|
||||||
|
q.eq('ownerId', ownerId).eq('path', normalized),
|
||||||
|
)
|
||||||
|
.unique();
|
||||||
|
if (clash && clash._id !== fileId) {
|
||||||
|
throw new ConvexError(`A dotfile already exists at ${normalized}.`);
|
||||||
|
}
|
||||||
|
await ctx.db.patch(fileId, { path: normalized, updatedAt: Date.now() });
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read by the decrypting Node action (userDotfilesNode.getFileContent).
|
||||||
|
export const getRawFileInternal = internalQuery({
|
||||||
|
args: { fileId: v.id('userDotfiles') },
|
||||||
|
handler: async (ctx, { fileId }) => await ctx.db.get(fileId),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Called by the encrypting Node action (userDotfilesNode). Upserts one file by
|
||||||
|
// (owner, path).
|
||||||
|
export const upsertFileInternal = internalMutation({
|
||||||
|
args: {
|
||||||
|
ownerId: v.id('users'),
|
||||||
|
path: v.string(),
|
||||||
|
encryptedContent: v.string(),
|
||||||
|
size: v.number(),
|
||||||
|
isExecutable: v.optional(v.boolean()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query('userDotfiles')
|
||||||
|
.withIndex('by_owner_path', (q) =>
|
||||||
|
q.eq('ownerId', args.ownerId).eq('path', args.path),
|
||||||
|
)
|
||||||
|
.unique();
|
||||||
|
const now = Date.now();
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.patch(existing._id, {
|
||||||
|
encryptedContent: args.encryptedContent,
|
||||||
|
size: args.size,
|
||||||
|
isExecutable: args.isExecutable,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
return existing._id;
|
||||||
|
}
|
||||||
|
return await ctx.db.insert('userDotfiles', {
|
||||||
|
...args,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user