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
|
||||
|
||||
# Docker
|
||||
docker
|
||||
docker/*
|
||||
!docker/agent-job-rootfs
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
|
||||
@@ -53,7 +53,9 @@ jobs:
|
||||
printf '%s\n' "$DOTENV_PROD" > "$env_file"
|
||||
CI_ENV_FILE="$env_file" ./scripts/build-next-app production
|
||||
- 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
|
||||
run: |
|
||||
docker tag spoon-next:latest git.gbrown.org/gib/spoon-next:${{ gitea.sha }}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
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/ui`: shared shadcn-based UI components.
|
||||
- `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
|
||||
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
|
||||
@@ -23,6 +27,8 @@
|
||||
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_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
|
||||
|
||||
@@ -52,7 +58,21 @@
|
||||
- Agent workspace proxy env uses `SPOON_AGENT_WORKER_URL`,
|
||||
`SPOON_AGENT_WORKER_HTTP_PORT`, and `SPOON_AGENT_WORKER_INTERNAL_TOKEN`.
|
||||
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.
|
||||
- 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
|
||||
`CONVEX_SELF_HOSTED_URL` plus `CONVEX_SELF_HOSTED_ADMIN_KEY`, or
|
||||
`CONVEX_DEPLOYMENT`.
|
||||
@@ -77,6 +97,7 @@
|
||||
bun db:up # start Postgres, Convex, and dashboard
|
||||
bun dev:next # host Next + deploy/watch local Convex functions
|
||||
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 db:down # stop and preserve local data
|
||||
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,
|
||||
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 open>
|
||||
@@ -144,6 +150,7 @@ Workspace capabilities:
|
||||
- browse repository files
|
||||
- edit files in a browser editor
|
||||
- use optional Vim keybindings
|
||||
- resize the agent thread panel on desktop
|
||||
- inspect diffs
|
||||
- send thread messages to the agent
|
||||
- run configured commands
|
||||
@@ -154,6 +161,29 @@ Workspace capabilities:
|
||||
The browser never receives worker tokens and never talks directly to the worker
|
||||
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>
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
The job image includes Node 22, Bun, package managers through Corepack, git,
|
||||
ripgrep, Python, build tools, and the OpenCode CLI. It is not the forked
|
||||
The job image includes Node 22, Bun, pnpm and yarn through Corepack, npm, git,
|
||||
ripgrep, Python, build tools, OpenCode, and the Codex CLI. It is not the forked
|
||||
project's production runtime; it is the agent execution environment.
|
||||
|
||||
Production worker runtime requirements:
|
||||
@@ -184,6 +214,8 @@ Production worker runtime requirements:
|
||||
- `spoon-agent-worker` must run as a separate service.
|
||||
- The worker needs `/var/run/docker.sock` mounted so it can launch job
|
||||
containers.
|
||||
- Production should keep `SPOON_AGENT_CONTAINER_RUNTIME=docker` and
|
||||
`SPOON_AGENT_CONTAINER_ACCESS=network`.
|
||||
- The production Docker host must be logged into `git.gbrown.org` so worker jobs
|
||||
can pull the private `spoon-agent-job` image.
|
||||
- `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
|
||||
file, diff, message, command, and draft PR actions.
|
||||
- `spoon-agent-worker` also needs `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`.
|
||||
If the private key is stored in a single-line dotenv value, encode newlines as
|
||||
literal `\n` characters so the worker can restore the PEM before using it.
|
||||
|
||||
Useful production checks:
|
||||
|
||||
```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
|
||||
curl -H "Authorization: Bearer $SPOON_AGENT_WORKER_INTERNAL_TOKEN" \
|
||||
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
|
||||
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.
|
||||
@@ -423,7 +475,7 @@ not call Infisical.
|
||||
<summary><strong>Convex, storage, and runtime</strong></summary>
|
||||
|
||||
| Variable | Used for |
|
||||
| ----------------------------------- | ----------------------------------------------- |
|
||||
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| `CONVEX_SELF_HOSTED_URL` | Self-hosted Convex API URL |
|
||||
| `CONVEX_SELF_HOSTED_ADMIN_KEY` | Admin key for deploying/syncing Convex |
|
||||
| `CONVEX_CLOUD_ORIGIN` | Convex backend origin |
|
||||
@@ -437,9 +489,12 @@ not call Infisical.
|
||||
| `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | Server-only token for Next-to-worker proxy |
|
||||
| `SPOON_AGENT_JOB_IMAGE` | Agent job container image |
|
||||
| `SPOON_AGENT_RUNTIME` | Runtime mode, currently Docker/Podman-oriented |
|
||||
| `SPOON_AGENT_CONTAINER_RUNTIME` | Container CLI used by worker, `docker`/`podman` |
|
||||
| `SPOON_AGENT_CONTAINER_ACCESS` | `network` in prod, `host_port` for host dev |
|
||||
| `SPOON_AGENT_MAX_CONCURRENT_JOBS` | Worker concurrency limit |
|
||||
| `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>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
||||
@@ -19,14 +19,18 @@
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@opencode-ai/sdk": "latest",
|
||||
"convex": "catalog:convex",
|
||||
"dockerode": "^4.0.7",
|
||||
"execa": "latest",
|
||||
"ws": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@spoon/eslint-config": "workspace:*",
|
||||
"@spoon/prettier-config": "workspace:*",
|
||||
"@spoon/tsconfig": "workspace:*",
|
||||
"@types/dockerode": "^3.3.42",
|
||||
"@types/node": "catalog:",
|
||||
"@types/ws": "^8.18.1",
|
||||
"eslint": "catalog:",
|
||||
"prettier": "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 = {
|
||||
buildSha: process.env.SPOON_BUILD_SHA?.trim() ?? 'development',
|
||||
buildCreatedAt: process.env.SPOON_BUILD_CREATED_AT?.trim() ?? 'unknown',
|
||||
convexUrl:
|
||||
process.env.NEXT_PUBLIC_CONVEX_URL?.trim() ??
|
||||
process.env.CONVEX_SELF_HOSTED_URL?.trim() ??
|
||||
@@ -19,9 +21,38 @@ export const env = {
|
||||
workerToken: requiredEnv('SPOON_WORKER_TOKEN'),
|
||||
workerId: process.env.SPOON_AGENT_WORKER_ID?.trim() ?? 'local-worker',
|
||||
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:
|
||||
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',
|
||||
hostWorkdir: process.env.SPOON_AGENT_HOST_WORKDIR?.trim(),
|
||||
network: process.env.SPOON_AGENT_NETWORK?.trim(),
|
||||
pollMs: intEnv('SPOON_AGENT_POLL_MS', 5_000),
|
||||
httpPort: intEnv('SPOON_AGENT_WORKER_HTTP_PORT', 3921),
|
||||
|
||||
@@ -36,12 +36,16 @@ export const cloneRepository = async (args: {
|
||||
workBranch: string;
|
||||
redact: (value: string) => string;
|
||||
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 });
|
||||
const dirName = args.dirName ?? 'repo';
|
||||
const repoUrl = `https://x-access-token:${args.token}@github.com/${args.owner}/${args.repo}.git`;
|
||||
const clone = await run(
|
||||
'git',
|
||||
['clone', '--branch', args.baseBranch, '--single-branch', repoUrl, 'repo'],
|
||||
['clone', '--branch', args.baseBranch, '--single-branch', repoUrl, dirName],
|
||||
{
|
||||
cwd: args.workdir,
|
||||
redact: args.redact,
|
||||
@@ -51,7 +55,7 @@ export const cloneRepository = async (args: {
|
||||
if (clone.exitCode !== 0) {
|
||||
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], {
|
||||
cwd: repoDir,
|
||||
redact: args.redact,
|
||||
@@ -126,12 +130,41 @@ export const getDiff = async (
|
||||
export const getWorktreeDiff = async (
|
||||
repoDir: string,
|
||||
redact: (value: string) => string,
|
||||
) =>
|
||||
await run('git', ['diff', '--', '.'], {
|
||||
) => {
|
||||
const trackedDiff = await run('git', ['diff', '--', '.'], {
|
||||
cwd: repoDir,
|
||||
redact,
|
||||
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 (
|
||||
repoDir: string,
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
import { env } from './env';
|
||||
import { startWorkerServer } from './server';
|
||||
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();
|
||||
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 { 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: {
|
||||
workdir: string;
|
||||
containerHome?: string;
|
||||
containerCwd?: string;
|
||||
command: string[];
|
||||
environment: Record<string, string>;
|
||||
redact: (value: string) => string;
|
||||
timeoutMs: number;
|
||||
}) => {
|
||||
const envArgs = Object.entries(args.environment).flatMap(([name, value]) => [
|
||||
'-e',
|
||||
`${name}=${value}`,
|
||||
]);
|
||||
const networkArgs = env.network ? ['--network', env.network] : [];
|
||||
}): Promise<CommandResult> => {
|
||||
await ensureJobImagePulled();
|
||||
const result = await execa(
|
||||
'docker',
|
||||
containerRuntime(),
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
@@ -23,18 +114,110 @@ export const runInJobContainer = async (args: {
|
||||
'4g',
|
||||
'--cpus',
|
||||
'2',
|
||||
...networkArgs,
|
||||
...envArgs,
|
||||
...networkArgs(),
|
||||
...environmentArgs(args.environment),
|
||||
'-v',
|
||||
`${args.workdir}:/workspace`,
|
||||
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||
'-w',
|
||||
'/workspace/repo',
|
||||
args.containerCwd ?? '/workspace/repo',
|
||||
env.jobImage,
|
||||
...args.command,
|
||||
],
|
||||
{
|
||||
all: true,
|
||||
reject: false,
|
||||
stdin: 'ignore',
|
||||
timeout: args.timeoutMs,
|
||||
},
|
||||
);
|
||||
return normalizeRunResult(result, result.all, args.redact);
|
||||
};
|
||||
|
||||
export const startWorkspaceContainer = async (args: {
|
||||
workdir: string;
|
||||
containerHome?: string;
|
||||
containerCwd?: string;
|
||||
containerName: string;
|
||||
environment: Record<string, string>;
|
||||
command?: string[];
|
||||
publishTcpPort?: number;
|
||||
}) => {
|
||||
await ensureJobImagePulled();
|
||||
await execa(containerRuntime(), ['rm', '-f', args.containerName], {
|
||||
reject: false,
|
||||
});
|
||||
const result = await execa(
|
||||
containerRuntime(),
|
||||
[
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
args.containerName,
|
||||
'--memory',
|
||||
'4g',
|
||||
'--cpus',
|
||||
'2',
|
||||
...networkArgs(),
|
||||
...(args.publishTcpPort
|
||||
? ['-p', `127.0.0.1::${args.publishTcpPort}`]
|
||||
: []),
|
||||
...environmentArgs(args.environment),
|
||||
'-v',
|
||||
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||
'-w',
|
||||
args.containerCwd ?? '/workspace/repo',
|
||||
env.jobImage,
|
||||
...(args.command ?? ['sleep', 'infinity']),
|
||||
],
|
||||
{ all: true, stdin: 'ignore' },
|
||||
);
|
||||
return {
|
||||
containerId: result.stdout.trim(),
|
||||
containerName: args.containerName,
|
||||
hostPort: args.publishTcpPort
|
||||
? await getPublishedPort(args.containerName, args.publishTcpPort)
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const getPublishedPort = async (
|
||||
containerName: string,
|
||||
containerPort: number,
|
||||
) => {
|
||||
const result = await execa(
|
||||
containerRuntime(),
|
||||
['port', containerName, `${containerPort}/tcp`],
|
||||
{ all: true, reject: false, stdin: 'ignore' },
|
||||
);
|
||||
const output = result.all.trim();
|
||||
const match = /:(\d+)\s*$/.exec(output);
|
||||
if (!match?.[1]) {
|
||||
throw new Error(
|
||||
`Could not determine published port for ${containerName}:${containerPort}.`,
|
||||
);
|
||||
}
|
||||
return match[1];
|
||||
};
|
||||
|
||||
export const execInWorkspaceContainer = async (args: {
|
||||
containerName: string;
|
||||
command: string[];
|
||||
environment?: Record<string, string>;
|
||||
redact: (value: string) => string;
|
||||
timeoutMs: number;
|
||||
}): Promise<CommandResult> => {
|
||||
const result = await execa(
|
||||
containerRuntime(),
|
||||
[
|
||||
'exec',
|
||||
...(args.environment ? environmentArgs(args.environment) : []),
|
||||
args.containerName,
|
||||
...args.command,
|
||||
],
|
||||
{
|
||||
all: true,
|
||||
reject: false,
|
||||
stdin: 'ignore',
|
||||
timeout: args.timeoutMs,
|
||||
},
|
||||
);
|
||||
@@ -43,3 +226,241 @@ export const runInJobContainer = async (args: {
|
||||
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 type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
|
||||
import { env } from './env';
|
||||
import { attachTerminalServer } from './terminal';
|
||||
import {
|
||||
abortWorkspaceAgent,
|
||||
cleanupOrphanedWorkspaces,
|
||||
getWorkerHealth,
|
||||
getWorkspaceAgentStatus,
|
||||
getWorkspaceDiff,
|
||||
listWorkspaceTree,
|
||||
openWorkspacePullRequest,
|
||||
readWorkspaceFile,
|
||||
replyToInteraction,
|
||||
runWorkspaceCommand,
|
||||
sendWorkspaceMessage,
|
||||
stopWorkspace,
|
||||
@@ -43,7 +51,7 @@ const requireAuth = (request: IncomingMessage) => {
|
||||
};
|
||||
|
||||
const jobRoute = (pathname: string) => {
|
||||
const match = /^\/jobs\/([^/]+)\/([^/]+)$/.exec(pathname);
|
||||
const match = /^\/jobs\/([^/]+)\/(.+)$/.exec(pathname);
|
||||
if (!match?.[1] || !match[2]) return null;
|
||||
return { jobId: decodeURIComponent(match[1]), action: match[2] };
|
||||
};
|
||||
@@ -57,8 +65,12 @@ export const startWorkerServer = () => {
|
||||
request.url ?? '/',
|
||||
`http://localhost:${env.httpPort}`,
|
||||
);
|
||||
if (url.pathname === '/health') {
|
||||
sendJson(response, 200, { ok: true, workerId: env.workerId });
|
||||
if (url.pathname === '/health' && request.method === 'GET') {
|
||||
sendJson(response, 200, await getWorkerHealth());
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/cleanup' && request.method === 'POST') {
|
||||
sendJson(response, 200, await cleanupOrphanedWorkspaces());
|
||||
return;
|
||||
}
|
||||
const route = jobRoute(url.pathname);
|
||||
@@ -108,6 +120,35 @@ export const startWorkerServer = () => {
|
||||
sendJson(response, 200, { success: true });
|
||||
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') {
|
||||
const body = await parseJson<{ command?: string }>(request);
|
||||
sendJson(
|
||||
@@ -128,12 +169,22 @@ export const startWorkerServer = () => {
|
||||
sendJson(response, 404, { error: 'Not found' });
|
||||
} catch (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,
|
||||
});
|
||||
}
|
||||
})();
|
||||
});
|
||||
attachTerminalServer(server);
|
||||
server.listen(env.httpPort, () => {
|
||||
console.log(
|
||||
`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);
|
||||
}
|
||||
};
|
||||
+953
-75
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 path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
|
||||
type TestWorkspace = {
|
||||
@@ -53,7 +52,9 @@ const writeConfig = async (
|
||||
config: Record<string, unknown> | string,
|
||||
) => {
|
||||
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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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": ["//"],
|
||||
"tasks": {
|
||||
"dev": {
|
||||
|
||||
@@ -21,16 +21,21 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@convex-dev/auth": "catalog:convex",
|
||||
"@git-diff-view/react": "^0.1.6",
|
||||
"@monaco-editor/react": "latest",
|
||||
"@sentry/nextjs": "^10.46.0",
|
||||
"@spoon/backend": "workspace:*",
|
||||
"@spoon/ui": "workspace:*",
|
||||
"@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",
|
||||
"monaco-editor": "latest",
|
||||
"monaco-vim": "latest",
|
||||
"next": "^16.2.1",
|
||||
"next-plausible": "^3.12.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "catalog:react19",
|
||||
"react-dom": "catalog:react19",
|
||||
"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';
|
||||
|
||||
const DashboardPage = () => {
|
||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
||||
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
|
||||
const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
|
||||
const activeSpoons = spoons.filter(
|
||||
(spoon) => spoon.status === 'active',
|
||||
).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(
|
||||
(spoon) => spoon.syncStatus === 'diverged',
|
||||
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy > 0,
|
||||
).length;
|
||||
const openPullRequests = spoons.reduce(
|
||||
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
|
||||
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -70,7 +72,7 @@ const DashboardPage = () => {
|
||||
<MetricCard
|
||||
label='Upstream commits'
|
||||
value={openPullRequests}
|
||||
note='Waiting across Spoons'
|
||||
note='Actionable after ignores'
|
||||
icon={ShieldCheck}
|
||||
/>
|
||||
</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 Link from 'next/link';
|
||||
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';
|
||||
|
||||
@@ -11,6 +11,8 @@ const settingsItems = [
|
||||
{ href: '/settings/profile', label: 'Profile', icon: User },
|
||||
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
|
||||
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
|
||||
{ href: '/settings/dotfiles', label: 'Dotfiles', icon: FileCog },
|
||||
{ href: '/settings/worker', label: 'Worker', icon: ServerCog },
|
||||
{ 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';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
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 { useQuery } from 'convex/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
const AgentWorkspacePage = () => {
|
||||
const router = useRouter();
|
||||
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 (
|
||||
<main className='space-y-4'>
|
||||
@@ -19,7 +37,7 @@ const AgentWorkspacePage = () => {
|
||||
Back to Spoon
|
||||
</Link>
|
||||
</Button>
|
||||
<AgentWorkspaceShell jobId={params.jobId as Id<'agentJobs'>} />
|
||||
<AgentWorkspaceShell jobId={jobId} />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
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 { SpoonAgentSettingsForm } from '@/components/spoons/spoon-agent-settings-form';
|
||||
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 { SpoonSecretsForm } from '@/components/spoons/spoon-secrets-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 type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
@@ -55,6 +55,17 @@ const SpoonDetailPage = () => {
|
||||
});
|
||||
const agentJobs =
|
||||
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) {
|
||||
return <main className='text-muted-foreground p-6'>Loading Spoon...</main>;
|
||||
@@ -243,7 +254,7 @@ const SpoonDetailPage = () => {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='threads' className='space-y-4'>
|
||||
<AgentRequestForm
|
||||
<ThreadWorkspaceForm
|
||||
spoon={details.spoon}
|
||||
agentSettings={agentSettings}
|
||||
/>
|
||||
@@ -254,17 +265,29 @@ const SpoonDetailPage = () => {
|
||||
<CardContent className='space-y-3'>
|
||||
{threads.length ? (
|
||||
threads.map((thread) => (
|
||||
<Link
|
||||
<div
|
||||
key={thread._id}
|
||||
href={`/threads/${thread._id}`}
|
||||
className='border-border hover:border-primary/50 block rounded-md border p-3 transition-colors'
|
||||
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'
|
||||
>
|
||||
<p className='font-medium'>{thread.title}</p>
|
||||
<Link href={`/threads/${thread._id}`} className='min-w-0'>
|
||||
<p className='truncate font-medium'>{thread.title}</p>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{thread.status.replaceAll('_', ' ')} ·{' '}
|
||||
{thread.source.replaceAll('_', ' ')}
|
||||
{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'>
|
||||
@@ -273,7 +296,6 @@ const SpoonDetailPage = () => {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AgentJobList jobs={agentJobs} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='activity'>
|
||||
|
||||
@@ -32,7 +32,7 @@ const formatDate = (value?: number) =>
|
||||
|
||||
const SpoonsPage = () => {
|
||||
const router = useRouter();
|
||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
||||
const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
|
||||
const active = spoons.filter((spoon) => spoon.status === 'active').length;
|
||||
const needsReview = threads.filter(
|
||||
@@ -41,7 +41,7 @@ const SpoonsPage = () => {
|
||||
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
|
||||
).length;
|
||||
const upstreamWaiting = spoons.reduce(
|
||||
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
|
||||
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -152,10 +152,16 @@ const SpoonsPage = () => {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='text-sm'>
|
||||
<p>{spoon.upstreamAheadBy ?? 0} upstream</p>
|
||||
<p>{spoon.effectiveUpstreamAheadBy} actionable</p>
|
||||
<p className='text-muted-foreground'>
|
||||
{spoon.forkAheadBy ?? 0} fork-only
|
||||
{spoon.rawUpstreamAheadBy} raw upstream ·{' '}
|
||||
{spoon.forkAheadBy} fork-only
|
||||
</p>
|
||||
{spoon.ignoredUpstreamCount ? (
|
||||
<p className='text-muted-foreground'>
|
||||
{spoon.ignoredUpstreamCount} ignored
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='capitalize'>
|
||||
@@ -197,7 +203,8 @@ const SpoonsPage = () => {
|
||||
|
||||
{spoons.length ? (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Raw upstream commits waiting across all Spoons: {upstreamWaiting}
|
||||
Actionable upstream commits waiting across all Spoons:{' '}
|
||||
{upstreamWaiting}
|
||||
</p>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
@@ -1,50 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
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 { ArrowUpRight, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { ArrowUpRight, CheckCircle2, Play, XCircle } 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,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Textarea,
|
||||
} from '@spoon/ui';
|
||||
|
||||
const ThreadDetailPage = () => {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ threadId: string }>();
|
||||
const threadId = params.threadId as Id<'threads'>;
|
||||
const details = useQuery(api.threads.get, { threadId });
|
||||
const messages = useQuery(api.threads.listMessages, { threadId }) ?? [];
|
||||
const appendMessage = useMutation(api.threads.appendUserMessage);
|
||||
const createJob = useMutation(api.agentJobs.createForThread);
|
||||
const markResolved = useMutation(api.threads.markResolved);
|
||||
const cancel = useMutation(api.threads.cancel);
|
||||
const [queueing, setQueueing] = useState(false);
|
||||
|
||||
if (details === undefined) {
|
||||
return <main className='text-muted-foreground p-6'>Loading thread...</main>;
|
||||
}
|
||||
|
||||
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>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const value = form.get('message');
|
||||
const content = typeof value === 'string' ? value : '';
|
||||
const terminalThread = [
|
||||
'resolved',
|
||||
'ignored',
|
||||
'failed',
|
||||
'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 {
|
||||
await appendMessage({ threadId, content });
|
||||
event.currentTarget.reset();
|
||||
toast.success('Message added.');
|
||||
await createJob({ threadId, jobType });
|
||||
toast.success('Workspace run queued.');
|
||||
router.replace(`/threads/${threadId}`);
|
||||
} catch (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'>
|
||||
{latestJob ? (
|
||||
<Button variant='outline' asChild>
|
||||
<Link
|
||||
href={`/spoons/${latestJob.spoonId}/agent/${latestJob._id}`}
|
||||
>
|
||||
Open workspace
|
||||
</Link>
|
||||
<Link href={`/threads/${threadId}`}>Open workspace</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{latestJob?.pullRequestUrl ? (
|
||||
@@ -99,60 +141,99 @@ const ThreadDetailPage = () => {
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
markResolved({ threadId }).then(() =>
|
||||
toast.success('Thread resolved.'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{canQueueRun ? (
|
||||
<Button disabled={queueing} onClick={() => void startRun()}>
|
||||
<Play className='size-4' />
|
||||
{latestJob ? 'Rerun' : 'Start workspace run'}
|
||||
</Button>
|
||||
) : null}
|
||||
{!terminalThread ? (
|
||||
<>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant='outline'>
|
||||
<CheckCircle2 className='size-4' />
|
||||
Resolve
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
cancel({ threadId }).then(() =>
|
||||
toast.success('Thread cancelled.'),
|
||||
)
|
||||
}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Mark thread resolved?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This closes the thread without deleting its history.
|
||||
</AlertDialogDescription>
|
||||
</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 className='grid gap-6 xl:grid-cols-[1fr_320px]'>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle>Conversation</CardTitle>
|
||||
<CardTitle>Workspace</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message._id}
|
||||
className='border-border rounded-md border p-3'
|
||||
>
|
||||
<div className='mb-2 flex items-center justify-between gap-2'>
|
||||
<Badge variant='outline'>{message.role}</Badge>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{new Date(message.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-sm whitespace-pre-wrap'>{message.content}</p>
|
||||
</div>
|
||||
))}
|
||||
<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 className='space-y-4 text-sm'>
|
||||
<p className='text-muted-foreground'>
|
||||
Threads open into a full workspace where you can review agent
|
||||
activity, edit files, inspect diffs, and reply to the agent.
|
||||
</p>
|
||||
{canQueueRun ? (
|
||||
<Button disabled={queueing} onClick={() => void startRun()}>
|
||||
<Play className='size-4' />
|
||||
{latestJob ? 'Create new workspace run' : 'Start workspace run'}
|
||||
</Button>
|
||||
) : (
|
||||
<p className='text-muted-foreground'>
|
||||
This thread does not currently have a workspace that can be
|
||||
opened.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,28 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { DeleteThreadButton } from '@/components/threads/delete-thread-button';
|
||||
import { useMutation, useQuery } from 'convex/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 {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
Textarea,
|
||||
} from '@spoon/ui';
|
||||
|
||||
const formatTime = (value: number) => new Date(value).toLocaleString();
|
||||
|
||||
const ThreadsPage = () => {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
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 =
|
||||
useQuery(api.threads.listMine, {
|
||||
source: source as
|
||||
@@ -32,8 +57,76 @@ const ThreadsPage = () => {
|
||||
| 'merge_conflict'
|
||||
| 'manual_review'
|
||||
| 'system',
|
||||
status: status as
|
||||
| 'all'
|
||||
| 'open'
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'waiting_for_user'
|
||||
| 'changes_ready'
|
||||
| 'draft_pr_opened'
|
||||
| 'resolved'
|
||||
| 'ignored'
|
||||
| 'failed'
|
||||
| 'cancelled',
|
||||
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 (
|
||||
<main className='space-y-6'>
|
||||
@@ -46,20 +139,97 @@ const ThreadsPage = () => {
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href='/spoons'>
|
||||
<a href='#new-thread'>
|
||||
<Plus className='size-4' />
|
||||
New thread from Spoon
|
||||
</Link>
|
||||
New thread
|
||||
</a>
|
||||
</Button>
|
||||
</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
|
||||
value={source}
|
||||
onValueChange={(value) => {
|
||||
window.location.href =
|
||||
value === 'all' ? '/threads' : `/threads?source=${value}`;
|
||||
}}
|
||||
onValueChange={(value) => updateFilter('source', value)}
|
||||
>
|
||||
<SelectTrigger className='w-full md:w-56'>
|
||||
<SelectValue />
|
||||
@@ -73,21 +243,97 @@ const ThreadsPage = () => {
|
||||
<SelectItem value='system'>System</SelectItem>
|
||||
</SelectContent>
|
||||
</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 className='space-y-3'>
|
||||
{threads.length ? (
|
||||
threads.map((thread) => (
|
||||
<Link
|
||||
{visibleThreads.length ? (
|
||||
visibleThreads.map((thread) => (
|
||||
<Card
|
||||
key={thread._id}
|
||||
href={`/threads/${thread._id}`}
|
||||
className='block'
|
||||
role='link'
|
||||
tabIndex={0}
|
||||
className='hover:border-primary/50 cursor-pointer shadow-none transition-colors'
|
||||
onClick={() => router.push(threadTarget(thread))}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
router.push(threadTarget(thread));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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'>
|
||||
<div className='min-w-0'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<h2 className='truncate font-medium'>{thread.title}</h2>
|
||||
{thread.spoonName ? (
|
||||
<Badge variant='outline'>{thread.spoonName}</Badge>
|
||||
) : null}
|
||||
<Badge variant='outline'>
|
||||
{thread.source.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
@@ -106,10 +352,47 @@ const ThreadsPage = () => {
|
||||
<div className='text-muted-foreground text-xs md:text-right'>
|
||||
<p>{formatTime(thread.updatedAt)}</p>
|
||||
<p className='capitalize'>{thread.priority} priority</p>
|
||||
{thread.latestJobStatus ? (
|
||||
<p>{thread.latestJobStatus.replaceAll('_', ' ')}</p>
|
||||
) : null}
|
||||
{thread.latestJobWorkspaceStatus ? (
|
||||
<p>
|
||||
Workspace:{' '}
|
||||
{thread.latestJobWorkspaceStatus.replaceAll('_', ' ')}
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<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 { Geist, Geist_Mono } from 'next/font/google';
|
||||
import { Geist, Geist_Mono, Victor_Mono } from 'next/font/google';
|
||||
import { env } from '@/env';
|
||||
|
||||
import '@/app/styles.css';
|
||||
@@ -30,6 +30,13 @@ const geistMono = Geist_Mono({
|
||||
subsets: ['latin'],
|
||||
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 = ({
|
||||
children,
|
||||
@@ -44,7 +51,7 @@ const RootLayout = ({
|
||||
>
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${victorMono.variable} antialiased`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
|
||||
@@ -2,6 +2,22 @@
|
||||
@import 'tw-animate-css';
|
||||
@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}';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@@ -1,23 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Ban,
|
||||
FilePenLine,
|
||||
MessagesSquare,
|
||||
Send,
|
||||
Terminal,
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
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 = ({
|
||||
jobId,
|
||||
messages,
|
||||
events,
|
||||
interactions,
|
||||
workspaceChanges,
|
||||
disabled,
|
||||
agentTurnActive,
|
||||
onOpenFile,
|
||||
onOpenDiff,
|
||||
}: {
|
||||
jobId: string;
|
||||
messages: Doc<'agentJobMessages'>[];
|
||||
events: Doc<'agentJobEvents'>[];
|
||||
interactions: Doc<'agentInteractionRequests'>[];
|
||||
workspaceChanges: Doc<'agentWorkspaceChanges'>[];
|
||||
disabled: boolean;
|
||||
agentTurnActive: boolean;
|
||||
onOpenFile: (path: string) => void;
|
||||
onOpenDiff: (path: string) => void;
|
||||
}) => {
|
||||
const [content, setContent] = useState('');
|
||||
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 () => {
|
||||
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 (
|
||||
<div className='flex h-full min-h-[520px] flex-col'>
|
||||
<div className='border-border border-b p-3'>
|
||||
<div className='flex h-full min-h-0 flex-col overflow-hidden'>
|
||||
<div className='border-border flex flex-none items-start justify-between gap-3 border-b p-3'>
|
||||
<div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h2 className='text-sm font-semibold'>Agent thread</h2>
|
||||
{agentTurnActive ? (
|
||||
<Badge variant='secondary'>Working</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Messages persist with this workspace.
|
||||
Messages, tool activity, and requests persist with this workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 space-y-3 overflow-auto p-3'>
|
||||
{messages.map((message) => (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={disabled || !agentTurnActive}
|
||||
onClick={abort}
|
||||
>
|
||||
<Ban className='size-3' />
|
||||
Abort
|
||||
</Button>
|
||||
</div>
|
||||
<div className='border-border flex flex-none gap-1 overflow-x-auto border-b px-3 py-2'>
|
||||
{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
|
||||
key={message._id}
|
||||
className='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 capitalize'>{message.role}</span>
|
||||
<span className='text-muted-foreground text-xs capitalize'>
|
||||
{message.status}
|
||||
</span>
|
||||
<div className='mb-2 flex items-center gap-2'>
|
||||
<Terminal className='text-primary size-4' />
|
||||
<span className='font-medium'>Tool</span>
|
||||
{message.status === 'streaming' ? (
|
||||
<Badge variant='outline'>Running</Badge>
|
||||
) : null}
|
||||
</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>
|
||||
))}
|
||||
{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>
|
||||
<div className='border-border space-y-2 border-t p-3'>
|
||||
<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 className='border-border flex-none space-y-2 border-t p-3'>
|
||||
<Textarea
|
||||
value={content}
|
||||
placeholder='Ask the agent to inspect, explain, or change this fork.'
|
||||
disabled={disabled || sending}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
|
||||
@@ -1,30 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import type { CSSProperties, PointerEvent as ReactPointerEvent } 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 type { Id } from '@spoon/backend/convex/_generated/dataModel.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 { AgentThread } from './agent-thread';
|
||||
import { CodeEditor } from './code-editor';
|
||||
import { CommandPanel } from './command-panel';
|
||||
import { DiffViewer } from './diff-viewer';
|
||||
import { FileTabs } from './file-tabs';
|
||||
import { FileTree } from './file-tree';
|
||||
import { JobStatusBar } from './job-status-bar';
|
||||
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'> }) => {
|
||||
const job = useQuery(api.agentJobs.get, { jobId });
|
||||
const messages =
|
||||
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 [selectedPath, setSelectedPath] = useState<string>();
|
||||
const [fileContent, setFileContent] = useState('');
|
||||
const [files, setFiles] = useState<Record<string, OpenFileState>>({});
|
||||
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 [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 =
|
||||
!job ||
|
||||
@@ -33,10 +102,30 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
) ||
|
||||
['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 response = await fetch(`/api/agent-jobs/${jobId}/tree`);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const data = (await response.json()) as { tree: FileTreeNode | null };
|
||||
setWorkspaceError(undefined);
|
||||
setTree(data.tree);
|
||||
}, [jobId]);
|
||||
|
||||
@@ -44,34 +133,189 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/diff`);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const data = (await response.json()) as DiffResponse;
|
||||
setWorkspaceError(undefined);
|
||||
setDiff(data.diff);
|
||||
}, [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(
|
||||
async (path: string) => {
|
||||
setFiles((current) => ({
|
||||
...current,
|
||||
[path]: current[path] ?? {
|
||||
path,
|
||||
content: '',
|
||||
savedContent: '',
|
||||
loading: true,
|
||||
saving: false,
|
||||
},
|
||||
}));
|
||||
const response = await fetch(
|
||||
`/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`,
|
||||
);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const data = (await response.json()) as FileResponse;
|
||||
setSelectedPath(data.path);
|
||||
setFileContent(data.content);
|
||||
setFiles((current) => ({
|
||||
...current,
|
||||
[data.path]: {
|
||||
path: data.path,
|
||||
content: data.content,
|
||||
savedContent: data.content,
|
||||
loading: false,
|
||||
saving: false,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[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(() => {
|
||||
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(() => {
|
||||
void loadTree().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
});
|
||||
void loadDiff().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
});
|
||||
void loadTree().catch(handleError);
|
||||
void loadDiff().catch(handleError);
|
||||
void loadAgentStatus();
|
||||
}, 0);
|
||||
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) {
|
||||
return (
|
||||
@@ -79,28 +323,249 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const saveFile = async (content: string) => {
|
||||
if (!selectedPath) return;
|
||||
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
|
||||
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`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ path: selectedPath, content }),
|
||||
body: JSON.stringify({ path, content }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
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());
|
||||
}
|
||||
setFileContent(content);
|
||||
setFiles((current) => ({
|
||||
...current,
|
||||
[path]: {
|
||||
...(current[path] ?? {
|
||||
path,
|
||||
loading: false,
|
||||
}),
|
||||
content,
|
||||
savedContent: content,
|
||||
saving: false,
|
||||
},
|
||||
}));
|
||||
await loadDiff();
|
||||
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 (
|
||||
<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} />
|
||||
{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'>
|
||||
<WorkspaceActions job={job} disabled={workspaceDisabled} />
|
||||
</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'>
|
||||
<div className='border-border border-b p-3'>
|
||||
<h2 className='text-sm font-semibold'>Files</h2>
|
||||
@@ -108,59 +573,210 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
</div>
|
||||
<FileTree
|
||||
tree={tree}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={(path) => {
|
||||
void loadFile(path).catch((error) => {
|
||||
console.error(error);
|
||||
toast.error('Could not load file.');
|
||||
});
|
||||
}}
|
||||
selectedPath={activeFilePath}
|
||||
expandedPaths={expandedDirectoryPaths}
|
||||
onSelect={openFile}
|
||||
onToggleDirectory={toggleDirectory}
|
||||
/>
|
||||
</aside>
|
||||
<section className='bg-background flex min-w-0 flex-col'>
|
||||
<Tabs defaultValue='editor' className='flex min-h-0 flex-1 flex-col'>
|
||||
<TabsList
|
||||
variant='line'
|
||||
className='border-border h-11 flex-none justify-start rounded-none border-b px-3'
|
||||
<section className='bg-background flex min-w-0 flex-col overflow-hidden'>
|
||||
<Tabs
|
||||
value={activeWorkspaceTab}
|
||||
onValueChange={(value) =>
|
||||
setActiveWorkspaceTab(value as WorkspaceTab)
|
||||
}
|
||||
className='flex min-h-0 flex-1 flex-col'
|
||||
>
|
||||
<TabsTrigger value='editor'>Editor</TabsTrigger>
|
||||
<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
|
||||
</TabsTrigger>
|
||||
</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
|
||||
path={selectedPath}
|
||||
content={fileContent}
|
||||
path={activeFilePath}
|
||||
content={activeFile?.content ?? ''}
|
||||
savedContent={activeFile?.savedContent ?? ''}
|
||||
readOnly={workspaceDisabled}
|
||||
vimEnabled={vimEnabled}
|
||||
onSave={saveFile}
|
||||
onVimEnabledChange={setVimEnabled}
|
||||
onChange={(content) => {
|
||||
if (!activeFilePath) return;
|
||||
setFiles((current) => ({
|
||||
...current,
|
||||
[activeFilePath]: {
|
||||
...(current[activeFilePath] ?? {
|
||||
path: activeFilePath,
|
||||
savedContent: '',
|
||||
loading: false,
|
||||
saving: false,
|
||||
}),
|
||||
content,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value='terminal'
|
||||
className='m-0 min-h-0 flex-1 overflow-hidden'
|
||||
>
|
||||
<WorkspaceTerminal
|
||||
jobId={jobId}
|
||||
active={activeWorkspaceTab === 'terminal' && workspaceReady}
|
||||
/>
|
||||
</TabsContent>
|
||||
<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
|
||||
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
|
||||
jobId={jobId}
|
||||
messages={messages}
|
||||
events={events}
|
||||
interactions={interactions}
|
||||
workspaceChanges={workspaceChanges}
|
||||
disabled={workspaceDisabled}
|
||||
agentTurnActive={agentTurnActive}
|
||||
onOpenFile={openFileFromActivity}
|
||||
onOpenDiff={openDiffFromActivity}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<CommandPanel jobId={jobId} disabled={workspaceDisabled} />
|
||||
</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
|
||||
jobId={jobId}
|
||||
messages={messages}
|
||||
events={events}
|
||||
interactions={interactions}
|
||||
workspaceChanges={workspaceChanges}
|
||||
disabled={workspaceDisabled}
|
||||
agentTurnActive={agentTurnActive}
|
||||
onOpenFile={openFileFromActivity}
|
||||
onOpenDiff={openDiffFromActivity}
|
||||
/>
|
||||
</aside>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,13 +2,26 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
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'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const EDITOR_FONT_FAMILY =
|
||||
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, SFMono-Regular, monospace";
|
||||
|
||||
type MonacoEditorInstance = {
|
||||
getModel?: () => unknown;
|
||||
};
|
||||
@@ -20,26 +33,28 @@ type VimMode = {
|
||||
export const CodeEditor = ({
|
||||
path,
|
||||
content,
|
||||
savedContent,
|
||||
readOnly,
|
||||
vimEnabled,
|
||||
onSave,
|
||||
onChange,
|
||||
onVimEnabledChange,
|
||||
}: {
|
||||
path?: string;
|
||||
content: string;
|
||||
savedContent: string;
|
||||
readOnly: boolean;
|
||||
vimEnabled: boolean;
|
||||
onSave: (content: string) => Promise<void>;
|
||||
onChange: (content: string) => void;
|
||||
onVimEnabledChange: (enabled: boolean) => void;
|
||||
}) => {
|
||||
const [value, setValue] = useState(content);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [vimEnabled, setVimEnabled] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const editorRef = useRef<MonacoEditorInstance | null>(null);
|
||||
const vimRef = useRef<VimMode | null>(null);
|
||||
const statusRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(content);
|
||||
setDirty(false);
|
||||
}, [content, path]);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const editorTheme = resolvedTheme === 'light' ? SPOON_LIGHT : SPOON_DARK;
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
@@ -71,17 +86,21 @@ export const CodeEditor = ({
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(value);
|
||||
setDirty(false);
|
||||
await onSave(content);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dirty = content !== savedContent;
|
||||
|
||||
return (
|
||||
<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'>
|
||||
<p className='text-muted-foreground text-[11px] font-medium tracking-wide uppercase'>
|
||||
Editor
|
||||
</p>
|
||||
<p className='truncate font-mono text-xs'>{path}</p>
|
||||
{dirty ? (
|
||||
<p className='text-muted-foreground text-xs'>Unsaved changes</p>
|
||||
@@ -90,7 +109,7 @@ export const CodeEditor = ({
|
||||
<div className='flex items-center gap-3'>
|
||||
<label className='flex items-center gap-2 text-xs'>
|
||||
Vim
|
||||
<Switch checked={vimEnabled} onCheckedChange={setVimEnabled} />
|
||||
<Switch checked={vimEnabled} onCheckedChange={onVimEnabledChange} />
|
||||
</label>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -107,22 +126,40 @@ export const CodeEditor = ({
|
||||
height='100%'
|
||||
width='100%'
|
||||
path={path}
|
||||
value={value}
|
||||
theme='vs-dark'
|
||||
language={languageForPath(path)}
|
||||
value={content}
|
||||
theme={editorTheme}
|
||||
beforeMount={(monaco) => {
|
||||
configureSpoonMonaco(monaco as unknown as MonacoLike);
|
||||
}}
|
||||
options={{
|
||||
readOnly,
|
||||
minimap: { enabled: false },
|
||||
fontFamily: EDITOR_FONT_FAMILY,
|
||||
fontLigatures: true,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
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;
|
||||
remeasureFontsWhenReady(monaco as unknown as MonacoLike);
|
||||
}}
|
||||
onChange={(next) => {
|
||||
setValue(next ?? '');
|
||||
setDirty((next ?? '') !== content);
|
||||
const nextValue = next ?? '';
|
||||
onChange(nextValue);
|
||||
}}
|
||||
/>
|
||||
</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';
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
||||
ssr: false,
|
||||
});
|
||||
import type { DiffMode } from './diff-file-view';
|
||||
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 = ({
|
||||
diff,
|
||||
focusedPath,
|
||||
onRefresh,
|
||||
onClearFocusedPath,
|
||||
}: {
|
||||
diff: string;
|
||||
focusedPath?: string;
|
||||
onRefresh: () => Promise<void>;
|
||||
}) => (
|
||||
onClearFocusedPath?: () => void;
|
||||
}) => {
|
||||
const [mode, setMode] = useState<DiffMode>('unified');
|
||||
const theme = useDiffTheme();
|
||||
|
||||
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-11 items-center justify-between border-b px-3'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>Workspace diff</p>
|
||||
<p className='text-muted-foreground text-xs'>Current git diff</p>
|
||||
<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>
|
||||
{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>
|
||||
{visibleFiles.length > 0 ? (
|
||||
<div className='flex flex-1 flex-col gap-3 overflow-y-auto p-3'>
|
||||
{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'>
|
||||
No workspace diff yet.
|
||||
{focusedPath
|
||||
? 'No diff is recorded for this file yet.'
|
||||
: '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';
|
||||
|
||||
import { ChevronRight, FileCode, Folder } from 'lucide-react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileCode,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
@@ -9,38 +15,59 @@ import type { FileTreeNode } from './types';
|
||||
const TreeNode = ({
|
||||
node,
|
||||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelect,
|
||||
onToggle,
|
||||
depth = 0,
|
||||
}: {
|
||||
node: FileTreeNode;
|
||||
selectedPath?: string;
|
||||
expandedPaths: Set<string>;
|
||||
onSelect: (path: string) => void;
|
||||
onToggle: (path: string) => void;
|
||||
depth?: number;
|
||||
}) => {
|
||||
if (node.type === 'directory') {
|
||||
const isRoot = !node.path;
|
||||
const expanded = isRoot || expandedPaths.has(node.path);
|
||||
return (
|
||||
<div>
|
||||
{node.path ? (
|
||||
<div
|
||||
className='text-muted-foreground flex h-7 items-center gap-1 px-2 text-xs font-medium'
|
||||
{!isRoot ? (
|
||||
<button
|
||||
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 }}
|
||||
onClick={() => onToggle(node.path)}
|
||||
>
|
||||
<ChevronRight className='size-3' />
|
||||
<Folder className='size-3' />
|
||||
{expanded ? (
|
||||
<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>
|
||||
</div>
|
||||
</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>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,11 +89,15 @@ const TreeNode = ({
|
||||
export const FileTree = ({
|
||||
tree,
|
||||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelect,
|
||||
onToggleDirectory,
|
||||
}: {
|
||||
tree: FileTreeNode | null;
|
||||
selectedPath?: string;
|
||||
expandedPaths: string[];
|
||||
onSelect: (path: string) => void;
|
||||
onToggleDirectory: (path: string) => void;
|
||||
}) => {
|
||||
if (!tree) {
|
||||
return (
|
||||
@@ -76,8 +107,14 @@ export const FileTree = ({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='overflow-auto py-2'>
|
||||
<TreeNode node={tree} selectedPath={selectedPath} onSelect={onSelect} />
|
||||
<div className='h-full overflow-auto py-2'>
|
||||
<TreeNode
|
||||
node={tree}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={new Set(expandedPaths)}
|
||||
onSelect={onSelect}
|
||||
onToggle={onToggleDirectory}
|
||||
/>
|
||||
</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';
|
||||
|
||||
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 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 = ({
|
||||
job,
|
||||
@@ -13,6 +32,13 @@ export const WorkspaceActions = ({
|
||||
job: Doc<'agentJobs'>;
|
||||
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 () => {
|
||||
try {
|
||||
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 () => {
|
||||
try {
|
||||
const response = await fetch(`/api/agent-jobs/${job._id}/stop`, {
|
||||
@@ -63,6 +114,66 @@ export const WorkspaceActions = ({
|
||||
<Square className='size-4' />
|
||||
Stop
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 }) => {
|
||||
const pathname = usePathname();
|
||||
const isWorkspace = /\/spoons\/[^/]+\/agent\/[^/]+/.test(pathname);
|
||||
const isWorkspace =
|
||||
/\/spoons\/[^/]+\/agent\/[^/]+/.test(pathname) ||
|
||||
/^\/threads\/[^/]+/.test(pathname);
|
||||
|
||||
return (
|
||||
<div className='bg-muted/20 flex-1 border-t'>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import type { ProviderModelOption } from '@/lib/models-dev';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { loadModelsDevOptions } from '@/lib/models-dev';
|
||||
import type { ProviderModelOption } from '@/lib/provider-model-options';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
modelOptionsFromIds,
|
||||
suggestedModelOptions,
|
||||
supportsCustomModelOptions,
|
||||
} from '@/lib/provider-model-options';
|
||||
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||
import { makeFunctionReference } from 'convex/server';
|
||||
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 { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -50,6 +55,7 @@ const saveProfileRef = makeFunctionReference<
|
||||
secret?: string;
|
||||
baseUrl?: string;
|
||||
defaultModel: string;
|
||||
modelOptions?: string[];
|
||||
reasoningEffort: ReasoningEffort;
|
||||
enabled: boolean;
|
||||
},
|
||||
@@ -119,33 +125,24 @@ export const AiProviderProfilesPanel = () => {
|
||||
);
|
||||
const [secret, setSecret] = useState('');
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
const [defaultModelValue, setDefaultModelValue] = useState('');
|
||||
const [modelOptions, setModelOptions] = useState<ProviderModelOption[]>([]);
|
||||
const [defaultModelValue, setDefaultModelValue] = useState(
|
||||
suggestedModelOptions('openai')[0]?.id ?? '',
|
||||
);
|
||||
const [modelOptions, setModelOptions] = useState<ProviderModelOption[]>(
|
||||
suggestedModelOptions('openai'),
|
||||
);
|
||||
const [customModelId, setCustomModelId] = useState('');
|
||||
const [reasoningEffort, setReasoningEffort] =
|
||||
useState<ReasoningEffort>('medium');
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadModelsDevOptions(provider)
|
||||
.then((options) => {
|
||||
if (cancelled) return;
|
||||
const resetModelOptions = (nextProvider: Provider) => {
|
||||
const options = suggestedModelOptions(nextProvider);
|
||||
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;
|
||||
setDefaultModelValue(options[0]?.id ?? '');
|
||||
setCustomModelId('');
|
||||
};
|
||||
}, [provider]);
|
||||
|
||||
const reset = () => {
|
||||
setProfileId(undefined);
|
||||
@@ -153,6 +150,8 @@ export const AiProviderProfilesPanel = () => {
|
||||
setSecret('');
|
||||
setBaseUrl('');
|
||||
setDefaultModelValue('');
|
||||
setModelOptions(suggestedModelOptions('openai'));
|
||||
setCustomModelId('');
|
||||
setReasoningEffort('medium');
|
||||
setEnabled(true);
|
||||
setName('OpenAI');
|
||||
@@ -165,6 +164,14 @@ export const AiProviderProfilesPanel = () => {
|
||||
setSecret('');
|
||||
setBaseUrl(profile.baseUrl ?? '');
|
||||
setDefaultModelValue(profile.defaultModel);
|
||||
setModelOptions(
|
||||
modelOptionsFromIds(
|
||||
profile.modelOptions?.length
|
||||
? profile.modelOptions
|
||||
: [profile.defaultModel],
|
||||
),
|
||||
);
|
||||
setCustomModelId('');
|
||||
setReasoningEffort(profile.reasoningEffort as ReasoningEffort);
|
||||
setEnabled(profile.enabled);
|
||||
};
|
||||
@@ -181,6 +188,7 @@ export const AiProviderProfilesPanel = () => {
|
||||
secret: secret.trim() ? secret : undefined,
|
||||
baseUrl: baseUrl.trim() || undefined,
|
||||
defaultModel: defaultModelValue,
|
||||
modelOptions: modelOptions.map((model) => model.id),
|
||||
reasoningEffort,
|
||||
enabled,
|
||||
});
|
||||
@@ -310,6 +318,7 @@ export const AiProviderProfilesPanel = () => {
|
||||
onValueChange={(value) => {
|
||||
const nextProvider = value as Provider;
|
||||
setProvider(nextProvider);
|
||||
resetModelOptions(nextProvider);
|
||||
setName(
|
||||
providerOptions
|
||||
.find((option) => option.value === nextProvider)
|
||||
@@ -397,9 +406,47 @@ export const AiProviderProfilesPanel = () => {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Models are loaded from Models.dev, the catalog OpenCode uses
|
||||
for provider/model metadata.
|
||||
Saved model options are used by Spoons. Add custom model IDs
|
||||
for compatible provider gateways.
|
||||
</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 className='grid gap-2'>
|
||||
<Label>Thinking</Label>
|
||||
|
||||
@@ -75,7 +75,7 @@ const features = [
|
||||
{
|
||||
title: 'Provider-owned AI',
|
||||
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,
|
||||
},
|
||||
{
|
||||
@@ -119,7 +119,7 @@ const ownership = [
|
||||
{
|
||||
title: 'Your providers',
|
||||
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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -63,6 +63,14 @@ export default function Footer() {
|
||||
Integrations
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href='/settings/worker'
|
||||
className='text-muted-foreground hover:text-foreground transition-colors'
|
||||
>
|
||||
Worker
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
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';
|
||||
|
||||
import type { ProviderModelOption } from '@/lib/models-dev';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { loadModelsDevOptions } from '@/lib/models-dev';
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -53,6 +51,7 @@ export const SpoonAgentSettingsForm = ({
|
||||
}) => {
|
||||
const update = useMutation(api.spoonAgentSettings.update);
|
||||
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||
const modelCatalog = useQuery(api.aiProviderModels.listAvailableForUser);
|
||||
const configuredProfiles = profiles.filter(
|
||||
(profile) => profile.enabled && profile.configured,
|
||||
);
|
||||
@@ -99,8 +98,12 @@ export const SpoonAgentSettingsForm = ({
|
||||
? defaultProfile?._id
|
||||
: aiProviderProfileId),
|
||||
);
|
||||
const [availableModels, setAvailableModels] = useState<ProviderModelOption[]>(
|
||||
[],
|
||||
const selectedModelProfile = modelCatalog?.profiles.find(
|
||||
(profile) =>
|
||||
profile.profileId ===
|
||||
(aiProviderProfileId === '__default'
|
||||
? defaultProfile?._id
|
||||
: aiProviderProfileId),
|
||||
);
|
||||
const [agentModel, setAgentModel] = useState(
|
||||
settings?.aiProviderProfileId ? settings.agentModel : '',
|
||||
@@ -115,42 +118,17 @@ export const SpoonAgentSettingsForm = ({
|
||||
: settings.reasoningEffort,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProfile?.configured) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
loadModelsDevOptions(selectedProfile.provider)
|
||||
.then((models) => {
|
||||
if (cancelled) return;
|
||||
setAvailableModels(models);
|
||||
setAgentModel((current) =>
|
||||
current && models.some((model) => model.id === current)
|
||||
? 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 selectableModels = selectedModelProfile?.configured
|
||||
? selectedModelProfile.models
|
||||
: [];
|
||||
const selectedAgentModel =
|
||||
agentModel && selectableModels.some((model) => model.id === agentModel)
|
||||
? agentModel
|
||||
: selectableModels.some(
|
||||
(model) => model.id === selectedModelProfile?.defaultModel,
|
||||
)
|
||||
? (selectedModelProfile?.defaultModel ?? '')
|
||||
: (selectableModels[0]?.id ?? '');
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
@@ -163,9 +141,7 @@ export const SpoonAgentSettingsForm = ({
|
||||
installCommand: installCommand || undefined,
|
||||
checkCommand: checkCommand || undefined,
|
||||
testCommand: testCommand || undefined,
|
||||
agentModel: agentModel.trim()
|
||||
? agentModel
|
||||
: (selectableModels[0]?.id ?? undefined),
|
||||
agentModel: selectedAgentModel || undefined,
|
||||
reasoningEffort,
|
||||
envFilePath: envFilePath as
|
||||
| '.env'
|
||||
@@ -200,7 +176,7 @@ export const SpoonAgentSettingsForm = ({
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-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
|
||||
id='agentEnabled'
|
||||
checked={enabled}
|
||||
@@ -249,7 +225,8 @@ export const SpoonAgentSettingsForm = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
@@ -271,7 +248,7 @@ export const SpoonAgentSettingsForm = ({
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='agentModel'>Model</Label>
|
||||
<Select
|
||||
value={agentModel}
|
||||
value={selectedAgentModel}
|
||||
onValueChange={setAgentModel}
|
||||
disabled={!selectableModels.length}
|
||||
>
|
||||
@@ -288,8 +265,8 @@ export const SpoonAgentSettingsForm = ({
|
||||
</Select>
|
||||
{!selectableModels.length ? (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Configure an enabled AI provider profile in Settings before
|
||||
choosing a model.
|
||||
Configure an enabled AI provider profile with saved model
|
||||
options in Settings before choosing a model.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -423,7 +400,7 @@ export const SpoonAgentSettingsForm = ({
|
||||
onClick={save}
|
||||
disabled={
|
||||
!selectedProfile?.configured ||
|
||||
!selectableModels.some((model) => model.id === agentModel)
|
||||
!selectableModels.some((model) => model.id === selectedAgentModel)
|
||||
}
|
||||
>
|
||||
Save agent settings
|
||||
|
||||
@@ -9,7 +9,13 @@ const formatDate = (value?: number) =>
|
||||
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
|
||||
: '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'>
|
||||
<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'>
|
||||
@@ -45,8 +51,15 @@ export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
|
||||
<p className='font-medium'>{formatDate(spoon.lastCheckedAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Upstream waiting</p>
|
||||
<p className='font-medium'>{spoon.upstreamAheadBy ?? 0}</p>
|
||||
<p className='text-muted-foreground'>Actionable upstream</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>
|
||||
<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';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { MessageSquarePlus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
@@ -35,13 +36,14 @@ type AgentSettings = {
|
||||
aiProviderProfileId?: Id<'aiProviderProfiles'>;
|
||||
};
|
||||
|
||||
export const AgentRequestForm = ({
|
||||
export const ThreadWorkspaceForm = ({
|
||||
spoon,
|
||||
agentSettings,
|
||||
}: {
|
||||
spoon: Doc<'spoons'>;
|
||||
agentSettings?: AgentSettings | null;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const secrets =
|
||||
useQuery(api.spoonSecrets.listForSpoon, {
|
||||
spoonId: spoon._id,
|
||||
@@ -90,7 +92,7 @@ export const AgentRequestForm = ({
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createThread({
|
||||
const threadId = await createThread({
|
||||
spoonId: spoon._id,
|
||||
prompt,
|
||||
baseBranch,
|
||||
@@ -105,9 +107,10 @@ export const AgentRequestForm = ({
|
||||
setPrompt('');
|
||||
setRequestedBranchName('');
|
||||
toast.success('Thread created.');
|
||||
router.push(`/threads/${threadId}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not queue agent job.');
|
||||
toast.error('Could not create thread workspace.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -117,16 +120,16 @@ export const AgentRequestForm = ({
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='flex items-center gap-2 text-base'>
|
||||
<Bot className='size-4' />
|
||||
Request agent work
|
||||
<MessageSquarePlus className='size-4' />
|
||||
Create thread workspace
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={submit} className='space-y-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='agentPrompt'>Prompt</Label>
|
||||
<Label htmlFor='threadPrompt'>Prompt</Label>
|
||||
<Textarea
|
||||
id='agentPrompt'
|
||||
id='threadPrompt'
|
||||
required
|
||||
minLength={12}
|
||||
value={prompt}
|
||||
@@ -22,6 +22,9 @@ export const env = createEnv({
|
||||
SPOON_AGENT_WORKER_URL: z.url().default('http://localhost:3921'),
|
||||
SPOON_AGENT_WORKER_INTERNAL_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_ORG: 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.
|
||||
@@ -59,6 +66,9 @@ export const env = createEnv({
|
||||
SPOON_AGENT_WORKER_INTERNAL_TOKEN:
|
||||
process.env.SPOON_AGENT_WORKER_INTERNAL_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_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
|
||||
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'server-only';
|
||||
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { env } from '@/env';
|
||||
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 { 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 = {
|
||||
params: Promise<{ jobId: string }> | { jobId: string };
|
||||
};
|
||||
@@ -32,6 +53,45 @@ export const requireOwnedJob = async (jobId: Id<'agentJobs'>) => {
|
||||
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 (
|
||||
jobId: Id<'agentJobs'>,
|
||||
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 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 { 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', () => ({
|
||||
useConvexAuth: () => ({ isAuthenticated: false }),
|
||||
useMutation: () => vi.fn(),
|
||||
useMutation: mockUseMutation,
|
||||
useQuery: mockUseQuery,
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useParams: mockUseParams,
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
|
||||
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', () => {
|
||||
it('renders the Spoon landing headline', () => {
|
||||
render(<Hero />);
|
||||
@@ -36,4 +53,194 @@ describe('component test harness', () => {
|
||||
expect(screen.getByLabelText(/upstream owner/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 { 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({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': srcAlias,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
projects: [
|
||||
nodeProject('unit', ['tests/unit/**/*.test.{ts,tsx}']),
|
||||
withNextAlias(nodeProject('unit', ['tests/unit/**/*.test.{ts,tsx}'])),
|
||||
withNextAlias(
|
||||
nodeProject('integration', ['tests/integration/**/*.test.{ts,tsx}']),
|
||||
),
|
||||
withNextAlias(
|
||||
jsdomProject('component', ['tests/component/**/*.test.{ts,tsx}']),
|
||||
),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -23,14 +23,18 @@
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@opencode-ai/sdk": "latest",
|
||||
"convex": "catalog:convex",
|
||||
"dockerode": "^4.0.7",
|
||||
"execa": "latest",
|
||||
"ws": "catalog:",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@spoon/eslint-config": "workspace:*",
|
||||
"@spoon/prettier-config": "workspace:*",
|
||||
"@spoon/tsconfig": "workspace:*",
|
||||
"@types/dockerode": "^3.3.42",
|
||||
"@types/node": "catalog:",
|
||||
"@types/ws": "^8.18.1",
|
||||
"eslint": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
@@ -97,16 +101,21 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@convex-dev/auth": "catalog:convex",
|
||||
"@git-diff-view/react": "^0.1.6",
|
||||
"@monaco-editor/react": "latest",
|
||||
"@sentry/nextjs": "^10.46.0",
|
||||
"@spoon/backend": "workspace:*",
|
||||
"@spoon/ui": "workspace:*",
|
||||
"@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",
|
||||
"monaco-editor": "latest",
|
||||
"monaco-vim": "latest",
|
||||
"next": "^16.2.1",
|
||||
"next-plausible": "^3.12.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "catalog:react19",
|
||||
"react-dom": "catalog:react19",
|
||||
"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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -705,6 +716,16 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -1513,6 +1554,10 @@
|
||||
|
||||
"@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-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/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-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/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/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/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/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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"@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/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="],
|
||||
@@ -1701,6 +1762,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
|
||||
|
||||
"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=="],
|
||||
@@ -1799,7 +1868,7 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -1881,6 +1950,8 @@
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -2157,6 +2236,8 @@
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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.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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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.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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"nan": ["nan@2.27.0", "", {}, "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ=="],
|
||||
|
||||
"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=="],
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"split-ca": ["split-ca@1.0.1", "", {}, "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ=="],
|
||||
|
||||
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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_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-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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -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/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/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=="],
|
||||
|
||||
"@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/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/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/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/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/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/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=="],
|
||||
|
||||
"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/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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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/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/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_ORG
|
||||
ARG NEXT_PUBLIC_SENTRY_PROJECT_NAME
|
||||
ARG NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL
|
||||
|
||||
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
|
||||
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_ORG=$NEXT_PUBLIC_SENTRY_ORG
|
||||
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 . .
|
||||
|
||||
@@ -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 LANG=en_US.UTF-8
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
# Core toolchain + interactive/QoL CLI tooling. Everything below is in the
|
||||
# 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-completion \
|
||||
bat \
|
||||
bubblewrap \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
curl \
|
||||
eza \
|
||||
fd-find \
|
||||
findutils \
|
||||
fzf \
|
||||
gcc \
|
||||
gcc-c++ \
|
||||
gh \
|
||||
git \
|
||||
glibc-langpack-en \
|
||||
gum \
|
||||
gzip \
|
||||
jq \
|
||||
openssh-client \
|
||||
less \
|
||||
make \
|
||||
ncurses \
|
||||
neovim \
|
||||
nodejs \
|
||||
nodejs-npm \
|
||||
openssh-clients \
|
||||
procps-ng \
|
||||
python3 \
|
||||
python3-pip \
|
||||
ripgrep \
|
||||
&& corepack enable \
|
||||
&& npm install -g bun@1.3.10 opencode-ai@latest @openai/codex@latest \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
tar \
|
||||
tmux \
|
||||
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
|
||||
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
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 \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
docker.io \
|
||||
git \
|
||||
jq \
|
||||
openssh-client \
|
||||
&& 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
|
||||
|
||||
COPY package.json bun.lock* turbo.json ./
|
||||
|
||||
@@ -71,15 +71,20 @@ services:
|
||||
- SPOON_AGENT_WORKER_ID=${SPOON_AGENT_WORKER_ID:-local-worker}
|
||||
- SPOON_AGENT_JOB_IMAGE=${SPOON_AGENT_JOB_IMAGE:-spoon-agent-job:latest}
|
||||
- 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_MAX_CONCURRENT_JOBS=${SPOON_AGENT_MAX_CONCURRENT_JOBS:-1}
|
||||
- SPOON_AGENT_JOB_TIMEOUT_MS=${SPOON_AGENT_JOB_TIMEOUT_MS:-1800000}
|
||||
- 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_PRIVATE_KEY=${GITHUB_APP_PRIVATE_KEY}
|
||||
volumes:
|
||||
- /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:
|
||||
convex-backend:
|
||||
condition: service_healthy
|
||||
@@ -88,4 +93,3 @@ services:
|
||||
volumes:
|
||||
postgres-data:
|
||||
convex-data:
|
||||
agent-work:
|
||||
|
||||
+14
-4
@@ -17,9 +17,11 @@ services:
|
||||
NEXT_PUBLIC_SENTRY_URL: ${NEXT_PUBLIC_SENTRY_URL}
|
||||
NEXT_PUBLIC_SENTRY_ORG: ${NEXT_PUBLIC_SENTRY_ORG}
|
||||
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: git.gbrown.org/gib/spoon-next:latest
|
||||
container_name: ${NEXT_CONTAINER_NAME}
|
||||
labels: ['com.centurylinklabs.watchtower.enable=true']
|
||||
environment:
|
||||
- NODE_ENV=${NODE_ENV}
|
||||
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
|
||||
@@ -95,6 +97,7 @@ services:
|
||||
image: spoon-agent-worker:latest
|
||||
container_name: ${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}']
|
||||
environment:
|
||||
- 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_JOB_IMAGE=${SPOON_AGENT_JOB_IMAGE:-spoon-agent-job:latest}
|
||||
- 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_MAX_CONCURRENT_JOBS=${SPOON_AGENT_MAX_CONCURRENT_JOBS:-1}
|
||||
- SPOON_AGENT_JOB_TIMEOUT_MS=${SPOON_AGENT_JOB_TIMEOUT_MS:-1800000}
|
||||
- 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_PRIVATE_KEY=${GITHUB_APP_PRIVATE_KEY}
|
||||
volumes:
|
||||
- /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:
|
||||
spoon-backend:
|
||||
condition: service_healthy
|
||||
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: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:agent": "turbo run dev -F @spoon/agent-worker",
|
||||
"dev:agent:staging": "INFISICAL_ENV=staging 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 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: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",
|
||||
@@ -73,6 +75,7 @@
|
||||
"sync:convex:production": "scripts/sync-convex-env production",
|
||||
"sync:convex:prod": "scripts/sync-convex-env prod",
|
||||
"auth:keys": "node scripts/generate-convex-auth-keys.mjs",
|
||||
"smoke:agent-container": "scripts/smoke-agent-container",
|
||||
"db:up": "bash scripts/db/up",
|
||||
"db:down": "bash scripts/db/down",
|
||||
"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",
|
||||
"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}": [
|
||||
"eslint --flag unstable_native_nodejs_ts_config --fix --no-warn-ignored --config packages/backend/eslint.config.ts",
|
||||
"prettier --write"
|
||||
|
||||
@@ -36,6 +36,12 @@ const workspaceStatus = v.union(
|
||||
v.literal('failed'),
|
||||
);
|
||||
|
||||
const agentRuntimeMode = v.union(
|
||||
v.literal('opencode_server'),
|
||||
v.literal('codex_exec'),
|
||||
v.literal('legacy_cli'),
|
||||
);
|
||||
|
||||
const messageRole = v.union(
|
||||
v.literal('user'),
|
||||
v.literal('assistant'),
|
||||
@@ -100,6 +106,22 @@ const artifactContentType = v.union(
|
||||
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(
|
||||
v.literal('sync'),
|
||||
v.literal('ignore'),
|
||||
@@ -138,6 +160,27 @@ const requireWorkerToken = (workerToken: string) => {
|
||||
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) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
@@ -172,6 +215,84 @@ const normalizeEnvFilePath = (value?: string) => {
|
||||
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 settings = await ctx.db
|
||||
.query('spoonAgentSettings')
|
||||
@@ -451,7 +572,10 @@ export const createForThread = mutation({
|
||||
throw new ConvexError('Thread not found.');
|
||||
}
|
||||
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 promptMessage = await ctx.db
|
||||
@@ -514,7 +638,12 @@ export const createForThreadInternal = internalMutation({
|
||||
if (thread?.ownerId !== args.ownerId || !thread.spoonId) {
|
||||
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);
|
||||
if (spoon?.ownerId !== args.ownerId) {
|
||||
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({
|
||||
args: { jobId: v.id('agentJobs'), content: v.string() },
|
||||
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({
|
||||
args: { workerId: v.string() },
|
||||
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({
|
||||
args: {
|
||||
workerToken: v.string(),
|
||||
@@ -1103,7 +1569,9 @@ export const appendMessage = mutation({
|
||||
role: args.role,
|
||||
content: args.content,
|
||||
status: args.status,
|
||||
metadata: args.metadata,
|
||||
metadata: mergeMessageMetadata(args.metadata, {
|
||||
agentJobMessageId: messageId,
|
||||
}),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
@@ -1136,6 +1604,32 @@ export const updateMessage = mutation({
|
||||
if (args.status !== undefined) patch.status = args.status;
|
||||
if (args.metadata !== undefined) patch.metadata = args.metadata;
|
||||
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 };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
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_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({
|
||||
ownerId: v.id('users'),
|
||||
name: v.string(),
|
||||
@@ -444,6 +468,7 @@ const applicationTables = {
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
enabled: v.boolean(),
|
||||
// Legacy records may contain openai_direct. New writes use opencode only.
|
||||
runtime: v.optional(
|
||||
v.union(v.literal('opencode'), v.literal('openai_direct')),
|
||||
),
|
||||
@@ -507,6 +532,7 @@ const applicationTables = {
|
||||
v.literal('timed_out'),
|
||||
),
|
||||
prompt: v.string(),
|
||||
// Legacy jobs may contain openai_direct. New jobs use opencode only.
|
||||
runtime: v.optional(
|
||||
v.union(v.literal('openai_direct'), v.literal('opencode')),
|
||||
),
|
||||
@@ -524,6 +550,14 @@ const applicationTables = {
|
||||
baseBranch: v.string(),
|
||||
workBranch: 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()),
|
||||
workspaceUrl: v.optional(v.string()),
|
||||
workspaceExpiresAt: v.optional(v.number()),
|
||||
@@ -587,6 +621,49 @@ const applicationTables = {
|
||||
})
|
||||
.index('by_job', ['jobId'])
|
||||
.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({
|
||||
jobId: v.id('agentJobs'),
|
||||
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({
|
||||
args: { spoonId: v.id('spoons') },
|
||||
handler: async (ctx, { spoonId }) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import type { Doc } from './_generated/dataModel';
|
||||
import type { MutationCtx } from './_generated/server';
|
||||
import { internal } from './_generated/api';
|
||||
import {
|
||||
internalMutation,
|
||||
@@ -68,6 +69,53 @@ const titleFromPrompt = (prompt: string) => {
|
||||
|
||||
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({
|
||||
args: {
|
||||
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))
|
||||
.order('desc')
|
||||
.take(args.limit ?? 50);
|
||||
return threads.filter((thread) => {
|
||||
const filtered = threads.filter((thread) => {
|
||||
if (
|
||||
args.status &&
|
||||
args.status !== 'all' &&
|
||||
@@ -100,6 +148,28 @@ export const listMine = query({
|
||||
if (args.spoonId && thread.spoonId !== args.spoonId) return false;
|
||||
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 }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
await getOwnedSpoon(ctx, spoonId, ownerId);
|
||||
return await ctx.db
|
||||
const threads = await ctx.db
|
||||
.query('threads')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||
.order('desc')
|
||||
.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,
|
||||
role: 'user',
|
||||
content: requireText(content, 'Message'),
|
||||
status: 'queued',
|
||||
status: 'completed',
|
||||
createdAt: 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({
|
||||
args: {
|
||||
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