diff --git a/AGENTS.md b/AGENTS.md index e003d1a..7146b81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,9 @@ - `apps/next`: Next.js 16 frontend. - `apps/agent-worker`: optional server-side coding-agent worker. It polls Convex for queued jobs and may control Docker/Podman to run ephemeral job - containers. + containers. It also exposes a server-only HTTP API, defaulting to port 3921, + that Next route handlers proxy to for active workspace files, diffs, + messages, commands, and draft PR actions. - `apps/expo`: Expo scaffold; only work here when explicitly requested. - `packages/backend/convex`: self-hosted Convex functions, schema, and auth. - `packages/ui`: shared shadcn-based UI components. @@ -37,6 +39,9 @@ UseSend, `SITE_URL`, `SPOON_WORKER_TOKEN`, encryption, and Convex Auth signing variables from Infisical into the selected Convex deployment. Backend dev/setup scripts run it before `convex dev`. +- 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. - 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 diff --git a/README.md b/README.md index 7258671..be4821f 100644 --- a/README.md +++ b/README.md @@ -27,19 +27,23 @@ Implemented today: - Per-user OpenAI settings for upstream compatibility review. - Per-Spoon encrypted project secrets and agent runtime settings. - Optional `apps/agent-worker` service that can claim queued jobs, clone the - GitHub fork, ask OpenAI for bounded file edits, run checks, push a branch, and - open a draft PR. + GitHub fork, keep an interactive workspace active, expose file browsing and + edits through a server-side proxy, run selected commands, call OpenCode or the + OpenAI direct fallback, push a branch, and open a draft PR. +- Browser agent workspace at `/spoons/[spoonId]/agent/[jobId]` with persisted + thread messages, file tree, Monaco editor with optional Vim mode, diff view, + command panel, and draft PR actions. - Password auth and Authentik OAuth through Convex Auth. - Expo companion app shell with password and Authentik sign-in. - Self-hosted local Convex using Postgres storage. Not implemented yet: -- Browser IDE/editor. - Automatic merge. - Additional Git provider automation beyond preserving provider-neutral fields. - Additional remotes as push targets. - Long-running service-stack orchestration inside agent jobs. +- Direct browser access to agent containers. - Production mobile build/release setup. ## Architecture @@ -61,6 +65,8 @@ The core domain objects are: - `syncRuns`: future upstream checks, merge attempts, and AI reviews. - `agentRequests`: prompt-driven agent work requests. - `agentJobs`: worker-executed coding-agent jobs and their PR lifecycle. +- `agentJobMessages`: persisted per-job agent workspace thread messages. +- `agentWorkspaceChanges`: recorded user, agent, and command workspace changes. - `spoonSecrets`: encrypted per-Spoon environment variables. - `spoonAgentSettings`: per-Spoon agent model, branch, and command settings. @@ -112,6 +118,10 @@ Run the optional local agent worker in a separate terminal: bun dev:agent ``` +The worker also starts an internal HTTP API, defaulting to +`http://localhost:3921`, for server-side Next route handlers. The browser never +receives the worker token or talks to this API directly. + The Docker Compose local worker service is disabled by default behind the `agent` profile. Build the job image before using Docker-backed jobs: @@ -120,6 +130,9 @@ docker build -f docker/agent-job.Dockerfile -t spoon-agent-job:latest . docker compose -f docker/compose.local.yml --profile agent up spoon-agent-worker ``` +The job image includes the OpenCode CLI. Rebuild it after changes to +`docker/agent-job.Dockerfile`. + ## Environment model Local `dev` and `staging` values come from Infisical through `scripts/with-env`. @@ -175,6 +188,9 @@ GITHUB_APP_INSTALLATION_ID GITHUB_APP_OWNER SPOON_ENCRYPTION_KEY SPOON_WORKER_TOKEN +SPOON_AGENT_WORKER_INTERNAL_TOKEN +SPOON_AGENT_WORKER_HTTP_PORT +SPOON_AGENT_WORKER_URL USESEND_API_KEY USESEND_URL USESEND_FROM_EMAIL diff --git a/apps/agent-worker/package.json b/apps/agent-worker/package.json index 29f73c6..e84d94f 100644 --- a/apps/agent-worker/package.json +++ b/apps/agent-worker/package.json @@ -17,10 +17,9 @@ "dependencies": { "@octokit/auth-app": "^8.2.0", "@octokit/rest": "^22.0.1", - "@openai/agents": "latest", + "@opencode-ai/sdk": "latest", "convex": "catalog:convex", "execa": "latest", - "openai": "^6.44.0", "zod": "catalog:" }, "devDependencies": { diff --git a/apps/agent-worker/src/agent.ts b/apps/agent-worker/src/agent.ts deleted file mode 100644 index f546fa3..0000000 --- a/apps/agent-worker/src/agent.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { execa } from 'execa'; -import OpenAI from 'openai'; - -const editSchema = { - type: 'object', - additionalProperties: false, - properties: { - summary: { type: 'string' }, - files: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - properties: { - path: { type: 'string' }, - content: { type: 'string' }, - }, - required: ['path', 'content'], - }, - }, - commands: { - type: 'array', - items: { type: 'string' }, - }, - limitations: { - type: 'array', - items: { type: 'string' }, - }, - }, - required: ['summary', 'files', 'commands', 'limitations'], -} as const; - -type AgentEdit = { - summary: string; - files: { path: string; content: string }[]; - commands: string[]; - limitations: string[]; -}; - -const maxContextFiles = 40; -const maxFileBytes = 12_000; - -const safeContextFile = (file: string) => - !file.includes('node_modules/') && - !file.includes('.git/') && - !file.includes('dist/') && - !file.includes('build/') && - !file.includes('.next/') && - !file.endsWith('.lock') && - !file.endsWith('.png') && - !file.endsWith('.jpg') && - !file.endsWith('.jpeg') && - !file.endsWith('.webp') && - !file.endsWith('.gif') && - !file.endsWith('.pdf'); - -const listFiles = async (repoDir: string) => { - const result = await execa('git', ['ls-files'], { - cwd: repoDir, - all: true, - reject: false, - }); - return result.all - .split('\n') - .map((file) => file.trim()) - .filter(Boolean) - .filter(safeContextFile); -}; - -const chooseContextFiles = (files: string[], prompt: string) => { - const promptWords = new Set( - prompt - .toLowerCase() - .split(/[^a-z0-9]+/) - .filter((word) => word.length > 3), - ); - const scored = files.map((file) => { - const lower = file.toLowerCase(); - const score = [...promptWords].reduce( - (sum, word) => sum + (lower.includes(word) ? 2 : 0), - /(readme|package\.json|auth|env|config|route|provider)/i.exec(file) - ? 3 - : 0, - ); - return { file, score }; - }); - return scored - .sort((a, b) => b.score - a.score) - .slice(0, maxContextFiles) - .map((item) => item.file); -}; - -const readContext = async (repoDir: string, files: string[]) => { - const chunks = []; - for (const file of files) { - try { - const content = await readFile(path.join(repoDir, file), 'utf8'); - chunks.push({ - path: file, - content: - content.length > maxFileBytes - ? `${content.slice(0, maxFileBytes)}\n[truncated]` - : content, - }); - } catch { - // Ignore files that disappeared while context was being gathered. - } - } - return chunks; -}; - -const parseEdit = (value: string): AgentEdit => { - const parsed = JSON.parse(value) as AgentEdit; - if (!Array.isArray(parsed.files)) { - throw new Error('OpenAI returned an edit without a files array.'); - } - return parsed; -}; - -const safePath = (repoDir: string, filePath: string) => { - const resolved = path.resolve(repoDir, filePath); - if (!resolved.startsWith(path.resolve(repoDir))) { - throw new Error(`Refusing to write outside the repository: ${filePath}`); - } - return resolved; -}; - -export const runOpenAiEdit = async (args: { - repoDir: string; - apiKey: string; - model: string; - reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; - prompt: string; - secretNames: string[]; - spoonName: string; - upstreamFullName: string; - forkFullName: string; -}) => { - const files = await listFiles(args.repoDir); - const selectedFiles = chooseContextFiles(files, args.prompt); - const contextFiles = await readContext(args.repoDir, selectedFiles); - const response = await new OpenAI({ apiKey: args.apiKey }).responses.create({ - model: args.model, - store: false, - reasoning: - args.reasoningEffort === 'none' - ? undefined - : { effort: args.reasoningEffort }, - input: [ - { - role: 'system', - content: - 'You are a conservative coding agent working in a fork. Return complete replacement contents only for files that must change. Keep the diff minimal. Do not include secrets. Do not claim commands passed unless they are listed for the worker to run. If the context is insufficient, make the safest small change and describe limitations.', - }, - { - role: 'user', - content: JSON.stringify( - { - task: args.prompt, - spoon: args.spoonName, - upstream: args.upstreamFullName, - fork: args.forkFullName, - availableSecretNames: args.secretNames, - repositoryFiles: files.slice(0, 500), - contextFiles, - }, - null, - 2, - ), - }, - ], - text: { - format: { - type: 'json_schema', - name: 'spoon_agent_file_edits', - strict: true, - schema: editSchema, - }, - }, - }); - const edit = parseEdit(response.output_text); - for (const file of edit.files) { - const target = safePath(args.repoDir, file.path); - await mkdir(path.dirname(target), { recursive: true }); - await writeFile(target, file.content); - } - return edit; -}; diff --git a/apps/agent-worker/src/env.ts b/apps/agent-worker/src/env.ts index fa4d49c..0494b2a 100644 --- a/apps/agent-worker/src/env.ts +++ b/apps/agent-worker/src/env.ts @@ -24,6 +24,11 @@ export const env = { workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work', network: process.env.SPOON_AGENT_NETWORK?.trim(), pollMs: intEnv('SPOON_AGENT_POLL_MS', 5_000), + httpPort: intEnv('SPOON_AGENT_WORKER_HTTP_PORT', 3921), + internalToken: + process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN?.trim() ?? + process.env.SPOON_WORKER_TOKEN?.trim() ?? + '', maxConcurrentJobs: intEnv('SPOON_AGENT_MAX_CONCURRENT_JOBS', 1), jobTimeoutMs: intEnv('SPOON_AGENT_JOB_TIMEOUT_MS', 1_800_000), githubAppId: requiredEnv('GITHUB_APP_ID'), diff --git a/apps/agent-worker/src/index.ts b/apps/agent-worker/src/index.ts index 9623767..dab4d8f 100644 --- a/apps/agent-worker/src/index.ts +++ b/apps/agent-worker/src/index.ts @@ -1,3 +1,5 @@ +import { startWorkerServer } from './server'; import { startWorker } from './worker'; +startWorkerServer(); await startWorker(); diff --git a/apps/agent-worker/src/server.ts b/apps/agent-worker/src/server.ts new file mode 100644 index 0000000..99a76fa --- /dev/null +++ b/apps/agent-worker/src/server.ts @@ -0,0 +1,142 @@ +import { createServer } from 'node:http'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +import { env } from './env'; +import { + getWorkspaceDiff, + listWorkspaceTree, + openWorkspacePullRequest, + readWorkspaceFile, + runWorkspaceCommand, + sendWorkspaceMessage, + stopWorkspace, + writeWorkspaceFile, +} from './worker'; + +const sendJson = (response: ServerResponse, status: number, body: unknown) => { + response.writeHead(status, { 'content-type': 'application/json' }); + response.end(JSON.stringify(body)); +}; + +const readBody = async (request: IncomingMessage) => + await new Promise((resolve, reject) => { + let body = ''; + request.on('data', (chunk: Buffer) => { + body += chunk.toString('utf8'); + }); + request.on('end', () => resolve(body)); + request.on('error', reject); + }); + +const parseJson = async (request: IncomingMessage) => { + const body = await readBody(request); + if (!body.trim()) return {} as T; + return JSON.parse(body) as T; +}; + +const requireAuth = (request: IncomingMessage) => { + const header = request.headers.authorization; + const token = header?.startsWith('Bearer ') ? header.slice(7) : ''; + if (!env.internalToken || token !== env.internalToken) { + throw new Error('Unauthorized'); + } +}; + +const jobRoute = (pathname: string) => { + const match = /^\/jobs\/([^/]+)\/([^/]+)$/.exec(pathname); + if (!match?.[1] || !match[2]) return null; + return { jobId: decodeURIComponent(match[1]), action: match[2] }; +}; + +export const startWorkerServer = () => { + const server = createServer((request, response) => { + void (async () => { + try { + requireAuth(request); + const url = new URL( + request.url ?? '/', + `http://localhost:${env.httpPort}`, + ); + if (url.pathname === '/health') { + sendJson(response, 200, { ok: true, workerId: env.workerId }); + return; + } + const route = jobRoute(url.pathname); + if (!route) { + sendJson(response, 404, { error: 'Not found' }); + return; + } + + if (request.method === 'GET' && route.action === 'tree') { + sendJson(response, 200, { + tree: await listWorkspaceTree(route.jobId), + }); + return; + } + if (request.method === 'GET' && route.action === 'file') { + const filePath = url.searchParams.get('path') ?? ''; + sendJson(response, 200, { + path: filePath, + content: await readWorkspaceFile(route.jobId, filePath), + }); + return; + } + if (request.method === 'PUT' && route.action === 'file') { + const body = await parseJson<{ path?: string; content?: string }>( + request, + ); + sendJson( + response, + 200, + await writeWorkspaceFile( + route.jobId, + body.path ?? '', + body.content ?? '', + ), + ); + return; + } + if (request.method === 'GET' && route.action === 'diff') { + sendJson(response, 200, { + diff: await getWorkspaceDiff(route.jobId), + }); + return; + } + if (request.method === 'POST' && route.action === 'message') { + const body = await parseJson<{ content?: string }>(request); + await sendWorkspaceMessage(route.jobId, body.content ?? ''); + sendJson(response, 200, { success: true }); + return; + } + if (request.method === 'POST' && route.action === 'run-command') { + const body = await parseJson<{ command?: string }>(request); + sendJson( + response, + 200, + await runWorkspaceCommand(route.jobId, body.command ?? ''), + ); + return; + } + if (request.method === 'POST' && route.action === 'open-pr') { + sendJson(response, 200, await openWorkspacePullRequest(route.jobId)); + return; + } + if (request.method === 'POST' && route.action === 'stop') { + sendJson(response, 200, await stopWorkspace(route.jobId)); + 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, + }); + } + })(); + }); + server.listen(env.httpPort, () => { + console.log( + `Spoon agent worker HTTP server listening on port ${env.httpPort}`, + ); + }); +}; diff --git a/apps/agent-worker/src/worker.ts b/apps/agent-worker/src/worker.ts index 47271ad..b4a448b 100644 --- a/apps/agent-worker/src/worker.ts +++ b/apps/agent-worker/src/worker.ts @@ -1,11 +1,18 @@ -import { access, readFile, rm } from 'node:fs/promises'; +import { + access, + mkdir, + readdir, + readFile, + rm, + stat, + writeFile, +} from 'node:fs/promises'; 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 { runOpenAiEdit } from './agent'; import { env } from './env'; import { cloneRepository, @@ -22,6 +29,10 @@ type Claim = { job: { _id: Id<'agentJobs'>; prompt: string; + runtime?: 'openai_direct' | 'opencode'; + jobType?: 'user_change' | 'maintenance_review' | 'conflict_resolution'; + envFilePath?: string; + materializeEnvFile?: boolean; baseBranch: string; workBranch: string; forkOwner: string; @@ -31,7 +42,26 @@ type Claim = { }; spoon: { name: string }; openai: { - apiKey: string; + apiKey?: string; + model: string; + reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; + }; + aiProviderProfile?: { + id: string; + name: string; + provider: + | 'openai' + | 'anthropic' + | 'google' + | 'openrouter' + | 'requesty' + | 'litellm' + | 'cloudflare_ai_gateway' + | 'custom_openai_compatible' + | 'opencode_openai_login'; + authType: 'api_key' | 'opencode_auth_json' | 'none'; + secret?: string; + baseUrl?: string; model: string; reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; }; @@ -40,13 +70,30 @@ type Claim = { installCommand?: string; checkCommand?: string; testCommand?: string; + autoDetectCommands?: boolean; } | null; secrets: { name: string; value: string }[]; }; +type ActiveWorkspace = { + claim: Claim; + workdir: string; + repoDir: string; + githubToken: string; + redact: (value: string) => string; +}; + +type FileTreeNode = { + name: string; + path: string; + type: 'file' | 'directory'; + children?: FileTreeNode[]; +}; + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const client = new ConvexHttpClient(env.convexUrl); +const activeWorkspaces = new Map(); const appendEvent = async ( jobId: Id<'agentJobs'>, @@ -129,8 +176,232 @@ const completeWithDraftPr = async (args: { ...args, }); +const applyMaintenanceDecision = async ( + jobId: Id<'agentJobs'>, + decision: MaintenanceDecision, +) => + await client.mutation(api.agentJobs.applyMaintenanceDecision, { + workerToken: env.workerToken, + workerId: env.workerId, + jobId, + ...decision, + }); + +const markWorkspaceActive = async (args: { + jobId: Id<'agentJobs'>; + opencodeSessionId?: string; + containerId?: string; + workspaceUrl?: string; +}) => + await client.mutation(api.agentJobs.markWorkspaceActive, { + workerToken: env.workerToken, + workerId: env.workerId, + workspaceExpiresAt: Date.now() + 2 * 60 * 60 * 1000, + ...args, + }); + +const markWorkspaceStopped = async ( + jobId: Id<'agentJobs'>, + workspaceStatus: 'stopped' | 'expired' | 'failed' = 'stopped', +) => + await client.mutation(api.agentJobs.markWorkspaceStopped, { + workerToken: env.workerToken, + workerId: env.workerId, + jobId, + workspaceStatus, + }); + +const appendMessage = async (args: { + jobId: Id<'agentJobs'>; + role: 'user' | 'assistant' | 'system' | 'tool'; + content: string; + status: 'queued' | 'streaming' | 'completed' | 'failed'; + metadata?: string; +}) => + await client.mutation(api.agentJobs.appendMessage, { + workerToken: env.workerToken, + workerId: env.workerId, + ...args, + }); + +const recordWorkspaceChange = async (args: { + jobId: Id<'agentJobs'>; + path: string; + source: 'user' | 'agent' | 'command'; + changeType: 'added' | 'modified' | 'deleted' | 'renamed'; + diff?: string; +}) => + await client.mutation(api.agentJobs.recordWorkspaceChange, { + workerToken: env.workerToken, + workerId: env.workerId, + ...args, + }); + const commandToShell = (command: string) => ['bash', '-lc', command]; +const providerEnvironment = (claim: Claim): Record => { + const profile = claim.aiProviderProfile; + const secret = profile?.secret ?? claim.openai.apiKey; + if (!secret) { + throw new Error('No AI provider credential is configured for this job.'); + } + const baseUrl: Record = profile?.baseUrl + ? { OPENAI_BASE_URL: profile.baseUrl } + : {}; + if (!profile || profile.provider === 'openai') { + return { OPENAI_API_KEY: secret, ...baseUrl }; + } + if (profile.provider === 'anthropic') return { ANTHROPIC_API_KEY: secret }; + if (profile.provider === 'google') return { GOOGLE_API_KEY: secret }; + if (profile.provider === 'openrouter') { + return { OPENROUTER_API_KEY: secret, ...baseUrl }; + } + if (profile.provider === 'requesty') { + return { REQUESTY_API_KEY: secret, ...baseUrl }; + } + if (profile.provider === 'cloudflare_ai_gateway') { + return { CLOUDFLARE_API_KEY: secret, ...baseUrl }; + } + if ( + profile.provider === 'litellm' || + profile.provider === 'custom_openai_compatible' + ) { + return { OPENAI_API_KEY: secret, ...baseUrl }; + } + throw new Error( + 'OpenCode login profiles are saved but need auth-file injection before execution.', + ); +}; + +const opencodeModel = (claim: Claim) => { + const profile = claim.aiProviderProfile; + const model = profile?.model ?? claim.openai.model; + if (model.includes('/')) return model; + if (!profile) return `openai/${model}`; + if ( + profile.provider === 'custom_openai_compatible' || + profile.provider === 'cloudflare_ai_gateway' + ) { + return model; + } + if (profile.provider === 'opencode_openai_login') return `openai/${model}`; + return `${profile.provider}/${model}`; +}; + +const systemPromptForJob = (claim: Claim) => { + const base = [ + `Spoon: ${claim.spoon.name}`, + `Fork: ${claim.job.forkOwner}/${claim.job.forkRepo}`, + `Upstream: ${claim.job.upstreamOwner}/${claim.job.upstreamRepo}`, + `Selected secret names: ${claim.secrets.map((secret) => secret.name).join(', ') || 'none'}`, + ].join('\n'); + if (claim.job.jobType === 'maintenance_review') { + return `${base} + +You are reviewing upstream changes for a maintained fork. +Determine whether the upstream commits can be safely applied. +If the fork has no relevant customizations, recommend sync. +If upstream changes are irrelevant to this fork, recommend ignore and list commit SHAs. +If changes may affect custom fork commits, explain risks and recommend review PR or manual review. +Do not claim tests passed unless commands were run. +End with a JSON maintenance decision in this exact shape: +{ + "decision": "sync" | "ignore" | "open_review_pr" | "manual_review" | "conflict_resolution" | "unknown", + "risk": "low" | "medium" | "high" | "unknown", + "summary": "string", + "ignoredCommitShas": ["string"], + "ignoredReason": "string", + "recommendedAction": "string", + "requiresUserApproval": true +} + +User/system request: +${claim.job.prompt}`; + } + if (claim.job.jobType === 'conflict_resolution') { + return `${base} + +You are resolving upstream merge conflicts in a maintained fork. +Preserve fork customizations unless the user explicitly removed that upstream behavior. +Prefer small, reviewable changes. +Produce a draft PR rather than committing to main. + +Request: +${claim.job.prompt}`; + } + return `${base} + +You are working on a maintained fork managed by Spoon. +Make the requested change only. +Preserve fork-specific customizations. +Do not commit secrets. +Use selected environment variables only for running/building/testing. +Open a draft PR only when instructed by Spoon. + +Request: +${claim.job.prompt}`; +}; + +type MaintenanceDecision = { + decision: + | 'sync' + | 'ignore' + | 'open_review_pr' + | 'manual_review' + | 'conflict_resolution' + | 'unknown'; + risk: 'low' | 'medium' | 'high' | 'unknown'; + summary: string; + ignoredCommitShas: string[]; + ignoredReason: string; + recommendedAction: string; + requiresUserApproval: boolean; +}; + +const parseMaintenanceDecision = ( + output: string, +): MaintenanceDecision | null => { + const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(output)?.[1]; + const candidates = [fenced, output.slice(output.indexOf('{'))].filter( + Boolean, + ) as string[]; + for (const candidate of candidates) { + try { + const parsed = JSON.parse( + candidate.trim(), + ) as Partial; + if ( + parsed.decision && + parsed.risk && + typeof parsed.summary === 'string' + ) { + return { + decision: parsed.decision, + risk: parsed.risk, + summary: parsed.summary, + ignoredCommitShas: Array.isArray(parsed.ignoredCommitShas) + ? parsed.ignoredCommitShas.filter( + (sha): sha is string => typeof sha === 'string', + ) + : [], + ignoredReason: + typeof parsed.ignoredReason === 'string' + ? parsed.ignoredReason + : '', + recommendedAction: + typeof parsed.recommendedAction === 'string' + ? parsed.recommendedAction + : parsed.summary, + requiresUserApproval: parsed.requiresUserApproval ?? true, + }; + } + } catch { + // Try the next candidate. + } + } + return null; +}; + const fileExists = async (filePath: string) => { try { await access(filePath); @@ -180,28 +451,91 @@ const runProjectCommand = async (args: { } }; +const quoteShell = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + +const resolveWorkspace = (jobId: string) => { + const workspace = activeWorkspaces.get(jobId); + if (!workspace) { + throw new Error('Agent workspace is not active on this worker.'); + } + return workspace; +}; + +const safeWorkspacePath = (repoDir: string, filePath: string) => { + const resolved = path.resolve(repoDir, filePath); + const root = path.resolve(repoDir); + if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) { + throw new Error(`Refusing to access path outside repository: ${filePath}`); + } + return resolved; +}; + +const fileChangedType = async (repoDir: string, filePath: string) => { + const status = await run('git', ['status', '--short', '--', filePath], { + cwd: repoDir, + redact: (value) => value, + timeoutMs: 60_000, + }); + const code = status.output.trim().slice(0, 2); + if (code.includes('D')) return 'deleted' as const; + if (code.includes('A') || code.includes('?')) return 'added' as const; + if (code.includes('R')) return 'renamed' as const; + return 'modified' as const; +}; + +const materializeEnvFile = async (workspace: ActiveWorkspace) => { + const { claim, repoDir } = workspace; + if (!claim.job.materializeEnvFile || !claim.job.envFilePath) return; + const envPath = safeWorkspacePath(repoDir, claim.job.envFilePath); + await mkdir(path.dirname(envPath), { recursive: true }); + const content = `${claim.secrets + .map((secret) => `${secret.name}=${JSON.stringify(secret.value)}`) + .join('\n')}\n`; + await writeFile(envPath, content); + await appendEvent( + claim.job._id, + 'info', + 'clone', + `Materialized selected secrets into ${claim.job.envFilePath}.`, + ); +}; + const detectPackageCommands = async ( repoDir: string, -): Promise<{ install?: string; check?: string; test?: string }> => { +): Promise<{ + packageManager?: string; + scripts?: string[]; + install?: string; + check?: string; + test?: string; + build?: string; +}> => { const packageJsonPath = path.join(repoDir, 'package.json'); try { const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as { + packageManager?: string; scripts?: Record; }; const scripts = packageJson.scripts ?? {}; - const packageManager = (await fileExists(path.join(repoDir, 'bun.lock'))) + const declaredPackageManager = packageJson.packageManager?.split('@')[0]; + const detectedPackageManager = (await fileExists( + path.join(repoDir, 'bun.lock'), + )) ? 'bun' : (await fileExists(path.join(repoDir, 'pnpm-lock.yaml'))) ? 'pnpm' : (await fileExists(path.join(repoDir, 'yarn.lock'))) ? 'yarn' : 'npm'; + const packageManager = declaredPackageManager ?? detectedPackageManager; const runScript = (script: string) => packageManager === 'npm' ? `npm run ${script}` : `${packageManager} run ${script}`; return { + packageManager, + scripts: Object.keys(scripts).sort(), install: `${packageManager} install`, check: scripts.typecheck ? runScript('typecheck') @@ -213,6 +547,7 @@ const detectPackageCommands = async ( ? 'npm test' : `${packageManager} test` : undefined, + build: scripts.build ? runScript('build') : undefined, }; } catch { return {}; @@ -250,13 +585,28 @@ ${ Generated by Spoon.`; +const ensureNoEnvFilesStaged = async (workspace: ActiveWorkspace) => { + const status = await run('git', ['status', '--short'], { + cwd: workspace.repoDir, + redact: workspace.redact, + timeoutMs: 60_000, + }); + const envLine = status.output + .split('\n') + .find((line) => /\s\.env(?:$|[./-])/.test(line)); + if (envLine) { + throw new Error(`Refusing to commit env file changes: ${envLine.trim()}`); + } +}; + const runClaim = async (claim: Claim) => { const jobId = claim.job._id; const workdir = path.resolve(env.workdir, jobId); const secretValues = [ - claim.openai.apiKey, + claim.openai.apiKey ?? '', + claim.aiProviderProfile?.secret ?? '', ...claim.secrets.map((secret) => secret.value), - ]; + ].filter(Boolean); const redact = createRedactor(secretValues); try { await updateStatus(jobId, 'preparing'); @@ -275,118 +625,37 @@ const runClaim = async (claim: Claim) => { redact, timeoutMs: env.jobTimeoutMs, }); - await updateStatus(jobId, 'running'); - await appendEvent(jobId, 'info', 'plan', 'Gathering repo context.'); - const edit = await runOpenAiEdit({ + const workspace: ActiveWorkspace = { + claim, + workdir, repoDir, - apiKey: claim.openai.apiKey, - model: claim.openai.model, - reasoningEffort: claim.openai.reasoningEffort, - prompt: claim.job.prompt, - secretNames: claim.secrets.map((secret) => secret.name), - spoonName: claim.spoon.name, - upstreamFullName: `${claim.job.upstreamOwner}/${claim.job.upstreamRepo}`, - forkFullName: `${claim.job.forkOwner}/${claim.job.forkRepo}`, - }); - await addArtifact({ - jobId, - kind: 'plan', - title: 'Agent plan', - content: edit.summary, - contentType: 'text/markdown', - }); - const status = await getStatus(repoDir, redact); - if (!status.output.trim()) { - throw new Error('No changes produced by the agent.'); - } - const diff = await getWorktreeDiff(repoDir, redact); - await addArtifact({ - jobId, - kind: 'diff', - title: 'Git diff', - content: truncate(diff.output, 200_000), - contentType: 'text/x-diff', - }); - await updateStatus(jobId, 'checks_running'); - const detected = await detectPackageCommands(repoDir); - const settings = claim.agentSettings; - const installCommand = settings?.installCommand ?? detected.install; - const checkCommand = settings?.checkCommand ?? detected.check; - const testCommand = settings?.testCommand ?? detected.test; - if (installCommand) { - await runProjectCommand({ - command: installCommand, - phase: 'install', - claim, - workdir, - repoDir, - redact, - }); - } - if (checkCommand) { - await runProjectCommand({ - command: checkCommand, - phase: 'check', - claim, - workdir, - repoDir, - redact, - }); - } - if (testCommand) { - await runProjectCommand({ - command: testCommand, - phase: 'test', - claim, - workdir, - repoDir, - redact, - }); - } - await appendEvent(jobId, 'info', 'commit', 'Committing changes.'); - const commitSha = await commitAndPush({ - repoDir, - workBranch: claim.job.workBranch, - message: `Agent: ${claim.job.prompt.slice(0, 72)}`, + githubToken, redact, - timeoutMs: env.jobTimeoutMs, - }); - const prBody = buildPrBody({ - prompt: claim.job.prompt, - summary: edit.summary, - commands: [ - installCommand, - checkCommand, - testCommand, - ...edit.commands, - ].filter((command): command is string => Boolean(command)), - limitations: edit.limitations, - }); + }; + await materializeEnvFile(workspace); + const detected = await detectPackageCommands(repoDir); await addArtifact({ jobId, - kind: 'pr_body', - title: 'Draft PR body', - content: prBody, - contentType: 'text/markdown', + kind: 'summary', + title: 'Detected project commands', + content: JSON.stringify(detected, null, 2), + contentType: 'application/json', }); - await appendEvent(jobId, 'info', 'pr', 'Opening draft pull request.'); - const pullRequest = await openDraftPullRequest({ - installationId: claim.github.installationId, - forkOwner: claim.job.forkOwner, - forkRepo: claim.job.forkRepo, - baseBranch: claim.job.baseBranch, - workBranch: claim.job.workBranch, - title: `Agent: ${claim.job.prompt.slice(0, 64)}`, - body: prBody, + activeWorkspaces.set(jobId, workspace); + await markWorkspaceActive({ jobId }); + await updateStatus(jobId, 'running', { + summary: 'Workspace is active.', }); - await completeWithDraftPr({ + await appendMessage({ jobId, - commitSha, - pullRequestUrl: pullRequest.html_url, - pullRequestNumber: pullRequest.number, - summary: edit.summary, + role: 'system', + status: 'completed', + content: + 'Workspace is ready. You can browse files, edit manually, run commands, or send messages to the agent.', }); - await appendEvent(jobId, 'info', 'cleanup', 'Agent job completed.'); + 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); await appendEvent( @@ -407,11 +676,304 @@ const runClaim = async (claim: Claim) => { message.toLowerCase().includes('timed out') ? 'timed_out' : 'failed', { error: truncate(redact(message), 10_000) }, ); - } finally { - await rm(workdir, { recursive: true, force: true }); + await markWorkspaceStopped( + jobId, + message.toLowerCase().includes('timed out') ? 'expired' : 'failed', + ).catch((stopError: unknown) => { + console.error(stopError); + }); } }; +export const listWorkspaceTree = async (jobId: string) => { + const workspace = resolveWorkspace(jobId); + const buildNode = async ( + absolutePath: string, + relativePath: string, + ): Promise => { + const basename = path.basename(absolutePath); + if (['.git', 'node_modules', '.next', 'dist', 'build'].includes(basename)) { + return null; + } + const stats = await stat(absolutePath); + if (stats.isDirectory()) { + const entries = await readdir(absolutePath); + const children = ( + await Promise.all( + entries + .sort((a, b) => a.localeCompare(b)) + .map((entry) => + buildNode( + path.join(absolutePath, entry), + path.join(relativePath, entry), + ), + ), + ) + ).filter((node): node is FileTreeNode => Boolean(node)); + return { + name: relativePath ? basename : workspace.claim.job.forkRepo, + path: relativePath, + type: 'directory', + children, + }; + } + return { name: basename, path: relativePath, type: 'file' }; + }; + return await buildNode(workspace.repoDir, ''); +}; + +export const readWorkspaceFile = async (jobId: string, filePath: string) => { + const workspace = resolveWorkspace(jobId); + const target = safeWorkspacePath(workspace.repoDir, filePath); + return await readFile(target, 'utf8'); +}; + +export const writeWorkspaceFile = async ( + jobId: string, + filePath: string, + content: string, +) => { + const workspace = resolveWorkspace(jobId); + const target = safeWorkspacePath(workspace.repoDir, filePath); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, content); + const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact); + await recordWorkspaceChange({ + jobId: workspace.claim.job._id, + path: filePath, + source: 'user', + changeType: await fileChangedType(workspace.repoDir, filePath), + diff: truncate(diff.output, 50_000), + }); + await appendEvent( + workspace.claim.job._id, + 'info', + 'edit', + `Saved ${filePath}.`, + ); + return { success: true }; +}; + +export const getWorkspaceDiff = async (jobId: string) => { + const workspace = resolveWorkspace(jobId); + const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact); + return diff.output; +}; + +export const runWorkspaceCommand = async (jobId: string, command: string) => { + const workspace = resolveWorkspace(jobId); + await updateStatus(workspace.claim.job._id, 'checks_running'); + await runProjectCommand({ + command, + phase: command.includes('test') ? 'test' : 'check', + claim: workspace.claim, + workdir: workspace.workdir, + repoDir: workspace.repoDir, + 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, + ), + }); + return { success: true }; +}; + +export const sendWorkspaceMessage = async (jobId: string, prompt: string) => { + const workspace = resolveWorkspace(jobId); + const { claim, repoDir, redact, workdir } = workspace; + await appendMessage({ + jobId: claim.job._id, + role: 'user', + status: 'completed', + content: prompt, + }); + 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.'); + } + const model = opencodeModel(claim); + const aiEnv = providerEnvironment(claim); + const secretEnv = Object.fromEntries( + claim.secrets.map((secret) => [secret.name, secret.value]), + ); + const result = + env.runtime === 'docker' + ? await runInJobContainer({ + workdir, + command: commandToShell( + `opencode run --model ${quoteShell(model)} ${quoteShell(prompt)}`, + ), + environment: { + ...aiEnv, + ...secretEnv, + }, + redact, + timeoutMs: env.jobTimeoutMs, + }) + : await run( + 'bash', + [ + '-lc', + `opencode run --model ${quoteShell(model)} ${quoteShell(prompt)}`, + ], + { + cwd: repoDir, + env: { + ...aiEnv, + ...secretEnv, + }, + redact, + timeoutMs: env.jobTimeoutMs, + }, + ); + await appendMessage({ + jobId: claim.job._id, + role: 'assistant', + status: result.exitCode === 0 ? 'completed' : 'failed', + content: truncate(result.output, 40_000), + }); + if (result.exitCode !== 0) { + throw new Error(`opencode failed:\n${result.output}`); + } + if (claim.job.jobType === 'maintenance_review') { + const decision = parseMaintenanceDecision(result.output); + if (decision) { + await addArtifact({ + jobId: claim.job._id, + kind: 'summary', + title: 'Maintenance decision', + content: JSON.stringify(decision, null, 2), + contentType: 'application/json', + }); + await applyMaintenanceDecision(claim.job._id, decision); + } else { + await updateStatus(claim.job._id, 'changes_ready', { + summary: + 'OpenCode completed the review, but Spoon could not parse a structured maintenance decision.', + }); + } + } + const diff = await getWorktreeDiff(repoDir, redact); + await addArtifact({ + jobId: claim.job._id, + kind: 'diff', + title: 'Git diff', + 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), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await appendEvent( + claim.job._id, + 'error', + 'cleanup', + truncate(redact(message), 20_000), + ); + await appendMessage({ + jobId: claim.job._id, + role: 'assistant', + status: 'failed', + content: truncate(redact(message), 40_000), + }); + throw error; + } +}; + +export const openWorkspacePullRequest = async (jobId: string) => { + const workspace = resolveWorkspace(jobId); + const { claim, repoDir, redact } = workspace; + await ensureNoEnvFilesStaged(workspace); + const status = await getStatus(repoDir, redact); + if (!status.output.trim()) { + throw new Error('No changes are ready for a draft PR.'); + } + const diff = await getWorktreeDiff(repoDir, redact); + const settings = claim.agentSettings; + const detected = await detectPackageCommands(repoDir); + const installCommand = settings?.installCommand ?? detected.install; + const checkCommand = settings?.checkCommand ?? detected.check; + const testCommand = settings?.testCommand ?? detected.test; + const prBody = buildPrBody({ + prompt: claim.job.prompt, + summary: 'Interactive Spoon agent workspace changes.', + commands: [installCommand, checkCommand, testCommand].filter( + (command): command is string => Boolean(command), + ), + limitations: ['Review the draft PR before merging.'], + }); + await addArtifact({ + jobId: claim.job._id, + kind: 'diff', + title: 'Final Git diff', + content: truncate(diff.output, 200_000), + contentType: 'text/x-diff', + }); + await addArtifact({ + jobId: claim.job._id, + kind: 'pr_body', + title: 'Draft PR body', + content: prBody, + contentType: 'text/markdown', + }); + const commitSha = await commitAndPush({ + repoDir, + workBranch: claim.job.workBranch, + message: `Agent: ${claim.job.prompt.slice(0, 72)}`, + redact, + timeoutMs: env.jobTimeoutMs, + }); + if (!claim.github.installationId) { + throw new Error('GitHub installation ID is missing.'); + } + const pullRequest = await openDraftPullRequest({ + installationId: claim.github.installationId, + forkOwner: claim.job.forkOwner, + forkRepo: claim.job.forkRepo, + baseBranch: claim.job.baseBranch, + workBranch: claim.job.workBranch, + title: `Agent: ${claim.job.prompt.slice(0, 64)}`, + body: prBody, + }); + await completeWithDraftPr({ + jobId: claim.job._id, + commitSha, + pullRequestUrl: pullRequest.html_url, + pullRequestNumber: pullRequest.number, + summary: 'Draft PR opened from interactive workspace.', + }); + await markWorkspaceStopped(claim.job._id); + activeWorkspaces.delete(jobId); + await rm(workspace.workdir, { recursive: true, force: true }); + return { + pullRequestUrl: pullRequest.html_url, + pullRequestNumber: pullRequest.number, + }; +}; + +export const stopWorkspace = async (jobId: string) => { + const workspace = resolveWorkspace(jobId); + await markWorkspaceStopped(workspace.claim.job._id); + activeWorkspaces.delete(jobId); + await rm(workspace.workdir, { recursive: true, force: true }); + return { success: true }; +}; + export const startWorker = async () => { console.log(`Spoon agent worker ${env.workerId} polling ${env.convexUrl}`); for (;;) { diff --git a/apps/expo/src/app/index.tsx b/apps/expo/src/app/index.tsx index 8d8c308..7d7704c 100644 --- a/apps/expo/src/app/index.tsx +++ b/apps/expo/src/app/index.tsx @@ -29,11 +29,9 @@ const Index = () => { api.syncRuns.listRecent, isAuthenticated ? { limit: 5 } : 'skip', ) ?? []; - const agentRequests = - useQuery( - api.agentRequests.listRecent, - isAuthenticated ? { limit: 5 } : 'skip', - ) ?? []; + const threads = + useQuery(api.threads.listMine, isAuthenticated ? { limit: 5 } : 'skip') ?? + []; const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [submitting, setSubmitting] = useState(false); @@ -101,8 +99,8 @@ const Index = () => { - - + + diff --git a/apps/next/package.json b/apps/next/package.json index c1ca35d..442f1d0 100644 --- a/apps/next/package.json +++ b/apps/next/package.json @@ -21,11 +21,14 @@ }, "dependencies": { "@convex-dev/auth": "catalog:convex", + "@monaco-editor/react": "latest", "@sentry/nextjs": "^10.46.0", "@spoon/backend": "workspace:*", "@spoon/ui": "workspace:*", "@t3-oss/env-nextjs": "^0.13.11", "convex": "catalog:convex", + "monaco-editor": "latest", + "monaco-vim": "latest", "next": "^16.2.1", "next-plausible": "^3.12.5", "react": "catalog:react19", diff --git a/apps/next/src/app/(app)/agents/page.tsx b/apps/next/src/app/(app)/agents/page.tsx index f2e63ee..cff9752 100644 --- a/apps/next/src/app/(app)/agents/page.tsx +++ b/apps/next/src/app/(app)/agents/page.tsx @@ -1,146 +1,7 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useState } from 'react'; -import { useMutation, useQuery } from 'convex/react'; -import { toast } from 'sonner'; - -import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; -import { api } from '@spoon/backend/convex/_generated/api.js'; -import { - Button, - Card, - CardContent, - CardHeader, - CardTitle, - Input, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Textarea, -} from '@spoon/ui'; - -const AgentsPage = () => { - const spoons = useQuery(api.spoons.listMine, {}) ?? []; - const requests = useQuery(api.agentRequests.listRecent, { limit: 50 }) ?? []; - const createRequest = useMutation(api.agentRequests.create); - const [spoonId, setSpoonId] = useState(''); - const [targetBranch, setTargetBranch] = useState(''); - const [prompt, setPrompt] = useState(''); - const [submitting, setSubmitting] = useState(false); - - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - if (!spoonId) { - toast.error('Choose a Spoon first.'); - return; - } - setSubmitting(true); - try { - await createRequest({ - spoonId: spoonId as Id<'spoons'>, - prompt, - targetBranch: targetBranch || undefined, - }); - setPrompt(''); - setTargetBranch(''); - toast.success('Agent request queued.'); - } catch (error) { - console.error(error); - toast.error('Could not queue agent request.'); - } finally { - setSubmitting(false); - } - }; - - return ( -
-
-

Agents

-

- Queue prompt-driven work for future AI merge request automation. -

-
-
- - - Request work - - -
-
- - -
-
- - setTargetBranch(event.target.value)} - /> -
-
- -