diff --git a/AGENTS.md b/AGENTS.md index 6fadcce..9e95883 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 3bd2ab4..d739d1c 100644 --- a/README.md +++ b/README.md @@ -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 +``` +
@@ -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 | diff --git a/apps/agent-worker/package.json b/apps/agent-worker/package.json index e84d94f..1124fdb 100644 --- a/apps/agent-worker/package.json +++ b/apps/agent-worker/package.json @@ -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", diff --git a/apps/agent-worker/src/agent-events.ts b/apps/agent-worker/src/agent-events.ts new file mode 100644 index 0000000..6437c2d --- /dev/null +++ b/apps/agent-worker/src/agent-events.ts @@ -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 | null => + value && typeof value === 'object' + ? (value as Record) + : null; + +const textFromPart = (part: Record) => { + 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; +}; diff --git a/apps/agent-worker/src/env.ts b/apps/agent-worker/src/env.ts index 0494b2a..028b0aa 100644 --- a/apps/agent-worker/src/env.ts +++ b/apps/agent-worker/src/env.ts @@ -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', diff --git a/apps/agent-worker/src/opencode-session.ts b/apps/agent-worker/src/opencode-session.ts new file mode 100644 index 0000000..1beb1e5 --- /dev/null +++ b/apps/agent-worker/src/opencode-session.ts @@ -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; +}) => { + 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.'); + } +}; diff --git a/apps/agent-worker/src/runtime/docker.ts b/apps/agent-worker/src/runtime/docker.ts index bfa0181..a7d4b5d 100644 --- a/apps/agent-worker/src/runtime/docker.ts +++ b/apps/agent-worker/src/runtime/docker.ts @@ -2,20 +2,30 @@ import { execa } from 'execa'; import { env } from '../env'; +type CommandResult = { + exitCode: number; + output: string; +}; + +const environmentArgs = (environment: Record) => + 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; 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 => { 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; + 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; + redact: (value: string) => string; + timeoutMs: number; +}): Promise => { + 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; + redact: (value: string) => string; + timeoutMs: number; + onStdoutLine?: (line: string) => Promise; + onStderrLine?: (line: string) => Promise; +}): Promise => { + 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, + ) => { + 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)); +}; diff --git a/apps/agent-worker/src/server.ts b/apps/agent-worker/src/server.ts index 99a76fa..778a8f5 100644 --- a/apps/agent-worker/src/server.ts +++ b/apps/agent-worker/src/server.ts @@ -1,12 +1,19 @@ import { createServer } from 'node:http'; import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; + import { env } from './env'; import { + abortWorkspaceAgent, + cleanupOrphanedWorkspaces, + getWorkerHealth, + getWorkspaceAgentStatus, getWorkspaceDiff, listWorkspaceTree, openWorkspacePullRequest, readWorkspaceFile, + replyToInteraction, runWorkspaceCommand, sendWorkspaceMessage, stopWorkspace, @@ -43,7 +50,7 @@ const requireAuth = (request: IncomingMessage) => { }; const jobRoute = (pathname: string) => { - const match = /^\/jobs\/([^/]+)\/([^/]+)$/.exec(pathname); + const match = /^\/jobs\/([^/]+)\/(.+)$/.exec(pathname); if (!match?.[1] || !match[2]) return null; return { jobId: decodeURIComponent(match[1]), action: match[2] }; }; @@ -57,8 +64,12 @@ export const startWorkerServer = () => { request.url ?? '/', `http://localhost:${env.httpPort}`, ); - if (url.pathname === '/health') { - sendJson(response, 200, { ok: true, workerId: env.workerId }); + if (url.pathname === '/health' && request.method === 'GET') { + sendJson(response, 200, await getWorkerHealth()); + return; + } + if (url.pathname === '/cleanup' && request.method === 'POST') { + sendJson(response, 200, await cleanupOrphanedWorkspaces()); return; } const route = jobRoute(url.pathname); @@ -108,6 +119,34 @@ export const startWorkerServer = () => { sendJson(response, 200, { success: true }); return; } + if (request.method === 'GET' && route.action === 'agent/status') { + sendJson(response, 200, getWorkspaceAgentStatus(route.jobId)); + return; + } + if (request.method === 'POST' && route.action === 'agent/abort') { + sendJson(response, 200, await abortWorkspaceAgent(route.jobId)); + return; + } + const interactionMatch = + /^interactions\/([^/]+)\/reply$/.exec(route.action); + if (request.method === 'POST' && interactionMatch?.[1]) { + const body = await parseJson<{ + externalRequestId?: string; + response?: string; + }>(request); + sendJson( + response, + 200, + await replyToInteraction(route.jobId, { + interactionId: decodeURIComponent( + interactionMatch[1], + ) as Id<'agentInteractionRequests'>, + externalRequestId: body.externalRequestId ?? '', + response: body.response ?? 'once', + }), + ); + return; + } if (request.method === 'POST' && route.action === 'run-command') { const body = await parseJson<{ command?: string }>(request); sendJson( @@ -126,12 +165,18 @@ export const startWorkerServer = () => { return; } sendJson(response, 404, { error: 'Not found' }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - sendJson(response, message === 'Unauthorized' ? 401 : 500, { - error: message, - }); - } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const status = + message === 'Unauthorized' + ? 401 + : message.includes('not supported') + ? 409 + : 500; + sendJson(response, status, { + error: message, + }); + } })(); }); server.listen(env.httpPort, () => { diff --git a/apps/agent-worker/src/worker.ts b/apps/agent-worker/src/worker.ts index 1c74236..4802dcf 100644 --- a/apps/agent-worker/src/worker.ts +++ b/apps/agent-worker/src/worker.ts @@ -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>(); +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((resolve, reject) => { + const timeout = setTimeout(() => { + workspace.resolveTurn = undefined; + reject(new Error('OpenCode turn timed out.')); + }, env.jobTimeoutMs); + workspace.resolveTurn = () => { + clearTimeout(timeout); + resolve(); + }; + }); + await promptOpenCodeSession({ + session, + prompt, + model: opencodeModel(workspace.claim), + directory: '/workspace/repo', + }); + await turnDone; +}; + const systemPromptForJob = (claim: Claim) => { const base = [ `Spoon: ${claim.spoon.name}`, @@ -759,8 +1135,8 @@ const runClaim = async (claim: Claim) => { await appendEvent(jobId, 'info', 'plan', 'Interactive workspace is ready.'); await sendWorkspaceMessage(jobId, systemPromptForJob(claim)); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); await appendEvent( jobId, 'error', @@ -888,9 +1264,80 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => { return { success: true }; }; +export const getWorkspaceAgentStatus = (jobId: string) => { + const workspace = resolveWorkspace(jobId); + return { + runtimeMode: workspace.runtimeMode ?? 'legacy_cli', + opencodeSessionId: workspace.opencodeSession?.sessionId, + codexSessionId: workspace.codexSessionId, + containerId: workspace.containerId, + active: Boolean(workspace.agentTurnActive), + }; +}; + +export const abortWorkspaceAgent = async (jobId: string) => { + const workspace = resolveWorkspace(jobId); + if (workspace.opencodeSession) { + await abortOpenCodeSession(workspace.opencodeSession); + workspace.agentTurnActive = false; + workspace.resolveTurn?.(); + workspace.resolveTurn = undefined; + await appendEvent(workspace.claim.job._id, 'warn', 'cleanup', 'Agent turn aborted.'); + return { success: true }; + } + if (workspace.runtimeMode === 'codex_exec') { + throw new Error('Codex agent turns cannot be aborted from Spoon yet.'); + } + return { success: true }; +}; + +export const replyToInteraction = async ( + jobId: string, + args: { + interactionId: Id<'agentInteractionRequests'>; + externalRequestId: string; + response: string; + }, +) => { + const workspace = resolveWorkspace(jobId); + if (workspace.runtimeMode === 'codex_exec') { + throw new Error('Codex interaction replies are not supported yet.'); + } + if (!workspace.opencodeSession) { + throw new Error('OpenCode session is not active.'); + } + const mapped = + args.response === 'reject' + ? 'reject' + : args.response === 'always' + ? 'always' + : 'once'; + await replyOpenCodePermission({ + session: workspace.opencodeSession, + permissionId: args.externalRequestId, + response: mapped, + directory: '/workspace/repo', + }); + await patchInteractionRequest({ + interactionId: args.interactionId, + status: mapped === 'reject' ? 'rejected' : 'approved', + response: mapped, + }); + await appendMessage({ + jobId: workspace.claim.job._id, + role: 'system', + status: 'completed', + content: `Interaction ${mapped === 'reject' ? 'rejected' : 'approved'}.`, + }); + return { success: true }; +}; + export const sendWorkspaceMessage = async (jobId: string, prompt: string) => { const workspace = resolveWorkspace(jobId); - const { claim, repoDir, redact, workdir } = workspace; + const { claim, redact } = workspace; + if (workspace.agentTurnActive) { + throw new Error('Wait for the current agent turn to finish or abort it.'); + } await appendMessage({ jobId: claim.job._id, role: 'user', @@ -903,50 +1350,62 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => { if ((claim.job.runtime ?? 'opencode') !== 'opencode') { throw new Error('Legacy OpenAI direct jobs are no longer supported.'); } - const aiEnv = providerEnvironment( - claim, - env.runtime === 'docker' ? jobContainerWorkspace : workdir, - ); - const secretEnv = Object.fromEntries( - claim.secrets.map((secret) => [secret.name, secret.value]), - ); - const command = agentCommand(claim, prompt); - const result = - env.runtime === 'docker' - ? await runInJobContainer({ - workdir, - command, - environment: { - ...aiEnv, - ...secretEnv, - }, - redact, - timeoutMs: env.jobTimeoutMs, - }) - : await run( - 'bash', - command.slice(1), - { - cwd: repoDir, - env: { - ...aiEnv, - ...secretEnv, - }, - redact, - timeoutMs: env.jobTimeoutMs, - }, - ); - await appendMessage({ + workspace.agentTurnActive = true; + const assistantMessageId = await appendMessage({ jobId: claim.job._id, role: 'assistant', - status: result.exitCode === 0 ? 'completed' : 'failed', - content: truncate(result.output, 40_000), + status: 'streaming', + content: '', }); - if (result.exitCode !== 0) { - throw new Error(`${agentFailurePrefix(claim)}:\n${result.output}`); + const assistantContent = { value: '' }; + if (isCodexLoginProfile(claim)) { + await runCodexTurn({ + workspace, + prompt, + assistantMessageId, + assistantContent, + }); + } else if (env.runtime === 'docker') { + await runOpenCodeTurn({ + workspace, + prompt, + assistantMessageId, + assistantContent, + }); + } else { + const aiEnv = providerEnvironment(claim); + const secretEnv = Object.fromEntries( + claim.secrets.map((secret) => [secret.name, secret.value]), + ); + const result = await run('bash', ['-lc', `opencode run --format json --model ${quoteShell(opencodeModel(claim))} ${quoteShell(prompt)}`], { + cwd: workspace.repoDir, + env: { + ...aiEnv, + ...secretEnv, + }, + redact, + timeoutMs: env.jobTimeoutMs, + }); + await updateMessage({ + messageId: assistantMessageId, + status: result.exitCode === 0 ? 'completed' : 'failed', + content: truncate(result.output, 40_000), + }); + if (result.exitCode !== 0) { + throw new Error(`${agentFailurePrefix(claim)}:\n${result.output}`); + } } - if (claim.job.jobType === 'maintenance_review') { - const decision = parseMaintenanceDecision(result.output); + if (isCodexLoginProfile(claim)) { + await updateMessage({ + messageId: assistantMessageId, + status: 'completed', + content: assistantContent.value, + }); + workspace.agentTurnActive = false; + } + workspace.agentTurnActive = false; + if (claim.job.jobType === 'maintenance_review') { + const decision = parseMaintenanceDecision(assistantContent.value); if (decision) { await addArtifact({ jobId: claim.job._id, @@ -959,11 +1418,11 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => { } else { await updateStatus(claim.job._id, 'changes_ready', { summary: - 'OpenCode completed the review, but Spoon could not parse a structured maintenance decision.', + 'The agent completed the review, but Spoon could not parse a structured maintenance decision.', }); } } - const diff = await getWorktreeDiff(repoDir, redact); + const diff = await getWorktreeDiff(workspace.repoDir, redact); await addArtifact({ jobId: claim.job._id, kind: 'diff', @@ -978,8 +1437,11 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => { changeType: 'modified', diff: truncate(diff.output, 50_000), }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); + } catch (error) { + workspace.agentTurnActive = false; + workspace.resolveTurn?.(); + workspace.resolveTurn = undefined; + const message = error instanceof Error ? error.message : String(error); await appendEvent( claim.job._id, 'error', @@ -1059,6 +1521,10 @@ export const openWorkspacePullRequest = async (jobId: string) => { summary: 'Draft PR opened from interactive workspace.', }); await markWorkspaceStopped(claim.job._id); + workspace.opencodeSession?.close(); + if (workspace.containerName) { + await stopWorkspaceContainer(workspace.containerName); + } activeWorkspaces.delete(jobId); await rm(workspace.workdir, { recursive: true, force: true }); return { @@ -1070,11 +1536,88 @@ export const openWorkspacePullRequest = async (jobId: string) => { export const stopWorkspace = async (jobId: string) => { const workspace = resolveWorkspace(jobId); await markWorkspaceStopped(workspace.claim.job._id); + workspace.opencodeSession?.close(); + if (workspace.containerName) { + await stopWorkspaceContainer(workspace.containerName); + } activeWorkspaces.delete(jobId); await rm(workspace.workdir, { recursive: true, force: true }); return { success: true }; }; +export const getWorkerHealth = async () => { + const active = [...activeWorkspaces.entries()].map(([jobId, workspace]) => ({ + jobId, + runtimeMode: workspace.runtimeMode ?? 'legacy_cli', + containerName: workspace.containerName, + workdir: workspace.workdir, + agentTurnActive: Boolean(workspace.agentTurnActive), + })); + const containerNames = await listWorkspaceContainerNames('spoon-agent-job-'); + return { + ok: true, + workerId: env.workerId, + convexUrl: env.convexUrl, + runtime: env.runtime, + containerRuntime: env.containerRuntime, + containerAccess: env.containerAccess, + jobImage: env.jobImage, + workdir: env.workdir, + network: env.network, + httpPort: env.httpPort, + maxConcurrentJobs: env.maxConcurrentJobs, + jobTimeoutMs: env.jobTimeoutMs, + activeWorkspaceCount: active.length, + activeWorkspaces: active, + workspaceContainers: containerNames, + }; +}; + +export const cleanupOrphanedWorkspaces = async () => { + const activeContainers = new Set( + [...activeWorkspaces.values()] + .map((workspace) => workspace.containerName) + .filter((value): value is string => Boolean(value)), + ); + const activeWorkdirs = new Set( + [...activeWorkspaces.values()].map((workspace) => + path.resolve(workspace.workdir), + ), + ); + const removedContainers: string[] = []; + for (const containerName of await listWorkspaceContainerNames( + 'spoon-agent-job-', + )) { + if (activeContainers.has(containerName)) continue; + await stopWorkspaceContainer(containerName); + removedContainers.push(containerName); + } + + const removedWorkdirs: string[] = []; + const root = path.resolve(env.workdir); + try { + const entries = await readdir(root, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue; + const target = path.resolve(root, entry.name); + if (activeWorkdirs.has(target)) continue; + await rm(target, { recursive: true, force: true }); + removedWorkdirs.push(target); + } + } catch (error) { + const code = error && typeof error === 'object' ? 'code' in error : false; + if (!code || (error as { code?: string }).code !== 'ENOENT') { + throw error; + } + } + + return { + success: true, + removedContainers, + removedWorkdirs, + }; +}; + export const startWorker = async () => { console.log(`Spoon agent worker ${env.workerId} polling ${env.convexUrl}`); for (;;) { diff --git a/apps/agent-worker/tests/unit/agent-events.test.ts b/apps/agent-worker/tests/unit/agent-events.test.ts new file mode 100644 index 0000000..6979c54 --- /dev/null +++ b/apps/agent-worker/tests/unit/agent-events.test.ts @@ -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}', + }); + }); +}); diff --git a/apps/next/src/app/(app)/settings/layout.tsx b/apps/next/src/app/(app)/settings/layout.tsx index 495d675..1a8d026 100644 --- a/apps/next/src/app/(app)/settings/layout.tsx +++ b/apps/next/src/app/(app)/settings/layout.tsx @@ -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 }, ]; diff --git a/apps/next/src/app/(app)/settings/worker/page.tsx b/apps/next/src/app/(app)/settings/worker/page.tsx new file mode 100644 index 0000000..3db557c --- /dev/null +++ b/apps/next/src/app/(app)/settings/worker/page.tsx @@ -0,0 +1,15 @@ +import { WorkerHealthPanel } from '@/components/settings/worker-health-panel'; + +const WorkerSettingsPage = () => ( +
+
+

Worker

+

+ Monitor the agent worker and clean up old workspace state. +

+
+ +
+); + +export default WorkerSettingsPage; diff --git a/apps/next/src/app/api/agent-jobs/[jobId]/agent/abort/route.ts b/apps/next/src/app/api/agent-jobs/[jobId]/agent/abort/route.ts new file mode 100644 index 0000000..fd241b1 --- /dev/null +++ b/apps/next/src/app/api/agent-jobs/[jobId]/agent/abort/route.ts @@ -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' }), + ); diff --git a/apps/next/src/app/api/agent-jobs/[jobId]/agent/status/route.ts b/apps/next/src/app/api/agent-jobs/[jobId]/agent/status/route.ts new file mode 100644 index 0000000..970fc65 --- /dev/null +++ b/apps/next/src/app/api/agent-jobs/[jobId]/agent/status/route.ts @@ -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' }), + ); diff --git a/apps/next/src/app/api/agent-jobs/[jobId]/interactions/[interactionId]/reply/route.ts b/apps/next/src/app/api/agent-jobs/[jobId]/interactions/[interactionId]/reply/route.ts new file mode 100644 index 0000000..2436026 --- /dev/null +++ b/apps/next/src/app/api/agent-jobs/[jobId]/interactions/[interactionId]/reply/route.ts @@ -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(), + }, + ); +}; diff --git a/apps/next/src/app/api/agent-worker/cleanup/route.ts b/apps/next/src/app/api/agent-worker/cleanup/route.ts new file mode 100644 index 0000000..55dd4fd --- /dev/null +++ b/apps/next/src/app/api/agent-worker/cleanup/route.ts @@ -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' }); +}; diff --git a/apps/next/src/app/api/agent-worker/health/route.ts b/apps/next/src/app/api/agent-worker/health/route.ts new file mode 100644 index 0000000..37d9eea --- /dev/null +++ b/apps/next/src/app/api/agent-worker/health/route.ts @@ -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' }); +}; diff --git a/apps/next/src/components/agent-workspace/agent-thread.tsx b/apps/next/src/components/agent-workspace/agent-thread.tsx index 04f6c41..1be2602 100644 --- a/apps/next/src/components/agent-workspace/agent-thread.tsx +++ b/apps/next/src/components/agent-workspace/agent-thread.tsx @@ -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(); 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 (
-
-

Agent thread

-

- Messages persist with this workspace. -

+
+
+

Agent thread

+

+ Messages, tool activity, and requests persist with this workspace. +

+
+
+ {interactions.map((interaction) => ( +
+
+ {interaction.title} + + {interaction.status} + +
+

{interaction.body}

+ {interaction.status === 'pending' ? ( +
+ + +
+ ) : null} +
+ ))} {messages.map((message) => (
{message.role} - + {message.status} - +
-

{message.content}

+

+ {message.content || + (message.status === 'streaming' ? 'Working...' : '')} +

+
+ ))} + {events.slice(-20).map((event) => ( +
+
+ + {event.phase} / {event.level} + + {new Date(event.createdAt).toLocaleTimeString()} +
+

{event.message}

))}
diff --git a/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx b/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx index 1de588f..d6ee644 100644 --- a/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx +++ b/apps/next/src/components/agent-workspace/agent-workspace-shell.tsx @@ -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(null); - const [selectedPath, setSelectedPath] = useState(); - const [fileContent, setFileContent] = useState(''); + const [files, setFiles] = useState>({}); + const [openFilePaths, setOpenFilePaths] = useState([]); + const [activeFilePath, setActiveFilePath] = useState(); + 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 (
Loading workspace...
); } + 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 (
@@ -108,13 +291,10 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
{ - void loadFile(path).catch((error) => { - console.error(error); - toast.error('Could not load file.'); - }); - }} + selectedPath={activeFilePath} + expandedPaths={expandedDirectoryPaths} + onSelect={openFile} + onToggleDirectory={toggleDirectory} />
@@ -129,12 +309,44 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { Thread - + + ({ + path, + dirty: files[path] + ? files[path].content !== files[path].savedContent + : false, + }))} + activePath={activeFilePath} + onActivate={setActiveFilePath} + onClose={closeFile} + /> { + if (!activeFilePath) return; + setFiles((current) => ({ + ...current, + [activeFilePath]: { + ...(current[activeFilePath] ?? { + path: activeFilePath, + savedContent: '', + loading: false, + saving: false, + }), + content, + }, + })); + }} /> @@ -147,6 +359,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { @@ -157,6 +371,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => { diff --git a/apps/next/src/components/agent-workspace/code-editor.tsx b/apps/next/src/components/agent-workspace/code-editor.tsx index c0c0bba..8d11170 100644 --- a/apps/next/src/components/agent-workspace/code-editor.tsx +++ b/apps/next/src/components/agent-workspace/code-editor.tsx @@ -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; + 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(null); const vimRef = useRef(null); const statusRef = useRef(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 (
@@ -90,7 +93,7 @@ export const CodeEditor = ({
diff --git a/apps/next/src/components/agent-workspace/diff-viewer.tsx b/apps/next/src/components/agent-workspace/diff-viewer.tsx index e9c4b55..4df280e 100644 --- a/apps/next/src/components/agent-workspace/diff-viewer.tsx +++ b/apps/next/src/components/agent-workspace/diff-viewer.tsx @@ -8,42 +8,62 @@ const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), { ssr: false, }); +const diffStats = (diff: string) => { + const files = new Set(); + 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; -}) => ( -
-
-
-

Workspace diff

-

Current git diff

+}) => { + const stats = diffStats(diff); + return ( +
+
+
+

Workspace diff

+

+ {diff.trim() + ? `${stats.files} files, +${stats.additions} -${stats.removals}` + : 'Current git diff'} +

+
+
- + {diff.trim() ? ( + + ) : ( +
+ No workspace diff yet. +
+ )}
- {diff.trim() ? ( - - ) : ( -
- No workspace diff yet. -
- )} -
-); + ); +}; diff --git a/apps/next/src/components/agent-workspace/file-tabs.tsx b/apps/next/src/components/agent-workspace/file-tabs.tsx new file mode 100644 index 0000000..ebf4657 --- /dev/null +++ b/apps/next/src/components/agent-workspace/file-tabs.tsx @@ -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 ( +
+ {tabs.map((tab) => { + const active = tab.path === activePath; + return ( +
+ + +
+ ); + })} +
+ ); +}; diff --git a/apps/next/src/components/agent-workspace/file-tree.tsx b/apps/next/src/components/agent-workspace/file-tree.tsx index da1c53c..330faca 100644 --- a/apps/next/src/components/agent-workspace/file-tree.tsx +++ b/apps/next/src/components/agent-workspace/file-tree.tsx @@ -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; 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 (
- {node.path ? ( -
onToggle(node.path)} > - - + {expanded ? ( + + ) : ( + + )} + {expanded ? ( + + ) : ( + + )} {node.name} + + ) : null} + {expanded ? ( +
+ {node.children?.map((child) => ( + + ))}
) : null} -
- {node.children?.map((child) => ( - - ))} -
); } @@ -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 ( -
- +
+
); }; diff --git a/apps/next/src/components/agent-workspace/languages.ts b/apps/next/src/components/agent-workspace/languages.ts new file mode 100644 index 0000000..3a5c4bf --- /dev/null +++ b/apps/next/src/components/agent-workspace/languages.ts @@ -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; diff --git a/apps/next/src/components/agent-workspace/workspace-actions.tsx b/apps/next/src/components/agent-workspace/workspace-actions.tsx index 77a1f93..5eab426 100644 --- a/apps/next/src/components/agent-workspace/workspace-actions.tsx +++ b/apps/next/src/components/agent-workspace/workspace-actions.tsx @@ -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 = ({ Stop + {canDelete ? ( + + ) : null}
); }; diff --git a/apps/next/src/components/agents/agent-job-list.tsx b/apps/next/src/components/agents/agent-job-list.tsx index 41bfe43..634fd08 100644 --- a/apps/next/src/components/agents/agent-job-list.tsx +++ b/apps/next/src/components/agents/agent-job-list.tsx @@ -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( 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 + {selectedJobCanDelete ? ( + + ) : null}
) : null} diff --git a/apps/next/src/components/settings/worker-health-panel.tsx b/apps/next/src/components/settings/worker-health-panel.tsx new file mode 100644 index 0000000..ae9eb4c --- /dev/null +++ b/apps/next/src/components/settings/worker-health-panel.tsx @@ -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(null); + const [healthError, setHealthError] = useState(); + 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 ( +
+ + +
+ Worker health +

+ Runtime status for the server-side agent worker. +

+
+ +
+ + {healthError ? ( +
+ {healthError} +
+ ) : null} + {health ? ( + <> +
+ + {health.ok ? 'healthy' : 'unhealthy'} + + {health.workerId} + + {health.containerRuntime} / {health.containerAccess} + +
+
+
+
Convex
+
{health.convexUrl}
+
+
+
Job image
+
{health.jobImage}
+
+
+
Workdir
+
{health.workdir}
+
+
+
Network
+
+ {health.network ?? 'none'} +
+
+
+
HTTP port
+
{health.httpPort}
+
+
+
Active workspaces
+
{health.activeWorkspaceCount}
+
+
+
+

+ Workspace containers +

+

+ {health.workspaceContainers.length + ? health.workspaceContainers.join(', ') + : 'none'} +

+
+ + ) : !healthError ? ( +

+ {loadingHealth ? 'Checking worker...' : 'No worker response yet.'} +

+ ) : null} +
+
+ + + + Cleanup +

+ Remove stopped workspace records and orphaned local worker + resources. +

+
+ +
+ +

+ {deletableCount} stopped, cancelled, failed, timed out, or expired + workspaces match this age filter. +

+ +
+ +
+
+

Orphaned worker resources

+

+ Remove inactive Spoon job containers and inactive directories + under the configured worker workdir. +

+
+ +
+
+
+
+ ); +}; diff --git a/apps/next/src/lib/agent-worker-proxy.ts b/apps/next/src/lib/agent-worker-proxy.ts index 3c32d8b..c3e1e31 100644 --- a/apps/next/src/lib/agent-worker-proxy.ts +++ b/apps/next/src/lib/agent-worker-proxy.ts @@ -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, diff --git a/apps/next/tests/unit/workspace-languages.test.ts b/apps/next/tests/unit/workspace-languages.test.ts new file mode 100644 index 0000000..0ec33d7 --- /dev/null +++ b/apps/next/tests/unit/workspace-languages.test.ts @@ -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'); + }); +}); diff --git a/docker/compose.local.yml b/docker/compose.local.yml index da1581c..5b10d43 100644 --- a/docker/compose.local.yml +++ b/docker/compose.local.yml @@ -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} diff --git a/docker/compose.yml b/docker/compose.yml index 5d2a628..58c9dce 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -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} diff --git a/package.json b/package.json index 5ed7602..39bb743 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/backend/convex/agentJobs.ts b/packages/backend/convex/agentJobs.ts index e9f7d4c..f5d8dfd 100644 --- a/packages/backend/convex/agentJobs.ts +++ b/packages/backend/convex/agentJobs.ts @@ -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(), diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index e0f1329..26a435f 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -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'), diff --git a/packages/backend/tests/unit/harness.test.ts b/packages/backend/tests/unit/harness.test.ts index a8d4c43..1ba44c1 100644 --- a/packages/backend/tests/unit/harness.test.ts +++ b/packages/backend/tests/unit/harness.test.ts @@ -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, + 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 user’s 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.'); + }); }); diff --git a/scripts/build-agent-images b/scripts/build-agent-images index 52bebd9..1c726a8 100755 --- a/scripts/build-agent-images +++ b/scripts/build-agent-images @@ -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" diff --git a/scripts/dev-agent-worker b/scripts/dev-agent-worker new file mode 100755 index 0000000..ae62e7a --- /dev/null +++ b/scripts/dev-agent-worker @@ -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 -- [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 "$@" diff --git a/scripts/smoke-agent-container b/scripts/smoke-agent-container new file mode 100755 index 0000000..5fd0fde --- /dev/null +++ b/scripts/smoke-agent-container @@ -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 +' diff --git a/turbo.json b/turbo.json index 12da69b..9e3496b 100644 --- a/turbo.json +++ b/turbo.json @@ -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",