Add features & update project

This commit is contained in:
Gabriel Brown
2026-06-23 01:46:08 -04:00
parent 930fbf5965
commit fe72fc2957
39 changed files with 3106 additions and 178 deletions
+9
View File
@@ -52,6 +52,14 @@
- Agent workspace proxy env uses `SPOON_AGENT_WORKER_URL`, - Agent workspace proxy env uses `SPOON_AGENT_WORKER_URL`,
`SPOON_AGENT_WORKER_HTTP_PORT`, and `SPOON_AGENT_WORKER_INTERNAL_TOKEN`. `SPOON_AGENT_WORKER_HTTP_PORT`, and `SPOON_AGENT_WORKER_INTERNAL_TOKEN`.
Keep these server-only; the browser must never receive worker tokens. Keep these server-only; the browser must never receive worker tokens.
- Host-run worker dev uses `scripts/dev-agent-worker` after Infisical env
loading. It prefers Podman, sets `SPOON_AGENT_CONTAINER_ACCESS=host_port`,
and expects `spoon-agent-job:latest` to exist locally.
- `bun smoke:agent-container` checks that the local job image has Node, Bun,
git, ripgrep, jq, Python, OpenCode, and Codex available.
- Old terminal workspaces can be deleted from `Settings -> Worker`; orphaned
containers/workdirs are cleaned through the worker HTTP API, not from the
browser directly.
- CI uses Gitea-injected secrets or `CI_ENV_FILE` and must not call Infisical. - CI uses Gitea-injected secrets or `CI_ENV_FILE` and must not call Infisical.
- CI must provide Convex deployment env for codegen, either - CI must provide Convex deployment env for codegen, either
`CONVEX_SELF_HOSTED_URL` plus `CONVEX_SELF_HOSTED_ADMIN_KEY`, or `CONVEX_SELF_HOSTED_URL` plus `CONVEX_SELF_HOSTED_ADMIN_KEY`, or
@@ -77,6 +85,7 @@
bun db:up # start Postgres, Convex, and dashboard bun db:up # start Postgres, Convex, and dashboard
bun dev:next # host Next + deploy/watch local Convex functions bun dev:next # host Next + deploy/watch local Convex functions
bun dev:agent # run the optional coding-agent worker on the host bun dev:agent # run the optional coding-agent worker on the host
bun dev:next:worker # run Next, backend, and agent worker together
bun sync:convex # sync Infisical values into Convex bun sync:convex # sync Infisical values into Convex
bun db:down # stop and preserve local data bun db:down # stop and preserve local data
bun db:down:wipe # remove local data volumes and generated admin key bun db:down:wipe # remove local data volumes and generated admin key
+27
View File
@@ -154,6 +154,29 @@ Workspace capabilities:
The browser never receives worker tokens and never talks directly to the worker The browser never receives worker tokens and never talks directly to the worker
or job container. or job container.
Worker cleanup is available in `Settings -> Worker`. It can delete old terminal
workspace records and ask the active worker to remove orphaned job containers
and inactive work directories.
Local worker development:
```sh
scripts/build-agent-images
bun smoke:agent-container
bun dev:next:worker
bun dev:next:worker:staging
```
Local host-run worker commands still load env through Infisical, then
`scripts/dev-agent-worker` selects Podman when available, falls back to Docker,
and publishes the OpenCode server on a localhost port so the host worker can
reach the job container. Override with:
```env
SPOON_AGENT_CONTAINER_RUNTIME=podman
SPOON_AGENT_CONTAINER_ACCESS=host_port
```
</details> </details>
<details> <details>
@@ -184,6 +207,8 @@ Production worker runtime requirements:
- `spoon-agent-worker` must run as a separate service. - `spoon-agent-worker` must run as a separate service.
- The worker needs `/var/run/docker.sock` mounted so it can launch job - The worker needs `/var/run/docker.sock` mounted so it can launch job
containers. containers.
- Production should keep `SPOON_AGENT_CONTAINER_RUNTIME=docker` and
`SPOON_AGENT_CONTAINER_ACCESS=network`.
- The production Docker host must be logged into `git.gbrown.org` so worker jobs - The production Docker host must be logged into `git.gbrown.org` so worker jobs
can pull the private `spoon-agent-job` image. can pull the private `spoon-agent-job` image.
- `SPOON_WORKER_TOKEN` must match the value stored in Convex production env. - `SPOON_WORKER_TOKEN` must match the value stored in Convex production env.
@@ -437,6 +462,8 @@ not call Infisical.
| `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | Server-only token for Next-to-worker proxy | | `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | Server-only token for Next-to-worker proxy |
| `SPOON_AGENT_JOB_IMAGE` | Agent job container image | | `SPOON_AGENT_JOB_IMAGE` | Agent job container image |
| `SPOON_AGENT_RUNTIME` | Runtime mode, currently Docker/Podman-oriented | | `SPOON_AGENT_RUNTIME` | Runtime mode, currently Docker/Podman-oriented |
| `SPOON_AGENT_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_MAX_CONCURRENT_JOBS` | Worker concurrency limit |
| `SPOON_AGENT_JOB_TIMEOUT_MS` | Job timeout | | `SPOON_AGENT_JOB_TIMEOUT_MS` | Job timeout |
| `SPOON_AGENT_WORKDIR` | Worker work directory | | `SPOON_AGENT_WORKDIR` | Worker work directory |
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "bun with-env src/index.ts", "dev": "bun with-env bash ../../scripts/dev-agent-worker -- bun src/index.ts",
"start": "bun src/index.ts", "start": "bun src/index.ts",
"format": "prettier --check . --ignore-path ../../.gitignore", "format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint --flag unstable_native_nodejs_ts_config", "lint": "eslint --flag unstable_native_nodejs_ts_config",
+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'), workerToken: requiredEnv('SPOON_WORKER_TOKEN'),
workerId: process.env.SPOON_AGENT_WORKER_ID?.trim() ?? 'local-worker', workerId: process.env.SPOON_AGENT_WORKER_ID?.trim() ?? 'local-worker',
runtime: process.env.SPOON_AGENT_RUNTIME?.trim() ?? 'docker', runtime: process.env.SPOON_AGENT_RUNTIME?.trim() ?? 'docker',
containerRuntime:
process.env.SPOON_AGENT_CONTAINER_RUNTIME?.trim() ??
process.env.SPOON_CONTAINER_RUNTIME?.trim() ??
'docker',
containerAccess:
process.env.SPOON_AGENT_CONTAINER_ACCESS?.trim() === 'host_port'
? 'host_port'
: 'network',
jobImage: jobImage:
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest', process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work', 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'; 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: { export const runInJobContainer = async (args: {
workdir: string; workdir: string;
command: string[]; command: string[];
environment: Record<string, string>; environment: Record<string, string>;
redact: (value: string) => string; redact: (value: string) => string;
timeoutMs: number; timeoutMs: number;
}) => { }): Promise<CommandResult> => {
const envArgs = Object.entries(args.environment).flatMap(([name, value]) => [
'-e',
`${name}=${value}`,
]);
const networkArgs = env.network ? ['--network', env.network] : [];
const result = await execa( const result = await execa(
'docker', containerRuntime(),
[ [
'run', 'run',
'--rm', '--rm',
@@ -23,8 +33,8 @@ export const runInJobContainer = async (args: {
'4g', '4g',
'--cpus', '--cpus',
'2', '2',
...networkArgs, ...networkArgs(),
...envArgs, ...environmentArgs(args.environment),
'-v', '-v',
`${args.workdir}:/workspace`, `${args.workdir}:/workspace`,
'-w', '-w',
@@ -43,3 +53,202 @@ export const runInJobContainer = async (args: {
output: args.redact(result.all), 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 { createServer } from 'node:http';
import type { IncomingMessage, ServerResponse } from 'node:http'; import type { IncomingMessage, ServerResponse } from 'node:http';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { env } from './env'; import { env } from './env';
import { import {
abortWorkspaceAgent,
cleanupOrphanedWorkspaces,
getWorkerHealth,
getWorkspaceAgentStatus,
getWorkspaceDiff, getWorkspaceDiff,
listWorkspaceTree, listWorkspaceTree,
openWorkspacePullRequest, openWorkspacePullRequest,
readWorkspaceFile, readWorkspaceFile,
replyToInteraction,
runWorkspaceCommand, runWorkspaceCommand,
sendWorkspaceMessage, sendWorkspaceMessage,
stopWorkspace, stopWorkspace,
@@ -43,7 +50,7 @@ const requireAuth = (request: IncomingMessage) => {
}; };
const jobRoute = (pathname: string) => { const jobRoute = (pathname: string) => {
const match = /^\/jobs\/([^/]+)\/([^/]+)$/.exec(pathname); const match = /^\/jobs\/([^/]+)\/(.+)$/.exec(pathname);
if (!match?.[1] || !match[2]) return null; if (!match?.[1] || !match[2]) return null;
return { jobId: decodeURIComponent(match[1]), action: match[2] }; return { jobId: decodeURIComponent(match[1]), action: match[2] };
}; };
@@ -57,8 +64,12 @@ export const startWorkerServer = () => {
request.url ?? '/', request.url ?? '/',
`http://localhost:${env.httpPort}`, `http://localhost:${env.httpPort}`,
); );
if (url.pathname === '/health') { if (url.pathname === '/health' && request.method === 'GET') {
sendJson(response, 200, { ok: true, workerId: env.workerId }); sendJson(response, 200, await getWorkerHealth());
return;
}
if (url.pathname === '/cleanup' && request.method === 'POST') {
sendJson(response, 200, await cleanupOrphanedWorkspaces());
return; return;
} }
const route = jobRoute(url.pathname); const route = jobRoute(url.pathname);
@@ -108,6 +119,34 @@ export const startWorkerServer = () => {
sendJson(response, 200, { success: true }); sendJson(response, 200, { success: true });
return; return;
} }
if (request.method === 'GET' && route.action === 'agent/status') {
sendJson(response, 200, getWorkspaceAgentStatus(route.jobId));
return;
}
if (request.method === 'POST' && route.action === 'agent/abort') {
sendJson(response, 200, await abortWorkspaceAgent(route.jobId));
return;
}
const interactionMatch =
/^interactions\/([^/]+)\/reply$/.exec(route.action);
if (request.method === 'POST' && interactionMatch?.[1]) {
const body = await parseJson<{
externalRequestId?: string;
response?: string;
}>(request);
sendJson(
response,
200,
await replyToInteraction(route.jobId, {
interactionId: decodeURIComponent(
interactionMatch[1],
) as Id<'agentInteractionRequests'>,
externalRequestId: body.externalRequestId ?? '',
response: body.response ?? 'once',
}),
);
return;
}
if (request.method === 'POST' && route.action === 'run-command') { if (request.method === 'POST' && route.action === 'run-command') {
const body = await parseJson<{ command?: string }>(request); const body = await parseJson<{ command?: string }>(request);
sendJson( sendJson(
@@ -126,12 +165,18 @@ export const startWorkerServer = () => {
return; return;
} }
sendJson(response, 404, { error: 'Not found' }); sendJson(response, 404, { error: 'Not found' });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
sendJson(response, message === 'Unauthorized' ? 401 : 500, { const status =
error: message, message === 'Unauthorized'
}); ? 401
} : message.includes('not supported')
? 409
: 500;
sendJson(response, status, {
error: message,
});
}
})(); })();
}); });
server.listen(env.httpPort, () => { server.listen(env.httpPort, () => {
+602 -59
View File
@@ -7,12 +7,15 @@ import {
stat, stat,
writeFile, writeFile,
} from 'node:fs/promises'; } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import path from 'node:path'; import path from 'node:path';
import { ConvexHttpClient } from 'convex/browser'; import { ConvexHttpClient } from 'convex/browser';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js'; import { api } from '@spoon/backend/convex/_generated/api.js';
import type { NormalizedAgentEvent } from './agent-events';
import { normalizeCodexJsonLine } from './agent-events';
import { env } from './env'; import { env } from './env';
import { import {
cloneRepository, cloneRepository,
@@ -22,8 +25,21 @@ import {
run, run,
} from './git'; } from './git';
import { getInstallationToken, openDraftPullRequest } from './github'; 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 { createRedactor, truncate } from './redact';
import { runInJobContainer } from './runtime/docker'; import {
listWorkspaceContainerNames,
runInJobContainer,
startWorkspaceContainer,
stopWorkspaceContainer,
streamInJobContainer,
} from './runtime/docker';
type Claim = { type Claim = {
job: { job: {
@@ -81,6 +97,14 @@ type ActiveWorkspace = {
repoDir: string; repoDir: string;
githubToken: string; githubToken: string;
redact: (value: string) => 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 = { type FileTreeNode = {
@@ -225,6 +249,70 @@ const appendMessage = async (args: {
...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: { const recordWorkspaceChange = async (args: {
jobId: Id<'agentJobs'>; jobId: Id<'agentJobs'>;
path: string; path: string;
@@ -240,6 +328,9 @@ const recordWorkspaceChange = async (args: {
const commandToShell = (command: string) => ['bash', '-lc', command]; 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) => const isCodexLoginProfile = (claim: Claim) =>
claim.aiProviderProfile?.provider === 'opencode_openai_login' || claim.aiProviderProfile?.provider === 'opencode_openai_login' ||
claim.aiProviderProfile?.authType === 'opencode_auth_json'; 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) => const agentFailurePrefix = (claim: Claim) =>
isCodexLoginProfile(claim) ? 'codex failed' : 'opencode failed'; 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 systemPromptForJob = (claim: Claim) => {
const base = [ const base = [
`Spoon: ${claim.spoon.name}`, `Spoon: ${claim.spoon.name}`,
@@ -759,8 +1135,8 @@ const runClaim = async (claim: Claim) => {
await appendEvent(jobId, 'info', 'plan', 'Interactive workspace is ready.'); await appendEvent(jobId, 'info', 'plan', 'Interactive workspace is ready.');
await sendWorkspaceMessage(jobId, systemPromptForJob(claim)); await sendWorkspaceMessage(jobId, systemPromptForJob(claim));
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
await appendEvent( await appendEvent(
jobId, jobId,
'error', 'error',
@@ -888,9 +1264,80 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => {
return { success: true }; 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) => { export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
const workspace = resolveWorkspace(jobId); 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({ await appendMessage({
jobId: claim.job._id, jobId: claim.job._id,
role: 'user', role: 'user',
@@ -903,50 +1350,62 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
if ((claim.job.runtime ?? 'opencode') !== 'opencode') { if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
throw new Error('Legacy OpenAI direct jobs are no longer supported.'); throw new Error('Legacy OpenAI direct jobs are no longer supported.');
} }
const aiEnv = providerEnvironment( workspace.agentTurnActive = true;
claim, const assistantMessageId = await appendMessage({
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({
jobId: claim.job._id, jobId: claim.job._id,
role: 'assistant', role: 'assistant',
status: result.exitCode === 0 ? 'completed' : 'failed', status: 'streaming',
content: truncate(result.output, 40_000), content: '',
}); });
if (result.exitCode !== 0) { const assistantContent = { value: '' };
throw new Error(`${agentFailurePrefix(claim)}:\n${result.output}`); 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') { if (isCodexLoginProfile(claim)) {
const decision = parseMaintenanceDecision(result.output); 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) { if (decision) {
await addArtifact({ await addArtifact({
jobId: claim.job._id, jobId: claim.job._id,
@@ -959,11 +1418,11 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
} else { } else {
await updateStatus(claim.job._id, 'changes_ready', { await updateStatus(claim.job._id, 'changes_ready', {
summary: 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({ await addArtifact({
jobId: claim.job._id, jobId: claim.job._id,
kind: 'diff', kind: 'diff',
@@ -978,8 +1437,11 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
changeType: 'modified', changeType: 'modified',
diff: truncate(diff.output, 50_000), diff: truncate(diff.output, 50_000),
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); workspace.agentTurnActive = false;
workspace.resolveTurn?.();
workspace.resolveTurn = undefined;
const message = error instanceof Error ? error.message : String(error);
await appendEvent( await appendEvent(
claim.job._id, claim.job._id,
'error', 'error',
@@ -1059,6 +1521,10 @@ export const openWorkspacePullRequest = async (jobId: string) => {
summary: 'Draft PR opened from interactive workspace.', summary: 'Draft PR opened from interactive workspace.',
}); });
await markWorkspaceStopped(claim.job._id); await markWorkspaceStopped(claim.job._id);
workspace.opencodeSession?.close();
if (workspace.containerName) {
await stopWorkspaceContainer(workspace.containerName);
}
activeWorkspaces.delete(jobId); activeWorkspaces.delete(jobId);
await rm(workspace.workdir, { recursive: true, force: true }); await rm(workspace.workdir, { recursive: true, force: true });
return { return {
@@ -1070,11 +1536,88 @@ export const openWorkspacePullRequest = async (jobId: string) => {
export const stopWorkspace = async (jobId: string) => { export const stopWorkspace = async (jobId: string) => {
const workspace = resolveWorkspace(jobId); const workspace = resolveWorkspace(jobId);
await markWorkspaceStopped(workspace.claim.job._id); await markWorkspaceStopped(workspace.claim.job._id);
workspace.opencodeSession?.close();
if (workspace.containerName) {
await stopWorkspaceContainer(workspace.containerName);
}
activeWorkspaces.delete(jobId); activeWorkspaces.delete(jobId);
await rm(workspace.workdir, { recursive: true, force: true }); await rm(workspace.workdir, { recursive: true, force: true });
return { success: 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 () => { export const startWorker = async () => {
console.log(`Spoon agent worker ${env.workerId} polling ${env.convexUrl}`); console.log(`Spoon agent worker ${env.workerId} polling ${env.convexUrl}`);
for (;;) { 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}',
});
});
});
+2 -1
View File
@@ -3,7 +3,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Brain, Github, Shield, User } from 'lucide-react'; import { Brain, Github, ServerCog, Shield, User } from 'lucide-react';
import { cn } from '@spoon/ui'; import { cn } from '@spoon/ui';
@@ -11,6 +11,7 @@ const settingsItems = [
{ href: '/settings/profile', label: 'Profile', icon: User }, { href: '/settings/profile', label: 'Profile', icon: User },
{ href: '/settings/integrations', label: 'Integrations', icon: Github }, { href: '/settings/integrations', label: 'Integrations', icon: Github },
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain }, { href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
{ href: '/settings/worker', label: 'Worker', icon: ServerCog },
{ href: '/settings/security', label: 'Security', icon: Shield }, { href: '/settings/security', label: 'Security', icon: Shield },
]; ];
@@ -0,0 +1,15 @@
import { WorkerHealthPanel } from '@/components/settings/worker-health-panel';
const WorkerSettingsPage = () => (
<section className='max-w-5xl space-y-4'>
<div>
<h2 className='text-xl font-semibold'>Worker</h2>
<p className='text-muted-foreground mt-1 text-sm'>
Monitor the agent worker and clean up old workspace state.
</p>
</div>
<WorkerHealthPanel />
</section>
);
export default WorkerSettingsPage;
@@ -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' });
};
@@ -1,23 +1,28 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { Send } from 'lucide-react'; import { Ban, Send } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button, Textarea } from '@spoon/ui'; import { Badge, Button, Textarea } from '@spoon/ui';
export const AgentThread = ({ export const AgentThread = ({
jobId, jobId,
messages, messages,
events,
interactions,
disabled, disabled,
}: { }: {
jobId: string; jobId: string;
messages: Doc<'agentJobMessages'>[]; messages: Doc<'agentJobMessages'>[];
events: Doc<'agentJobEvents'>[];
interactions: Doc<'agentInteractionRequests'>[];
disabled: boolean; disabled: boolean;
}) => { }) => {
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [replying, setReplying] = useState<string>();
const send = async () => { const send = async () => {
if (!content.trim()) return; if (!content.trim()) return;
@@ -37,27 +42,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 ( return (
<div className='flex h-full min-h-[520px] flex-col'> <div className='flex h-full min-h-[520px] flex-col'>
<div className='border-border border-b p-3'> <div className='border-border flex items-start justify-between gap-3 border-b p-3'>
<h2 className='text-sm font-semibold'>Agent thread</h2> <div>
<p className='text-muted-foreground text-xs'> <h2 className='text-sm font-semibold'>Agent thread</h2>
Messages persist with this workspace. <p className='text-muted-foreground text-xs'>
</p> Messages, tool activity, and requests persist with this workspace.
</p>
</div>
<Button
type='button'
variant='outline'
size='sm'
disabled={disabled}
onClick={abort}
>
<Ban className='size-3' />
Abort
</Button>
</div> </div>
<div className='min-h-0 flex-1 space-y-3 overflow-auto p-3'> <div className='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) => ( {messages.map((message) => (
<article <article
key={message._id} 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'> <div className='mb-2 flex items-center justify-between gap-2'>
<span className='font-medium capitalize'>{message.role}</span> <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} {message.status}
</span> </Badge>
</div> </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> </article>
))} ))}
</div> </div>
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useQuery } from 'convex/react'; import { useMutation, useQuery } from 'convex/react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -13,17 +13,42 @@ import { AgentThread } from './agent-thread';
import { CodeEditor } from './code-editor'; import { CodeEditor } from './code-editor';
import { CommandPanel } from './command-panel'; import { CommandPanel } from './command-panel';
import { DiffViewer } from './diff-viewer'; import { DiffViewer } from './diff-viewer';
import { FileTabs } from './file-tabs';
import { FileTree } from './file-tree'; import { FileTree } from './file-tree';
import { JobStatusBar } from './job-status-bar'; import { JobStatusBar } from './job-status-bar';
import { WorkspaceActions } from './workspace-actions'; import { WorkspaceActions } from './workspace-actions';
type OpenFileState = {
path: string;
content: string;
savedContent: string;
loading: boolean;
saving: boolean;
error?: string;
};
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const job = useQuery(api.agentJobs.get, { jobId }); const job = useQuery(api.agentJobs.get, { jobId });
const messages = const messages =
useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? []; useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? [];
const events =
useQuery(api.agentJobs.listEvents, { jobId, limit: 200 }) ?? [];
const interactions =
useQuery(api.agentJobs.listInteractionRequests, {
jobId,
status: 'all',
}) ?? [];
const uiState = useQuery(api.agentJobs.getWorkspaceUiState, { jobId });
const patchUiState = useMutation(api.agentJobs.patchWorkspaceUiState);
const [tree, setTree] = useState<FileTreeNode | null>(null); const [tree, setTree] = useState<FileTreeNode | null>(null);
const [selectedPath, setSelectedPath] = useState<string>(); const [files, setFiles] = useState<Record<string, OpenFileState>>({});
const [fileContent, setFileContent] = useState(''); const [openFilePaths, setOpenFilePaths] = useState<string[]>([]);
const [activeFilePath, setActiveFilePath] = useState<string>();
const [expandedDirectoryPaths, setExpandedDirectoryPaths] = useState<
string[]
>([]);
const [vimEnabled, setVimEnabled] = useState(false);
const [hydratedUiState, setHydratedUiState] = useState(false);
const [diff, setDiff] = useState(''); const [diff, setDiff] = useState('');
const workspaceDisabled = const workspaceDisabled =
@@ -49,17 +74,59 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const loadFile = useCallback( const loadFile = useCallback(
async (path: string) => { async (path: string) => {
setFiles((current) => ({
...current,
[path]: current[path] ?? {
path,
content: '',
savedContent: '',
loading: true,
saving: false,
},
}));
const response = await fetch( const response = await fetch(
`/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`, `/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`,
); );
if (!response.ok) throw new Error(await response.text()); if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as FileResponse; const data = (await response.json()) as FileResponse;
setSelectedPath(data.path); setFiles((current) => ({
setFileContent(data.content); ...current,
[data.path]: {
path: data.path,
content: data.content,
savedContent: data.content,
loading: false,
saving: false,
},
}));
}, },
[jobId], [jobId],
); );
const openFile = useCallback(
(path: string) => {
setOpenFilePaths((current) =>
current.includes(path) ? current : [...current, path],
);
setActiveFilePath(path);
if (!files[path]) {
void loadFile(path).catch((error) => {
console.error(error);
setFiles((current) => {
const next = { ...current };
delete next[path];
return next;
});
setOpenFilePaths((current) =>
current.filter((filePath) => filePath !== path),
);
toast.error('Could not load file.');
});
}
},
[files, loadFile],
);
useEffect(() => { useEffect(() => {
if (!job) return; if (!job) return;
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
@@ -73,27 +140,143 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
return () => window.clearTimeout(timeout); return () => window.clearTimeout(timeout);
}, [job, loadDiff, loadTree]); }, [job, loadDiff, loadTree]);
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) { if (job === undefined) {
return ( return (
<main className='text-muted-foreground p-6'>Loading workspace...</main> <main className='text-muted-foreground p-6'>Loading workspace...</main>
); );
} }
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
const saveFile = async (content: string) => { 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`, { const response = await fetch(`/api/agent-jobs/${jobId}/file`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ path: selectedPath, content }), body: JSON.stringify({ path: activeFilePath, content }),
}); });
if (!response.ok) { if (!response.ok) {
toast.error('Could not save file.'); 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()); 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(); await loadDiff();
toast.success('File saved.'); 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 ( return (
<main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'> <main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'>
<JobStatusBar job={job} /> <JobStatusBar job={job} />
@@ -108,13 +291,10 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
</div> </div>
<FileTree <FileTree
tree={tree} tree={tree}
selectedPath={selectedPath} selectedPath={activeFilePath}
onSelect={(path) => { expandedPaths={expandedDirectoryPaths}
void loadFile(path).catch((error) => { onSelect={openFile}
console.error(error); onToggleDirectory={toggleDirectory}
toast.error('Could not load file.');
});
}}
/> />
</aside> </aside>
<section className='bg-background flex min-w-0 flex-col'> <section className='bg-background flex min-w-0 flex-col'>
@@ -129,12 +309,44 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
Thread Thread
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value='editor' className='m-0 min-h-0 flex-1'> <TabsContent
value='editor'
className='m-0 flex min-h-0 flex-1 flex-col'
>
<FileTabs
tabs={openFilePaths.map((path) => ({
path,
dirty: files[path]
? files[path].content !== files[path].savedContent
: false,
}))}
activePath={activeFilePath}
onActivate={setActiveFilePath}
onClose={closeFile}
/>
<CodeEditor <CodeEditor
path={selectedPath} path={activeFilePath}
content={fileContent} content={activeFile?.content ?? ''}
savedContent={activeFile?.savedContent ?? ''}
readOnly={workspaceDisabled} readOnly={workspaceDisabled}
vimEnabled={vimEnabled}
onSave={saveFile} onSave={saveFile}
onVimEnabledChange={setVimEnabled}
onChange={(content) => {
if (!activeFilePath) return;
setFiles((current) => ({
...current,
[activeFilePath]: {
...(current[activeFilePath] ?? {
path: activeFilePath,
savedContent: '',
loading: false,
saving: false,
}),
content,
},
}));
}}
/> />
</TabsContent> </TabsContent>
<TabsContent value='diff' className='m-0 min-h-0 flex-1'> <TabsContent value='diff' className='m-0 min-h-0 flex-1'>
@@ -147,6 +359,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
<AgentThread <AgentThread
jobId={jobId} jobId={jobId}
messages={messages} messages={messages}
events={events}
interactions={interactions}
disabled={workspaceDisabled} disabled={workspaceDisabled}
/> />
</TabsContent> </TabsContent>
@@ -157,6 +371,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
<AgentThread <AgentThread
jobId={jobId} jobId={jobId}
messages={messages} messages={messages}
events={events}
interactions={interactions}
disabled={workspaceDisabled} disabled={workspaceDisabled}
/> />
</aside> </aside>
@@ -5,6 +5,8 @@ import dynamic from 'next/dynamic';
import { Button, Switch } from '@spoon/ui'; import { Button, Switch } from '@spoon/ui';
import { languageForPath } from './languages';
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), { const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
ssr: false, ssr: false,
}); });
@@ -20,27 +22,27 @@ type VimMode = {
export const CodeEditor = ({ export const CodeEditor = ({
path, path,
content, content,
savedContent,
readOnly, readOnly,
vimEnabled,
onSave, onSave,
onChange,
onVimEnabledChange,
}: { }: {
path?: string; path?: string;
content: string; content: string;
savedContent: string;
readOnly: boolean; readOnly: boolean;
vimEnabled: boolean;
onSave: (content: string) => Promise<void>; onSave: (content: string) => Promise<void>;
onChange: (content: string) => void;
onVimEnabledChange: (enabled: boolean) => void;
}) => { }) => {
const [value, setValue] = useState(content);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [vimEnabled, setVimEnabled] = useState(false);
const [dirty, setDirty] = useState(false);
const editorRef = useRef<MonacoEditorInstance | null>(null); const editorRef = useRef<MonacoEditorInstance | null>(null);
const vimRef = useRef<VimMode | null>(null); const vimRef = useRef<VimMode | null>(null);
const statusRef = useRef<HTMLDivElement | null>(null); const statusRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setValue(content);
setDirty(false);
}, [content, path]);
useEffect(() => { useEffect(() => {
const editor = editorRef.current; const editor = editorRef.current;
if (!editor) return; if (!editor) return;
@@ -71,13 +73,14 @@ export const CodeEditor = ({
const save = async () => { const save = async () => {
setSaving(true); setSaving(true);
try { try {
await onSave(value); await onSave(content);
setDirty(false);
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const dirty = content !== savedContent;
return ( return (
<div className='flex h-full min-h-0 flex-col'> <div className='flex h-full min-h-0 flex-col'>
<div className='border-border flex h-11 items-center justify-between gap-3 border-b px-3'> <div className='border-border flex h-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'> <div className='flex items-center gap-3'>
<label className='flex items-center gap-2 text-xs'> <label className='flex items-center gap-2 text-xs'>
Vim Vim
<Switch checked={vimEnabled} onCheckedChange={setVimEnabled} /> <Switch checked={vimEnabled} onCheckedChange={onVimEnabledChange} />
</label> </label>
<Button <Button
type='button' type='button'
@@ -107,7 +110,8 @@ export const CodeEditor = ({
height='100%' height='100%'
width='100%' width='100%'
path={path} path={path}
value={value} language={languageForPath(path)}
value={content}
theme='vs-dark' theme='vs-dark'
options={{ options={{
readOnly, readOnly,
@@ -116,13 +120,20 @@ export const CodeEditor = ({
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
wordWrap: 'on', wordWrap: 'on',
automaticLayout: true, automaticLayout: true,
scrollbar: { alwaysConsumeMouseWheel: false },
quickSuggestions: true,
suggestOnTriggerCharacters: true,
tabCompletion: 'on',
wordBasedSuggestions: 'matchingDocuments',
bracketPairColorization: { enabled: true },
renderWhitespace: 'selection',
}} }}
onMount={(editor) => { onMount={(editor) => {
editorRef.current = editor as MonacoEditorInstance; editorRef.current = editor as MonacoEditorInstance;
}} }}
onChange={(next) => { onChange={(next) => {
setValue(next ?? ''); const nextValue = next ?? '';
setDirty((next ?? '') !== content); onChange(nextValue);
}} }}
/> />
</div> </div>
@@ -8,42 +8,62 @@ const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
ssr: false, 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 = ({ export const DiffViewer = ({
diff, diff,
onRefresh, onRefresh,
}: { }: {
diff: string; diff: string;
onRefresh: () => Promise<void>; onRefresh: () => Promise<void>;
}) => ( }) => {
<div className='flex h-full min-h-0 flex-col'> const stats = diffStats(diff);
<div className='border-border flex h-11 items-center justify-between border-b px-3'> return (
<div> <div className='flex h-full min-h-0 flex-col'>
<p className='text-sm font-medium'>Workspace diff</p> <div className='border-border flex h-12 items-center justify-between gap-3 border-b px-3'>
<p className='text-muted-foreground text-xs'>Current git diff</p> <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> </div>
<Button type='button' variant='outline' size='sm' onClick={onRefresh}> {diff.trim() ? (
Refresh <MonacoEditor
</Button> 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> </div>
{diff.trim() ? ( );
<MonacoEditor };
height='100%'
width='100%'
language='diff'
theme='vs-dark'
value={diff}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
automaticLayout: true,
}}
/>
) : (
<div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'>
No workspace diff yet.
</div>
)}
</div>
);
@@ -0,0 +1,65 @@
'use client';
import { Circle, X } from 'lucide-react';
import { Button } from '@spoon/ui';
import { basename } from './languages';
export type OpenFileTab = {
path: string;
dirty: boolean;
};
export const FileTabs = ({
tabs,
activePath,
onActivate,
onClose,
}: {
tabs: OpenFileTab[];
activePath?: string;
onActivate: (path: string) => void;
onClose: (path: string) => void;
}) => {
if (tabs.length === 0) return null;
return (
<div className='border-border bg-muted/30 flex h-10 flex-none items-stretch overflow-x-auto border-b'>
{tabs.map((tab) => {
const active = tab.path === activePath;
return (
<div
key={tab.path}
className={
active
? 'border-primary bg-background flex max-w-56 min-w-0 items-center border-t-2 border-r'
: 'border-border flex max-w-56 min-w-0 items-center border-r'
}
title={tab.path}
>
<button
type='button'
className='flex h-full min-w-0 flex-1 items-center gap-2 px-3 text-left text-xs'
onClick={() => onActivate(tab.path)}
>
{tab.dirty ? (
<Circle className='fill-primary text-primary size-2 flex-none' />
) : null}
<span className='truncate font-mono'>{basename(tab.path)}</span>
</button>
<Button
type='button'
variant='ghost'
size='icon'
className='mr-1 size-6 flex-none'
aria-label={`Close ${tab.path}`}
onClick={() => onClose(tab.path)}
>
<X className='size-3' />
</Button>
</div>
);
})}
</div>
);
};
@@ -1,6 +1,12 @@
'use client'; 'use client';
import { ChevronRight, FileCode, Folder } from 'lucide-react'; import {
ChevronDown,
ChevronRight,
FileCode,
Folder,
FolderOpen,
} from 'lucide-react';
import { Button } from '@spoon/ui'; import { Button } from '@spoon/ui';
@@ -9,38 +15,59 @@ import type { FileTreeNode } from './types';
const TreeNode = ({ const TreeNode = ({
node, node,
selectedPath, selectedPath,
expandedPaths,
onSelect, onSelect,
onToggle,
depth = 0, depth = 0,
}: { }: {
node: FileTreeNode; node: FileTreeNode;
selectedPath?: string; selectedPath?: string;
expandedPaths: Set<string>;
onSelect: (path: string) => void; onSelect: (path: string) => void;
onToggle: (path: string) => void;
depth?: number; depth?: number;
}) => { }) => {
if (node.type === 'directory') { if (node.type === 'directory') {
const isRoot = !node.path;
const expanded = isRoot || expandedPaths.has(node.path);
return ( return (
<div> <div>
{node.path ? ( {!isRoot ? (
<div <button
className='text-muted-foreground flex h-7 items-center gap-1 px-2 text-xs font-medium' type='button'
aria-expanded={expanded}
className='text-muted-foreground hover:bg-muted flex h-7 w-full items-center gap-1 px-2 text-left text-xs font-medium'
style={{ paddingLeft: depth * 12 + 8 }} style={{ paddingLeft: depth * 12 + 8 }}
onClick={() => onToggle(node.path)}
> >
<ChevronRight className='size-3' /> {expanded ? (
<Folder className='size-3' /> <ChevronDown className='size-3 flex-none' />
) : (
<ChevronRight className='size-3 flex-none' />
)}
{expanded ? (
<FolderOpen className='size-3 flex-none' />
) : (
<Folder className='size-3 flex-none' />
)}
<span className='truncate'>{node.name}</span> <span className='truncate'>{node.name}</span>
</button>
) : null}
{expanded ? (
<div>
{node.children?.map((child) => (
<TreeNode
key={`${child.type}:${child.path}`}
node={child}
selectedPath={selectedPath}
expandedPaths={expandedPaths}
onSelect={onSelect}
onToggle={onToggle}
depth={node.path ? depth + 1 : depth}
/>
))}
</div> </div>
) : null} ) : null}
<div>
{node.children?.map((child) => (
<TreeNode
key={`${child.type}:${child.path}`}
node={child}
selectedPath={selectedPath}
onSelect={onSelect}
depth={node.path ? depth + 1 : depth}
/>
))}
</div>
</div> </div>
); );
} }
@@ -62,11 +89,15 @@ const TreeNode = ({
export const FileTree = ({ export const FileTree = ({
tree, tree,
selectedPath, selectedPath,
expandedPaths,
onSelect, onSelect,
onToggleDirectory,
}: { }: {
tree: FileTreeNode | null; tree: FileTreeNode | null;
selectedPath?: string; selectedPath?: string;
expandedPaths: string[];
onSelect: (path: string) => void; onSelect: (path: string) => void;
onToggleDirectory: (path: string) => void;
}) => { }) => {
if (!tree) { if (!tree) {
return ( return (
@@ -76,8 +107,14 @@ export const FileTree = ({
); );
} }
return ( return (
<div className='overflow-auto py-2'> <div className='h-full overflow-auto py-2'>
<TreeNode node={tree} selectedPath={selectedPath} onSelect={onSelect} /> <TreeNode
node={tree}
selectedPath={selectedPath}
expandedPaths={new Set(expandedPaths)}
onSelect={onSelect}
onToggle={onToggleDirectory}
/>
</div> </div>
); );
}; };
@@ -0,0 +1,27 @@
export const languageForPath = (path?: string) => {
if (!path) return undefined;
const name = path.toLowerCase().split('/').at(-1) ?? path.toLowerCase();
if (name === '.env' || name.startsWith('.env.')) return 'plaintext';
if (name.endsWith('.tsx') || name.endsWith('.ts')) return 'typescript';
if (
name.endsWith('.jsx') ||
name.endsWith('.js') ||
name.endsWith('.mjs') ||
name.endsWith('.cjs')
) {
return 'javascript';
}
if (name.endsWith('.json')) return 'json';
if (name.endsWith('.css')) return 'css';
if (name.endsWith('.scss')) return 'scss';
if (name.endsWith('.html')) return 'html';
if (name.endsWith('.md') || name.endsWith('.mdx')) return 'markdown';
if (name.endsWith('.yml') || name.endsWith('.yaml')) return 'yaml';
if (name.endsWith('.sh') || name.endsWith('.bash')) return 'shell';
if (name.endsWith('.py')) return 'python';
if (name.endsWith('.rs')) return 'rust';
if (name.endsWith('.go')) return 'go';
return undefined;
};
export const basename = (path: string) => path.split('/').at(-1) ?? path;
@@ -1,9 +1,17 @@
'use client'; 'use client';
import { ExternalLink, GitPullRequestDraft, Square } from 'lucide-react'; import { useRouter } from 'next/navigation';
import { useMutation } from 'convex/react';
import {
ExternalLink,
GitPullRequestDraft,
Square,
Trash2,
} from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button } from '@spoon/ui'; import { Button } from '@spoon/ui';
export const WorkspaceActions = ({ export const WorkspaceActions = ({
@@ -13,6 +21,12 @@ export const WorkspaceActions = ({
job: Doc<'agentJobs'>; job: Doc<'agentJobs'>;
disabled: boolean; 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 () => { const openPr = async () => {
try { try {
const response = await fetch(`/api/agent-jobs/${job._id}/open-pr`, { const response = await fetch(`/api/agent-jobs/${job._id}/open-pr`, {
@@ -26,6 +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 () => { const stop = async () => {
try { try {
const response = await fetch(`/api/agent-jobs/${job._id}/stop`, { const response = await fetch(`/api/agent-jobs/${job._id}/stop`, {
@@ -63,6 +95,12 @@ export const WorkspaceActions = ({
<Square className='size-4' /> <Square className='size-4' />
Stop Stop
</Button> </Button>
{canDelete ? (
<Button type='button' variant='destructive' size='sm' onClick={remove}>
<Trash2 className='size-4' />
Delete workspace
</Button>
) : null}
</div> </div>
); );
}; };
@@ -3,7 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useMutation } from 'convex/react'; 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 { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js'; 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'>[] }) => { export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
const cancel = useMutation(api.agentJobs.cancel); const cancel = useMutation(api.agentJobs.cancel);
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
const [selectedJobId, setSelectedJobId] = useState<string | null>( const [selectedJobId, setSelectedJobId] = useState<string | null>(
jobs[0]?._id ?? null, jobs[0]?._id ?? null,
); );
const selectedJob = jobs.find((job) => job._id === selectedJobId) ?? jobs[0]; 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) { if (!jobs.length) {
return ( return (
@@ -110,6 +117,32 @@ export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
Open workspace Open workspace
</Link> </Link>
</Button> </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} /> <AgentJobDetail job={selectedJob} />
</div> </div>
) : null} ) : null}
@@ -0,0 +1,258 @@
'use client';
import { useEffect, useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { 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 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>
<dd className='font-mono break-all'>{health.convexUrl}</dd>
</div>
<div>
<dt className='text-muted-foreground'>Job image</dt>
<dd className='font-mono break-all'>{health.jobImage}</dd>
</div>
<div>
<dt className='text-muted-foreground'>Workdir</dt>
<dd className='font-mono break-all'>{health.workdir}</dd>
</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>
);
};
+39
View File
@@ -32,6 +32,45 @@ export const requireOwnedJob = async (jobId: Id<'agentJobs'>) => {
return { ok: true as const }; return { ok: true as const };
}; };
export const requireAuthenticatedUser = async () => {
const token = await convexAuthNextjsToken();
if (!token) {
return {
ok: false as const,
response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
};
}
await fetchQuery(api.auth.getUser, {}, { token });
return { ok: true as const };
};
export const proxyWorkerRoot = async (path: string, init?: RequestInit) => {
const token = workerToken();
if (!token) {
return NextResponse.json(
{ error: 'SPOON_AGENT_WORKER_INTERNAL_TOKEN is not configured.' },
{ status: 500 },
);
}
const url = new URL(path, env.SPOON_AGENT_WORKER_URL);
const response = await fetch(url, {
...init,
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
...init?.headers,
},
});
const text = await response.text();
return new NextResponse(text, {
status: response.status,
headers: {
'content-type':
response.headers.get('content-type') ?? 'application/json',
},
});
};
export const proxyWorker = async ( export const proxyWorker = async (
jobId: Id<'agentJobs'>, jobId: Id<'agentJobs'>,
action: string, action: string,
@@ -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
@@ -71,6 +71,8 @@ services:
- SPOON_AGENT_WORKER_ID=${SPOON_AGENT_WORKER_ID:-local-worker} - SPOON_AGENT_WORKER_ID=${SPOON_AGENT_WORKER_ID:-local-worker}
- SPOON_AGENT_JOB_IMAGE=${SPOON_AGENT_JOB_IMAGE:-spoon-agent-job:latest} - SPOON_AGENT_JOB_IMAGE=${SPOON_AGENT_JOB_IMAGE:-spoon-agent-job:latest}
- SPOON_AGENT_RUNTIME=${SPOON_AGENT_RUNTIME:-docker} - SPOON_AGENT_RUNTIME=${SPOON_AGENT_RUNTIME:-docker}
- SPOON_AGENT_CONTAINER_RUNTIME=${SPOON_AGENT_CONTAINER_RUNTIME:-docker}
- SPOON_AGENT_CONTAINER_ACCESS=${SPOON_AGENT_CONTAINER_ACCESS:-network}
- SPOON_AGENT_NETWORK=${SPOON_AGENT_NETWORK:-spoon-local_default} - SPOON_AGENT_NETWORK=${SPOON_AGENT_NETWORK:-spoon-local_default}
- SPOON_AGENT_MAX_CONCURRENT_JOBS=${SPOON_AGENT_MAX_CONCURRENT_JOBS:-1} - SPOON_AGENT_MAX_CONCURRENT_JOBS=${SPOON_AGENT_MAX_CONCURRENT_JOBS:-1}
- SPOON_AGENT_JOB_TIMEOUT_MS=${SPOON_AGENT_JOB_TIMEOUT_MS:-1800000} - SPOON_AGENT_JOB_TIMEOUT_MS=${SPOON_AGENT_JOB_TIMEOUT_MS:-1800000}
+2
View File
@@ -102,6 +102,8 @@ services:
- SPOON_AGENT_WORKER_ID=${SPOON_AGENT_WORKER_ID:-production-worker} - SPOON_AGENT_WORKER_ID=${SPOON_AGENT_WORKER_ID:-production-worker}
- SPOON_AGENT_JOB_IMAGE=${SPOON_AGENT_JOB_IMAGE:-spoon-agent-job:latest} - SPOON_AGENT_JOB_IMAGE=${SPOON_AGENT_JOB_IMAGE:-spoon-agent-job:latest}
- SPOON_AGENT_RUNTIME=${SPOON_AGENT_RUNTIME:-docker} - SPOON_AGENT_RUNTIME=${SPOON_AGENT_RUNTIME:-docker}
- SPOON_AGENT_CONTAINER_RUNTIME=${SPOON_AGENT_CONTAINER_RUNTIME:-docker}
- SPOON_AGENT_CONTAINER_ACCESS=${SPOON_AGENT_CONTAINER_ACCESS:-network}
- SPOON_AGENT_NETWORK=${SPOON_AGENT_NETWORK:-nginx-bridge} - SPOON_AGENT_NETWORK=${SPOON_AGENT_NETWORK:-nginx-bridge}
- SPOON_AGENT_MAX_CONCURRENT_JOBS=${SPOON_AGENT_MAX_CONCURRENT_JOBS:-1} - SPOON_AGENT_MAX_CONCURRENT_JOBS=${SPOON_AGENT_MAX_CONCURRENT_JOBS:-1}
- SPOON_AGENT_JOB_TIMEOUT_MS=${SPOON_AGENT_JOB_TIMEOUT_MS:-1800000} - SPOON_AGENT_JOB_TIMEOUT_MS=${SPOON_AGENT_JOB_TIMEOUT_MS:-1800000}
+5 -2
View File
@@ -53,8 +53,10 @@
"dev:tunnel": "turbo run dev:tunnel", "dev:tunnel": "turbo run dev:tunnel",
"dev:next": "turbo run dev -F @spoon/next -F @spoon/backend", "dev:next": "turbo run dev -F @spoon/next -F @spoon/backend",
"dev:next:staging": "INFISICAL_ENV=staging turbo run dev -F @spoon/next -F @spoon/backend", "dev:next:staging": "INFISICAL_ENV=staging turbo run dev -F @spoon/next -F @spoon/backend",
"dev:agent": "turbo run dev -F @spoon/agent-worker", "dev:agent": "SPOON_AGENT_WORKER_URL=http://localhost:3921 SPOON_AGENT_CONTAINER_ACCESS=host_port turbo run dev -F @spoon/agent-worker",
"dev:agent:staging": "INFISICAL_ENV=staging turbo run dev -F @spoon/agent-worker", "dev:agent:staging": "INFISICAL_ENV=staging SPOON_AGENT_WORKER_URL=http://localhost:3921 SPOON_AGENT_CONTAINER_ACCESS=host_port turbo run dev -F @spoon/agent-worker",
"dev:next:worker": "SPOON_AGENT_WORKER_URL=http://localhost:3921 SPOON_AGENT_CONTAINER_ACCESS=host_port turbo run dev -F @spoon/next -F @spoon/backend -F @spoon/agent-worker",
"dev:next:worker:staging": "INFISICAL_ENV=staging SPOON_AGENT_WORKER_URL=http://localhost:3921 SPOON_AGENT_CONTAINER_ACCESS=host_port turbo run dev -F @spoon/next -F @spoon/backend -F @spoon/agent-worker",
"dev:next:web": "turbo run dev:web -F @spoon/next -F @spoon/backend", "dev:next:web": "turbo run dev:web -F @spoon/next -F @spoon/backend",
"dev:next:web:staging": "INFISICAL_ENV=staging turbo run dev:web -F @spoon/next -F @spoon/backend", "dev:next:web:staging": "INFISICAL_ENV=staging turbo run dev:web -F @spoon/next -F @spoon/backend",
"dev:expo": "turbo run dev -F @spoon/expo -F @spoon/backend", "dev:expo": "turbo run dev -F @spoon/expo -F @spoon/backend",
@@ -73,6 +75,7 @@
"sync:convex:production": "scripts/sync-convex-env production", "sync:convex:production": "scripts/sync-convex-env production",
"sync:convex:prod": "scripts/sync-convex-env prod", "sync:convex:prod": "scripts/sync-convex-env prod",
"auth:keys": "node scripts/generate-convex-auth-keys.mjs", "auth:keys": "node scripts/generate-convex-auth-keys.mjs",
"smoke:agent-container": "scripts/smoke-agent-container",
"db:up": "bash scripts/db/up", "db:up": "bash scripts/db/up",
"db:down": "bash scripts/db/down", "db:down": "bash scripts/db/down",
"db:down:wipe": "bash scripts/db/down --wipe", "db:down:wipe": "bash scripts/db/down --wipe",
+397
View File
@@ -36,6 +36,12 @@ const workspaceStatus = v.union(
v.literal('failed'), v.literal('failed'),
); );
const agentRuntimeMode = v.union(
v.literal('opencode_server'),
v.literal('codex_exec'),
v.literal('legacy_cli'),
);
const messageRole = v.union( const messageRole = v.union(
v.literal('user'), v.literal('user'),
v.literal('assistant'), v.literal('assistant'),
@@ -100,6 +106,22 @@ const artifactContentType = v.union(
v.literal('text/x-diff'), v.literal('text/x-diff'),
); );
const interactionRuntime = v.union(v.literal('opencode'), v.literal('codex'));
const interactionKind = v.union(
v.literal('question'),
v.literal('permission'),
v.literal('tool_confirmation'),
);
const interactionStatus = v.union(
v.literal('pending'),
v.literal('answered'),
v.literal('approved'),
v.literal('rejected'),
v.literal('expired'),
);
const maintenanceDecision = v.union( const maintenanceDecision = v.union(
v.literal('sync'), v.literal('sync'),
v.literal('ignore'), v.literal('ignore'),
@@ -172,6 +194,79 @@ const normalizeEnvFilePath = (value?: string) => {
return trimmed; return trimmed;
}; };
const normalizeWorkspacePath = (value: string) => {
const trimmed = optionalText(value);
if (!trimmed) throw new ConvexError('Workspace path is required.');
if (
trimmed.startsWith('/') ||
trimmed.includes('\0') ||
trimmed.split('/').includes('..') ||
trimmed === '.git' ||
trimmed.startsWith('.git/')
) {
throw new ConvexError('Workspace path must stay inside the repository.');
}
return trimmed.replace(/^\.\/+/, '');
};
const normalizeWorkspacePaths = (values: string[] | undefined, max: number) =>
values
?.map(normalizeWorkspacePath)
.filter((value, index, all) => all.indexOf(value) === index)
.slice(0, max);
const isDeletableWorkspace = (job: Doc<'agentJobs'>) =>
['failed', 'cancelled', 'timed_out'].includes(job.status) ||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
const deleteWorkspaceRows = async (ctx: MutationCtx, job: Doc<'agentJobs'>) => {
const messages = await ctx.db
.query('agentJobMessages')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect();
const events = await ctx.db
.query('agentJobEvents')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect();
const artifacts = await ctx.db
.query('agentJobArtifacts')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect();
const changes = await ctx.db
.query('agentWorkspaceChanges')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect();
const uiStates = await ctx.db
.query('agentWorkspaceUiStates')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect();
const interactions = await ctx.db
.query('agentInteractionRequests')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect();
for (const row of [
...messages,
...events,
...artifacts,
...changes,
...uiStates,
...interactions,
]) {
await ctx.db.delete(row._id);
}
if (job.threadId) {
const thread = await ctx.db.get(job.threadId);
if (thread?.latestAgentJobId === job._id) {
await ctx.db.patch(job.threadId, {
latestAgentJobId: undefined,
updatedAt: Date.now(),
});
}
}
await ctx.db.delete(job._id);
};
const getAgentSettings = async (ctx: MutationCtx, spoon: Doc<'spoons'>) => { const getAgentSettings = async (ctx: MutationCtx, spoon: Doc<'spoons'>) => {
const settings = await ctx.db const settings = await ctx.db
.query('spoonAgentSettings') .query('spoonAgentSettings')
@@ -609,6 +704,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({ export const appendUserMessage = mutation({
args: { jobId: v.id('agentJobs'), content: v.string() }, args: { jobId: v.id('agentJobs'), content: v.string() },
handler: async (ctx, { jobId, content }) => { handler: async (ctx, { jobId, content }) => {
@@ -709,6 +913,67 @@ 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 countOldWorkspaces = query({
args: { olderThanDays: v.optional(v.number()) },
handler: async (ctx, { olderThanDays }) => {
const ownerId = await getRequiredUserId(ctx);
const cutoff =
olderThanDays && olderThanDays > 0
? Date.now() - olderThanDays * 24 * 60 * 60 * 1000
: Number.POSITIVE_INFINITY;
const jobs = await ctx.db
.query('agentJobs')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.collect();
return jobs.filter(
(job) => isDeletableWorkspace(job) && job.updatedAt <= cutoff,
).length;
},
});
export const deleteOldWorkspaces = mutation({
args: {
olderThanDays: v.optional(v.number()),
limit: v.optional(v.number()),
},
handler: async (ctx, { olderThanDays, limit }) => {
const ownerId = await getRequiredUserId(ctx);
const cutoff =
olderThanDays && olderThanDays > 0
? Date.now() - olderThanDays * 24 * 60 * 60 * 1000
: Number.POSITIVE_INFINITY;
const max = Math.min(Math.max(limit ?? 50, 1), 100);
const jobs = await ctx.db
.query('agentJobs')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.collect();
const deletable = jobs
.filter((job) => isDeletableWorkspace(job) && job.updatedAt <= cutoff)
.sort((a, b) => a.updatedAt - b.updatedAt)
.slice(0, max);
for (const job of deletable) {
await deleteWorkspaceRows(ctx, job);
}
return { deleted: deletable.length };
},
});
export const claimNextInternal = internalMutation({ export const claimNextInternal = internalMutation({
args: { workerId: v.string() }, args: { workerId: v.string() },
handler: async (ctx, { workerId }) => { handler: async (ctx, { workerId }) => {
@@ -867,6 +1132,138 @@ export const markWorkspaceActive = mutation({
}, },
}); });
export const setRuntimeSession = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
jobId: v.id('agentJobs'),
agentRuntimeMode,
opencodeSessionId: v.optional(v.string()),
codexSessionId: v.optional(v.string()),
containerId: v.optional(v.string()),
},
handler: async (ctx, args) => {
requireWorkerToken(args.workerToken);
const job = await ctx.db.get(args.jobId);
if (job?.claimedBy !== args.workerId) {
throw new ConvexError('Agent job not claimed by this worker.');
}
await ctx.db.patch(args.jobId, {
agentRuntimeMode: args.agentRuntimeMode,
opencodeSessionId: optionalText(args.opencodeSessionId),
codexSessionId: optionalText(args.codexSessionId),
containerId: optionalText(args.containerId),
updatedAt: Date.now(),
});
return { success: true };
},
});
export const setCodexSessionId = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
jobId: v.id('agentJobs'),
codexSessionId: v.string(),
},
handler: async (ctx, args) => {
requireWorkerToken(args.workerToken);
const job = await ctx.db.get(args.jobId);
if (job?.claimedBy !== args.workerId) {
throw new ConvexError('Agent job not claimed by this worker.');
}
await ctx.db.patch(args.jobId, {
codexSessionId: optionalText(args.codexSessionId),
agentRuntimeMode: 'codex_exec',
updatedAt: Date.now(),
});
return { success: true };
},
});
export const createInteractionRequest = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
jobId: v.id('agentJobs'),
runtime: interactionRuntime,
externalRequestId: v.string(),
kind: interactionKind,
title: v.string(),
body: v.string(),
options: v.optional(v.array(v.string())),
metadata: v.optional(v.string()),
},
handler: async (ctx, args) => {
requireWorkerToken(args.workerToken);
const job = await ctx.db.get(args.jobId);
if (job?.claimedBy !== args.workerId) {
throw new ConvexError('Agent job not claimed by this worker.');
}
const now = Date.now();
const existing = (
await ctx.db
.query('agentInteractionRequests')
.withIndex('by_job', (q) => q.eq('jobId', args.jobId))
.collect()
).find((request) => request.externalRequestId === args.externalRequestId);
const record = {
runtime: args.runtime,
externalRequestId: args.externalRequestId,
kind: args.kind,
title: args.title,
body: args.body,
options: args.options,
metadata: args.metadata,
status: 'pending' as const,
updatedAt: now,
};
if (existing) {
await ctx.db.patch(existing._id, record);
return existing._id;
}
const requestId = await ctx.db.insert('agentInteractionRequests', {
jobId: args.jobId,
spoonId: job.spoonId,
ownerId: job.ownerId,
...record,
createdAt: now,
});
await ctx.db.patch(args.jobId, {
status: 'running',
updatedAt: now,
});
return requestId;
},
});
export const patchInteractionRequest = mutation({
args: {
workerToken: v.string(),
workerId: v.string(),
interactionId: v.id('agentInteractionRequests'),
status: interactionStatus,
response: v.optional(v.string()),
metadata: v.optional(v.string()),
},
handler: async (ctx, args) => {
requireWorkerToken(args.workerToken);
const interaction = await ctx.db.get(args.interactionId);
if (!interaction) throw new ConvexError('Interaction request not found.');
const job = await ctx.db.get(interaction.jobId);
if (job?.claimedBy !== args.workerId) {
throw new ConvexError('Agent job not claimed by this worker.');
}
await ctx.db.patch(args.interactionId, {
status: args.status,
response: optionalText(args.response),
metadata: args.metadata,
updatedAt: Date.now(),
});
return { success: true };
},
});
export const markWorkspaceStopped = mutation({ export const markWorkspaceStopped = mutation({
args: { args: {
workerToken: v.string(), workerToken: v.string(),
+50
View File
@@ -524,6 +524,14 @@ const applicationTables = {
baseBranch: v.string(), baseBranch: v.string(),
workBranch: v.string(), workBranch: v.string(),
opencodeSessionId: v.optional(v.string()), opencodeSessionId: v.optional(v.string()),
codexSessionId: v.optional(v.string()),
agentRuntimeMode: v.optional(
v.union(
v.literal('opencode_server'),
v.literal('codex_exec'),
v.literal('legacy_cli'),
),
),
containerId: v.optional(v.string()), containerId: v.optional(v.string()),
workspaceUrl: v.optional(v.string()), workspaceUrl: v.optional(v.string()),
workspaceExpiresAt: v.optional(v.number()), workspaceExpiresAt: v.optional(v.number()),
@@ -587,6 +595,48 @@ const applicationTables = {
}) })
.index('by_job', ['jobId']) .index('by_job', ['jobId'])
.index('by_owner', ['ownerId']), .index('by_owner', ['ownerId']),
agentWorkspaceUiStates: defineTable({
jobId: v.id('agentJobs'),
spoonId: v.id('spoons'),
ownerId: v.id('users'),
openFilePaths: v.array(v.string()),
activeFilePath: v.optional(v.string()),
vimEnabled: v.boolean(),
expandedDirectoryPaths: v.array(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_job', ['jobId'])
.index('by_owner', ['ownerId']),
agentInteractionRequests: defineTable({
jobId: v.id('agentJobs'),
spoonId: v.id('spoons'),
ownerId: v.id('users'),
runtime: v.union(v.literal('opencode'), v.literal('codex')),
externalRequestId: v.string(),
kind: v.union(
v.literal('question'),
v.literal('permission'),
v.literal('tool_confirmation'),
),
title: v.string(),
body: v.string(),
options: v.optional(v.array(v.string())),
status: v.union(
v.literal('pending'),
v.literal('answered'),
v.literal('approved'),
v.literal('rejected'),
v.literal('expired'),
),
response: v.optional(v.string()),
metadata: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_job', ['jobId'])
.index('by_job_status', ['jobId', 'status'])
.index('by_owner', ['ownerId']),
agentWorkspaceChanges: defineTable({ agentWorkspaceChanges: defineTable({
jobId: v.id('agentJobs'), jobId: v.id('agentJobs'),
spoonId: v.id('spoons'), spoonId: v.id('spoons'),
+122
View File
@@ -1,6 +1,7 @@
import { convexTest } from 'convex-test'; import { convexTest } from 'convex-test';
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import type { Id } from '../../convex/_generated/dataModel.js';
import { api } from '../../convex/_generated/api.js'; import { api } from '../../convex/_generated/api.js';
import schema from '../../convex/schema'; import schema from '../../convex/schema';
@@ -33,6 +34,60 @@ const spoonInput = {
productionRefStrategy: 'default_branch' as const, productionRefStrategy: 'default_branch' as const,
}; };
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', () => { describe('convex-test harness', () => {
test('boots and executes against the project schema', async () => { test('boots and executes against the project schema', async () => {
const t = convexTest(schema, modules); const t = convexTest(schema, modules);
@@ -89,4 +144,71 @@ describe('convex-test harness', () => {
}), }),
).rejects.toThrow('Spoon not found.'); ).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.');
});
}); });
+14 -2
View File
@@ -2,6 +2,18 @@
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" 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" if [[ -z "$RUNTIME" ]]; then
docker build -f "$ROOT_DIR/docker/agent-job.Dockerfile" -t spoon-agent-job:latest "$ROOT_DIR" 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 "$@"
+28
View File
@@ -0,0 +1,28 @@
#!/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
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_WORKER_ID",
"SPOON_AGENT_JOB_IMAGE", "SPOON_AGENT_JOB_IMAGE",
"SPOON_AGENT_RUNTIME", "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_MAX_CONCURRENT_JOBS",
"SPOON_AGENT_JOB_TIMEOUT_MS", "SPOON_AGENT_JOB_TIMEOUT_MS",
"SPOON_AGENT_WORKDIR", "SPOON_AGENT_WORKDIR",