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`,
`SPOON_AGENT_WORKER_HTTP_PORT`, and `SPOON_AGENT_WORKER_INTERNAL_TOKEN`.
Keep these server-only; the browser must never receive worker tokens.
- Host-run worker dev uses `scripts/dev-agent-worker` after Infisical env
loading. It prefers Podman, sets `SPOON_AGENT_CONTAINER_ACCESS=host_port`,
and expects `spoon-agent-job:latest` to exist locally.
- `bun smoke:agent-container` checks that the local job image has Node, 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 must provide Convex deployment env for codegen, either
`CONVEX_SELF_HOSTED_URL` plus `CONVEX_SELF_HOSTED_ADMIN_KEY`, or
@@ -77,6 +85,7 @@
bun db:up # start Postgres, Convex, and dashboard
bun dev:next # host Next + deploy/watch local Convex functions
bun dev:agent # run the optional coding-agent worker on the host
bun dev:next:worker # run Next, backend, and agent worker together
bun sync:convex # sync Infisical values into Convex
bun db:down # stop and preserve local data
bun db:down:wipe # remove local data volumes and generated admin key
+27
View File
@@ -154,6 +154,29 @@ Workspace capabilities:
The browser never receives worker tokens and never talks directly to the worker
or job container.
Worker cleanup is available in `Settings -> Worker`. It can delete old terminal
workspace records and ask the active worker to remove orphaned job containers
and inactive work directories.
Local worker development:
```sh
scripts/build-agent-images
bun smoke:agent-container
bun dev:next:worker
bun dev:next:worker:staging
```
Local host-run worker commands still load env through Infisical, then
`scripts/dev-agent-worker` selects Podman when available, falls back to Docker,
and publishes the OpenCode server on a localhost port so the host worker can
reach the job container. Override with:
```env
SPOON_AGENT_CONTAINER_RUNTIME=podman
SPOON_AGENT_CONTAINER_ACCESS=host_port
```
</details>
<details>
@@ -184,6 +207,8 @@ Production worker runtime requirements:
- `spoon-agent-worker` must run as a separate service.
- The worker needs `/var/run/docker.sock` mounted so it can launch job
containers.
- Production should keep `SPOON_AGENT_CONTAINER_RUNTIME=docker` and
`SPOON_AGENT_CONTAINER_ACCESS=network`.
- The production Docker host must be logged into `git.gbrown.org` so worker jobs
can pull the private `spoon-agent-job` image.
- `SPOON_WORKER_TOKEN` must match the value stored in Convex production env.
@@ -437,6 +462,8 @@ not call Infisical.
| `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | Server-only token for Next-to-worker proxy |
| `SPOON_AGENT_JOB_IMAGE` | Agent job container image |
| `SPOON_AGENT_RUNTIME` | Runtime mode, currently Docker/Podman-oriented |
| `SPOON_AGENT_CONTAINER_RUNTIME` | Container CLI used by worker, `docker`/`podman` |
| `SPOON_AGENT_CONTAINER_ACCESS` | `network` in prod, `host_port` for host dev |
| `SPOON_AGENT_MAX_CONCURRENT_JOBS` | Worker concurrency limit |
| `SPOON_AGENT_JOB_TIMEOUT_MS` | Job timeout |
| `SPOON_AGENT_WORKDIR` | Worker work directory |
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "bun with-env src/index.ts",
"dev": "bun with-env bash ../../scripts/dev-agent-worker -- bun src/index.ts",
"start": "bun src/index.ts",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint --flag unstable_native_nodejs_ts_config",
+231
View File
@@ -0,0 +1,231 @@
export type NormalizedAgentEvent =
| { kind: 'assistant_delta'; content: string; externalMessageId?: string }
| {
kind: 'assistant_completed';
content?: string;
externalMessageId?: string;
}
| {
kind: 'tool_started';
name: string;
input?: string;
externalMessageId?: string;
}
| {
kind: 'tool_completed';
name: string;
output?: string;
externalMessageId?: string;
}
| { kind: 'file_edited'; path: string }
| {
kind: 'command_executed';
command: string;
exitCode?: number;
output?: string;
}
| {
kind: 'permission_requested';
externalRequestId: string;
title: string;
body: string;
metadata?: string;
}
| {
kind: 'question_requested';
externalRequestId: string;
title: string;
body: string;
options?: string[];
metadata?: string;
}
| { kind: 'session'; sessionId: string }
| { kind: 'status'; status: string; metadata?: string }
| { kind: 'error'; message: string; metadata?: string };
const stringify = (value: unknown) => {
if (typeof value === 'string') return value;
if (value === undefined || value === null) return '';
if (
typeof value === 'number' ||
typeof value === 'boolean' ||
typeof value === 'bigint'
) {
return value.toString();
}
try {
return JSON.stringify(value, null, 2);
} catch {
return '';
}
};
const asRecord = (value: unknown): Record<string, unknown> | null =>
value && typeof value === 'object'
? (value as Record<string, unknown>)
: null;
const textFromPart = (part: Record<string, unknown>) => {
const text = part.text ?? part.content ?? part.delta;
return typeof text === 'string' ? text : '';
};
export const normalizeCodexJsonLine = (
line: string,
): NormalizedAgentEvent[] => {
if (!line.trim()) return [];
let parsed: unknown;
try {
parsed = JSON.parse(line) as unknown;
} catch {
return [{ kind: 'status', status: line }];
}
const event = asRecord(parsed);
if (!event) return [];
const type = stringify(event.type ?? event.event);
const id = event.id ?? event.session_id ?? event.sessionId;
const sessionId =
typeof id === 'string' && type.toLowerCase().includes('session')
? id
: undefined;
const events: NormalizedAgentEvent[] = sessionId
? [{ kind: 'session', sessionId }]
: [];
const message = asRecord(event.message);
const item = asRecord(event.item);
const data = asRecord(event.data);
const part = asRecord(event.part);
const delta = event.delta ?? data?.delta;
if (typeof delta === 'string') {
events.push({ kind: 'assistant_delta', content: delta });
}
const text =
(part ? textFromPart(part) : '') ||
(message ? stringify(message.content ?? message.text) : '') ||
(item ? stringify(item.content ?? item.text) : '');
if (
text &&
(type.includes('message') ||
type.includes('response.output_text') ||
type.includes('agent_message'))
) {
events.push({ kind: 'assistant_delta', content: text });
}
const command = event.command ?? data?.command;
if (typeof command === 'string') {
events.push({
kind: 'command_executed',
command,
output: stringify(event.output ?? data?.output),
});
}
const file = event.file ?? event.path ?? data?.file ?? data?.path;
if (typeof file === 'string' && type.includes('file')) {
events.push({ kind: 'file_edited', path: file });
}
if (type.includes('error')) {
events.push({
kind: 'error',
message: stringify(event.message ?? event.error ?? data),
});
}
if (type.includes('completed') || type.includes('turn.done')) {
events.push({ kind: 'assistant_completed' });
}
if (events.length === 0) {
events.push({ kind: 'status', status: type || 'codex_event' });
}
return events;
};
export const normalizeOpenCodeEvent = (
input: unknown,
): NormalizedAgentEvent[] => {
const event = asRecord(input);
if (!event) return [];
const type = stringify(event.type);
const properties = asRecord(event.properties) ?? asRecord(event.data) ?? event;
const events: NormalizedAgentEvent[] = [];
const sessionId = properties.sessionID ?? properties.sessionId;
if (typeof sessionId === 'string' && type.includes('session')) {
events.push({ kind: 'session', sessionId });
}
if (type === 'message.part.delta') {
const part = asRecord(properties.part) ?? properties;
const text = textFromPart(part);
if (text) {
events.push({
kind: 'assistant_delta',
content: text,
externalMessageId: stringify(properties.messageID),
});
}
}
if (type === 'message.updated' || type === 'message.part.updated') {
const part = asRecord(properties.part);
const text = part ? textFromPart(part) : stringify(properties.message);
if (text) {
events.push({
kind: 'assistant_delta',
content: text,
externalMessageId: stringify(properties.messageID),
});
}
}
if (type.includes('tool.started')) {
events.push({
kind: 'tool_started',
name: stringify(properties.tool ?? properties.name ?? 'tool'),
input: stringify(properties.input),
externalMessageId: stringify(properties.messageID),
});
}
if (type.includes('tool.finished') || type.includes('tool.completed')) {
events.push({
kind: 'tool_completed',
name: stringify(properties.tool ?? properties.name ?? 'tool'),
output: stringify(properties.output ?? properties.result),
externalMessageId: stringify(properties.messageID),
});
}
if (type === 'file.edited') {
const file = properties.file;
if (typeof file === 'string') events.push({ kind: 'file_edited', path: file });
}
if (type === 'command.executed') {
events.push({
kind: 'command_executed',
command: stringify(properties.command),
output: stringify(properties.output),
});
}
if (type.includes('permission') && type.includes('asked')) {
events.push({
kind: 'permission_requested',
externalRequestId: stringify(properties.permissionID ?? properties.id),
title: 'Permission requested',
body: stringify(properties.permission ?? properties.message ?? properties),
metadata: stringify(properties),
});
}
if (type.includes('question') && type.includes('asked')) {
events.push({
kind: 'question_requested',
externalRequestId: stringify(properties.requestID ?? properties.id),
title: 'Agent question',
body: stringify(properties.question ?? properties.message ?? properties),
metadata: stringify(properties),
});
}
if (type === 'session.idle') events.push({ kind: 'assistant_completed' });
if (type === 'session.error') {
events.push({
kind: 'error',
message: stringify(properties.error ?? properties.message ?? properties),
});
}
if (events.length === 0 && type) {
events.push({ kind: 'status', status: type, metadata: stringify(properties) });
}
return events;
};
+8
View File
@@ -19,6 +19,14 @@ export const env = {
workerToken: requiredEnv('SPOON_WORKER_TOKEN'),
workerId: process.env.SPOON_AGENT_WORKER_ID?.trim() ?? 'local-worker',
runtime: process.env.SPOON_AGENT_RUNTIME?.trim() ?? 'docker',
containerRuntime:
process.env.SPOON_AGENT_CONTAINER_RUNTIME?.trim() ??
process.env.SPOON_CONTAINER_RUNTIME?.trim() ??
'docker',
containerAccess:
process.env.SPOON_AGENT_CONTAINER_ACCESS?.trim() === 'host_port'
? 'host_port'
: 'network',
jobImage:
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
+126
View File
@@ -0,0 +1,126 @@
import { createOpencodeClient } from '@opencode-ai/sdk';
import type { OpencodeClient } from '@opencode-ai/sdk';
import type { NormalizedAgentEvent } from './agent-events';
import { normalizeOpenCodeEvent } from './agent-events';
export type OpenCodeSession = {
client: OpencodeClient;
sessionId: string;
close: () => void;
};
const basicAuth = (username: string, password: string) =>
`Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
const modelParts = (model: string) => {
const [rawProviderId, ...rest] = model.split('/');
const providerID =
rawProviderId && rawProviderId.length > 0 ? rawProviderId : 'openai';
const modelID = rest.length > 0 ? rest.join('/') : model;
return {
providerID,
modelID,
};
};
export const createOpenCodeSession = async (args: {
baseUrl: string;
password: string;
directory: string;
title: string;
onEvent: (event: NormalizedAgentEvent) => Promise<void>;
}) => {
const abortController = new AbortController();
const client = createOpencodeClient({
baseUrl: args.baseUrl,
directory: args.directory,
headers: {
authorization: basicAuth('opencode', args.password),
},
});
const created = await client.session.create({
query: { directory: args.directory },
body: { title: args.title },
});
if (!created.data) {
throw new Error('OpenCode session could not be created.');
}
const sessionId = created.data.id;
void (async () => {
const events = await client.event.subscribe({
signal: abortController.signal,
query: { directory: args.directory },
onSseEvent: (event) => {
for (const normalized of normalizeOpenCodeEvent(event.data)) {
void args.onEvent(normalized);
}
},
onSseError: (error) => {
void args.onEvent({
kind: 'error',
message: error instanceof Error ? error.message : String(error),
});
},
});
for await (const event of events.stream) {
for (const normalized of normalizeOpenCodeEvent(event)) {
await args.onEvent(normalized);
}
}
})().catch((error: unknown) => {
if (!abortController.signal.aborted) {
void args.onEvent({
kind: 'error',
message: error instanceof Error ? error.message : String(error),
});
}
});
return {
client,
sessionId,
close: () => abortController.abort(),
} satisfies OpenCodeSession;
};
export const promptOpenCodeSession = async (args: {
session: OpenCodeSession;
prompt: string;
model: string;
directory: string;
}) => {
const model = modelParts(args.model);
const result = await args.session.client.session.promptAsync({
path: { id: args.session.sessionId },
query: { directory: args.directory },
body: {
model,
parts: [{ type: 'text', text: args.prompt }],
},
});
if (result.error) {
throw new Error('OpenCode prompt was rejected.');
}
};
export const abortOpenCodeSession = async (session: OpenCodeSession) => {
await session.client.session.abort({
path: { id: session.sessionId },
});
};
export const replyOpenCodePermission = async (args: {
session: OpenCodeSession;
permissionId: string;
response: 'once' | 'always' | 'reject';
directory: string;
}) => {
const result = await args.session.client.postSessionIdPermissionsPermissionId({
path: { id: args.session.sessionId, permissionID: args.permissionId },
query: { directory: args.directory },
body: { response: args.response },
});
if (result.error) {
throw new Error('OpenCode permission response was rejected.');
}
};
+218 -9
View File
@@ -2,20 +2,30 @@ import { execa } from 'execa';
import { env } from '../env';
type CommandResult = {
exitCode: number;
output: string;
};
const environmentArgs = (environment: Record<string, string>) =>
Object.entries(environment).flatMap(([name, value]) => [
'-e',
`${name}=${value}`,
]);
const networkArgs = () => (env.network ? ['--network', env.network] : []);
const containerRuntime = () => env.containerRuntime;
export const runInJobContainer = async (args: {
workdir: string;
command: string[];
environment: Record<string, string>;
redact: (value: string) => string;
timeoutMs: number;
}) => {
const envArgs = Object.entries(args.environment).flatMap(([name, value]) => [
'-e',
`${name}=${value}`,
]);
const networkArgs = env.network ? ['--network', env.network] : [];
}): Promise<CommandResult> => {
const result = await execa(
'docker',
containerRuntime(),
[
'run',
'--rm',
@@ -23,8 +33,8 @@ export const runInJobContainer = async (args: {
'4g',
'--cpus',
'2',
...networkArgs,
...envArgs,
...networkArgs(),
...environmentArgs(args.environment),
'-v',
`${args.workdir}:/workspace`,
'-w',
@@ -43,3 +53,202 @@ export const runInJobContainer = async (args: {
output: args.redact(result.all),
};
};
export const startWorkspaceContainer = async (args: {
workdir: string;
containerName: string;
environment: Record<string, string>;
command?: string[];
publishTcpPort?: number;
}) => {
await execa(
containerRuntime(),
[
'rm',
'-f',
args.containerName,
],
{ reject: false },
);
const result = await execa(
containerRuntime(),
[
'run',
'-d',
'--name',
args.containerName,
'--memory',
'4g',
'--cpus',
'2',
...networkArgs(),
...(args.publishTcpPort
? ['-p', `127.0.0.1::${args.publishTcpPort}`]
: []),
...environmentArgs(args.environment),
'-v',
`${args.workdir}:/workspace`,
'-w',
'/workspace/repo',
env.jobImage,
...(args.command ?? ['sleep', 'infinity']),
],
{ all: true },
);
return {
containerId: result.stdout.trim(),
containerName: args.containerName,
hostPort: args.publishTcpPort
? await getPublishedPort(args.containerName, args.publishTcpPort)
: undefined,
};
};
const getPublishedPort = async (containerName: string, containerPort: number) => {
const result = await execa(
containerRuntime(),
['port', containerName, `${containerPort}/tcp`],
{ all: true, reject: false },
);
const output = result.all.trim();
const match = /:(\d+)\s*$/.exec(output);
if (!match?.[1]) {
throw new Error(
`Could not determine published port for ${containerName}:${containerPort}.`,
);
}
return match[1];
};
export const execInWorkspaceContainer = async (args: {
containerName: string;
command: string[];
environment?: Record<string, string>;
redact: (value: string) => string;
timeoutMs: number;
}): Promise<CommandResult> => {
const result = await execa(
containerRuntime(),
[
'exec',
...(args.environment ? environmentArgs(args.environment) : []),
args.containerName,
...args.command,
],
{
all: true,
reject: false,
timeout: args.timeoutMs,
},
);
return {
exitCode: result.exitCode ?? 0,
output: args.redact(result.all),
};
};
export const streamInJobContainer = async (args: {
workdir: string;
command: string[];
environment: Record<string, string>;
redact: (value: string) => string;
timeoutMs: number;
onStdoutLine?: (line: string) => Promise<void>;
onStderrLine?: (line: string) => Promise<void>;
}): Promise<CommandResult> => {
const subprocess = execa(
containerRuntime(),
[
'run',
'--rm',
'--memory',
'4g',
'--cpus',
'2',
...networkArgs(),
...environmentArgs(args.environment),
'-v',
`${args.workdir}:/workspace`,
'-w',
'/workspace/repo',
env.jobImage,
...args.command,
],
{
all: true,
reject: false,
timeout: args.timeoutMs,
},
);
let stdoutBuffer = '';
let stderrBuffer = '';
const output: string[] = [];
const consume = async (
chunk: Buffer,
source: 'stdout' | 'stderr',
handler?: (line: string) => Promise<void>,
) => {
output.push(chunk.toString('utf8'));
const next = `${source === 'stdout' ? stdoutBuffer : stderrBuffer}${chunk.toString('utf8')}`;
const lines = next.split(/\r?\n/);
const remainder = lines.pop() ?? '';
if (source === 'stdout') stdoutBuffer = remainder;
else stderrBuffer = remainder;
for (const line of lines) {
if (handler) {
await handler(args.redact(line));
}
}
};
subprocess.stdout.on('data', (chunk: Buffer) => {
void consume(chunk, 'stdout', args.onStdoutLine);
});
subprocess.stderr.on('data', (chunk: Buffer) => {
void consume(chunk, 'stderr', args.onStderrLine);
});
const result = await subprocess;
if (stdoutBuffer && args.onStdoutLine) {
await args.onStdoutLine(args.redact(stdoutBuffer));
}
if (stderrBuffer && args.onStderrLine) {
await args.onStderrLine(args.redact(stderrBuffer));
}
return {
exitCode: result.exitCode ?? 0,
output: args.redact(output.join('')),
};
};
export const stopWorkspaceContainer = async (containerName: string) => {
await execa(containerRuntime(), ['rm', '-f', containerName], {
reject: false,
});
};
export const inspectWorkspaceContainer = async (containerName: string) => {
const result = await execa(
containerRuntime(),
['inspect', containerName],
{
all: true,
reject: false,
},
);
return {
exists: result.exitCode === 0,
output: result.all,
};
};
export const listWorkspaceContainerNames = async (prefix: string) => {
const result = await execa(
containerRuntime(),
['ps', '-a', '--format', '{{.Names}}'],
{ all: true, reject: false },
);
if (result.exitCode !== 0) return [];
return result.all
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith(prefix));
};
+49 -4
View File
@@ -1,12 +1,19 @@
import { createServer } from 'node:http';
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { env } from './env';
import {
abortWorkspaceAgent,
cleanupOrphanedWorkspaces,
getWorkerHealth,
getWorkspaceAgentStatus,
getWorkspaceDiff,
listWorkspaceTree,
openWorkspacePullRequest,
readWorkspaceFile,
replyToInteraction,
runWorkspaceCommand,
sendWorkspaceMessage,
stopWorkspace,
@@ -43,7 +50,7 @@ const requireAuth = (request: IncomingMessage) => {
};
const jobRoute = (pathname: string) => {
const match = /^\/jobs\/([^/]+)\/([^/]+)$/.exec(pathname);
const match = /^\/jobs\/([^/]+)\/(.+)$/.exec(pathname);
if (!match?.[1] || !match[2]) return null;
return { jobId: decodeURIComponent(match[1]), action: match[2] };
};
@@ -57,8 +64,12 @@ export const startWorkerServer = () => {
request.url ?? '/',
`http://localhost:${env.httpPort}`,
);
if (url.pathname === '/health') {
sendJson(response, 200, { ok: true, workerId: env.workerId });
if (url.pathname === '/health' && request.method === 'GET') {
sendJson(response, 200, await getWorkerHealth());
return;
}
if (url.pathname === '/cleanup' && request.method === 'POST') {
sendJson(response, 200, await cleanupOrphanedWorkspaces());
return;
}
const route = jobRoute(url.pathname);
@@ -108,6 +119,34 @@ export const startWorkerServer = () => {
sendJson(response, 200, { success: true });
return;
}
if (request.method === 'GET' && route.action === 'agent/status') {
sendJson(response, 200, getWorkspaceAgentStatus(route.jobId));
return;
}
if (request.method === 'POST' && route.action === 'agent/abort') {
sendJson(response, 200, await abortWorkspaceAgent(route.jobId));
return;
}
const interactionMatch =
/^interactions\/([^/]+)\/reply$/.exec(route.action);
if (request.method === 'POST' && interactionMatch?.[1]) {
const body = await parseJson<{
externalRequestId?: string;
response?: string;
}>(request);
sendJson(
response,
200,
await replyToInteraction(route.jobId, {
interactionId: decodeURIComponent(
interactionMatch[1],
) as Id<'agentInteractionRequests'>,
externalRequestId: body.externalRequestId ?? '',
response: body.response ?? 'once',
}),
);
return;
}
if (request.method === 'POST' && route.action === 'run-command') {
const body = await parseJson<{ command?: string }>(request);
sendJson(
@@ -128,7 +167,13 @@ export const startWorkerServer = () => {
sendJson(response, 404, { error: 'Not found' });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
sendJson(response, message === 'Unauthorized' ? 401 : 500, {
const status =
message === 'Unauthorized'
? 401
: message.includes('not supported')
? 409
: 500;
sendJson(response, status, {
error: message,
});
}
+586 -43
View File
@@ -7,12 +7,15 @@ import {
stat,
writeFile,
} from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import path from 'node:path';
import { ConvexHttpClient } from 'convex/browser';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import type { NormalizedAgentEvent } from './agent-events';
import { normalizeCodexJsonLine } from './agent-events';
import { env } from './env';
import {
cloneRepository,
@@ -22,8 +25,21 @@ import {
run,
} from './git';
import { getInstallationToken, openDraftPullRequest } from './github';
import type { OpenCodeSession } from './opencode-session';
import {
abortOpenCodeSession,
createOpenCodeSession,
promptOpenCodeSession,
replyOpenCodePermission,
} from './opencode-session';
import { createRedactor, truncate } from './redact';
import { runInJobContainer } from './runtime/docker';
import {
listWorkspaceContainerNames,
runInJobContainer,
startWorkspaceContainer,
stopWorkspaceContainer,
streamInJobContainer,
} from './runtime/docker';
type Claim = {
job: {
@@ -81,6 +97,14 @@ type ActiveWorkspace = {
repoDir: string;
githubToken: string;
redact: (value: string) => string;
runtimeMode?: 'opencode_server' | 'codex_exec' | 'legacy_cli';
containerName?: string;
containerId?: string;
opencodePassword?: string;
opencodeSession?: OpenCodeSession;
codexSessionId?: string;
agentTurnActive?: boolean;
resolveTurn?: () => void;
};
type FileTreeNode = {
@@ -225,6 +249,70 @@ const appendMessage = async (args: {
...args,
});
const updateMessage = async (args: {
messageId: Id<'agentJobMessages'>;
content?: string;
status?: 'queued' | 'streaming' | 'completed' | 'failed';
metadata?: string;
}) =>
await client.mutation(api.agentJobs.updateMessage, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const setRuntimeSession = async (args: {
jobId: Id<'agentJobs'>;
agentRuntimeMode: 'opencode_server' | 'codex_exec' | 'legacy_cli';
opencodeSessionId?: string;
codexSessionId?: string;
containerId?: string;
}) =>
await client.mutation(api.agentJobs.setRuntimeSession, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const setCodexSessionId = async (
jobId: Id<'agentJobs'>,
codexSessionId: string,
) =>
await client.mutation(api.agentJobs.setCodexSessionId, {
workerToken: env.workerToken,
workerId: env.workerId,
jobId,
codexSessionId,
});
const createInteractionRequest = async (args: {
jobId: Id<'agentJobs'>;
runtime: 'opencode' | 'codex';
externalRequestId: string;
kind: 'question' | 'permission' | 'tool_confirmation';
title: string;
body: string;
options?: string[];
metadata?: string;
}) =>
await client.mutation(api.agentJobs.createInteractionRequest, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const patchInteractionRequest = async (args: {
interactionId: Id<'agentInteractionRequests'>;
status: 'pending' | 'answered' | 'approved' | 'rejected' | 'expired';
response?: string;
metadata?: string;
}) =>
await client.mutation(api.agentJobs.patchInteractionRequest, {
workerToken: env.workerToken,
workerId: env.workerId,
...args,
});
const recordWorkspaceChange = async (args: {
jobId: Id<'agentJobs'>;
path: string;
@@ -240,6 +328,9 @@ const recordWorkspaceChange = async (args: {
const commandToShell = (command: string) => ['bash', '-lc', command];
const workspaceContainerName = (jobId: string) =>
`spoon-agent-job-${jobId.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
const isCodexLoginProfile = (claim: Claim) =>
claim.aiProviderProfile?.provider === 'opencode_openai_login' ||
claim.aiProviderProfile?.authType === 'opencode_auth_json';
@@ -373,20 +464,305 @@ const prepareCodexAuth = async (workspace: ActiveWorkspace) => {
);
};
const agentCommand = (claim: Claim, prompt: string) => {
if (isCodexLoginProfile(claim)) {
return commandToShell(
`codex exec --model ${quoteShell(codexModel(claim))} --sandbox workspace-write ${quoteShell(prompt)}`,
);
}
return commandToShell(
`opencode run --model ${quoteShell(opencodeModel(claim))} ${quoteShell(prompt)}`,
);
};
const agentFailurePrefix = (claim: Claim) =>
isCodexLoginProfile(claim) ? 'codex failed' : 'opencode failed';
const handleAgentEvent = async (args: {
workspace: ActiveWorkspace;
event: NormalizedAgentEvent;
assistantMessageId: Id<'agentJobMessages'>;
assistantContent: { value: string };
}) => {
const { workspace, event, assistantMessageId, assistantContent } = args;
const jobId = workspace.claim.job._id;
if (event.kind === 'assistant_delta') {
assistantContent.value = truncate(
`${assistantContent.value}${event.content}`,
40_000,
);
await updateMessage({
messageId: assistantMessageId,
content: assistantContent.value,
status: 'streaming',
metadata: event.externalMessageId
? JSON.stringify({ externalMessageId: event.externalMessageId })
: undefined,
});
return;
}
if (event.kind === 'assistant_completed') {
workspace.agentTurnActive = false;
workspace.resolveTurn?.();
workspace.resolveTurn = undefined;
if (event.content) {
assistantContent.value = truncate(
`${assistantContent.value}${event.content}`,
40_000,
);
}
await updateMessage({
messageId: assistantMessageId,
content: assistantContent.value,
status: 'completed',
});
return;
}
if (event.kind === 'session') {
if (workspace.runtimeMode === 'codex_exec') {
workspace.codexSessionId = event.sessionId;
await setCodexSessionId(jobId, event.sessionId);
}
return;
}
if (event.kind === 'tool_started' || event.kind === 'tool_completed') {
const detail =
event.kind === 'tool_started' ? event.input : event.output;
await appendMessage({
jobId,
role: 'tool',
status: event.kind === 'tool_started' ? 'streaming' : 'completed',
content: truncate(
`${event.name}${detail ? `\n\n${detail}` : ''}`,
20_000,
),
metadata: JSON.stringify({
kind: event.kind,
externalMessageId: event.externalMessageId,
}),
});
return;
}
if (event.kind === 'file_edited') {
const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact);
await recordWorkspaceChange({
jobId,
path: event.path,
source: 'agent',
changeType: await fileChangedType(workspace.repoDir, event.path),
diff: truncate(diff.output, 50_000),
});
await appendEvent(jobId, 'info', 'edit', `Agent edited ${event.path}.`);
return;
}
if (event.kind === 'command_executed') {
await appendEvent(
jobId,
event.exitCode && event.exitCode !== 0 ? 'warn' : 'info',
'check',
event.command,
event.output ? truncate(event.output, 10_000) : undefined,
);
return;
}
if (
event.kind === 'permission_requested' ||
event.kind === 'question_requested'
) {
await createInteractionRequest({
jobId,
runtime: workspace.runtimeMode === 'codex_exec' ? 'codex' : 'opencode',
externalRequestId: event.externalRequestId,
kind: event.kind === 'permission_requested' ? 'permission' : 'question',
title: event.title,
body: truncate(event.body, 20_000),
options: event.kind === 'question_requested' ? event.options : undefined,
metadata: event.metadata,
});
await appendMessage({
jobId,
role: 'system',
status: 'completed',
content: `${event.title}\n\n${truncate(event.body, 20_000)}`,
metadata: JSON.stringify({ kind: event.kind }),
});
return;
}
if (event.kind === 'status') {
await appendEvent(
jobId,
'debug',
'plan',
event.status,
event.metadata ? truncate(event.metadata, 10_000) : undefined,
);
return;
}
await appendEvent(jobId, 'error', 'plan', truncate(event.message, 20_000));
};
const ensureOpenCodeSession = async (workspace: ActiveWorkspace) => {
if (workspace.opencodeSession) return workspace.opencodeSession;
const containerName = workspaceContainerName(workspace.claim.job._id);
const password = randomBytes(24).toString('hex');
const aiEnv = providerEnvironment(workspace.claim);
const secretEnv = Object.fromEntries(
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
);
const container = await startWorkspaceContainer({
workdir: workspace.workdir,
containerName,
environment: {
...aiEnv,
...secretEnv,
OPENCODE_SERVER_PASSWORD: password,
OPENCODE_SERVER_USERNAME: 'opencode',
},
command: ['opencode', 'serve', '--hostname', '0.0.0.0', '--port', '4096'],
publishTcpPort: env.containerAccess === 'host_port' ? 4096 : undefined,
});
const baseUrl =
env.containerAccess === 'host_port'
? `http://127.0.0.1:${container.hostPort}`
: `http://${containerName}:4096`;
workspace.containerName = container.containerName;
workspace.containerId = container.containerId;
workspace.opencodePassword = password;
workspace.runtimeMode = 'opencode_server';
await setRuntimeSession({
jobId: workspace.claim.job._id,
agentRuntimeMode: 'opencode_server',
containerId: container.containerId,
});
let lastError: unknown;
for (let attempt = 0; attempt < 20; attempt += 1) {
try {
const session = await createOpenCodeSession({
baseUrl,
password,
directory: '/workspace/repo',
title: workspace.claim.job.prompt.slice(0, 80) || 'Spoon workspace',
onEvent: async (event) => {
const messageId = workspaceCurrentMessage.get(workspace.claim.job._id);
if (!messageId) return;
await handleAgentEvent({
workspace,
event,
assistantMessageId: messageId,
assistantContent:
workspaceCurrentContent.get(workspace.claim.job._id) ?? {
value: '',
},
});
},
});
workspace.opencodeSession = session;
await setRuntimeSession({
jobId: workspace.claim.job._id,
agentRuntimeMode: 'opencode_server',
opencodeSessionId: session.sessionId,
containerId: container.containerId,
});
return session;
} catch (error) {
lastError = error;
await sleep(500);
}
}
throw lastError instanceof Error
? lastError
: new Error('OpenCode server did not become ready.');
};
const workspaceCurrentMessage = new Map<string, Id<'agentJobMessages'>>();
const workspaceCurrentContent = new Map<
string,
{
value: string;
}
>();
const runCodexTurn = async (args: {
workspace: ActiveWorkspace;
prompt: string;
assistantMessageId: Id<'agentJobMessages'>;
assistantContent: { value: string };
}) => {
const { workspace, prompt, assistantMessageId, assistantContent } = args;
workspace.runtimeMode = 'codex_exec';
await setRuntimeSession({
jobId: workspace.claim.job._id,
agentRuntimeMode: 'codex_exec',
codexSessionId: workspace.codexSessionId,
});
const command = workspace.codexSessionId
? commandToShell(
`codex exec resume --json --model ${quoteShell(
codexModel(workspace.claim),
)} ${quoteShell(workspace.codexSessionId)} ${quoteShell(prompt)}`,
)
: commandToShell(
`codex exec --json --model ${quoteShell(
codexModel(workspace.claim),
)} --sandbox workspace-write ${quoteShell(prompt)}`,
);
const aiEnv = providerEnvironment(workspace.claim, jobContainerWorkspace);
const secretEnv = Object.fromEntries(
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
);
const result = await streamInJobContainer({
workdir: workspace.workdir,
command,
environment: {
...aiEnv,
...secretEnv,
},
redact: workspace.redact,
timeoutMs: env.jobTimeoutMs,
onStdoutLine: async (line) => {
for (const event of normalizeCodexJsonLine(line)) {
await handleAgentEvent({
workspace,
event,
assistantMessageId,
assistantContent,
});
}
},
onStderrLine: async (line) => {
if (line.trim()) {
await appendEvent(
workspace.claim.job._id,
'debug',
'plan',
truncate(line, 10_000),
);
}
},
});
if (result.exitCode !== 0) {
throw new Error(`codex failed:\n${result.output}`);
}
};
const runOpenCodeTurn = async (args: {
workspace: ActiveWorkspace;
prompt: string;
assistantMessageId: Id<'agentJobMessages'>;
assistantContent: { value: string };
}) => {
const { workspace, prompt, assistantMessageId, assistantContent } = args;
workspaceCurrentMessage.set(workspace.claim.job._id, assistantMessageId);
workspaceCurrentContent.set(workspace.claim.job._id, assistantContent);
const session = await ensureOpenCodeSession(workspace);
const turnDone = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
workspace.resolveTurn = undefined;
reject(new Error('OpenCode turn timed out.'));
}, env.jobTimeoutMs);
workspace.resolveTurn = () => {
clearTimeout(timeout);
resolve();
};
});
await promptOpenCodeSession({
session,
prompt,
model: opencodeModel(workspace.claim),
directory: '/workspace/repo',
});
await turnDone;
};
const systemPromptForJob = (claim: Claim) => {
const base = [
`Spoon: ${claim.spoon.name}`,
@@ -888,9 +1264,80 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => {
return { success: true };
};
export const getWorkspaceAgentStatus = (jobId: string) => {
const workspace = resolveWorkspace(jobId);
return {
runtimeMode: workspace.runtimeMode ?? 'legacy_cli',
opencodeSessionId: workspace.opencodeSession?.sessionId,
codexSessionId: workspace.codexSessionId,
containerId: workspace.containerId,
active: Boolean(workspace.agentTurnActive),
};
};
export const abortWorkspaceAgent = async (jobId: string) => {
const workspace = resolveWorkspace(jobId);
if (workspace.opencodeSession) {
await abortOpenCodeSession(workspace.opencodeSession);
workspace.agentTurnActive = false;
workspace.resolveTurn?.();
workspace.resolveTurn = undefined;
await appendEvent(workspace.claim.job._id, 'warn', 'cleanup', 'Agent turn aborted.');
return { success: true };
}
if (workspace.runtimeMode === 'codex_exec') {
throw new Error('Codex agent turns cannot be aborted from Spoon yet.');
}
return { success: true };
};
export const replyToInteraction = async (
jobId: string,
args: {
interactionId: Id<'agentInteractionRequests'>;
externalRequestId: string;
response: string;
},
) => {
const workspace = resolveWorkspace(jobId);
if (workspace.runtimeMode === 'codex_exec') {
throw new Error('Codex interaction replies are not supported yet.');
}
if (!workspace.opencodeSession) {
throw new Error('OpenCode session is not active.');
}
const mapped =
args.response === 'reject'
? 'reject'
: args.response === 'always'
? 'always'
: 'once';
await replyOpenCodePermission({
session: workspace.opencodeSession,
permissionId: args.externalRequestId,
response: mapped,
directory: '/workspace/repo',
});
await patchInteractionRequest({
interactionId: args.interactionId,
status: mapped === 'reject' ? 'rejected' : 'approved',
response: mapped,
});
await appendMessage({
jobId: workspace.claim.job._id,
role: 'system',
status: 'completed',
content: `Interaction ${mapped === 'reject' ? 'rejected' : 'approved'}.`,
});
return { success: true };
};
export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
const workspace = resolveWorkspace(jobId);
const { claim, repoDir, redact, workdir } = workspace;
const { claim, redact } = workspace;
if (workspace.agentTurnActive) {
throw new Error('Wait for the current agent turn to finish or abort it.');
}
await appendMessage({
jobId: claim.job._id,
role: 'user',
@@ -903,50 +1350,62 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
throw new Error('Legacy OpenAI direct jobs are no longer supported.');
}
const aiEnv = providerEnvironment(
claim,
env.runtime === 'docker' ? jobContainerWorkspace : workdir,
);
workspace.agentTurnActive = true;
const assistantMessageId = await appendMessage({
jobId: claim.job._id,
role: 'assistant',
status: 'streaming',
content: '',
});
const assistantContent = { value: '' };
if (isCodexLoginProfile(claim)) {
await runCodexTurn({
workspace,
prompt,
assistantMessageId,
assistantContent,
});
} else if (env.runtime === 'docker') {
await runOpenCodeTurn({
workspace,
prompt,
assistantMessageId,
assistantContent,
});
} else {
const aiEnv = providerEnvironment(claim);
const secretEnv = Object.fromEntries(
claim.secrets.map((secret) => [secret.name, secret.value]),
);
const 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,
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 appendMessage({
jobId: claim.job._id,
role: 'assistant',
});
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 (isCodexLoginProfile(claim)) {
await updateMessage({
messageId: assistantMessageId,
status: 'completed',
content: assistantContent.value,
});
workspace.agentTurnActive = false;
}
workspace.agentTurnActive = false;
if (claim.job.jobType === 'maintenance_review') {
const decision = parseMaintenanceDecision(result.output);
const decision = parseMaintenanceDecision(assistantContent.value);
if (decision) {
await addArtifact({
jobId: claim.job._id,
@@ -959,11 +1418,11 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
} else {
await updateStatus(claim.job._id, 'changes_ready', {
summary:
'OpenCode completed the review, but Spoon could not parse a structured maintenance decision.',
'The agent completed the review, but Spoon could not parse a structured maintenance decision.',
});
}
}
const diff = await getWorktreeDiff(repoDir, redact);
const diff = await getWorktreeDiff(workspace.repoDir, redact);
await addArtifact({
jobId: claim.job._id,
kind: 'diff',
@@ -979,6 +1438,9 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
diff: truncate(diff.output, 50_000),
});
} catch (error) {
workspace.agentTurnActive = false;
workspace.resolveTurn?.();
workspace.resolveTurn = undefined;
const message = error instanceof Error ? error.message : String(error);
await appendEvent(
claim.job._id,
@@ -1059,6 +1521,10 @@ export const openWorkspacePullRequest = async (jobId: string) => {
summary: 'Draft PR opened from interactive workspace.',
});
await markWorkspaceStopped(claim.job._id);
workspace.opencodeSession?.close();
if (workspace.containerName) {
await stopWorkspaceContainer(workspace.containerName);
}
activeWorkspaces.delete(jobId);
await rm(workspace.workdir, { recursive: true, force: true });
return {
@@ -1070,11 +1536,88 @@ export const openWorkspacePullRequest = async (jobId: string) => {
export const stopWorkspace = async (jobId: string) => {
const workspace = resolveWorkspace(jobId);
await markWorkspaceStopped(workspace.claim.job._id);
workspace.opencodeSession?.close();
if (workspace.containerName) {
await stopWorkspaceContainer(workspace.containerName);
}
activeWorkspaces.delete(jobId);
await rm(workspace.workdir, { recursive: true, force: true });
return { success: true };
};
export const getWorkerHealth = async () => {
const active = [...activeWorkspaces.entries()].map(([jobId, workspace]) => ({
jobId,
runtimeMode: workspace.runtimeMode ?? 'legacy_cli',
containerName: workspace.containerName,
workdir: workspace.workdir,
agentTurnActive: Boolean(workspace.agentTurnActive),
}));
const containerNames = await listWorkspaceContainerNames('spoon-agent-job-');
return {
ok: true,
workerId: env.workerId,
convexUrl: env.convexUrl,
runtime: env.runtime,
containerRuntime: env.containerRuntime,
containerAccess: env.containerAccess,
jobImage: env.jobImage,
workdir: env.workdir,
network: env.network,
httpPort: env.httpPort,
maxConcurrentJobs: env.maxConcurrentJobs,
jobTimeoutMs: env.jobTimeoutMs,
activeWorkspaceCount: active.length,
activeWorkspaces: active,
workspaceContainers: containerNames,
};
};
export const cleanupOrphanedWorkspaces = async () => {
const activeContainers = new Set(
[...activeWorkspaces.values()]
.map((workspace) => workspace.containerName)
.filter((value): value is string => Boolean(value)),
);
const activeWorkdirs = new Set(
[...activeWorkspaces.values()].map((workspace) =>
path.resolve(workspace.workdir),
),
);
const removedContainers: string[] = [];
for (const containerName of await listWorkspaceContainerNames(
'spoon-agent-job-',
)) {
if (activeContainers.has(containerName)) continue;
await stopWorkspaceContainer(containerName);
removedContainers.push(containerName);
}
const removedWorkdirs: string[] = [];
const root = path.resolve(env.workdir);
try {
const entries = await readdir(root, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
const target = path.resolve(root, entry.name);
if (activeWorkdirs.has(target)) continue;
await rm(target, { recursive: true, force: true });
removedWorkdirs.push(target);
}
} catch (error) {
const code = error && typeof error === 'object' ? 'code' in error : false;
if (!code || (error as { code?: string }).code !== 'ENOENT') {
throw error;
}
}
return {
success: true,
removedContainers,
removedWorkdirs,
};
};
export const startWorker = async () => {
console.log(`Spoon agent worker ${env.workerId} polling ${env.convexUrl}`);
for (;;) {
@@ -0,0 +1,97 @@
import { describe, expect, test } from 'vitest';
import {
normalizeCodexJsonLine,
normalizeOpenCodeEvent,
} from '../../src/agent-events';
describe('agent event normalization', () => {
test('normalizes Codex assistant deltas and session ids', () => {
expect(
normalizeCodexJsonLine(
JSON.stringify({
type: 'session.created',
session_id: 'codex-session-1',
}),
),
).toContainEqual({ kind: 'session', sessionId: 'codex-session-1' });
expect(
normalizeCodexJsonLine(
JSON.stringify({
type: 'response.output_text.delta',
delta: 'hello',
}),
),
).toContainEqual({ kind: 'assistant_delta', content: 'hello' });
});
test('normalizes Codex command and file events', () => {
expect(
normalizeCodexJsonLine(
JSON.stringify({
type: 'command.completed',
command: 'bun test',
output: 'ok',
}),
),
).toContainEqual({
kind: 'command_executed',
command: 'bun test',
output: 'ok',
});
expect(
normalizeCodexJsonLine(
JSON.stringify({
type: 'file.edited',
path: 'src/app.ts',
}),
),
).toContainEqual({ kind: 'file_edited', path: 'src/app.ts' });
});
test('normalizes OpenCode assistant, tool, and permission events', () => {
expect(
normalizeOpenCodeEvent({
type: 'message.part.delta',
properties: {
part: { text: 'streamed' },
messageID: 'message-1',
},
}),
).toContainEqual({
kind: 'assistant_delta',
content: 'streamed',
externalMessageId: 'message-1',
});
expect(
normalizeOpenCodeEvent({
type: 'tool.started',
properties: { tool: 'edit', input: { path: 'README.md' } },
}),
).toContainEqual({
kind: 'tool_started',
name: 'edit',
input: '{\n "path": "README.md"\n}',
externalMessageId: '',
});
expect(
normalizeOpenCodeEvent({
type: 'permission.asked',
properties: {
permissionID: 'perm-1',
message: 'Run bun test?',
},
}),
).toContainEqual({
kind: 'permission_requested',
externalRequestId: 'perm-1',
title: 'Permission requested',
body: 'Run bun test?',
metadata: '{\n "permissionID": "perm-1",\n "message": "Run bun test?"\n}',
});
});
});
+2 -1
View File
@@ -3,7 +3,7 @@
import type { ReactNode } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Brain, Github, Shield, User } from 'lucide-react';
import { Brain, Github, ServerCog, Shield, User } from 'lucide-react';
import { cn } from '@spoon/ui';
@@ -11,6 +11,7 @@ const settingsItems = [
{ href: '/settings/profile', label: 'Profile', icon: User },
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
{ href: '/settings/worker', label: 'Worker', icon: ServerCog },
{ href: '/settings/security', label: 'Security', icon: Shield },
];
@@ -0,0 +1,15 @@
import { WorkerHealthPanel } from '@/components/settings/worker-health-panel';
const WorkerSettingsPage = () => (
<section className='max-w-5xl space-y-4'>
<div>
<h2 className='text-xl font-semibold'>Worker</h2>
<p className='text-muted-foreground mt-1 text-sm'>
Monitor the agent worker and clean up old workspace state.
</p>
</div>
<WorkerHealthPanel />
</section>
);
export default WorkerSettingsPage;
@@ -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';
import { useState } from 'react';
import { Send } from 'lucide-react';
import { Ban, Send } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button, Textarea } from '@spoon/ui';
import { Badge, Button, Textarea } from '@spoon/ui';
export const AgentThread = ({
jobId,
messages,
events,
interactions,
disabled,
}: {
jobId: string;
messages: Doc<'agentJobMessages'>[];
events: Doc<'agentJobEvents'>[];
interactions: Doc<'agentInteractionRequests'>[];
disabled: boolean;
}) => {
const [content, setContent] = useState('');
const [sending, setSending] = useState(false);
const [replying, setReplying] = useState<string>();
const send = async () => {
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 (
<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'>
<div>
<h2 className='text-sm font-semibold'>Agent thread</h2>
<p className='text-muted-foreground text-xs'>
Messages persist with this workspace.
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 className='min-h-0 flex-1 space-y-3 overflow-auto p-3'>
{interactions.map((interaction) => (
<article
key={interaction._id}
className='border-primary/40 bg-primary/5 rounded-md border p-3 text-sm'
>
<div className='mb-2 flex items-center justify-between gap-2'>
<span className='font-medium'>{interaction.title}</span>
<Badge variant='outline' className='capitalize'>
{interaction.status}
</Badge>
</div>
<p className='text-sm whitespace-pre-wrap'>{interaction.body}</p>
{interaction.status === 'pending' ? (
<div className='mt-3 flex gap-2'>
<Button
type='button'
size='sm'
disabled={replying === interaction._id}
onClick={() => void reply(interaction, 'once')}
>
Approve
</Button>
<Button
type='button'
size='sm'
variant='outline'
disabled={replying === interaction._id}
onClick={() => void reply(interaction, 'reject')}
>
Reject
</Button>
</div>
) : null}
</article>
))}
{messages.map((message) => (
<article
key={message._id}
className='border-border bg-background rounded-md border p-3 text-sm'
className={
message.role === 'user'
? 'border-border bg-muted ml-6 rounded-md border p-3 text-sm'
: message.status === 'failed'
? 'border-destructive/40 bg-destructive/5 rounded-md border p-3 text-sm'
: 'border-border bg-background rounded-md border p-3 text-sm'
}
>
<div className='mb-2 flex items-center justify-between gap-2'>
<span className='font-medium capitalize'>{message.role}</span>
<span className='text-muted-foreground text-xs capitalize'>
<Badge
variant={
message.status === 'failed' ? 'destructive' : 'outline'
}
className='capitalize'
>
{message.status}
</span>
</Badge>
</div>
<p className='whitespace-pre-wrap'>{message.content}</p>
<p className='whitespace-pre-wrap'>
{message.content ||
(message.status === 'streaming' ? 'Working...' : '')}
</p>
</article>
))}
{events.slice(-20).map((event) => (
<article
key={event._id}
className='border-border text-muted-foreground rounded-md border border-dashed p-2 text-xs'
>
<div className='flex items-center justify-between gap-2'>
<span className='font-medium capitalize'>
{event.phase} / {event.level}
</span>
<span>{new Date(event.createdAt).toLocaleTimeString()}</span>
</div>
<p className='mt-1 whitespace-pre-wrap'>{event.message}</p>
</article>
))}
</div>
@@ -1,7 +1,7 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useQuery } from 'convex/react';
import { useMutation, useQuery } from 'convex/react';
import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -13,17 +13,42 @@ import { AgentThread } from './agent-thread';
import { CodeEditor } from './code-editor';
import { CommandPanel } from './command-panel';
import { DiffViewer } from './diff-viewer';
import { FileTabs } from './file-tabs';
import { FileTree } from './file-tree';
import { JobStatusBar } from './job-status-bar';
import { WorkspaceActions } from './workspace-actions';
type OpenFileState = {
path: string;
content: string;
savedContent: string;
loading: boolean;
saving: boolean;
error?: string;
};
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const job = useQuery(api.agentJobs.get, { jobId });
const messages =
useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? [];
const events =
useQuery(api.agentJobs.listEvents, { jobId, limit: 200 }) ?? [];
const interactions =
useQuery(api.agentJobs.listInteractionRequests, {
jobId,
status: 'all',
}) ?? [];
const uiState = useQuery(api.agentJobs.getWorkspaceUiState, { jobId });
const patchUiState = useMutation(api.agentJobs.patchWorkspaceUiState);
const [tree, setTree] = useState<FileTreeNode | null>(null);
const [selectedPath, setSelectedPath] = useState<string>();
const [fileContent, setFileContent] = useState('');
const [files, setFiles] = useState<Record<string, OpenFileState>>({});
const [openFilePaths, setOpenFilePaths] = useState<string[]>([]);
const [activeFilePath, setActiveFilePath] = useState<string>();
const [expandedDirectoryPaths, setExpandedDirectoryPaths] = useState<
string[]
>([]);
const [vimEnabled, setVimEnabled] = useState(false);
const [hydratedUiState, setHydratedUiState] = useState(false);
const [diff, setDiff] = useState('');
const workspaceDisabled =
@@ -49,17 +74,59 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const loadFile = useCallback(
async (path: string) => {
setFiles((current) => ({
...current,
[path]: current[path] ?? {
path,
content: '',
savedContent: '',
loading: true,
saving: false,
},
}));
const response = await fetch(
`/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`,
);
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as FileResponse;
setSelectedPath(data.path);
setFileContent(data.content);
setFiles((current) => ({
...current,
[data.path]: {
path: data.path,
content: data.content,
savedContent: data.content,
loading: false,
saving: false,
},
}));
},
[jobId],
);
const openFile = useCallback(
(path: string) => {
setOpenFilePaths((current) =>
current.includes(path) ? current : [...current, path],
);
setActiveFilePath(path);
if (!files[path]) {
void loadFile(path).catch((error) => {
console.error(error);
setFiles((current) => {
const next = { ...current };
delete next[path];
return next;
});
setOpenFilePaths((current) =>
current.filter((filePath) => filePath !== path),
);
toast.error('Could not load file.');
});
}
},
[files, loadFile],
);
useEffect(() => {
if (!job) return;
const timeout = window.setTimeout(() => {
@@ -73,27 +140,143 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
return () => window.clearTimeout(timeout);
}, [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) {
return (
<main className='text-muted-foreground p-6'>Loading workspace...</main>
);
}
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
const saveFile = async (content: string) => {
if (!selectedPath) return;
if (!activeFilePath) return;
setFiles((current) => ({
...current,
[activeFilePath]: {
...(current[activeFilePath] ?? {
path: activeFilePath,
savedContent: '',
loading: false,
}),
content,
saving: true,
},
}));
const response = await fetch(`/api/agent-jobs/${jobId}/file`, {
method: 'PUT',
body: JSON.stringify({ path: selectedPath, content }),
body: JSON.stringify({ path: activeFilePath, content }),
});
if (!response.ok) {
toast.error('Could not save file.');
setFiles((current) => ({
...current,
[activeFilePath]: {
...(current[activeFilePath] ?? {
path: activeFilePath,
content,
savedContent: '',
loading: false,
}),
saving: false,
},
}));
throw new Error(await response.text());
}
setFileContent(content);
setFiles((current) => ({
...current,
[activeFilePath]: {
...(current[activeFilePath] ?? {
path: activeFilePath,
loading: false,
}),
content,
savedContent: content,
saving: false,
},
}));
await loadDiff();
toast.success('File saved.');
};
const closeFile = (path: string) => {
const file = files[path];
if (file && file.content !== file.savedContent) {
const confirmed = window.confirm(
`Close ${path} and discard unsaved changes?`,
);
if (!confirmed) return;
}
const index = openFilePaths.indexOf(path);
const nextOpen = openFilePaths.filter((filePath) => filePath !== path);
setOpenFilePaths(nextOpen);
setFiles((current) => {
const next = { ...current };
delete next[path];
return next;
});
if (activeFilePath === path) {
setActiveFilePath(nextOpen[index - 1] ?? nextOpen[index] ?? undefined);
}
};
const toggleDirectory = (path: string) => {
setExpandedDirectoryPaths((current) =>
current.includes(path)
? current.filter((directoryPath) => directoryPath !== path)
: [...current, path],
);
};
return (
<main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'>
<JobStatusBar job={job} />
@@ -108,13 +291,10 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
</div>
<FileTree
tree={tree}
selectedPath={selectedPath}
onSelect={(path) => {
void loadFile(path).catch((error) => {
console.error(error);
toast.error('Could not load file.');
});
}}
selectedPath={activeFilePath}
expandedPaths={expandedDirectoryPaths}
onSelect={openFile}
onToggleDirectory={toggleDirectory}
/>
</aside>
<section className='bg-background flex min-w-0 flex-col'>
@@ -129,12 +309,44 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
Thread
</TabsTrigger>
</TabsList>
<TabsContent value='editor' className='m-0 min-h-0 flex-1'>
<TabsContent
value='editor'
className='m-0 flex min-h-0 flex-1 flex-col'
>
<FileTabs
tabs={openFilePaths.map((path) => ({
path,
dirty: files[path]
? files[path].content !== files[path].savedContent
: false,
}))}
activePath={activeFilePath}
onActivate={setActiveFilePath}
onClose={closeFile}
/>
<CodeEditor
path={selectedPath}
content={fileContent}
path={activeFilePath}
content={activeFile?.content ?? ''}
savedContent={activeFile?.savedContent ?? ''}
readOnly={workspaceDisabled}
vimEnabled={vimEnabled}
onSave={saveFile}
onVimEnabledChange={setVimEnabled}
onChange={(content) => {
if (!activeFilePath) return;
setFiles((current) => ({
...current,
[activeFilePath]: {
...(current[activeFilePath] ?? {
path: activeFilePath,
savedContent: '',
loading: false,
saving: false,
}),
content,
},
}));
}}
/>
</TabsContent>
<TabsContent value='diff' className='m-0 min-h-0 flex-1'>
@@ -147,6 +359,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
<AgentThread
jobId={jobId}
messages={messages}
events={events}
interactions={interactions}
disabled={workspaceDisabled}
/>
</TabsContent>
@@ -157,6 +371,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
<AgentThread
jobId={jobId}
messages={messages}
events={events}
interactions={interactions}
disabled={workspaceDisabled}
/>
</aside>
@@ -5,6 +5,8 @@ import dynamic from 'next/dynamic';
import { Button, Switch } from '@spoon/ui';
import { languageForPath } from './languages';
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
ssr: false,
});
@@ -20,27 +22,27 @@ type VimMode = {
export const CodeEditor = ({
path,
content,
savedContent,
readOnly,
vimEnabled,
onSave,
onChange,
onVimEnabledChange,
}: {
path?: string;
content: string;
savedContent: string;
readOnly: boolean;
vimEnabled: boolean;
onSave: (content: string) => Promise<void>;
onChange: (content: string) => void;
onVimEnabledChange: (enabled: boolean) => void;
}) => {
const [value, setValue] = useState(content);
const [saving, setSaving] = useState(false);
const [vimEnabled, setVimEnabled] = useState(false);
const [dirty, setDirty] = useState(false);
const editorRef = useRef<MonacoEditorInstance | null>(null);
const vimRef = useRef<VimMode | null>(null);
const statusRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setValue(content);
setDirty(false);
}, [content, path]);
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
@@ -71,13 +73,14 @@ export const CodeEditor = ({
const save = async () => {
setSaving(true);
try {
await onSave(value);
setDirty(false);
await onSave(content);
} finally {
setSaving(false);
}
};
const dirty = content !== savedContent;
return (
<div className='flex h-full min-h-0 flex-col'>
<div className='border-border flex h-11 items-center justify-between gap-3 border-b px-3'>
@@ -90,7 +93,7 @@ export const CodeEditor = ({
<div className='flex items-center gap-3'>
<label className='flex items-center gap-2 text-xs'>
Vim
<Switch checked={vimEnabled} onCheckedChange={setVimEnabled} />
<Switch checked={vimEnabled} onCheckedChange={onVimEnabledChange} />
</label>
<Button
type='button'
@@ -107,7 +110,8 @@ export const CodeEditor = ({
height='100%'
width='100%'
path={path}
value={value}
language={languageForPath(path)}
value={content}
theme='vs-dark'
options={{
readOnly,
@@ -116,13 +120,20 @@ export const CodeEditor = ({
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
scrollbar: { alwaysConsumeMouseWheel: false },
quickSuggestions: true,
suggestOnTriggerCharacters: true,
tabCompletion: 'on',
wordBasedSuggestions: 'matchingDocuments',
bracketPairColorization: { enabled: true },
renderWhitespace: 'selection',
}}
onMount={(editor) => {
editorRef.current = editor as MonacoEditorInstance;
}}
onChange={(next) => {
setValue(next ?? '');
setDirty((next ?? '') !== content);
const nextValue = next ?? '';
onChange(nextValue);
}}
/>
</div>
@@ -8,18 +8,36 @@ const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
ssr: false,
});
const diffStats = (diff: string) => {
const files = new Set<string>();
let additions = 0;
let removals = 0;
for (const line of diff.split('\n')) {
if (line.startsWith('diff --git ')) files.add(line);
if (line.startsWith('+') && !line.startsWith('+++')) additions += 1;
if (line.startsWith('-') && !line.startsWith('---')) removals += 1;
}
return { files: files.size, additions, removals };
};
export const DiffViewer = ({
diff,
onRefresh,
}: {
diff: string;
onRefresh: () => Promise<void>;
}) => (
}) => {
const stats = diffStats(diff);
return (
<div className='flex h-full min-h-0 flex-col'>
<div className='border-border flex h-11 items-center justify-between border-b px-3'>
<div>
<div className='border-border flex h-12 items-center justify-between gap-3 border-b px-3'>
<div className='min-w-0'>
<p className='text-sm font-medium'>Workspace diff</p>
<p className='text-muted-foreground text-xs'>Current git 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
@@ -38,6 +56,7 @@ export const DiffViewer = ({
fontSize: 13,
scrollBeyondLastLine: false,
automaticLayout: true,
scrollbar: { alwaysConsumeMouseWheel: false },
}}
/>
) : (
@@ -47,3 +66,4 @@ export const DiffViewer = ({
)}
</div>
);
};
@@ -0,0 +1,65 @@
'use client';
import { Circle, X } from 'lucide-react';
import { Button } from '@spoon/ui';
import { basename } from './languages';
export type OpenFileTab = {
path: string;
dirty: boolean;
};
export const FileTabs = ({
tabs,
activePath,
onActivate,
onClose,
}: {
tabs: OpenFileTab[];
activePath?: string;
onActivate: (path: string) => void;
onClose: (path: string) => void;
}) => {
if (tabs.length === 0) return null;
return (
<div className='border-border bg-muted/30 flex h-10 flex-none items-stretch overflow-x-auto border-b'>
{tabs.map((tab) => {
const active = tab.path === activePath;
return (
<div
key={tab.path}
className={
active
? 'border-primary bg-background flex max-w-56 min-w-0 items-center border-t-2 border-r'
: 'border-border flex max-w-56 min-w-0 items-center border-r'
}
title={tab.path}
>
<button
type='button'
className='flex h-full min-w-0 flex-1 items-center gap-2 px-3 text-left text-xs'
onClick={() => onActivate(tab.path)}
>
{tab.dirty ? (
<Circle className='fill-primary text-primary size-2 flex-none' />
) : null}
<span className='truncate font-mono'>{basename(tab.path)}</span>
</button>
<Button
type='button'
variant='ghost'
size='icon'
className='mr-1 size-6 flex-none'
aria-label={`Close ${tab.path}`}
onClick={() => onClose(tab.path)}
>
<X className='size-3' />
</Button>
</div>
);
})}
</div>
);
};
@@ -1,6 +1,12 @@
'use client';
import { ChevronRight, FileCode, Folder } from 'lucide-react';
import {
ChevronDown,
ChevronRight,
FileCode,
Folder,
FolderOpen,
} from 'lucide-react';
import { Button } from '@spoon/ui';
@@ -9,38 +15,59 @@ import type { FileTreeNode } from './types';
const TreeNode = ({
node,
selectedPath,
expandedPaths,
onSelect,
onToggle,
depth = 0,
}: {
node: FileTreeNode;
selectedPath?: string;
expandedPaths: Set<string>;
onSelect: (path: string) => void;
onToggle: (path: string) => void;
depth?: number;
}) => {
if (node.type === 'directory') {
const isRoot = !node.path;
const expanded = isRoot || expandedPaths.has(node.path);
return (
<div>
{node.path ? (
<div
className='text-muted-foreground flex h-7 items-center gap-1 px-2 text-xs font-medium'
{!isRoot ? (
<button
type='button'
aria-expanded={expanded}
className='text-muted-foreground hover:bg-muted flex h-7 w-full items-center gap-1 px-2 text-left text-xs font-medium'
style={{ paddingLeft: depth * 12 + 8 }}
onClick={() => onToggle(node.path)}
>
<ChevronRight className='size-3' />
<Folder className='size-3' />
{expanded ? (
<ChevronDown className='size-3 flex-none' />
) : (
<ChevronRight className='size-3 flex-none' />
)}
{expanded ? (
<FolderOpen className='size-3 flex-none' />
) : (
<Folder className='size-3 flex-none' />
)}
<span className='truncate'>{node.name}</span>
</div>
</button>
) : null}
{expanded ? (
<div>
{node.children?.map((child) => (
<TreeNode
key={`${child.type}:${child.path}`}
node={child}
selectedPath={selectedPath}
expandedPaths={expandedPaths}
onSelect={onSelect}
onToggle={onToggle}
depth={node.path ? depth + 1 : depth}
/>
))}
</div>
) : null}
</div>
);
}
@@ -62,11 +89,15 @@ const TreeNode = ({
export const FileTree = ({
tree,
selectedPath,
expandedPaths,
onSelect,
onToggleDirectory,
}: {
tree: FileTreeNode | null;
selectedPath?: string;
expandedPaths: string[];
onSelect: (path: string) => void;
onToggleDirectory: (path: string) => void;
}) => {
if (!tree) {
return (
@@ -76,8 +107,14 @@ export const FileTree = ({
);
}
return (
<div className='overflow-auto py-2'>
<TreeNode node={tree} selectedPath={selectedPath} onSelect={onSelect} />
<div className='h-full overflow-auto py-2'>
<TreeNode
node={tree}
selectedPath={selectedPath}
expandedPaths={new Set(expandedPaths)}
onSelect={onSelect}
onToggle={onToggleDirectory}
/>
</div>
);
};
@@ -0,0 +1,27 @@
export const languageForPath = (path?: string) => {
if (!path) return undefined;
const name = path.toLowerCase().split('/').at(-1) ?? path.toLowerCase();
if (name === '.env' || name.startsWith('.env.')) return 'plaintext';
if (name.endsWith('.tsx') || name.endsWith('.ts')) return 'typescript';
if (
name.endsWith('.jsx') ||
name.endsWith('.js') ||
name.endsWith('.mjs') ||
name.endsWith('.cjs')
) {
return 'javascript';
}
if (name.endsWith('.json')) return 'json';
if (name.endsWith('.css')) return 'css';
if (name.endsWith('.scss')) return 'scss';
if (name.endsWith('.html')) return 'html';
if (name.endsWith('.md') || name.endsWith('.mdx')) return 'markdown';
if (name.endsWith('.yml') || name.endsWith('.yaml')) return 'yaml';
if (name.endsWith('.sh') || name.endsWith('.bash')) return 'shell';
if (name.endsWith('.py')) return 'python';
if (name.endsWith('.rs')) return 'rust';
if (name.endsWith('.go')) return 'go';
return undefined;
};
export const basename = (path: string) => path.split('/').at(-1) ?? path;
@@ -1,9 +1,17 @@
'use client';
import { ExternalLink, GitPullRequestDraft, Square } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useMutation } from 'convex/react';
import {
ExternalLink,
GitPullRequestDraft,
Square,
Trash2,
} from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button } from '@spoon/ui';
export const WorkspaceActions = ({
@@ -13,6 +21,12 @@ export const WorkspaceActions = ({
job: Doc<'agentJobs'>;
disabled: boolean;
}) => {
const router = useRouter();
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
const canDelete =
['failed', 'cancelled', 'timed_out'].includes(job.status) ||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
const openPr = async () => {
try {
const response = await fetch(`/api/agent-jobs/${job._id}/open-pr`, {
@@ -26,6 +40,24 @@ export const WorkspaceActions = ({
}
};
const remove = async () => {
if (
!window.confirm(
'Delete this workspace and its messages, events, artifacts, diffs, and UI state? This cannot be undone.',
)
) {
return;
}
try {
await deleteWorkspace({ jobId: job._id });
toast.success('Workspace deleted.');
router.push(`/spoons/${job.spoonId}`);
} catch (error) {
console.error(error);
toast.error('Could not delete workspace.');
}
};
const stop = async () => {
try {
const response = await fetch(`/api/agent-jobs/${job._id}/stop`, {
@@ -63,6 +95,12 @@ export const WorkspaceActions = ({
<Square className='size-4' />
Stop
</Button>
{canDelete ? (
<Button type='button' variant='destructive' size='sm' onClick={remove}>
<Trash2 className='size-4' />
Delete workspace
</Button>
) : null}
</div>
);
};
@@ -3,7 +3,7 @@
import { useState } from 'react';
import Link from 'next/link';
import { useMutation } from 'convex/react';
import { ExternalLink, MonitorUp, XCircle } from 'lucide-react';
import { ExternalLink, MonitorUp, Trash2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -22,10 +22,17 @@ const formatTime = (value: number) =>
export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
const cancel = useMutation(api.agentJobs.cancel);
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
const [selectedJobId, setSelectedJobId] = useState<string | null>(
jobs[0]?._id ?? null,
);
const selectedJob = jobs.find((job) => job._id === selectedJobId) ?? jobs[0];
const selectedJobCanDelete = selectedJob
? ['failed', 'cancelled', 'timed_out'].includes(selectedJob.status) ||
['stopped', 'expired', 'failed'].includes(
selectedJob.workspaceStatus ?? '',
)
: false;
if (!jobs.length) {
return (
@@ -110,6 +117,32 @@ export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
Open workspace
</Link>
</Button>
{selectedJobCanDelete ? (
<Button
type='button'
variant='destructive'
onClick={async () => {
if (
!window.confirm(
'Delete this workspace and its messages, events, artifacts, diffs, and UI state? This cannot be undone.',
)
) {
return;
}
try {
await deleteWorkspace({ jobId: selectedJob._id });
toast.success('Workspace deleted.');
setSelectedJobId(null);
} catch (error) {
console.error(error);
toast.error('Could not delete workspace.');
}
}}
>
<Trash2 className='size-4' />
Delete workspace
</Button>
) : null}
<AgentJobDetail job={selectedJob} />
</div>
) : null}
@@ -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 };
};
export const requireAuthenticatedUser = async () => {
const token = await convexAuthNextjsToken();
if (!token) {
return {
ok: false as const,
response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
};
}
await fetchQuery(api.auth.getUser, {}, { token });
return { ok: true as const };
};
export const proxyWorkerRoot = async (path: string, init?: RequestInit) => {
const token = workerToken();
if (!token) {
return NextResponse.json(
{ error: 'SPOON_AGENT_WORKER_INTERNAL_TOKEN is not configured.' },
{ status: 500 },
);
}
const url = new URL(path, env.SPOON_AGENT_WORKER_URL);
const response = await fetch(url, {
...init,
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
...init?.headers,
},
});
const text = await response.text();
return new NextResponse(text, {
status: response.status,
headers: {
'content-type':
response.headers.get('content-type') ?? 'application/json',
},
});
};
export const proxyWorker = async (
jobId: Id<'agentJobs'>,
action: string,
@@ -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_JOB_IMAGE=${SPOON_AGENT_JOB_IMAGE:-spoon-agent-job:latest}
- SPOON_AGENT_RUNTIME=${SPOON_AGENT_RUNTIME:-docker}
- SPOON_AGENT_CONTAINER_RUNTIME=${SPOON_AGENT_CONTAINER_RUNTIME:-docker}
- SPOON_AGENT_CONTAINER_ACCESS=${SPOON_AGENT_CONTAINER_ACCESS:-network}
- SPOON_AGENT_NETWORK=${SPOON_AGENT_NETWORK:-spoon-local_default}
- SPOON_AGENT_MAX_CONCURRENT_JOBS=${SPOON_AGENT_MAX_CONCURRENT_JOBS:-1}
- SPOON_AGENT_JOB_TIMEOUT_MS=${SPOON_AGENT_JOB_TIMEOUT_MS:-1800000}
+2
View File
@@ -102,6 +102,8 @@ services:
- SPOON_AGENT_WORKER_ID=${SPOON_AGENT_WORKER_ID:-production-worker}
- SPOON_AGENT_JOB_IMAGE=${SPOON_AGENT_JOB_IMAGE:-spoon-agent-job:latest}
- SPOON_AGENT_RUNTIME=${SPOON_AGENT_RUNTIME:-docker}
- SPOON_AGENT_CONTAINER_RUNTIME=${SPOON_AGENT_CONTAINER_RUNTIME:-docker}
- SPOON_AGENT_CONTAINER_ACCESS=${SPOON_AGENT_CONTAINER_ACCESS:-network}
- SPOON_AGENT_NETWORK=${SPOON_AGENT_NETWORK:-nginx-bridge}
- SPOON_AGENT_MAX_CONCURRENT_JOBS=${SPOON_AGENT_MAX_CONCURRENT_JOBS:-1}
- SPOON_AGENT_JOB_TIMEOUT_MS=${SPOON_AGENT_JOB_TIMEOUT_MS:-1800000}
+5 -2
View File
@@ -53,8 +53,10 @@
"dev:tunnel": "turbo run dev:tunnel",
"dev:next": "turbo run dev -F @spoon/next -F @spoon/backend",
"dev:next:staging": "INFISICAL_ENV=staging turbo run dev -F @spoon/next -F @spoon/backend",
"dev:agent": "turbo run dev -F @spoon/agent-worker",
"dev:agent:staging": "INFISICAL_ENV=staging turbo run dev -F @spoon/agent-worker",
"dev:agent": "SPOON_AGENT_WORKER_URL=http://localhost:3921 SPOON_AGENT_CONTAINER_ACCESS=host_port turbo run dev -F @spoon/agent-worker",
"dev:agent:staging": "INFISICAL_ENV=staging SPOON_AGENT_WORKER_URL=http://localhost:3921 SPOON_AGENT_CONTAINER_ACCESS=host_port turbo run dev -F @spoon/agent-worker",
"dev:next:worker": "SPOON_AGENT_WORKER_URL=http://localhost:3921 SPOON_AGENT_CONTAINER_ACCESS=host_port turbo run dev -F @spoon/next -F @spoon/backend -F @spoon/agent-worker",
"dev:next:worker:staging": "INFISICAL_ENV=staging SPOON_AGENT_WORKER_URL=http://localhost:3921 SPOON_AGENT_CONTAINER_ACCESS=host_port turbo run dev -F @spoon/next -F @spoon/backend -F @spoon/agent-worker",
"dev:next:web": "turbo run dev:web -F @spoon/next -F @spoon/backend",
"dev:next:web:staging": "INFISICAL_ENV=staging turbo run dev:web -F @spoon/next -F @spoon/backend",
"dev:expo": "turbo run dev -F @spoon/expo -F @spoon/backend",
@@ -73,6 +75,7 @@
"sync:convex:production": "scripts/sync-convex-env production",
"sync:convex:prod": "scripts/sync-convex-env prod",
"auth:keys": "node scripts/generate-convex-auth-keys.mjs",
"smoke:agent-container": "scripts/smoke-agent-container",
"db:up": "bash scripts/db/up",
"db:down": "bash scripts/db/down",
"db:down:wipe": "bash scripts/db/down --wipe",
+397
View File
@@ -36,6 +36,12 @@ const workspaceStatus = v.union(
v.literal('failed'),
);
const agentRuntimeMode = v.union(
v.literal('opencode_server'),
v.literal('codex_exec'),
v.literal('legacy_cli'),
);
const messageRole = v.union(
v.literal('user'),
v.literal('assistant'),
@@ -100,6 +106,22 @@ const artifactContentType = v.union(
v.literal('text/x-diff'),
);
const interactionRuntime = v.union(v.literal('opencode'), v.literal('codex'));
const interactionKind = v.union(
v.literal('question'),
v.literal('permission'),
v.literal('tool_confirmation'),
);
const interactionStatus = v.union(
v.literal('pending'),
v.literal('answered'),
v.literal('approved'),
v.literal('rejected'),
v.literal('expired'),
);
const maintenanceDecision = v.union(
v.literal('sync'),
v.literal('ignore'),
@@ -172,6 +194,79 @@ const normalizeEnvFilePath = (value?: string) => {
return trimmed;
};
const normalizeWorkspacePath = (value: string) => {
const trimmed = optionalText(value);
if (!trimmed) throw new ConvexError('Workspace path is required.');
if (
trimmed.startsWith('/') ||
trimmed.includes('\0') ||
trimmed.split('/').includes('..') ||
trimmed === '.git' ||
trimmed.startsWith('.git/')
) {
throw new ConvexError('Workspace path must stay inside the repository.');
}
return trimmed.replace(/^\.\/+/, '');
};
const normalizeWorkspacePaths = (values: string[] | undefined, max: number) =>
values
?.map(normalizeWorkspacePath)
.filter((value, index, all) => all.indexOf(value) === index)
.slice(0, max);
const isDeletableWorkspace = (job: Doc<'agentJobs'>) =>
['failed', 'cancelled', 'timed_out'].includes(job.status) ||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
const deleteWorkspaceRows = async (ctx: MutationCtx, job: Doc<'agentJobs'>) => {
const messages = await ctx.db
.query('agentJobMessages')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect();
const events = await ctx.db
.query('agentJobEvents')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect();
const artifacts = await ctx.db
.query('agentJobArtifacts')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect();
const changes = await ctx.db
.query('agentWorkspaceChanges')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect();
const uiStates = await ctx.db
.query('agentWorkspaceUiStates')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect();
const interactions = await ctx.db
.query('agentInteractionRequests')
.withIndex('by_job', (q) => q.eq('jobId', job._id))
.collect();
for (const row of [
...messages,
...events,
...artifacts,
...changes,
...uiStates,
...interactions,
]) {
await ctx.db.delete(row._id);
}
if (job.threadId) {
const thread = await ctx.db.get(job.threadId);
if (thread?.latestAgentJobId === job._id) {
await ctx.db.patch(job.threadId, {
latestAgentJobId: undefined,
updatedAt: Date.now(),
});
}
}
await ctx.db.delete(job._id);
};
const getAgentSettings = async (ctx: MutationCtx, spoon: Doc<'spoons'>) => {
const settings = await ctx.db
.query('spoonAgentSettings')
@@ -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({
args: { jobId: v.id('agentJobs'), content: v.string() },
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({
args: { workerId: v.string() },
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({
args: {
workerToken: v.string(),
+50
View File
@@ -524,6 +524,14 @@ const applicationTables = {
baseBranch: v.string(),
workBranch: v.string(),
opencodeSessionId: v.optional(v.string()),
codexSessionId: v.optional(v.string()),
agentRuntimeMode: v.optional(
v.union(
v.literal('opencode_server'),
v.literal('codex_exec'),
v.literal('legacy_cli'),
),
),
containerId: v.optional(v.string()),
workspaceUrl: v.optional(v.string()),
workspaceExpiresAt: v.optional(v.number()),
@@ -587,6 +595,48 @@ const applicationTables = {
})
.index('by_job', ['jobId'])
.index('by_owner', ['ownerId']),
agentWorkspaceUiStates: defineTable({
jobId: v.id('agentJobs'),
spoonId: v.id('spoons'),
ownerId: v.id('users'),
openFilePaths: v.array(v.string()),
activeFilePath: v.optional(v.string()),
vimEnabled: v.boolean(),
expandedDirectoryPaths: v.array(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_job', ['jobId'])
.index('by_owner', ['ownerId']),
agentInteractionRequests: defineTable({
jobId: v.id('agentJobs'),
spoonId: v.id('spoons'),
ownerId: v.id('users'),
runtime: v.union(v.literal('opencode'), v.literal('codex')),
externalRequestId: v.string(),
kind: v.union(
v.literal('question'),
v.literal('permission'),
v.literal('tool_confirmation'),
),
title: v.string(),
body: v.string(),
options: v.optional(v.array(v.string())),
status: v.union(
v.literal('pending'),
v.literal('answered'),
v.literal('approved'),
v.literal('rejected'),
v.literal('expired'),
),
response: v.optional(v.string()),
metadata: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_job', ['jobId'])
.index('by_job_status', ['jobId', 'status'])
.index('by_owner', ['ownerId']),
agentWorkspaceChanges: defineTable({
jobId: v.id('agentJobs'),
spoonId: v.id('spoons'),
+122
View File
@@ -1,6 +1,7 @@
import { convexTest } from 'convex-test';
import { describe, expect, test } from 'vitest';
import type { Id } from '../../convex/_generated/dataModel.js';
import { api } from '../../convex/_generated/api.js';
import schema from '../../convex/schema';
@@ -33,6 +34,60 @@ const spoonInput = {
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', () => {
test('boots and executes against the project schema', async () => {
const t = convexTest(schema, modules);
@@ -89,4 +144,71 @@ describe('convex-test harness', () => {
}),
).rejects.toThrow('Spoon not found.');
});
test('deletes terminal workspaces and associated rows', async () => {
const t = convexTest(schema, modules);
const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
const spoonId = await authed(t, ownerId).mutation(
api.spoons.createManual,
spoonInput,
);
const jobId = await createAgentJob(t, {
ownerId,
spoonId,
status: 'failed',
workspaceStatus: 'failed',
});
await authed(t, ownerId).mutation(api.agentJobs.deleteWorkspace, { jobId });
const job = await t.run(async (ctx) => await ctx.db.get(jobId));
const messages = await t.run(
async (ctx) =>
await ctx.db
.query('agentJobMessages')
.withIndex('by_job', (q) => q.eq('jobId', jobId))
.collect(),
);
expect(job).toBeNull();
expect(messages).toHaveLength(0);
});
test('does not delete active workspaces', async () => {
const t = convexTest(schema, modules);
const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
const spoonId = await authed(t, ownerId).mutation(
api.spoons.createManual,
spoonInput,
);
const jobId = await createAgentJob(t, {
ownerId,
spoonId,
status: 'running',
workspaceStatus: 'active',
});
await expect(
authed(t, ownerId).mutation(api.agentJobs.deleteWorkspace, { jobId }),
).rejects.toThrow('Only stopped, cancelled, failed, or expired workspaces');
});
test('does not delete another users workspace', async () => {
const t = convexTest(schema, modules);
const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
const otherId = (await createUser(t, 'other@example.com')) as Id<'users'>;
const spoonId = await authed(t, ownerId).mutation(
api.spoons.createManual,
spoonInput,
);
const jobId = await createAgentJob(t, {
ownerId,
spoonId,
status: 'cancelled',
workspaceStatus: 'stopped',
});
await expect(
authed(t, otherId).mutation(api.agentJobs.deleteWorkspace, { jobId }),
).rejects.toThrow('Agent job not found.');
});
});
+14 -2
View File
@@ -2,6 +2,18 @@
set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
RUNTIME="${SPOON_AGENT_CONTAINER_RUNTIME:-}"
docker build -f "$ROOT_DIR/docker/agent-worker.Dockerfile" -t spoon-agent-worker:latest "$ROOT_DIR"
docker build -f "$ROOT_DIR/docker/agent-job.Dockerfile" -t spoon-agent-job:latest "$ROOT_DIR"
if [[ -z "$RUNTIME" ]]; then
if command -v podman >/dev/null 2>&1; then
RUNTIME=podman
elif command -v docker >/dev/null 2>&1; then
RUNTIME=docker
else
printf 'build-agent-images: podman or docker is required.\n' >&2
exit 1
fi
fi
"$RUNTIME" build -f "$ROOT_DIR/docker/agent-worker.Dockerfile" -t spoon-agent-worker:latest "$ROOT_DIR"
"$RUNTIME" build -f "$ROOT_DIR/docker/agent-job.Dockerfile" -t spoon-agent-job:latest "$ROOT_DIR"
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" == "--" ]]; then
shift
fi
if [[ "$#" -eq 0 ]]; then
printf 'usage: dev-agent-worker -- <command> [args...]\n' >&2
exit 2
fi
if [[ -z "${SPOON_AGENT_CONTAINER_RUNTIME:-}" ]]; then
if command -v podman >/dev/null 2>&1; then
export SPOON_AGENT_CONTAINER_RUNTIME=podman
elif command -v docker >/dev/null 2>&1; then
export SPOON_AGENT_CONTAINER_RUNTIME=docker
else
printf 'dev-agent-worker: podman or docker is required for container-backed jobs.\n' >&2
exit 1
fi
fi
export SPOON_AGENT_RUNTIME="${SPOON_AGENT_RUNTIME:-docker}"
export SPOON_AGENT_CONTAINER_ACCESS="${SPOON_AGENT_CONTAINER_ACCESS:-host_port}"
export SPOON_AGENT_WORKER_URL="${SPOON_AGENT_WORKER_URL:-http://localhost:${SPOON_AGENT_WORKER_HTTP_PORT:-3921}}"
export SPOON_AGENT_WORKER_INTERNAL_TOKEN="${SPOON_AGENT_WORKER_INTERNAL_TOKEN:-${SPOON_WORKER_TOKEN:-}}"
export SPOON_AGENT_WORKDIR="${SPOON_AGENT_LOCAL_WORKDIR:-.local/agent-work/${WITH_ENV_ENVIRONMENT:-dev}}"
export SPOON_AGENT_JOB_IMAGE="${SPOON_AGENT_LOCAL_JOB_IMAGE:-spoon-agent-job:latest}"
if [[ "$SPOON_AGENT_CONTAINER_ACCESS" == "host_port" && -z "${SPOON_AGENT_KEEP_NETWORK:-}" ]]; then
unset SPOON_AGENT_NETWORK
fi
if ! "$SPOON_AGENT_CONTAINER_RUNTIME" image inspect "$SPOON_AGENT_JOB_IMAGE" >/dev/null 2>&1; then
printf 'dev-agent-worker: job image %s is not present locally.\n' "$SPOON_AGENT_JOB_IMAGE" >&2
printf 'Build it with: scripts/build-agent-images\n' >&2
fi
exec "$@"
+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_JOB_IMAGE",
"SPOON_AGENT_RUNTIME",
"SPOON_AGENT_CONTAINER_RUNTIME",
"SPOON_CONTAINER_RUNTIME",
"SPOON_AGENT_CONTAINER_ACCESS",
"SPOON_AGENT_LOCAL_WORKDIR",
"SPOON_AGENT_LOCAL_JOB_IMAGE",
"SPOON_AGENT_KEEP_NETWORK",
"SPOON_AGENT_MAX_CONCURRENT_JOBS",
"SPOON_AGENT_JOB_TIMEOUT_MS",
"SPOON_AGENT_WORKDIR",