Compare commits

...

2 Commits

Author SHA1 Message Date
Gabriel Brown d207b8b0b8 Add features & update project
Build and Push Spoon Images / quality (push) Successful in 1m41s
Build and Push Spoon Images / build-images (push) Successful in 7m4s
2026-06-23 02:06:58 -04:00
Gabriel Brown fe72fc2957 Add features & update project 2026-06-23 01:46:08 -04:00
57 changed files with 4356 additions and 402 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ 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
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 }}
+13
View File
@@ -23,6 +23,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 +54,17 @@
- 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.
- `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 +89,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
+49 -2
View File
@@ -154,6 +154,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 +198,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 +207,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 +216,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.
@@ -437,6 +482,8 @@ 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 |
+1 -1
View File
@@ -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",
+231
View File
@@ -0,0 +1,231 @@
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 : '';
};
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 [];
const type = stringify(event.type ?? event.event);
const id = event.id ?? event.session_id ?? event.sessionId;
const sessionId =
typeof id === 'string' && type.toLowerCase().includes('session')
? 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 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'))
) {
events.push({ kind: 'assistant_delta', content: text });
}
const command = event.command ?? data?.command;
if (typeof command === 'string') {
events.push({
kind: 'command_executed',
command,
output: stringify(event.output ?? data?.output),
});
}
const file = event.file ?? event.path ?? data?.file ?? data?.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') || 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 === '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;
};
+8
View File
@@ -19,6 +19,14 @@ 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',
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',
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
+126
View File
@@ -0,0 +1,126 @@
import { createOpencodeClient } from '@opencode-ai/sdk';
import type { OpencodeClient } 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.');
}
};
+218 -9
View File
@@ -2,20 +2,30 @@ 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;
export const runInJobContainer = async (args: {
workdir: 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> => {
const result = await execa(
'docker',
containerRuntime(),
[
'run',
'--rm',
@@ -23,8 +33,8 @@ export const runInJobContainer = async (args: {
'4g',
'--cpus',
'2',
...networkArgs,
...envArgs,
...networkArgs(),
...environmentArgs(args.environment),
'-v',
`${args.workdir}:/workspace`,
'-w',
@@ -43,3 +53,202 @@ export const runInJobContainer = async (args: {
output: args.redact(result.all),
};
};
export const startWorkspaceContainer = async (args: {
workdir: string;
containerName: string;
environment: Record<string, string>;
command?: string[];
publishTcpPort?: number;
}) => {
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',
`${args.workdir}:/workspace`,
'-w',
'/workspace/repo',
env.jobImage,
...(args.command ?? ['sleep', 'infinity']),
],
{ all: true },
);
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 },
);
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,
timeout: args.timeoutMs,
},
);
return {
exitCode: result.exitCode ?? 0,
output: args.redact(result.all),
};
};
export const streamInJobContainer = async (args: {
workdir: 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> => {
const subprocess = execa(
containerRuntime(),
[
'run',
'--rm',
'--memory',
'4g',
'--cpus',
'2',
...networkArgs(),
...environmentArgs(args.environment),
'-v',
`${args.workdir}:/workspace`,
'-w',
'/workspace/repo',
env.jobImage,
...args.command,
],
{
all: true,
reject: false,
timeout: args.timeoutMs,
},
);
let stdoutBuffer = '';
let stderrBuffer = '';
const output: string[] = [];
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(args.redact(line));
}
}
};
subprocess.stdout.on('data', (chunk: Buffer) => {
void consume(chunk, 'stdout', args.onStdoutLine);
});
subprocess.stderr.on('data', (chunk: Buffer) => {
void consume(chunk, 'stderr', args.onStderrLine);
});
const result = await subprocess;
if (stdoutBuffer && args.onStdoutLine) {
await args.onStdoutLine(args.redact(stdoutBuffer));
}
if (stderrBuffer && args.onStderrLine) {
await args.onStderrLine(args.redact(stderrBuffer));
}
return {
exitCode: result.exitCode ?? 0,
output: args.redact(output.join('')),
};
};
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));
};
+54 -9
View File
@@ -1,12 +1,19 @@
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 {
abortWorkspaceAgent,
cleanupOrphanedWorkspaces,
getWorkerHealth,
getWorkspaceAgentStatus,
getWorkspaceDiff,
listWorkspaceTree,
openWorkspacePullRequest,
readWorkspaceFile,
replyToInteraction,
runWorkspaceCommand,
sendWorkspaceMessage,
stopWorkspace,
@@ -43,7 +50,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 +64,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 +119,34 @@ 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(
@@ -126,12 +165,18 @@ export const startWorkerServer = () => {
return;
}
sendJson(response, 404, { error: 'Not found' });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
sendJson(response, message === 'Unauthorized' ? 401 : 500, {
error: message,
});
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const status =
message === 'Unauthorized'
? 401
: message.includes('not supported')
? 409
: 500;
sendJson(response, status, {
error: message,
});
}
})();
});
server.listen(env.httpPort, () => {
+602 -59
View File
@@ -7,12 +7,15 @@ import {
stat,
writeFile,
} from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
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 type { NormalizedAgentEvent } from './agent-events';
import { normalizeCodexJsonLine } from './agent-events';
import { env } from './env';
import {
cloneRepository,
@@ -22,8 +25,21 @@ import {
run,
} from './git';
import { getInstallationToken, openDraftPullRequest } from './github';
import type { OpenCodeSession } from './opencode-session';
import {
abortOpenCodeSession,
createOpenCodeSession,
promptOpenCodeSession,
replyOpenCodePermission,
} from './opencode-session';
import { createRedactor, truncate } from './redact';
import { runInJobContainer } from './runtime/docker';
import {
listWorkspaceContainerNames,
runInJobContainer,
startWorkspaceContainer,
stopWorkspaceContainer,
streamInJobContainer,
} from './runtime/docker';
type Claim = {
job: {
@@ -81,6 +97,14 @@ type ActiveWorkspace = {
repoDir: string;
githubToken: string;
redact: (value: string) => string;
runtimeMode?: 'opencode_server' | 'codex_exec' | 'legacy_cli';
containerName?: string;
containerId?: string;
opencodePassword?: string;
opencodeSession?: OpenCodeSession;
codexSessionId?: string;
agentTurnActive?: boolean;
resolveTurn?: () => void;
};
type FileTreeNode = {
@@ -225,6 +249,70 @@ const appendMessage = async (args: {
...args,
});
const updateMessage = async (args: {
messageId: Id<'agentJobMessages'>;
content?: string;
status?: 'queued' | 'streaming' | 'completed' | 'failed';
metadata?: string;
}) =>
await client.mutation(api.agentJobs.updateMessage, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const setRuntimeSession = async (args: {
jobId: Id<'agentJobs'>;
agentRuntimeMode: 'opencode_server' | 'codex_exec' | 'legacy_cli';
opencodeSessionId?: string;
codexSessionId?: string;
containerId?: string;
}) =>
await client.mutation(api.agentJobs.setRuntimeSession, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const setCodexSessionId = async (
jobId: Id<'agentJobs'>,
codexSessionId: string,
) =>
await client.mutation(api.agentJobs.setCodexSessionId, {
workerToken: env.workerToken,
workerId: env.workerId,
jobId,
codexSessionId,
});
const createInteractionRequest = async (args: {
jobId: Id<'agentJobs'>;
runtime: 'opencode' | 'codex';
externalRequestId: string;
kind: 'question' | 'permission' | 'tool_confirmation';
title: string;
body: string;
options?: string[];
metadata?: string;
}) =>
await client.mutation(api.agentJobs.createInteractionRequest, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const patchInteractionRequest = async (args: {
interactionId: Id<'agentInteractionRequests'>;
status: 'pending' | 'answered' | 'approved' | 'rejected' | 'expired';
response?: string;
metadata?: string;
}) =>
await client.mutation(api.agentJobs.patchInteractionRequest, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const recordWorkspaceChange = async (args: {
jobId: Id<'agentJobs'>;
path: string;
@@ -240,6 +328,9 @@ const recordWorkspaceChange = async (args: {
const commandToShell = (command: string) => ['bash', '-lc', command];
const workspaceContainerName = (jobId: string) =>
`spoon-agent-job-${jobId.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
const isCodexLoginProfile = (claim: Claim) =>
claim.aiProviderProfile?.provider === 'opencode_openai_login' ||
claim.aiProviderProfile?.authType === 'opencode_auth_json';
@@ -373,20 +464,305 @@ const prepareCodexAuth = async (workspace: ActiveWorkspace) => {
);
};
const agentCommand = (claim: Claim, prompt: string) => {
if (isCodexLoginProfile(claim)) {
return commandToShell(
`codex exec --model ${quoteShell(codexModel(claim))} --sandbox workspace-write ${quoteShell(prompt)}`,
);
}
return commandToShell(
`opencode run --model ${quoteShell(opencodeModel(claim))} ${quoteShell(prompt)}`,
);
};
const agentFailurePrefix = (claim: Claim) =>
isCodexLoginProfile(claim) ? 'codex failed' : 'opencode failed';
const handleAgentEvent = async (args: {
workspace: ActiveWorkspace;
event: NormalizedAgentEvent;
assistantMessageId: Id<'agentJobMessages'>;
assistantContent: { value: string };
}) => {
const { workspace, event, assistantMessageId, assistantContent } = args;
const jobId = workspace.claim.job._id;
if (event.kind === 'assistant_delta') {
assistantContent.value = truncate(
`${assistantContent.value}${event.content}`,
40_000,
);
await updateMessage({
messageId: assistantMessageId,
content: assistantContent.value,
status: 'streaming',
metadata: event.externalMessageId
? JSON.stringify({ externalMessageId: event.externalMessageId })
: undefined,
});
return;
}
if (event.kind === 'assistant_completed') {
workspace.agentTurnActive = false;
workspace.resolveTurn?.();
workspace.resolveTurn = undefined;
if (event.content) {
assistantContent.value = truncate(
`${assistantContent.value}${event.content}`,
40_000,
);
}
await updateMessage({
messageId: assistantMessageId,
content: assistantContent.value,
status: 'completed',
});
return;
}
if (event.kind === 'session') {
if (workspace.runtimeMode === 'codex_exec') {
workspace.codexSessionId = event.sessionId;
await setCodexSessionId(jobId, event.sessionId);
}
return;
}
if (event.kind === 'tool_started' || event.kind === 'tool_completed') {
const detail =
event.kind === 'tool_started' ? event.input : event.output;
await appendMessage({
jobId,
role: 'tool',
status: event.kind === 'tool_started' ? 'streaming' : 'completed',
content: truncate(
`${event.name}${detail ? `\n\n${detail}` : ''}`,
20_000,
),
metadata: JSON.stringify({
kind: event.kind,
externalMessageId: event.externalMessageId,
}),
});
return;
}
if (event.kind === 'file_edited') {
const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact);
await recordWorkspaceChange({
jobId,
path: event.path,
source: 'agent',
changeType: await fileChangedType(workspace.repoDir, event.path),
diff: truncate(diff.output, 50_000),
});
await appendEvent(jobId, 'info', 'edit', `Agent edited ${event.path}.`);
return;
}
if (event.kind === 'command_executed') {
await appendEvent(
jobId,
event.exitCode && event.exitCode !== 0 ? 'warn' : 'info',
'check',
event.command,
event.output ? truncate(event.output, 10_000) : undefined,
);
return;
}
if (
event.kind === 'permission_requested' ||
event.kind === 'question_requested'
) {
await createInteractionRequest({
jobId,
runtime: workspace.runtimeMode === 'codex_exec' ? 'codex' : 'opencode',
externalRequestId: event.externalRequestId,
kind: event.kind === 'permission_requested' ? 'permission' : 'question',
title: event.title,
body: truncate(event.body, 20_000),
options: event.kind === 'question_requested' ? event.options : undefined,
metadata: event.metadata,
});
await appendMessage({
jobId,
role: 'system',
status: 'completed',
content: `${event.title}\n\n${truncate(event.body, 20_000)}`,
metadata: JSON.stringify({ kind: event.kind }),
});
return;
}
if (event.kind === 'status') {
await appendEvent(
jobId,
'debug',
'plan',
event.status,
event.metadata ? truncate(event.metadata, 10_000) : undefined,
);
return;
}
await appendEvent(jobId, 'error', 'plan', truncate(event.message, 20_000));
};
const ensureOpenCodeSession = async (workspace: ActiveWorkspace) => {
if (workspace.opencodeSession) return workspace.opencodeSession;
const containerName = workspaceContainerName(workspace.claim.job._id);
const password = randomBytes(24).toString('hex');
const aiEnv = providerEnvironment(workspace.claim);
const secretEnv = Object.fromEntries(
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
);
const container = await startWorkspaceContainer({
workdir: workspace.workdir,
containerName,
environment: {
...aiEnv,
...secretEnv,
OPENCODE_SERVER_PASSWORD: password,
OPENCODE_SERVER_USERNAME: 'opencode',
},
command: ['opencode', 'serve', '--hostname', '0.0.0.0', '--port', '4096'],
publishTcpPort: env.containerAccess === 'host_port' ? 4096 : undefined,
});
const baseUrl =
env.containerAccess === 'host_port'
? `http://127.0.0.1:${container.hostPort}`
: `http://${containerName}:4096`;
workspace.containerName = container.containerName;
workspace.containerId = container.containerId;
workspace.opencodePassword = password;
workspace.runtimeMode = 'opencode_server';
await setRuntimeSession({
jobId: workspace.claim.job._id,
agentRuntimeMode: 'opencode_server',
containerId: container.containerId,
});
let lastError: unknown;
for (let attempt = 0; attempt < 20; attempt += 1) {
try {
const session = await createOpenCodeSession({
baseUrl,
password,
directory: '/workspace/repo',
title: workspace.claim.job.prompt.slice(0, 80) || 'Spoon workspace',
onEvent: async (event) => {
const messageId = workspaceCurrentMessage.get(workspace.claim.job._id);
if (!messageId) return;
await handleAgentEvent({
workspace,
event,
assistantMessageId: messageId,
assistantContent:
workspaceCurrentContent.get(workspace.claim.job._id) ?? {
value: '',
},
});
},
});
workspace.opencodeSession = session;
await setRuntimeSession({
jobId: workspace.claim.job._id,
agentRuntimeMode: 'opencode_server',
opencodeSessionId: session.sessionId,
containerId: container.containerId,
});
return session;
} catch (error) {
lastError = error;
await sleep(500);
}
}
throw lastError instanceof Error
? lastError
: new Error('OpenCode server did not become ready.');
};
const workspaceCurrentMessage = new Map<string, Id<'agentJobMessages'>>();
const workspaceCurrentContent = new Map<
string,
{
value: string;
}
>();
const runCodexTurn = async (args: {
workspace: ActiveWorkspace;
prompt: string;
assistantMessageId: Id<'agentJobMessages'>;
assistantContent: { value: string };
}) => {
const { workspace, prompt, assistantMessageId, assistantContent } = args;
workspace.runtimeMode = 'codex_exec';
await setRuntimeSession({
jobId: workspace.claim.job._id,
agentRuntimeMode: 'codex_exec',
codexSessionId: workspace.codexSessionId,
});
const command = workspace.codexSessionId
? commandToShell(
`codex exec resume --json --model ${quoteShell(
codexModel(workspace.claim),
)} ${quoteShell(workspace.codexSessionId)} ${quoteShell(prompt)}`,
)
: commandToShell(
`codex exec --json --model ${quoteShell(
codexModel(workspace.claim),
)} --sandbox workspace-write ${quoteShell(prompt)}`,
);
const aiEnv = providerEnvironment(workspace.claim, jobContainerWorkspace);
const secretEnv = Object.fromEntries(
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
);
const result = await streamInJobContainer({
workdir: workspace.workdir,
command,
environment: {
...aiEnv,
...secretEnv,
},
redact: workspace.redact,
timeoutMs: env.jobTimeoutMs,
onStdoutLine: async (line) => {
for (const event of normalizeCodexJsonLine(line)) {
await handleAgentEvent({
workspace,
event,
assistantMessageId,
assistantContent,
});
}
},
onStderrLine: async (line) => {
if (line.trim()) {
await appendEvent(
workspace.claim.job._id,
'debug',
'plan',
truncate(line, 10_000),
);
}
},
});
if (result.exitCode !== 0) {
throw new Error(`codex failed:\n${result.output}`);
}
};
const runOpenCodeTurn = async (args: {
workspace: ActiveWorkspace;
prompt: string;
assistantMessageId: Id<'agentJobMessages'>;
assistantContent: { value: string };
}) => {
const { workspace, prompt, assistantMessageId, assistantContent } = args;
workspaceCurrentMessage.set(workspace.claim.job._id, assistantMessageId);
workspaceCurrentContent.set(workspace.claim.job._id, assistantContent);
const session = await ensureOpenCodeSession(workspace);
const turnDone = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
workspace.resolveTurn = undefined;
reject(new Error('OpenCode turn timed out.'));
}, env.jobTimeoutMs);
workspace.resolveTurn = () => {
clearTimeout(timeout);
resolve();
};
});
await promptOpenCodeSession({
session,
prompt,
model: opencodeModel(workspace.claim),
directory: '/workspace/repo',
});
await turnDone;
};
const systemPromptForJob = (claim: Claim) => {
const base = [
`Spoon: ${claim.spoon.name}`,
@@ -759,8 +1135,8 @@ const runClaim = async (claim: Claim) => {
await appendEvent(jobId, 'info', 'plan', 'Interactive workspace is ready.');
await sendWorkspaceMessage(jobId, systemPromptForJob(claim));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await appendEvent(
jobId,
'error',
@@ -888,9 +1264,80 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => {
return { success: true };
};
export const getWorkspaceAgentStatus = (jobId: string) => {
const workspace = resolveWorkspace(jobId);
return {
runtimeMode: workspace.runtimeMode ?? 'legacy_cli',
opencodeSessionId: workspace.opencodeSession?.sessionId,
codexSessionId: workspace.codexSessionId,
containerId: workspace.containerId,
active: Boolean(workspace.agentTurnActive),
};
};
export const abortWorkspaceAgent = async (jobId: string) => {
const workspace = resolveWorkspace(jobId);
if (workspace.opencodeSession) {
await abortOpenCodeSession(workspace.opencodeSession);
workspace.agentTurnActive = false;
workspace.resolveTurn?.();
workspace.resolveTurn = undefined;
await appendEvent(workspace.claim.job._id, 'warn', 'cleanup', 'Agent turn aborted.');
return { success: true };
}
if (workspace.runtimeMode === 'codex_exec') {
throw new Error('Codex agent turns cannot be aborted from Spoon yet.');
}
return { success: true };
};
export const replyToInteraction = async (
jobId: string,
args: {
interactionId: Id<'agentInteractionRequests'>;
externalRequestId: string;
response: string;
},
) => {
const workspace = resolveWorkspace(jobId);
if (workspace.runtimeMode === 'codex_exec') {
throw new Error('Codex interaction replies are not supported yet.');
}
if (!workspace.opencodeSession) {
throw new Error('OpenCode session is not active.');
}
const mapped =
args.response === 'reject'
? 'reject'
: args.response === 'always'
? 'always'
: 'once';
await replyOpenCodePermission({
session: workspace.opencodeSession,
permissionId: args.externalRequestId,
response: mapped,
directory: '/workspace/repo',
});
await patchInteractionRequest({
interactionId: args.interactionId,
status: mapped === 'reject' ? 'rejected' : 'approved',
response: mapped,
});
await appendMessage({
jobId: workspace.claim.job._id,
role: 'system',
status: 'completed',
content: `Interaction ${mapped === 'reject' ? 'rejected' : 'approved'}.`,
});
return { success: true };
};
export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
const workspace = resolveWorkspace(jobId);
const { claim, repoDir, redact, workdir } = workspace;
const { claim, redact } = workspace;
if (workspace.agentTurnActive) {
throw new Error('Wait for the current agent turn to finish or abort it.');
}
await appendMessage({
jobId: claim.job._id,
role: 'user',
@@ -903,50 +1350,62 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
throw new Error('Legacy OpenAI direct jobs are no longer supported.');
}
const aiEnv = providerEnvironment(
claim,
env.runtime === 'docker' ? jobContainerWorkspace : workdir,
);
const secretEnv = Object.fromEntries(
claim.secrets.map((secret) => [secret.name, secret.value]),
);
const command = agentCommand(claim, prompt);
const result =
env.runtime === 'docker'
? await runInJobContainer({
workdir,
command,
environment: {
...aiEnv,
...secretEnv,
},
redact,
timeoutMs: env.jobTimeoutMs,
})
: await run(
'bash',
command.slice(1),
{
cwd: repoDir,
env: {
...aiEnv,
...secretEnv,
},
redact,
timeoutMs: env.jobTimeoutMs,
},
);
await appendMessage({
workspace.agentTurnActive = true;
const assistantMessageId = await appendMessage({
jobId: claim.job._id,
role: 'assistant',
status: result.exitCode === 0 ? 'completed' : 'failed',
content: truncate(result.output, 40_000),
status: 'streaming',
content: '',
});
if (result.exitCode !== 0) {
throw new Error(`${agentFailurePrefix(claim)}:\n${result.output}`);
const assistantContent = { value: '' };
if (isCodexLoginProfile(claim)) {
await runCodexTurn({
workspace,
prompt,
assistantMessageId,
assistantContent,
});
} else if (env.runtime === 'docker') {
await runOpenCodeTurn({
workspace,
prompt,
assistantMessageId,
assistantContent,
});
} else {
const aiEnv = providerEnvironment(claim);
const secretEnv = Object.fromEntries(
claim.secrets.map((secret) => [secret.name, secret.value]),
);
const result = await run('bash', ['-lc', `opencode run --format json --model ${quoteShell(opencodeModel(claim))} ${quoteShell(prompt)}`], {
cwd: workspace.repoDir,
env: {
...aiEnv,
...secretEnv,
},
redact,
timeoutMs: env.jobTimeoutMs,
});
await updateMessage({
messageId: assistantMessageId,
status: result.exitCode === 0 ? 'completed' : 'failed',
content: truncate(result.output, 40_000),
});
if (result.exitCode !== 0) {
throw new Error(`${agentFailurePrefix(claim)}:\n${result.output}`);
}
}
if (claim.job.jobType === 'maintenance_review') {
const decision = parseMaintenanceDecision(result.output);
if (isCodexLoginProfile(claim)) {
await updateMessage({
messageId: assistantMessageId,
status: 'completed',
content: assistantContent.value,
});
workspace.agentTurnActive = false;
}
workspace.agentTurnActive = false;
if (claim.job.jobType === 'maintenance_review') {
const decision = parseMaintenanceDecision(assistantContent.value);
if (decision) {
await addArtifact({
jobId: claim.job._id,
@@ -959,11 +1418,11 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
} else {
await updateStatus(claim.job._id, 'changes_ready', {
summary:
'OpenCode completed the review, but Spoon could not parse a structured maintenance decision.',
'The agent completed the review, but Spoon could not parse a structured maintenance decision.',
});
}
}
const diff = await getWorktreeDiff(repoDir, redact);
const diff = await getWorktreeDiff(workspace.repoDir, redact);
await addArtifact({
jobId: claim.job._id,
kind: 'diff',
@@ -978,8 +1437,11 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
changeType: 'modified',
diff: truncate(diff.output, 50_000),
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
} catch (error) {
workspace.agentTurnActive = false;
workspace.resolveTurn?.();
workspace.resolveTurn = undefined;
const message = error instanceof Error ? error.message : String(error);
await appendEvent(
claim.job._id,
'error',
@@ -1059,6 +1521,10 @@ export const openWorkspacePullRequest = async (jobId: string) => {
summary: 'Draft PR opened from interactive workspace.',
});
await markWorkspaceStopped(claim.job._id);
workspace.opencodeSession?.close();
if (workspace.containerName) {
await stopWorkspaceContainer(workspace.containerName);
}
activeWorkspaces.delete(jobId);
await rm(workspace.workdir, { recursive: true, force: true });
return {
@@ -1070,11 +1536,88 @@ export const openWorkspacePullRequest = async (jobId: string) => {
export const stopWorkspace = async (jobId: string) => {
const workspace = resolveWorkspace(jobId);
await markWorkspaceStopped(workspace.claim.job._id);
workspace.opencodeSession?.close();
if (workspace.containerName) {
await stopWorkspaceContainer(workspace.containerName);
}
activeWorkspaces.delete(jobId);
await rm(workspace.workdir, { recursive: true, force: true });
return { success: true };
};
export const getWorkerHealth = async () => {
const active = [...activeWorkspaces.entries()].map(([jobId, workspace]) => ({
jobId,
runtimeMode: workspace.runtimeMode ?? 'legacy_cli',
containerName: workspace.containerName,
workdir: workspace.workdir,
agentTurnActive: Boolean(workspace.agentTurnActive),
}));
const containerNames = await listWorkspaceContainerNames('spoon-agent-job-');
return {
ok: true,
workerId: env.workerId,
convexUrl: env.convexUrl,
runtime: env.runtime,
containerRuntime: env.containerRuntime,
containerAccess: env.containerAccess,
jobImage: env.jobImage,
workdir: env.workdir,
network: env.network,
httpPort: env.httpPort,
maxConcurrentJobs: env.maxConcurrentJobs,
jobTimeoutMs: env.jobTimeoutMs,
activeWorkspaceCount: active.length,
activeWorkspaces: active,
workspaceContainers: containerNames,
};
};
export const cleanupOrphanedWorkspaces = async () => {
const activeContainers = new Set(
[...activeWorkspaces.values()]
.map((workspace) => workspace.containerName)
.filter((value): value is string => Boolean(value)),
);
const activeWorkdirs = new Set(
[...activeWorkspaces.values()].map((workspace) =>
path.resolve(workspace.workdir),
),
);
const removedContainers: string[] = [];
for (const containerName of await listWorkspaceContainerNames(
'spoon-agent-job-',
)) {
if (activeContainers.has(containerName)) continue;
await stopWorkspaceContainer(containerName);
removedContainers.push(containerName);
}
const removedWorkdirs: string[] = [];
const root = path.resolve(env.workdir);
try {
const entries = await readdir(root, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
const target = path.resolve(root, entry.name);
if (activeWorkdirs.has(target)) continue;
await rm(target, { recursive: true, force: true });
removedWorkdirs.push(target);
}
} catch (error) {
const code = error && typeof error === 'object' ? 'code' in error : false;
if (!code || (error as { code?: string }).code !== 'ENOENT') {
throw error;
}
}
return {
success: true,
removedContainers,
removedWorkdirs,
};
};
export const startWorker = async () => {
console.log(`Spoon agent worker ${env.workerId} polling ${env.convexUrl}`);
for (;;) {
@@ -0,0 +1,97 @@
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 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 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}',
});
});
});
+7 -5
View File
@@ -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>
+2 -1
View File
@@ -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, Github, ServerCog, Shield, User } from 'lucide-react';
import { cn } from '@spoon/ui';
@@ -11,6 +11,7 @@ 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/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;
+12 -5
View File
@@ -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,9 +1,10 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
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';
@@ -23,28 +24,88 @@ const ThreadDetailPage = () => {
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 [sending, setSending] = useState(false);
const [queueing, setQueueing] = useState(false);
if (details === undefined) {
return <main className='text-muted-foreground p-6'>Loading thread...</main>;
}
const { thread, spoon, latestJob } = details;
const terminalThread = [
'resolved',
'ignored',
'failed',
'cancelled',
].includes(thread.status);
const activeJob =
latestJob &&
[
'claimed',
'preparing',
'running',
'checks_running',
'changes_ready',
].includes(latestJob.status) &&
['active', 'idle'].includes(latestJob.workspaceStatus ?? '');
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 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 : '';
setSending(true);
try {
await appendMessage({ threadId, content });
const response = await fetch(`/api/threads/${threadId}/message`, {
method: 'POST',
body: JSON.stringify({ content }),
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as {
error?: string;
recoverable?: boolean;
} | null;
throw new Error(payload?.error ?? (await response.text()));
}
event.currentTarget.reset();
toast.success('Message added.');
toast.success(activeJob ? 'Message sent to agent.' : 'Message added.');
} catch (error) {
console.error(error);
toast.error('Could not add message.');
toast.error('Could not send message.');
} finally {
setSending(false);
}
};
const startRun = async () => {
setQueueing(true);
try {
const jobId = await createJob({ threadId, jobType });
toast.success('Workspace run queued.');
window.location.href = `/spoons/${spoon?._id}/agent/${jobId}`;
} catch (error) {
console.error(error);
toast.error('Could not queue workspace run.');
} finally {
setQueueing(false);
}
};
@@ -99,28 +160,40 @@ const ThreadDetailPage = () => {
</a>
</Button>
) : null}
<Button
variant='outline'
onClick={() =>
markResolved({ threadId }).then(() =>
toast.success('Thread resolved.'),
)
}
>
<CheckCircle2 className='size-4' />
Resolve
</Button>
<Button
variant='outline'
onClick={() =>
cancel({ threadId }).then(() =>
toast.success('Thread cancelled.'),
)
}
>
<XCircle className='size-4' />
Cancel
</Button>
{canQueueRun ? (
<Button disabled={queueing} onClick={() => void startRun()}>
<Play className='size-4' />
{latestJob ? 'Rerun' : 'Start workspace run'}
</Button>
) : null}
{!terminalThread ? (
<>
<Button
variant='outline'
onClick={() => {
if (!window.confirm('Mark this thread as resolved?')) return;
void markResolved({ threadId }).then(() =>
toast.success('Thread resolved.'),
);
}}
>
<CheckCircle2 className='size-4' />
Resolve
</Button>
<Button
variant='outline'
onClick={() => {
if (!window.confirm('Cancel this thread?')) return;
void cancel({ threadId }).then(() =>
toast.success('Thread cancelled.'),
);
}}
>
<XCircle className='size-4' />
Cancel
</Button>
</>
) : null}
</div>
</div>
@@ -149,9 +222,28 @@ const ThreadDetailPage = () => {
name='message'
required
minLength={2}
placeholder='Add context or instructions for this thread.'
placeholder={
activeJob
? 'Send instructions to the active agent workspace.'
: 'Add context or instructions for the next run.'
}
disabled={sending || terminalThread}
/>
<Button type='submit'>Add message</Button>
<div className='flex flex-wrap items-center gap-2'>
<Button type='submit' disabled={sending || terminalThread}>
{sending
? 'Sending...'
: activeJob
? 'Send to agent'
: 'Add note'}
</Button>
{!activeJob ? (
<p className='text-muted-foreground text-xs'>
No active workspace is attached, so messages are saved as
thread notes until a run is started.
</p>
) : null}
</div>
</form>
</CardContent>
</Card>
+296 -39
View File
@@ -1,28 +1,52 @@
'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 { 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 +56,62 @@ 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 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 +124,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,43 +228,145 @@ 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(`/threads/${thread._id}`)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
router.push(`/threads/${thread._id}`);
}
}}
>
<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>
<Badge variant='outline'>
{thread.source.replaceAll('_', ' ')}
<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>
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
{thread.maintenanceOutcome ? (
<Badge variant='secondary'>
{thread.maintenanceOutcome.replaceAll('_', ' ')}
</Badge>
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
{thread.maintenanceOutcome ? (
<Badge variant='secondary'>
{thread.maintenanceOutcome.replaceAll('_', ' ')}
</Badge>
) : null}
</div>
<p className='text-muted-foreground mt-1 line-clamp-2 text-sm'>
{thread.summary ??
'No summary has been recorded for this thread yet.'}
</p>
) : null}
</div>
<div className='text-muted-foreground text-xs md:text-right'>
<p>{formatTime(thread.updatedAt)}</p>
<p className='capitalize'>{thread.priority} priority</p>
<p className='text-muted-foreground mt-1 line-clamp-2 text-sm'>
{thread.summary ??
'No summary has been recorded for this thread yet.'}
</p>
</div>
<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}
<div className='mt-2 flex justify-start gap-2 md:justify-end'>
{thread.latestAgentJobId ? (
<Button size='sm' variant='outline' asChild>
<Link
href={`/spoons/${thread.spoonId}/agent/${thread.latestAgentJobId}`}
onClick={(event) => event.stopPropagation()}
>
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}
</div>
</CardContent>
</Card>
</Link>
</div>
</CardContent>
</Card>
))
) : (
<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,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,23 +1,30 @@
'use client';
import { useState } from 'react';
import { Send } from 'lucide-react';
import { Ban, Send } 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';
export const AgentThread = ({
jobId,
messages,
events,
interactions,
disabled,
agentTurnActive,
}: {
jobId: string;
messages: Doc<'agentJobMessages'>[];
events: Doc<'agentJobEvents'>[];
interactions: Doc<'agentInteractionRequests'>[];
disabled: boolean;
agentTurnActive: boolean;
}) => {
const [content, setContent] = useState('');
const [sending, setSending] = useState(false);
const [replying, setReplying] = useState<string>();
const send = async () => {
if (!content.trim()) return;
@@ -37,27 +44,141 @@ 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'>
<h2 className='text-sm font-semibold'>Agent thread</h2>
<p className='text-muted-foreground text-xs'>
Messages persist with this workspace.
</p>
<div className='border-border flex items-start justify-between gap-3 border-b p-3'>
<div>
<h2 className='text-sm font-semibold'>Agent thread</h2>
<p className='text-muted-foreground text-xs'>
Messages, tool activity, and requests persist with this workspace.
</p>
</div>
<Button
type='button'
variant='outline'
size='sm'
disabled={disabled || !agentTurnActive}
onClick={abort}
>
<Ban className='size-3' />
Abort
</Button>
</div>
<div className='min-h-0 flex-1 space-y-3 overflow-auto p-3'>
{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>
))}
{messages.map((message) => (
<article
key={message._id}
className='border-border bg-background rounded-md border p-3 text-sm'
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 capitalize'>{message.role}</span>
<span className='text-muted-foreground text-xs capitalize'>
<Badge
variant={
message.status === 'failed' ? 'destructive' : 'outline'
}
className='capitalize'
>
{message.status}
</span>
</Badge>
</div>
<p className='whitespace-pre-wrap'>{message.content}</p>
<p className='whitespace-pre-wrap'>
{message.content ||
(message.status === 'streaming' ? 'Working...' : '')}
</p>
</article>
))}
{events.slice(-20).map((event) => (
<article
key={event._id}
className='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='font-medium capitalize'>
{event.phase} / {event.level}
</span>
<span>{new Date(event.createdAt).toLocaleTimeString()}</span>
</div>
<p className='mt-1 whitespace-pre-wrap'>{event.message}</p>
</article>
))}
</div>
@@ -1,30 +1,60 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useQuery } from 'convex/react';
import { useMutation, useQuery } from 'convex/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 { 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';
type OpenFileState = {
path: string;
content: string;
savedContent: string;
loading: boolean;
saving: boolean;
error?: 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 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 [vimEnabled, setVimEnabled] = useState(false);
const [hydratedUiState, setHydratedUiState] = useState(false);
const [diff, setDiff] = useState('');
const [workspaceError, setWorkspaceError] = useState<string>();
const [agentTurnActive, setAgentTurnActive] = useState(false);
const workspaceDisabled =
!job ||
@@ -37,6 +67,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
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 +75,151 @@ 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);
return;
}
const data = (await response.json()) as { active?: boolean };
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;
const timeout = window.setTimeout(() => {
void loadTree().catch((error: unknown) => {
console.error(error);
setWorkspaceError(
error instanceof Error ? error.message : String(error),
);
});
void loadDiff().catch((error: unknown) => {
console.error(error);
setWorkspaceError(
error instanceof Error ? error.message : String(error),
);
});
void loadAgentStatus();
}, 0);
return () => window.clearTimeout(timeout);
}, [job, loadDiff, loadTree]);
}, [job, loadAgentStatus, loadDiff, loadTree]);
useEffect(() => {
const interval = window.setInterval(() => {
void loadAgentStatus();
}, 5_000);
return () => window.clearInterval(interval);
}, [loadAgentStatus]);
useEffect(() => {
if (!uiState || hydratedUiState) return;
const timeout = window.setTimeout(() => {
setOpenFilePaths(uiState.openFilePaths);
setActiveFilePath(uiState.activeFilePath);
setExpandedDirectoryPaths(uiState.expandedDirectoryPaths);
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,
}).catch((error: unknown) => {
console.error(error);
});
}, 400);
return () => window.clearTimeout(timeout);
}, [
activeFilePath,
expandedDirectoryPaths,
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,24 +227,136 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
);
}
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
const recoverWorkspace = async () => {
if (!job.threadId) return;
const newJobId = await createJobForThread({
threadId: job.threadId,
jobType: job.jobType ?? 'user_change',
});
window.location.href = `/spoons/${job.spoonId}/agent/${newJobId}`;
};
const deleteStaleWorkspace = async () => {
if (!window.confirm('Delete this stale workspace record?')) return;
await markWorkspaceLost({ jobId });
await deleteWorkspace({ jobId });
window.location.href = job.threadId
? `/threads/${job.threadId}`
: `/spoons/${job.spoonId}`;
};
const saveFile = async (content: string) => {
if (!selectedPath) return;
if (!activeFilePath) return;
setFiles((current) => ({
...current,
[activeFilePath]: {
...(current[activeFilePath] ?? {
path: activeFilePath,
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: activeFilePath, content }),
});
if (!response.ok) {
toast.error('Could not save file.');
setFiles((current) => ({
...current,
[activeFilePath]: {
...(current[activeFilePath] ?? {
path: activeFilePath,
content,
savedContent: '',
loading: false,
}),
saving: false,
},
}));
throw new Error(await response.text());
}
setFileContent(content);
setFiles((current) => ({
...current,
[activeFilePath]: {
...(current[activeFilePath] ?? {
path: activeFilePath,
loading: false,
}),
content,
savedContent: content,
saving: false,
},
}));
await loadDiff();
toast.success('File saved.');
};
const closeFile = (path: string) => {
const file = files[path];
if (file && file.content !== file.savedContent) {
const confirmed = window.confirm(
`Close ${path} and discard unsaved changes?`,
);
if (!confirmed) return;
}
const index = openFilePaths.indexOf(path);
const nextOpen = openFilePaths.filter((filePath) => filePath !== path);
setOpenFilePaths(nextOpen);
setFiles((current) => {
const next = { ...current };
delete next[path];
return next;
});
if (activeFilePath === path) {
setActiveFilePath(nextOpen[index - 1] ?? nextOpen[index] ?? undefined);
}
};
const toggleDirectory = (path: string) => {
setExpandedDirectoryPaths((current) =>
current.includes(path)
? current.filter((directoryPath) => directoryPath !== path)
: [...current, path],
);
};
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} />
{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'>Workspace not active on this worker</p>
<p className='text-muted-foreground mt-1 text-sm'>
{workspaceError}
</p>
<div className='mt-3 flex flex-wrap gap-2'>
{job.threadId ? (
<Button type='button' onClick={() => void recoverWorkspace()}>
Recreate workspace run
</Button>
) : null}
<Button
type='button'
variant='outline'
onClick={() => void deleteStaleWorkspace()}
>
Delete stale workspace
</Button>
{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>
@@ -108,13 +368,10 @@ 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'>
@@ -129,12 +386,44 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
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='diff' className='m-0 min-h-0 flex-1'>
@@ -147,7 +436,10 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
<AgentThread
jobId={jobId}
messages={messages}
events={events}
interactions={interactions}
disabled={workspaceDisabled}
agentTurnActive={agentTurnActive}
/>
</TabsContent>
</Tabs>
@@ -157,7 +449,10 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
<AgentThread
jobId={jobId}
messages={messages}
events={events}
interactions={interactions}
disabled={workspaceDisabled}
agentTurnActive={agentTurnActive}
/>
</aside>
</div>
@@ -5,6 +5,8 @@ import dynamic from 'next/dynamic';
import { Button, Switch } from '@spoon/ui';
import { languageForPath } from './languages';
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
ssr: false,
});
@@ -20,27 +22,27 @@ 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]);
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
@@ -71,13 +73,14 @@ 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'>
@@ -90,7 +93,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,7 +110,8 @@ export const CodeEditor = ({
height='100%'
width='100%'
path={path}
value={value}
language={languageForPath(path)}
value={content}
theme='vs-dark'
options={{
readOnly,
@@ -116,13 +120,20 @@ export const CodeEditor = ({
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
scrollbar: { alwaysConsumeMouseWheel: false },
quickSuggestions: true,
suggestOnTriggerCharacters: true,
tabCompletion: 'on',
wordBasedSuggestions: 'matchingDocuments',
bracketPairColorization: { enabled: true },
renderWhitespace: 'selection',
}}
onMount={(editor) => {
editorRef.current = editor as MonacoEditorInstance;
}}
onChange={(next) => {
setValue(next ?? '');
setDirty((next ?? '') !== content);
const nextValue = next ?? '';
onChange(nextValue);
}}
/>
</div>
@@ -8,42 +8,62 @@ const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
ssr: false,
});
const diffStats = (diff: string) => {
const files = new Set<string>();
let additions = 0;
let removals = 0;
for (const line of diff.split('\n')) {
if (line.startsWith('diff --git ')) files.add(line);
if (line.startsWith('+') && !line.startsWith('+++')) additions += 1;
if (line.startsWith('-') && !line.startsWith('---')) removals += 1;
}
return { files: files.size, additions, removals };
};
export const DiffViewer = ({
diff,
onRefresh,
}: {
diff: string;
onRefresh: () => Promise<void>;
}) => (
<div className='flex h-full min-h-0 flex-col'>
<div className='border-border flex h-11 items-center justify-between border-b px-3'>
<div>
<p className='text-sm font-medium'>Workspace diff</p>
<p className='text-muted-foreground text-xs'>Current git diff</p>
}) => {
const stats = diffStats(diff);
return (
<div className='flex h-full min-h-0 flex-col'>
<div className='border-border flex h-12 items-center justify-between gap-3 border-b px-3'>
<div className='min-w-0'>
<p className='text-sm font-medium'>Workspace diff</p>
<p className='text-muted-foreground truncate text-xs'>
{diff.trim()
? `${stats.files} files, +${stats.additions} -${stats.removals}`
: 'Current git diff'}
</p>
</div>
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
Refresh
</Button>
</div>
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
Refresh
</Button>
{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,
scrollbar: { alwaysConsumeMouseWheel: false },
}}
/>
) : (
<div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'>
No workspace diff yet.
</div>
)}
</div>
{diff.trim() ? (
<MonacoEditor
height='100%'
width='100%'
language='diff'
theme='vs-dark'
value={diff}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
automaticLayout: true,
}}
/>
) : (
<div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'>
No workspace diff yet.
</div>
)}
</div>
);
);
};
@@ -0,0 +1,65 @@
'use client';
import { Circle, X } from 'lucide-react';
import { Button } from '@spoon/ui';
import { basename } from './languages';
export type OpenFileTab = {
path: string;
dirty: boolean;
};
export const FileTabs = ({
tabs,
activePath,
onActivate,
onClose,
}: {
tabs: OpenFileTab[];
activePath?: string;
onActivate: (path: string) => void;
onClose: (path: string) => void;
}) => {
if (tabs.length === 0) return null;
return (
<div className='border-border bg-muted/30 flex h-10 flex-none items-stretch overflow-x-auto border-b'>
{tabs.map((tab) => {
const active = tab.path === activePath;
return (
<div
key={tab.path}
className={
active
? 'border-primary bg-background flex max-w-56 min-w-0 items-center border-t-2 border-r'
: 'border-border flex max-w-56 min-w-0 items-center border-r'
}
title={tab.path}
>
<button
type='button'
className='flex h-full min-w-0 flex-1 items-center gap-2 px-3 text-left text-xs'
onClick={() => onActivate(tab.path)}
>
{tab.dirty ? (
<Circle className='fill-primary text-primary size-2 flex-none' />
) : null}
<span className='truncate font-mono'>{basename(tab.path)}</span>
</button>
<Button
type='button'
variant='ghost'
size='icon'
className='mr-1 size-6 flex-none'
aria-label={`Close ${tab.path}`}
onClick={() => onClose(tab.path)}
>
<X className='size-3' />
</Button>
</div>
);
})}
</div>
);
};
@@ -1,6 +1,12 @@
'use client';
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>
</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>
{node.children?.map((child) => (
<TreeNode
key={`${child.type}:${child.path}`}
node={child}
selectedPath={selectedPath}
onSelect={onSelect}
depth={node.path ? depth + 1 : depth}
/>
))}
</div>
</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;
@@ -1,9 +1,17 @@
'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 { api } from '@spoon/backend/convex/_generated/api.js';
import { Button } from '@spoon/ui';
export const WorkspaceActions = ({
@@ -13,6 +21,12 @@ export const WorkspaceActions = ({
job: Doc<'agentJobs'>;
disabled: boolean;
}) => {
const router = useRouter();
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
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 +40,24 @@ export const WorkspaceActions = ({
}
};
const remove = async () => {
if (
!window.confirm(
'Delete this workspace and its messages, events, artifacts, diffs, and UI state? This cannot be undone.',
)
) {
return;
}
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 stop = async () => {
try {
const response = await fetch(`/api/agent-jobs/${job._id}/stop`, {
@@ -63,6 +95,12 @@ export const WorkspaceActions = ({
<Square className='size-4' />
Stop
</Button>
{canDelete ? (
<Button type='button' variant='destructive' size='sm' onClick={remove}>
<Trash2 className='size-4' />
Delete workspace
</Button>
) : null}
</div>
);
};
@@ -3,7 +3,7 @@
import { useState } from 'react';
import Link from 'next/link';
import { useMutation } from 'convex/react';
import { ExternalLink, MonitorUp, XCircle } from 'lucide-react';
import { ExternalLink, MonitorUp, Trash2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -22,10 +22,17 @@ const formatTime = (value: number) =>
export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
const cancel = useMutation(api.agentJobs.cancel);
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
const [selectedJobId, setSelectedJobId] = useState<string | null>(
jobs[0]?._id ?? null,
);
const selectedJob = jobs.find((job) => job._id === selectedJobId) ?? jobs[0];
const selectedJobCanDelete = selectedJob
? ['failed', 'cancelled', 'timed_out'].includes(selectedJob.status) ||
['stopped', 'expired', 'failed'].includes(
selectedJob.workspaceStatus ?? '',
)
: false;
if (!jobs.length) {
return (
@@ -110,6 +117,32 @@ export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
Open workspace
</Link>
</Button>
{selectedJobCanDelete ? (
<Button
type='button'
variant='destructive'
onClick={async () => {
if (
!window.confirm(
'Delete this workspace and its messages, events, artifacts, diffs, and UI state? This cannot be undone.',
)
) {
return;
}
try {
await deleteWorkspace({ jobId: selectedJob._id });
toast.success('Workspace deleted.');
setSelectedJobId(null);
} catch (error) {
console.error(error);
toast.error('Could not delete workspace.');
}
}}
>
<Trash2 className='size-4' />
Delete workspace
</Button>
) : null}
<AgentJobDetail job={selectedJob} />
</div>
) : null}
@@ -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;
setModelOptions(options);
setDefaultModelValue((current) =>
current && options.some((option) => option.id === current)
? current
: (options[0]?.id ?? ''),
);
})
.catch((error: unknown) => {
console.error(error);
if (!cancelled) setModelOptions([]);
});
return () => {
cancelled = true;
};
}, [provider]);
const resetModelOptions = (nextProvider: Provider) => {
const options = suggestedModelOptions(nextProvider);
setModelOptions(options);
setDefaultModelValue(options[0]?.id ?? '');
setCustomModelId('');
};
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,277 @@
'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 {
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 () => {
if (
!window.confirm(
`Delete up to 100 stopped, cancelled, failed, or expired workspaces older than ${olderThanDays} days?`,
)
) {
return;
}
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>
<Button
type='button'
variant='destructive'
disabled={deleting || deletableCount === 0}
onClick={() => void deleteOld()}
>
<Trash2 className='size-4' />
Delete old
</Button>
</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'
@@ -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
+16 -3
View File
@@ -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>
+39
View File
@@ -32,6 +32,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,
-56
View File
@@ -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);
@@ -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');
});
});
+2
View File
@@ -15,6 +15,8 @@ RUN apt-get update \
python3 \
ripgrep \
&& corepack enable \
&& corepack prepare pnpm@latest --activate \
&& corepack prepare yarn@stable --activate \
&& npm install -g bun@1.3.10 opencode-ai@latest @openai/codex@latest \
&& rm -rf /var/lib/apt/lists/*
+2
View File
@@ -71,6 +71,8 @@ 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}
+2
View File
@@ -102,6 +102,8 @@ 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}
+5 -2
View File
@@ -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",
+436 -2
View File
@@ -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'),
@@ -172,6 +194,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 +551,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 +617,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 +717,115 @@ 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: [],
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())),
},
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,
),
}
: {}),
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 ?? [],
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 +926,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 +1169,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(),
@@ -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),
})),
};
}),
};
},
});
+50
View File
@@ -524,6 +524,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 +595,48 @@ 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()),
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'),
+58
View File
@@ -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 }) => {
+24 -2
View File
@@ -82,7 +82,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 +100,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,
};
}),
);
},
});
@@ -216,7 +238,7 @@ export const appendUserMessage = mutation({
spoonId: thread.spoonId,
role: 'user',
content: requireText(content, 'Message'),
status: 'queued',
status: 'completed',
createdAt: now,
updatedAt: now,
});
+264
View File
@@ -1,6 +1,7 @@
import { convexTest } from 'convex-test';
import { describe, expect, test } from 'vitest';
import type { Id } from '../../convex/_generated/dataModel.js';
import { api } from '../../convex/_generated/api.js';
import schema from '../../convex/schema';
@@ -33,6 +34,67 @@ const spoonInput = {
productionRefStrategy: 'default_branch' as const,
};
const githubSpoonInput = {
...spoonInput,
provider: 'github' as const,
upstreamUrl: 'https://github.com/upstream/editor',
forkUrl: 'https://github.com/team/editor-spoon',
};
const createAgentJob = async (
t: ReturnType<typeof convexTest>,
args: {
ownerId: Id<'users'>;
spoonId: Id<'spoons'>;
status: 'running' | 'failed' | 'cancelled';
workspaceStatus?: 'active' | 'stopped' | 'failed' | 'expired';
},
) =>
await t.mutation(async (ctx) => {
const now = Date.now();
const requestId = await ctx.db.insert('agentRequests', {
spoonId: args.spoonId,
ownerId: args.ownerId,
prompt: 'Clean this workspace',
status: 'running',
createdAt: now,
updatedAt: now,
});
const jobId = await ctx.db.insert('agentJobs', {
spoonId: args.spoonId,
ownerId: args.ownerId,
agentRequestId: requestId,
status: args.status,
prompt: 'Clean this workspace',
runtime: 'opencode',
workspaceStatus: args.workspaceStatus,
baseBranch: 'main',
workBranch: 'spoon/test',
forkOwner: 'team',
forkRepo: 'editor-spoon',
forkUrl: 'https://git.example.com/team/editor-spoon',
upstreamOwner: 'upstream',
upstreamRepo: 'editor',
selectedSecretIds: [],
model: 'openai/gpt-5.1-codex',
reasoningEffort: 'medium',
createdAt: now,
updatedAt: now,
});
await ctx.db.patch(requestId, { agentJobId: jobId });
await ctx.db.insert('agentJobMessages', {
jobId,
spoonId: args.spoonId,
ownerId: args.ownerId,
role: 'assistant',
content: 'done',
status: 'completed',
createdAt: now,
updatedAt: now,
});
return jobId;
});
describe('convex-test harness', () => {
test('boots and executes against the project schema', async () => {
const t = convexTest(schema, modules);
@@ -59,6 +121,54 @@ describe('convex-test harness', () => {
expect(spoons[0]?.ownerId).toBe(userId);
});
test('lists effective drift after ignored upstream changes', async () => {
const t = convexTest(schema, modules);
const ownerId = await createUser(t, 'owner@example.com');
const spoonId = await authed(t, ownerId).mutation(
api.spoons.createManual,
githubSpoonInput,
);
await t.mutation(async (ctx) => {
const now = Date.now();
await ctx.db.insert('spoonRepositoryStates', {
spoonId,
ownerId: ownerId as Id<'users'>,
upstreamFullName: 'upstream/editor',
forkFullName: 'team/editor-spoon',
upstreamDefaultBranch: 'main',
forkDefaultBranch: 'main',
upstreamHeadSha: 'upstream-head',
forkHeadSha: 'fork-head',
upstreamAheadBy: 2,
forkAheadBy: 1,
status: 'diverged',
openForkPullRequestCount: 0,
openUpstreamPullRequestCount: 0,
refreshedAt: now,
createdAt: now,
updatedAt: now,
});
await ctx.db.insert('ignoredUpstreamChanges', {
spoonId,
ownerId: ownerId as Id<'users'>,
upstreamTo: 'upstream-head',
commitShas: ['abc123'],
reason: 'irrelevant',
decidedBy: 'user',
createdAt: now,
});
});
const spoons = await authed(t, ownerId).query(
api.spoons.listMineWithState,
{},
);
expect(spoons[0]?.rawUpstreamAheadBy).toBe(2);
expect(spoons[0]?.effectiveUpstreamAheadBy).toBe(1);
expect(spoons[0]?.ignoredUpstreamCount).toBe(1);
});
test('does not allow reading another users Spoon', async () => {
const t = convexTest(schema, modules);
const ownerId = await createUser(t, 'owner@example.com');
@@ -73,6 +183,38 @@ describe('convex-test harness', () => {
).rejects.toThrow('Spoon not found.');
});
test('thread notes are completed when no workspace handles them', async () => {
const t = convexTest(schema, modules);
const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
const spoonId = await authed(t, ownerId).mutation(
api.spoons.createManual,
githubSpoonInput,
);
const threadId = await t.mutation(async (ctx) => {
return await ctx.db.insert('threads', {
ownerId,
spoonId,
title: 'Manual note',
source: 'user_request',
status: 'open',
priority: 'normal',
createdAt: Date.now(),
updatedAt: Date.now(),
});
});
const messageId = await authed(t, ownerId).mutation(
api.threads.appendUserMessage,
{
threadId,
content: 'extra context',
},
);
const message = await t.run(async (ctx) => await ctx.db.get(messageId));
expect(message?.status).toBe('completed');
});
test('requires Spoon ownership for agent requests', async () => {
const t = convexTest(schema, modules);
const ownerId = await createUser(t, 'owner@example.com');
@@ -89,4 +231,126 @@ describe('convex-test harness', () => {
}),
).rejects.toThrow('Spoon not found.');
});
test('deletes terminal workspaces and associated rows', async () => {
const t = convexTest(schema, modules);
const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
const spoonId = await authed(t, ownerId).mutation(
api.spoons.createManual,
spoonInput,
);
const jobId = await createAgentJob(t, {
ownerId,
spoonId,
status: 'failed',
workspaceStatus: 'failed',
});
await authed(t, ownerId).mutation(api.agentJobs.deleteWorkspace, { jobId });
const job = await t.run(async (ctx) => await ctx.db.get(jobId));
const messages = await t.run(
async (ctx) =>
await ctx.db
.query('agentJobMessages')
.withIndex('by_job', (q) => q.eq('jobId', jobId))
.collect(),
);
expect(job).toBeNull();
expect(messages).toHaveLength(0);
});
test('does not delete active workspaces', async () => {
const t = convexTest(schema, modules);
const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
const spoonId = await authed(t, ownerId).mutation(
api.spoons.createManual,
spoonInput,
);
const jobId = await createAgentJob(t, {
ownerId,
spoonId,
status: 'running',
workspaceStatus: 'active',
});
await expect(
authed(t, ownerId).mutation(api.agentJobs.deleteWorkspace, { jobId }),
).rejects.toThrow('Only stopped, cancelled, failed, or expired workspaces');
});
test('does not delete another users workspace', async () => {
const t = convexTest(schema, modules);
const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
const otherId = (await createUser(t, 'other@example.com')) as Id<'users'>;
const spoonId = await authed(t, ownerId).mutation(
api.spoons.createManual,
spoonInput,
);
const jobId = await createAgentJob(t, {
ownerId,
spoonId,
status: 'cancelled',
workspaceStatus: 'stopped',
});
await expect(
authed(t, otherId).mutation(api.agentJobs.deleteWorkspace, { jobId }),
).rejects.toThrow('Agent job not found.');
});
test('queues a new thread job after the previous job is terminal', async () => {
const t = convexTest(schema, modules);
const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
const spoonId = await authed(t, ownerId).mutation(
api.spoons.createManual,
githubSpoonInput,
);
await t.mutation(async (ctx) => {
await ctx.db.insert('aiProviderProfiles', {
ownerId,
name: 'Test provider',
provider: 'openai',
authType: 'none',
defaultModel: 'gpt-5.1-codex',
modelOptions: ['gpt-5.1-codex'],
reasoningEffort: 'medium',
enabled: true,
createdAt: Date.now(),
updatedAt: Date.now(),
});
});
const threadId = await t.mutation(async (ctx) => {
return await ctx.db.insert('threads', {
ownerId,
spoonId,
title: 'Retryable thread',
summary: 'try again',
source: 'user_request',
status: 'failed',
priority: 'normal',
createdAt: Date.now(),
updatedAt: Date.now(),
});
});
const failedJobId = await createAgentJob(t, {
ownerId,
spoonId,
status: 'failed',
workspaceStatus: 'failed',
});
await t.mutation(async (ctx) => {
await ctx.db.patch(threadId, { latestAgentJobId: failedJobId });
});
const newJobId = await authed(t, ownerId).mutation(
api.agentJobs.createForThread,
{
threadId,
jobType: 'user_change',
},
);
expect(newJobId).not.toBe(failedJobId);
});
});
+14 -2
View File
@@ -2,6 +2,18 @@
set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
RUNTIME="${SPOON_AGENT_CONTAINER_RUNTIME:-}"
docker build -f "$ROOT_DIR/docker/agent-worker.Dockerfile" -t spoon-agent-worker:latest "$ROOT_DIR"
docker build -f "$ROOT_DIR/docker/agent-job.Dockerfile" -t spoon-agent-job:latest "$ROOT_DIR"
if [[ -z "$RUNTIME" ]]; then
if command -v podman >/dev/null 2>&1; then
RUNTIME=podman
elif command -v docker >/dev/null 2>&1; then
RUNTIME=docker
else
printf 'build-agent-images: podman or docker is required.\n' >&2
exit 1
fi
fi
"$RUNTIME" build -f "$ROOT_DIR/docker/agent-worker.Dockerfile" -t spoon-agent-worker:latest "$ROOT_DIR"
"$RUNTIME" build -f "$ROOT_DIR/docker/agent-job.Dockerfile" -t spoon-agent-job:latest "$ROOT_DIR"
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" == "--" ]]; then
shift
fi
if [[ "$#" -eq 0 ]]; then
printf 'usage: dev-agent-worker -- <command> [args...]\n' >&2
exit 2
fi
if [[ -z "${SPOON_AGENT_CONTAINER_RUNTIME:-}" ]]; then
if command -v podman >/dev/null 2>&1; then
export SPOON_AGENT_CONTAINER_RUNTIME=podman
elif command -v docker >/dev/null 2>&1; then
export SPOON_AGENT_CONTAINER_RUNTIME=docker
else
printf 'dev-agent-worker: podman or docker is required for container-backed jobs.\n' >&2
exit 1
fi
fi
export SPOON_AGENT_RUNTIME="${SPOON_AGENT_RUNTIME:-docker}"
export SPOON_AGENT_CONTAINER_ACCESS="${SPOON_AGENT_CONTAINER_ACCESS:-host_port}"
export SPOON_AGENT_WORKER_URL="${SPOON_AGENT_WORKER_URL:-http://localhost:${SPOON_AGENT_WORKER_HTTP_PORT:-3921}}"
export SPOON_AGENT_WORKER_INTERNAL_TOKEN="${SPOON_AGENT_WORKER_INTERNAL_TOKEN:-${SPOON_WORKER_TOKEN:-}}"
export SPOON_AGENT_WORKDIR="${SPOON_AGENT_LOCAL_WORKDIR:-.local/agent-work/${WITH_ENV_ENVIRONMENT:-dev}}"
export SPOON_AGENT_JOB_IMAGE="${SPOON_AGENT_LOCAL_JOB_IMAGE:-spoon-agent-job:latest}"
if [[ "$SPOON_AGENT_CONTAINER_ACCESS" == "host_port" && -z "${SPOON_AGENT_KEEP_NETWORK:-}" ]]; then
unset SPOON_AGENT_NETWORK
fi
if ! "$SPOON_AGENT_CONTAINER_RUNTIME" image inspect "$SPOON_AGENT_JOB_IMAGE" >/dev/null 2>&1; then
printf 'dev-agent-worker: job image %s is not present locally.\n' "$SPOON_AGENT_JOB_IMAGE" >&2
printf 'Build it with: scripts/build-agent-images\n' >&2
fi
exec "$@"
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
RUNTIME="${SPOON_AGENT_CONTAINER_RUNTIME:-}"
IMAGE="${SPOON_AGENT_LOCAL_JOB_IMAGE:-${SPOON_AGENT_JOB_IMAGE:-spoon-agent-job:latest}}"
if [[ -z "$RUNTIME" ]]; then
if command -v podman >/dev/null 2>&1; then
RUNTIME=podman
elif command -v docker >/dev/null 2>&1; then
RUNTIME=docker
else
printf 'smoke-agent-container: podman or docker is required.\n' >&2
exit 1
fi
fi
"$RUNTIME" run --rm "$IMAGE" bash -lc '
set -euo pipefail
node --version
bun --version
pnpm --version
yarn --version
npm --version
git --version
rg --version >/dev/null
jq --version
python3 --version
opencode --version
codex --version
'
+6
View File
@@ -38,6 +38,12 @@
"SPOON_AGENT_WORKER_ID",
"SPOON_AGENT_JOB_IMAGE",
"SPOON_AGENT_RUNTIME",
"SPOON_AGENT_CONTAINER_RUNTIME",
"SPOON_CONTAINER_RUNTIME",
"SPOON_AGENT_CONTAINER_ACCESS",
"SPOON_AGENT_LOCAL_WORKDIR",
"SPOON_AGENT_LOCAL_JOB_IMAGE",
"SPOON_AGENT_KEEP_NETWORK",
"SPOON_AGENT_MAX_CONCURRENT_JOBS",
"SPOON_AGENT_JOB_TIMEOUT_MS",
"SPOON_AGENT_WORKDIR",