From a6f7ea7f78ad25142ad681ec38e1258b62bc498e Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 23 Jun 2026 14:57:05 -0400 Subject: [PATCH] Clean up old stuff & fix ui errors --- AGENTS.md | 4 + README.md | 7 + apps/agent-worker/src/agent-events.ts | 136 ++++++++- apps/agent-worker/src/codex-runtime.ts | 39 +++ apps/agent-worker/src/env.ts | 1 + apps/agent-worker/src/runtime/docker.ts | 15 +- apps/agent-worker/src/worker.ts | 132 ++++++-- .../tests/unit/agent-events.test.ts | 101 ++++++ .../tests/unit/codex-runtime.test.ts | 43 +++ .../tests/unit/docker-runtime.test.ts | 46 +++ .../spoons/[spoonId]/agent/[jobId]/page.tsx | 22 +- .../src/app/(app)/spoons/[spoonId]/page.tsx | 6 +- .../src/app/(app)/threads/[threadId]/page.tsx | 171 +++++------ apps/next/src/app/(app)/threads/page.tsx | 78 ++++- .../agent-workspace/agent-thread.tsx | 289 +++++++++++++++--- .../agent-workspace/agent-workspace-shell.tsx | 153 ++++++++-- .../agent-workspace/code-editor.tsx | 5 +- .../components/agent-workspace/diff-utils.ts | 26 ++ .../agent-workspace/diff-viewer.tsx | 46 ++- .../agent-workspace/workspace-actions.tsx | 43 ++- .../agents/agent-artifact-viewer.tsx | 51 ---- .../src/components/agents/agent-event-log.tsx | 47 --- .../components/agents/agent-job-detail.tsx | 66 ---- .../src/components/agents/agent-job-list.tsx | 151 --------- .../src/components/app-shell/app-shell.tsx | 4 +- .../spoons/spoon-agent-settings-form.tsx | 2 +- .../thread-workspace-form.tsx} | 19 +- apps/next/tests/component/render.test.tsx | 113 ++++++- apps/next/vitest.config.ts | 28 +- packages/backend/convex/agentJobs.ts | 62 +++- packages/backend/convex/schema.ts | 3 + packages/backend/convex/threads.ts | 84 +++++ packages/backend/tests/unit/harness.test.ts | 122 ++++++++ turbo.json | 1 + 34 files changed, 1565 insertions(+), 551 deletions(-) create mode 100644 apps/agent-worker/src/codex-runtime.ts create mode 100644 apps/agent-worker/tests/unit/codex-runtime.test.ts create mode 100644 apps/agent-worker/tests/unit/docker-runtime.test.ts create mode 100644 apps/next/src/components/agent-workspace/diff-utils.ts delete mode 100644 apps/next/src/components/agents/agent-artifact-viewer.tsx delete mode 100644 apps/next/src/components/agents/agent-event-log.tsx delete mode 100644 apps/next/src/components/agents/agent-job-detail.tsx delete mode 100644 apps/next/src/components/agents/agent-job-list.tsx rename apps/next/src/components/{agents/agent-request-form.tsx => threads/thread-workspace-form.tsx} (93%) diff --git a/AGENTS.md b/AGENTS.md index a47530d..98c173e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,10 @@ - `packages/backend/convex`: self-hosted Convex functions, schema, and auth. - `packages/ui`: shared shadcn-based UI components. - `tools`: shared ESLint, Prettier, Tailwind, TypeScript, and Vitest config. +- Threads are the canonical user-facing workspace route. Normal navigation + should open `/threads/[threadId]`; legacy job URLs under + `/spoons/[spoonId]/agent/[jobId]` are compatibility routes for jobs that do + not have a thread yet. - Local development uses host-run apps, local Convex on ports 3210/3211, local Postgres on port 5432 for Convex storage, and the Convex dashboard on port 6791. Agent jobs are opt-in; build `docker/agent-job.Dockerfile` as diff --git a/README.md b/README.md index 943ad52..aa247b5 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,12 @@ Common thread sources: Threads hold messages, status, outcomes, related sync runs, related jobs, workspace links, draft PR links, and ignored upstream decisions. +Opening a thread opens its workspace when a run exists. The workspace is the +primary surface for that thread: agent messages, tool activity, file edits, +manual edits, diffs, commands, and draft PR actions all happen there. Legacy +job URLs under `/spoons/[spoonId]/agent/[jobId]` are kept for compatibility, +but normal navigation targets `/threads/[threadId]`. +
@@ -144,6 +150,7 @@ Workspace capabilities: - browse repository files - edit files in a browser editor - use optional Vim keybindings +- resize the agent thread panel on desktop - inspect diffs - send thread messages to the agent - run configured commands diff --git a/apps/agent-worker/src/agent-events.ts b/apps/agent-worker/src/agent-events.ts index 6437c2d..d20c05f 100644 --- a/apps/agent-worker/src/agent-events.ts +++ b/apps/agent-worker/src/agent-events.ts @@ -70,6 +70,62 @@ const textFromPart = (part: Record) => { return typeof text === 'string' ? text : ''; }; +const commandString = (value: unknown) => { + if (Array.isArray(value)) return value.map((part) => stringify(part)).join(' '); + return stringify(value); +}; + +const toolNameFromRecord = (record: Record | null) => + stringify( + record?.tool ?? + record?.tool_name ?? + record?.toolName ?? + record?.name ?? + record?.function ?? + record?.type ?? + record?.command ?? + 'tool', + ); + +const toolInputFromRecord = (record: Record | null) => + commandString( + record?.input ?? + record?.arguments ?? + record?.args ?? + record?.params ?? + record?.command ?? + record?.cmd, + ); + +const toolOutputFromRecord = ( + record: Record | null, + fallback?: unknown, +) => + stringify( + record?.output ?? + record?.result ?? + record?.content ?? + record?.text ?? + fallback, + ); + +const recordLooksLikeTool = ( + type: string, + record: Record | null, +) => { + const recordType = stringify(record?.type).toLowerCase(); + const lowerType = type.toLowerCase(); + return ( + lowerType.includes('tool') || + lowerType.includes('function_call') || + recordType.includes('tool') || + recordType.includes('function_call') || + recordType.includes('local_shell_call') || + recordType.includes('mcp') || + Boolean(record?.tool ?? record?.tool_name ?? record?.name) + ); +}; + export const normalizeCodexJsonLine = ( line: string, ): NormalizedAgentEvent[] => { @@ -95,6 +151,37 @@ export const normalizeCodexJsonLine = ( const item = asRecord(event.item); const data = asRecord(event.data); const part = asRecord(event.part); + const itemType = item ? stringify(item.type) : ''; + const lowerType = type.toLowerCase(); + const lowerItemType = itemType.toLowerCase(); + if ( + item && + recordLooksLikeTool(type, item) && + (lowerType.includes('started') || + lowerType.includes('in_progress') || + lowerType.includes('created')) + ) { + events.push({ + kind: 'tool_started', + name: toolNameFromRecord(item), + input: toolInputFromRecord(item), + externalMessageId: stringify(item.id ?? event.id), + }); + } + if ( + item && + recordLooksLikeTool(type, item) && + (lowerType.includes('completed') || + lowerType.includes('done') || + lowerType.includes('finished')) + ) { + events.push({ + kind: 'tool_completed', + name: toolNameFromRecord(item), + output: toolOutputFromRecord(item, event.output ?? data?.output), + externalMessageId: stringify(item.id ?? event.id), + }); + } const delta = event.delta ?? data?.delta; if (typeof delta === 'string') { events.push({ kind: 'assistant_delta', content: delta }); @@ -107,19 +194,43 @@ export const normalizeCodexJsonLine = ( text && (type.includes('message') || type.includes('response.output_text') || - type.includes('agent_message')) + type.includes('agent_message') || + itemType.includes('message') || + itemType.includes('agent_message')) ) { events.push({ kind: 'assistant_delta', content: text }); } - const command = event.command ?? data?.command; + const error = event.error ?? item?.error; + if (error || itemType === 'error') { + events.push({ + kind: 'error', + message: stringify(error ?? item?.message ?? event.message), + }); + } + const command = + event.command ?? + data?.command ?? + (lowerItemType.includes('shell') ? item?.command : undefined); if (typeof command === 'string') { events.push({ kind: 'command_executed', command, output: stringify(event.output ?? data?.output), }); + } else if (Array.isArray(command)) { + events.push({ + kind: 'command_executed', + command: command.map((part) => stringify(part)).join(' '), + output: stringify(event.output ?? data?.output ?? item?.output), + }); } - const file = event.file ?? event.path ?? data?.file ?? data?.path; + const file = + event.file ?? + event.path ?? + data?.file ?? + data?.path ?? + item?.file ?? + item?.path; if (typeof file === 'string' && type.includes('file')) { events.push({ kind: 'file_edited', path: file }); } @@ -129,7 +240,16 @@ export const normalizeCodexJsonLine = ( message: stringify(event.message ?? event.error ?? data), }); } - if (type.includes('completed') || type.includes('turn.done')) { + if ( + type.includes('completed') && + itemType !== 'error' && + !itemType.includes('message') && + !itemType.includes('agent_message') && + !recordLooksLikeTool(type, item) + ) { + events.push({ kind: 'assistant_completed' }); + } + if (type.includes('turn.done')) { events.push({ kind: 'assistant_completed' }); } if (events.length === 0) { @@ -188,6 +308,14 @@ export const normalizeOpenCodeEvent = ( externalMessageId: stringify(properties.messageID), }); } + if (type.includes('tool.updated') || type.includes('tool.output')) { + events.push({ + kind: 'tool_completed', + name: stringify(properties.tool ?? properties.name ?? 'tool'), + output: stringify(properties.output ?? properties.result ?? properties), + externalMessageId: stringify(properties.messageID), + }); + } if (type === 'file.edited') { const file = properties.file; if (typeof file === 'string') events.push({ kind: 'file_edited', path: file }); diff --git a/apps/agent-worker/src/codex-runtime.ts b/apps/agent-worker/src/codex-runtime.ts new file mode 100644 index 0000000..05fb8ae --- /dev/null +++ b/apps/agent-worker/src/codex-runtime.ts @@ -0,0 +1,39 @@ +import { chmod, mkdir, stat } from 'node:fs/promises'; +import path from 'node:path'; + +export const codexContainerWorkspace = '/workspace'; +export const codexContainerRepo = '/workspace/repo'; + +export const prepareCodexWorkspaceFiles = async (args: { + workdir: string; + repoDir: string; +}) => { + await mkdir(path.join(args.workdir, '.codex'), { recursive: true }); + await mkdir(path.join(args.workdir, '.config'), { recursive: true }); + await mkdir(path.join(args.workdir, '.local', 'share'), { recursive: true }); + + await Promise.all([ + chmod(args.workdir, 0o755), + chmod(args.repoDir, 0o755), + chmod(path.join(args.workdir, '.codex'), 0o755), + chmod(path.join(args.workdir, '.config'), 0o755), + chmod(path.join(args.workdir, '.local'), 0o755), + chmod(path.join(args.workdir, '.local', 'share'), 0o755), + ]); + + const projectCodexDir = path.join(args.repoDir, '.codex'); + const projectConfig = path.join(projectCodexDir, 'config.toml'); + try { + if ((await stat(projectCodexDir)).isDirectory()) { + await chmod(projectCodexDir, 0o755); + } + if ((await stat(projectConfig)).isFile()) { + await chmod(projectConfig, 0o644); + } + } catch (error) { + const code = error && typeof error === 'object' ? 'code' in error : false; + if (!code || (error as { code?: string }).code !== 'ENOENT') { + throw error; + } + } +}; diff --git a/apps/agent-worker/src/env.ts b/apps/agent-worker/src/env.ts index 028b0aa..816b9fb 100644 --- a/apps/agent-worker/src/env.ts +++ b/apps/agent-worker/src/env.ts @@ -23,6 +23,7 @@ export const env = { process.env.SPOON_AGENT_CONTAINER_RUNTIME?.trim() ?? process.env.SPOON_CONTAINER_RUNTIME?.trim() ?? 'docker', + containerVolumeOptions: process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS?.trim(), containerAccess: process.env.SPOON_AGENT_CONTAINER_ACCESS?.trim() === 'host_port' ? 'host_port' diff --git a/apps/agent-worker/src/runtime/docker.ts b/apps/agent-worker/src/runtime/docker.ts index a7d4b5d..5e01af8 100644 --- a/apps/agent-worker/src/runtime/docker.ts +++ b/apps/agent-worker/src/runtime/docker.ts @@ -17,6 +17,15 @@ const networkArgs = () => (env.network ? ['--network', env.network] : []); const containerRuntime = () => env.containerRuntime; +export const jobWorkspaceVolumeSpec = (workdir: string) => { + const volumeOptions = + env.containerVolumeOptions ?? + (containerRuntime().endsWith('podman') ? 'Z' : undefined); + return volumeOptions + ? `${workdir}:/workspace:${volumeOptions}` + : `${workdir}:/workspace`; +}; + export const runInJobContainer = async (args: { workdir: string; command: string[]; @@ -36,7 +45,7 @@ export const runInJobContainer = async (args: { ...networkArgs(), ...environmentArgs(args.environment), '-v', - `${args.workdir}:/workspace`, + jobWorkspaceVolumeSpec(args.workdir), '-w', '/workspace/repo', env.jobImage, @@ -87,7 +96,7 @@ export const startWorkspaceContainer = async (args: { : []), ...environmentArgs(args.environment), '-v', - `${args.workdir}:/workspace`, + jobWorkspaceVolumeSpec(args.workdir), '-w', '/workspace/repo', env.jobImage, @@ -168,7 +177,7 @@ export const streamInJobContainer = async (args: { ...networkArgs(), ...environmentArgs(args.environment), '-v', - `${args.workdir}:/workspace`, + jobWorkspaceVolumeSpec(args.workdir), '-w', '/workspace/repo', env.jobImage, diff --git a/apps/agent-worker/src/worker.ts b/apps/agent-worker/src/worker.ts index 4802dcf..2016b5e 100644 --- a/apps/agent-worker/src/worker.ts +++ b/apps/agent-worker/src/worker.ts @@ -16,6 +16,11 @@ import { api } from '@spoon/backend/convex/_generated/api.js'; import type { NormalizedAgentEvent } from './agent-events'; import { normalizeCodexJsonLine } from './agent-events'; +import { + codexContainerRepo, + codexContainerWorkspace, + prepareCodexWorkspaceFiles, +} from './codex-runtime'; import { env } from './env'; import { cloneRepository, @@ -118,7 +123,6 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const client = new ConvexHttpClient(env.convexUrl); const activeWorkspaces = new Map(); -const jobContainerWorkspace = '/workspace'; const appendEvent = async ( jobId: Id<'agentJobs'>, @@ -442,6 +446,10 @@ const prepareCodexAuth = async (workspace: ActiveWorkspace) => { if (!secret) { throw new Error('Codex auth profile is missing auth.json contents.'); } + await prepareCodexWorkspaceFiles({ + workdir: workspace.workdir, + repoDir: workspace.repoDir, + }); const codexAuthPath = path.join(workspace.workdir, '.codex', 'auth.json'); await writeJsonFile(codexAuthPath, secret); @@ -688,14 +696,18 @@ const runCodexTurn = async (args: { ? commandToShell( `codex exec resume --json --model ${quoteShell( codexModel(workspace.claim), - )} ${quoteShell(workspace.codexSessionId)} ${quoteShell(prompt)}`, + )} --dangerously-bypass-approvals-and-sandbox ${quoteShell( + workspace.codexSessionId, + )} ${quoteShell(prompt)}`, ) : commandToShell( `codex exec --json --model ${quoteShell( codexModel(workspace.claim), - )} --sandbox workspace-write ${quoteShell(prompt)}`, + )} --dangerously-bypass-approvals-and-sandbox --cd ${quoteShell( + codexContainerRepo, + )} ${quoteShell(prompt)}`, ); - const aiEnv = providerEnvironment(workspace.claim, jobContainerWorkspace); + const aiEnv = providerEnvironment(workspace.claim, codexContainerWorkspace); const secretEnv = Object.fromEntries( workspace.claim.secrets.map((secret) => [secret.name, secret.value]), ); @@ -958,6 +970,89 @@ const fileChangedType = async (repoDir: string, filePath: string) => { return 'modified' as const; }; +const sensitiveWorkspacePath = (filePath: string) => { + const parts = filePath.split('/'); + if (parts.includes('.git') || parts.includes('.codex')) return true; + const name = parts.at(-1) ?? filePath; + if (name === '.env') return true; + if (name.startsWith('.env.') && name !== '.env.example') return true; + return false; +}; + +const changedFilesFromStatus = async ( + repoDir: string, + redact: (value: string) => string, +) => { + const status = await run('git', ['status', '--short'], { + cwd: repoDir, + redact, + timeoutMs: 60_000, + }); + if (status.exitCode !== 0) return []; + return status.output + .split('\n') + .map((line) => { + if (line.length < 4) return null; + const code = line.slice(0, 2); + const rawPath = line.slice(3).trim(); + if (!rawPath) return null; + const filePath = rawPath.includes(' -> ') + ? rawPath.split(' -> ').at(-1)?.trim() + : rawPath; + if (!filePath || sensitiveWorkspacePath(filePath)) return null; + const changeType = code.includes('D') + ? 'deleted' + : code.includes('R') + ? 'renamed' + : code.includes('A') || code.includes('?') + ? 'added' + : 'modified'; + return { + path: filePath, + changeType, + }; + }) + .filter( + ( + value, + ): value is { + path: string; + changeType: 'added' | 'modified' | 'deleted' | 'renamed'; + } => Boolean(value), + ); +}; + +const recordChangedFiles = async ( + workspace: ActiveWorkspace, + source: 'agent' | 'command', + diff: string, +) => { + const changes = await changedFilesFromStatus( + workspace.repoDir, + workspace.redact, + ); + for (const change of changes) { + await recordWorkspaceChange({ + jobId: workspace.claim.job._id, + path: change.path, + source, + changeType: change.changeType, + diff: truncate(diff, 50_000), + }); + } + if (changes.length > 0) { + await appendEvent( + workspace.claim.job._id, + 'info', + 'edit', + `Workspace has ${changes.length} changed file${ + changes.length === 1 ? '' : 's' + }.`, + JSON.stringify(changes), + ); + } +}; + const materializeEnvFile = async (workspace: ActiveWorkspace) => { const { claim, repoDir } = workspace; if (!claim.job.materializeEnvFile || !claim.job.envFilePath) return; @@ -1085,6 +1180,9 @@ const runClaim = async (claim: Claim) => { ].filter(Boolean); const redact = createRedactor(secretValues); try { + if ((claim.job.runtime ?? 'opencode') !== 'opencode') { + throw new Error('Legacy OpenAI direct jobs are no longer supported.'); + } await updateStatus(jobId, 'preparing'); await appendEvent(jobId, 'info', 'clone', 'Creating installation token.'); if (!claim.github.installationId) { @@ -1251,16 +1349,11 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => { redact: workspace.redact, }); await updateStatus(workspace.claim.job._id, 'running'); - await recordWorkspaceChange({ - jobId: workspace.claim.job._id, - path: '.', - source: 'command', - changeType: 'modified', - diff: truncate( - (await getWorktreeDiff(workspace.repoDir, workspace.redact)).output, - 50_000, - ), - }); + await recordChangedFiles( + workspace, + 'command', + (await getWorktreeDiff(workspace.repoDir, workspace.redact)).output, + ); return { success: true }; }; @@ -1347,9 +1440,6 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => { await appendEvent(claim.job._id, 'info', 'plan', 'Sending message to agent.'); try { - if ((claim.job.runtime ?? 'opencode') !== 'opencode') { - throw new Error('Legacy OpenAI direct jobs are no longer supported.'); - } workspace.agentTurnActive = true; const assistantMessageId = await appendMessage({ jobId: claim.job._id, @@ -1430,13 +1520,7 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => { content: truncate(diff.output, 200_000), contentType: 'text/x-diff', }); - await recordWorkspaceChange({ - jobId: claim.job._id, - path: '.', - source: 'agent', - changeType: 'modified', - diff: truncate(diff.output, 50_000), - }); + await recordChangedFiles(workspace, 'agent', diff.output); } catch (error) { workspace.agentTurnActive = false; workspace.resolveTurn?.(); diff --git a/apps/agent-worker/tests/unit/agent-events.test.ts b/apps/agent-worker/tests/unit/agent-events.test.ts index 6979c54..7b61c71 100644 --- a/apps/agent-worker/tests/unit/agent-events.test.ts +++ b/apps/agent-worker/tests/unit/agent-events.test.ts @@ -51,6 +51,91 @@ describe('agent event normalization', () => { ).toContainEqual({ kind: 'file_edited', path: 'src/app.ts' }); }); + test('normalizes current Codex item events', () => { + expect( + normalizeCodexJsonLine( + JSON.stringify({ + type: 'item.completed', + item: { + id: 'item-1', + type: 'agent_message', + text: 'I updated the auth provider.', + }, + }), + ), + ).toContainEqual({ + kind: 'assistant_delta', + content: 'I updated the auth provider.', + }); + + expect( + normalizeCodexJsonLine( + JSON.stringify({ + type: 'item.completed', + item: { + id: 'item-2', + type: 'error', + message: 'sandbox failed', + }, + }), + ), + ).toContainEqual({ + kind: 'error', + message: 'sandbox failed', + }); + + expect( + normalizeCodexJsonLine( + JSON.stringify({ + type: 'turn.failed', + error: { message: 'request failed' }, + }), + ), + ).toContainEqual({ + kind: 'error', + message: '{\n "message": "request failed"\n}', + }); + }); + + test('normalizes Codex tool item lifecycle events', () => { + expect( + normalizeCodexJsonLine( + JSON.stringify({ + type: 'item.started', + item: { + id: 'tool-1', + type: 'local_shell_call', + command: ['bash', '-lc', 'rg Authentik'], + }, + }), + ), + ).toContainEqual({ + kind: 'tool_started', + name: 'local_shell_call', + input: 'bash -lc rg Authentik', + externalMessageId: 'tool-1', + }); + + expect( + normalizeCodexJsonLine( + JSON.stringify({ + type: 'item.completed', + item: { + id: 'tool-1', + type: 'local_shell_call', + command: ['bash', '-lc', 'rg Authentik'], + output: 'apps/web/auth.ts', + }, + }), + ), + ).toContainEqual({ + kind: 'tool_completed', + name: 'local_shell_call', + output: 'apps/web/auth.ts', + externalMessageId: 'tool-1', + }); + }); + test('normalizes OpenCode assistant, tool, and permission events', () => { expect( normalizeOpenCodeEvent({ @@ -93,5 +178,21 @@ describe('agent event normalization', () => { body: 'Run bun test?', metadata: '{\n "permissionID": "perm-1",\n "message": "Run bun test?"\n}', }); + + expect( + normalizeOpenCodeEvent({ + type: 'tool.output', + properties: { + tool: 'read', + output: 'apps/web/auth.ts', + messageID: 'message-2', + }, + }), + ).toContainEqual({ + kind: 'tool_completed', + name: 'read', + output: 'apps/web/auth.ts', + externalMessageId: 'message-2', + }); }); }); diff --git a/apps/agent-worker/tests/unit/codex-runtime.test.ts b/apps/agent-worker/tests/unit/codex-runtime.test.ts new file mode 100644 index 0000000..ef75c80 --- /dev/null +++ b/apps/agent-worker/tests/unit/codex-runtime.test.ts @@ -0,0 +1,43 @@ +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, test } from 'vitest'; + +import { prepareCodexWorkspaceFiles } from '../../src/codex-runtime'; + +const tempDirs: string[] = []; + +const mode = async (filePath: string) => (await stat(filePath)).mode & 0o777; + +describe('Codex runtime preparation', () => { + afterEach(async () => { + await Promise.all( + tempDirs.map((dir) => rm(dir, { force: true, recursive: true })), + ); + tempDirs.length = 0; + }); + + test('prepares writable Codex directories and preserves project config contents', async () => { + const workdir = await mkdtemp(path.join(os.tmpdir(), 'spoon-codex-')); + tempDirs.push(workdir); + const repoDir = path.join(workdir, 'repo'); + await mkdir(path.join(repoDir, '.codex'), { recursive: true }); + const projectConfig = path.join(repoDir, '.codex', 'config.toml'); + await writeFile(projectConfig, '[features]\ncodex_hooks = true\n'); + + await prepareCodexWorkspaceFiles({ workdir, repoDir }); + + await expect(readFile(projectConfig, 'utf8')).resolves.toBe( + '[features]\ncodex_hooks = true\n', + ); + await expect(mode(workdir)).resolves.toBe(0o755); + await expect(mode(repoDir)).resolves.toBe(0o755); + await expect(mode(path.join(workdir, '.codex'))).resolves.toBe(0o755); + await expect(mode(path.join(workdir, '.config'))).resolves.toBe(0o755); + await expect(mode(path.join(workdir, '.local', 'share'))).resolves.toBe( + 0o755, + ); + await expect(mode(path.join(repoDir, '.codex'))).resolves.toBe(0o755); + await expect(mode(projectConfig)).resolves.toBe(0o644); + }); +}); diff --git a/apps/agent-worker/tests/unit/docker-runtime.test.ts b/apps/agent-worker/tests/unit/docker-runtime.test.ts new file mode 100644 index 0000000..f002abf --- /dev/null +++ b/apps/agent-worker/tests/unit/docker-runtime.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +const loadVolumeSpec = async () => { + vi.resetModules(); + process.env.SPOON_WORKER_TOKEN = 'test-worker-token'; + process.env.GITHUB_APP_ID = '123'; + process.env.GITHUB_APP_PRIVATE_KEY = + '-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----'; + return await import('../../src/runtime/docker'); +}; + +describe('Docker runtime', () => { + afterEach(() => { + delete process.env.SPOON_AGENT_CONTAINER_RUNTIME; + delete process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS; + vi.resetModules(); + }); + + test('adds SELinux relabel option for Podman workspace mounts by default', async () => { + process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'podman'; + const { jobWorkspaceVolumeSpec } = await loadVolumeSpec(); + + expect(jobWorkspaceVolumeSpec('/tmp/spoon-job')).toBe( + '/tmp/spoon-job:/workspace:Z', + ); + }); + + test('does not add Podman volume options for Docker by default', async () => { + process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'docker'; + const { jobWorkspaceVolumeSpec } = await loadVolumeSpec(); + + expect(jobWorkspaceVolumeSpec('/tmp/spoon-job')).toBe( + '/tmp/spoon-job:/workspace', + ); + }); + + test('allows explicit workspace mount options', async () => { + process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'podman'; + process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS = 'z'; + const { jobWorkspaceVolumeSpec } = await loadVolumeSpec(); + + expect(jobWorkspaceVolumeSpec('/tmp/spoon-job')).toBe( + '/tmp/spoon-job:/workspace:z', + ); + }); +}); diff --git a/apps/next/src/app/(app)/spoons/[spoonId]/agent/[jobId]/page.tsx b/apps/next/src/app/(app)/spoons/[spoonId]/agent/[jobId]/page.tsx index ff0793b..06a8883 100644 --- a/apps/next/src/app/(app)/spoons/[spoonId]/agent/[jobId]/page.tsx +++ b/apps/next/src/app/(app)/spoons/[spoonId]/agent/[jobId]/page.tsx @@ -1,15 +1,33 @@ 'use client'; +import { useEffect } from 'react'; import Link from 'next/link'; -import { useParams } from 'next/navigation'; +import { useParams, useRouter } from 'next/navigation'; import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell'; +import { useQuery } from 'convex/react'; import { ArrowLeft } from 'lucide-react'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; +import { api } from '@spoon/backend/convex/_generated/api.js'; import { Button } from '@spoon/ui'; const AgentWorkspacePage = () => { + const router = useRouter(); const params = useParams<{ spoonId: string; jobId: string }>(); + const jobId = params.jobId as Id<'agentJobs'>; + const job = useQuery(api.agentJobs.get, { jobId }); + + useEffect(() => { + if (job?.threadId) router.replace(`/threads/${job.threadId}`); + }, [job?.threadId, router]); + + if (job?.threadId) { + return ( +
+ Opening thread workspace... +
+ ); + } return (
@@ -19,7 +37,7 @@ const AgentWorkspacePage = () => { Back to Spoon - } /> +
); }; diff --git a/apps/next/src/app/(app)/spoons/[spoonId]/page.tsx b/apps/next/src/app/(app)/spoons/[spoonId]/page.tsx index 4d6d88b..500099e 100644 --- a/apps/next/src/app/(app)/spoons/[spoonId]/page.tsx +++ b/apps/next/src/app/(app)/spoons/[spoonId]/page.tsx @@ -2,8 +2,6 @@ import Link from 'next/link'; import { useParams } from 'next/navigation'; -import { AgentJobList } from '@/components/agents/agent-job-list'; -import { AgentRequestForm } from '@/components/agents/agent-request-form'; import { SpoonActivityTimeline } from '@/components/spoons/spoon-activity-timeline'; import { SpoonAgentSettingsForm } from '@/components/spoons/spoon-agent-settings-form'; import { SpoonClonePanel } from '@/components/spoons/spoon-clone-panel'; @@ -13,6 +11,7 @@ import { SpoonMetrics } from '@/components/spoons/spoon-metrics'; import { SpoonPrList } from '@/components/spoons/spoon-pr-list'; import { SpoonSecretsForm } from '@/components/spoons/spoon-secrets-form'; import { SpoonSettingsForm } from '@/components/spoons/spoon-settings-form'; +import { ThreadWorkspaceForm } from '@/components/threads/thread-workspace-form'; import { useQuery } from 'convex/react'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; @@ -243,7 +242,7 @@ const SpoonDetailPage = () => { - @@ -273,7 +272,6 @@ const SpoonDetailPage = () => { )} - diff --git a/apps/next/src/app/(app)/threads/[threadId]/page.tsx b/apps/next/src/app/(app)/threads/[threadId]/page.tsx index dcd1669..0987303 100644 --- a/apps/next/src/app/(app)/threads/[threadId]/page.tsx +++ b/apps/next/src/app/(app)/threads/[threadId]/page.tsx @@ -2,9 +2,16 @@ import { useState } from 'react'; import Link from 'next/link'; -import { useParams } from 'next/navigation'; +import { useParams, useRouter } from 'next/navigation'; +import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell'; import { useMutation, useQuery } from 'convex/react'; -import { ArrowUpRight, CheckCircle2, Play, XCircle } from 'lucide-react'; +import { + ArrowUpRight, + CheckCircle2, + Play, + Trash2, + XCircle, +} from 'lucide-react'; import { toast } from 'sonner'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; @@ -16,41 +23,45 @@ import { CardContent, CardHeader, CardTitle, - Textarea, } from '@spoon/ui'; const ThreadDetailPage = () => { + const router = useRouter(); const params = useParams<{ threadId: string }>(); const threadId = params.threadId as Id<'threads'>; const details = useQuery(api.threads.get, { threadId }); - const messages = useQuery(api.threads.listMessages, { threadId }) ?? []; const createJob = useMutation(api.agentJobs.createForThread); const markResolved = useMutation(api.threads.markResolved); const cancel = useMutation(api.threads.cancel); - const [sending, setSending] = useState(false); + const deleteThread = useMutation(api.threads.deleteThread); const [queueing, setQueueing] = useState(false); + const [deleting, setDeleting] = useState(false); if (details === undefined) { return
Loading thread...
; } const { thread, spoon, latestJob } = details; + if (latestJob && spoon) { + return ( +
+ + +
+ ); + } + const terminalThread = [ 'resolved', 'ignored', 'failed', 'cancelled', ].includes(thread.status); - const activeJob = - latestJob && - [ - 'claimed', - 'preparing', - 'running', - 'checks_running', - 'changes_ready', - ].includes(latestJob.status) && - ['active', 'idle'].includes(latestJob.workspaceStatus ?? ''); const canQueueRun = spoon && (!latestJob || @@ -67,40 +78,12 @@ const ThreadDetailPage = () => { ? ('maintenance_review' as const) : ('user_change' as const); - const submit = async (event: React.FormEvent) => { - event.preventDefault(); - const form = new FormData(event.currentTarget); - const value = form.get('message'); - const content = typeof value === 'string' ? value : ''; - setSending(true); - try { - const response = await fetch(`/api/threads/${threadId}/message`, { - method: 'POST', - body: JSON.stringify({ content }), - }); - if (!response.ok) { - const payload = (await response.json().catch(() => null)) as { - error?: string; - recoverable?: boolean; - } | null; - throw new Error(payload?.error ?? (await response.text())); - } - event.currentTarget.reset(); - toast.success(activeJob ? 'Message sent to agent.' : 'Message added.'); - } catch (error) { - console.error(error); - toast.error('Could not send message.'); - } finally { - setSending(false); - } - }; - const startRun = async () => { setQueueing(true); try { - const jobId = await createJob({ threadId, jobType }); + await createJob({ threadId, jobType }); toast.success('Workspace run queued.'); - window.location.href = `/spoons/${spoon?._id}/agent/${jobId}`; + router.replace(`/threads/${threadId}`); } catch (error) { console.error(error); toast.error('Could not queue workspace run.'); @@ -109,6 +92,29 @@ const ThreadDetailPage = () => { } }; + const removeThread = async () => { + if ( + !window.confirm( + 'Delete this thread and any terminal workspace records attached to it? This cannot be undone.', + ) + ) { + return; + } + setDeleting(true); + try { + await deleteThread({ threadId }); + toast.success('Thread deleted.'); + router.push('/threads'); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error ? error.message : 'Could not delete thread.', + ); + } finally { + setDeleting(false); + } + }; + return (
@@ -142,11 +148,7 @@ const ThreadDetailPage = () => {
{latestJob ? ( ) : null} {latestJob?.pullRequestUrl ? ( @@ -194,57 +196,38 @@ const ThreadDetailPage = () => { ) : null} +
- Conversation + Workspace - - {messages.map((message) => ( -
-
- {message.role} - - {new Date(message.createdAt).toLocaleString()} - -
-

{message.content}

-
- ))} -
-