diff --git a/.gitea/workflows/build-next.yml b/.gitea/workflows/build-next.yml index ca34d4a..43acbf9 100644 --- a/.gitea/workflows/build-next.yml +++ b/.gitea/workflows/build-next.yml @@ -53,7 +53,7 @@ jobs: printf '%s\n' "$DOTENV_PROD" > "$env_file" CI_ENV_FILE="$env_file" ./scripts/build-next-app production - name: Build agent images - run: ./scripts/build-agent-images + run: SPOON_AGENT_CONTAINER_RUNTIME=docker ./scripts/build-agent-images - name: Tag and push images run: | docker tag spoon-next:latest git.gbrown.org/gib/spoon-next:${{ gitea.sha }} diff --git a/AGENTS.md b/AGENTS.md index 9e95883..a47530d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,8 @@ access to the host Docker socket. API-key provider jobs run through OpenCode; Codex ChatGPT login profiles run through the Codex CLI with an injected `CODEX_HOME/.codex/auth.json` inside the isolated job workspace. + The job image must keep Node, npm, Bun, pnpm, yarn, git, ripgrep, jq, + Python, OpenCode, and Codex available. ## Protected and generated files @@ -55,12 +57,14 @@ - 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. +- `bun smoke:agent-container` checks that the local job image has Node, npm, + Bun, pnpm, yarn, 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. +- Gitea image builds force `SPOON_AGENT_CONTAINER_RUNTIME=docker`; keep local + Podman auto-detection out of CI image tagging/pushing. - CI must provide Convex deployment env for codegen, either `CONVEX_SELF_HOSTED_URL` plus `CONVEX_SELF_HOSTED_ADMIN_KEY`, or `CONVEX_DEPLOYMENT`. diff --git a/README.md b/README.md index d739d1c..943ad52 100644 --- a/README.md +++ b/README.md @@ -198,8 +198,8 @@ production should use the repo-provided JS/TS workbench image: SPOON_AGENT_JOB_IMAGE="git.gbrown.org/gib/spoon-agent-job:latest" ``` -The job image includes Node 22, Bun, package managers through Corepack, git, -ripgrep, Python, build tools, and the OpenCode CLI. It is not the forked +The job image includes Node 22, Bun, pnpm and yarn through Corepack, npm, git, +ripgrep, Python, build tools, OpenCode, and the Codex CLI. It is not the forked project's production runtime; it is the agent execution environment. Production worker runtime requirements: @@ -216,15 +216,35 @@ Production worker runtime requirements: `SPOON_AGENT_WORKER_INTERNAL_TOKEN` so Next API routes can proxy workspace file, diff, message, command, and draft PR actions. - `spoon-agent-worker` also needs `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`. + If the private key is stored in a single-line dotenv value, encode newlines as + literal `\n` characters so the worker can restore the PEM before using it. Useful production checks: ```sh +docker login git.gbrown.org +docker pull git.gbrown.org/gib/spoon-agent-worker:latest +docker pull git.gbrown.org/gib/spoon-agent-job:latest docker logs --tail=200 spoon-agent-worker curl -H "Authorization: Bearer $SPOON_AGENT_WORKER_INTERNAL_TOKEN" \ http://spoon-agent-worker:3921/health ``` +Deployment readiness checklist: + +1. Production Convex env has `SPOON_WORKER_TOKEN`, `SPOON_ENCRYPTION_KEY`, + GitHub App env, and Convex Auth signing keys. +2. Compose env has `SPOON_AGENT_WORKER_URL`, + `SPOON_AGENT_WORKER_INTERNAL_TOKEN`, `SPOON_AGENT_JOB_IMAGE`, and the GitHub + App private key. +3. The production Docker host can pull private images from `git.gbrown.org`. +4. `Settings -> Worker` reports the expected job image, runtime, network, and + active workspace count. +5. The first test thread uses a configured API-key provider or a trusted Codex + login profile. +6. If a worker restart leaves stale workspace state, use the workspace recovery + panel or `Settings -> Worker` cleanup. + API-key based AI provider profiles run through OpenCode. Codex ChatGPT login profiles run through the Codex CLI: Spoon writes the encrypted `auth.json` into the isolated job workspace as `CODEX_HOME/.codex/auth.json` before execution. diff --git a/apps/next/src/app/(app)/dashboard/page.tsx b/apps/next/src/app/(app)/dashboard/page.tsx index 5faddcc..1edaf22 100644 --- a/apps/next/src/app/(app)/dashboard/page.tsx +++ b/apps/next/src/app/(app)/dashboard/page.tsx @@ -11,18 +11,20 @@ import { api } from '@spoon/backend/convex/_generated/api.js'; import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui'; const DashboardPage = () => { - const spoons = useQuery(api.spoons.listMine, {}) ?? []; + const spoons = useQuery(api.spoons.listMineWithState, {}) ?? []; const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? []; const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? []; const activeSpoons = spoons.filter( (spoon) => spoon.status === 'active', ).length; - const behind = spoons.filter((spoon) => spoon.syncStatus === 'behind').length; + const behind = spoons.filter( + (spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy === 0, + ).length; const diverged = spoons.filter( - (spoon) => spoon.syncStatus === 'diverged', + (spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy > 0, ).length; const openPullRequests = spoons.reduce( - (total, spoon) => total + (spoon.upstreamAheadBy ?? 0), + (total, spoon) => total + spoon.effectiveUpstreamAheadBy, 0, ); @@ -70,7 +72,7 @@ const DashboardPage = () => { diff --git a/apps/next/src/app/(app)/spoons/page.tsx b/apps/next/src/app/(app)/spoons/page.tsx index aa42035..0358e14 100644 --- a/apps/next/src/app/(app)/spoons/page.tsx +++ b/apps/next/src/app/(app)/spoons/page.tsx @@ -32,7 +32,7 @@ const formatDate = (value?: number) => const SpoonsPage = () => { const router = useRouter(); - const spoons = useQuery(api.spoons.listMine, {}) ?? []; + const spoons = useQuery(api.spoons.listMineWithState, {}) ?? []; const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? []; const active = spoons.filter((spoon) => spoon.status === 'active').length; const needsReview = threads.filter( @@ -41,7 +41,7 @@ const SpoonsPage = () => { !['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status), ).length; const upstreamWaiting = spoons.reduce( - (total, spoon) => total + (spoon.upstreamAheadBy ?? 0), + (total, spoon) => total + spoon.effectiveUpstreamAheadBy, 0, ); @@ -152,10 +152,16 @@ const SpoonsPage = () => {
-

{spoon.upstreamAheadBy ?? 0} upstream

+

{spoon.effectiveUpstreamAheadBy} actionable

- {spoon.forkAheadBy ?? 0} fork-only + {spoon.rawUpstreamAheadBy} raw upstream ·{' '} + {spoon.forkAheadBy} fork-only

+ {spoon.ignoredUpstreamCount ? ( +

+ {spoon.ignoredUpstreamCount} ignored +

+ ) : null}
@@ -197,7 +203,8 @@ const SpoonsPage = () => { {spoons.length ? (

- Raw upstream commits waiting across all Spoons: {upstreamWaiting} + Actionable upstream commits waiting across all Spoons:{' '} + {upstreamWaiting}

) : null} diff --git a/apps/next/src/app/(app)/threads/[threadId]/page.tsx b/apps/next/src/app/(app)/threads/[threadId]/page.tsx index 8404d27..dcd1669 100644 --- a/apps/next/src/app/(app)/threads/[threadId]/page.tsx +++ b/apps/next/src/app/(app)/threads/[threadId]/page.tsx @@ -1,9 +1,10 @@ 'use client'; +import { useState } from 'react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { useMutation, useQuery } from 'convex/react'; -import { ArrowUpRight, CheckCircle2, XCircle } from 'lucide-react'; +import { ArrowUpRight, CheckCircle2, Play, XCircle } from 'lucide-react'; import { toast } from 'sonner'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; @@ -23,28 +24,88 @@ const ThreadDetailPage = () => { const threadId = params.threadId as Id<'threads'>; const details = useQuery(api.threads.get, { threadId }); const messages = useQuery(api.threads.listMessages, { threadId }) ?? []; - const appendMessage = useMutation(api.threads.appendUserMessage); + const createJob = useMutation(api.agentJobs.createForThread); const markResolved = useMutation(api.threads.markResolved); const cancel = useMutation(api.threads.cancel); + const [sending, setSending] = useState(false); + const [queueing, setQueueing] = useState(false); if (details === undefined) { return
Loading thread...
; } const { thread, spoon, latestJob } = details; + 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 || + ['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes( + latestJob.status, + ) || + ['stopped', 'expired', 'failed'].includes( + latestJob.workspaceStatus ?? '', + )); + const jobType = + thread.source === 'merge_conflict' + ? ('conflict_resolution' as const) + : thread.source === 'upstream_update' + ? ('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 { - await appendMessage({ threadId, content }); + 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('Message added.'); + toast.success(activeJob ? 'Message sent to agent.' : 'Message added.'); } catch (error) { console.error(error); - toast.error('Could not add message.'); + toast.error('Could not send message.'); + } finally { + setSending(false); + } + }; + + const startRun = async () => { + setQueueing(true); + try { + const jobId = await createJob({ threadId, jobType }); + toast.success('Workspace run queued.'); + window.location.href = `/spoons/${spoon?._id}/agent/${jobId}`; + } catch (error) { + console.error(error); + toast.error('Could not queue workspace run.'); + } finally { + setQueueing(false); } }; @@ -99,28 +160,40 @@ const ThreadDetailPage = () => { ) : null} - - + {canQueueRun ? ( + + ) : null} + {!terminalThread ? ( + <> + + + + ) : null} @@ -149,9 +222,28 @@ const ThreadDetailPage = () => { name='message' required minLength={2} - placeholder='Add context or instructions for this thread.' + placeholder={ + activeJob + ? 'Send instructions to the active agent workspace.' + : 'Add context or instructions for the next run.' + } + disabled={sending || terminalThread} /> - +
+ + {!activeJob ? ( +

+ No active workspace is attached, so messages are saved as + thread notes until a run is started. +

+ ) : null} +
diff --git a/apps/next/src/app/(app)/threads/page.tsx b/apps/next/src/app/(app)/threads/page.tsx index 445c09a..8fb6e4b 100644 --- a/apps/next/src/app/(app)/threads/page.tsx +++ b/apps/next/src/app/(app)/threads/page.tsx @@ -1,28 +1,52 @@ 'use client'; +import { useState } from 'react'; import Link from 'next/link'; -import { useSearchParams } from 'next/navigation'; -import { useQuery } from 'convex/react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useMutation, useQuery } from 'convex/react'; import { MessageSquare, Plus } from 'lucide-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 { Badge, Button, Card, CardContent, + CardHeader, + CardTitle, + Input, + Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, + Switch, + Textarea, } from '@spoon/ui'; const formatTime = (value: number) => new Date(value).toLocaleString(); const ThreadsPage = () => { + const router = useRouter(); const params = useSearchParams(); const source = params.get('source') ?? 'all'; + const status = params.get('status') ?? 'all'; + const [spoonFilter, setSpoonFilter] = useState('all'); + const [priorityFilter, setPriorityFilter] = useState('all'); + const [outcomeFilter, setOutcomeFilter] = useState('all'); + const [spoonId, setSpoonId] = useState(''); + const [title, setTitle] = useState(''); + const [prompt, setPrompt] = useState(''); + const [materializeEnvFile, setMaterializeEnvFile] = useState(false); + const [envFilePath, setEnvFilePath] = useState('.env.local'); + const [creating, setCreating] = useState(false); + const createThread = useMutation(api.threads.createUserThread); + const spoons = useQuery(api.spoons.listMineWithState, {}) ?? []; + const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? []; + const defaultProfile = profiles.find((profile) => profile.isDefault); const threads = useQuery(api.threads.listMine, { source: source as @@ -32,8 +56,62 @@ const ThreadsPage = () => { | 'merge_conflict' | 'manual_review' | 'system', + status: status as + | 'all' + | 'open' + | 'queued' + | 'running' + | 'waiting_for_user' + | 'changes_ready' + | 'draft_pr_opened' + | 'resolved' + | 'ignored' + | 'failed' + | 'cancelled', limit: 100, }) ?? []; + const visibleThreads = threads.filter((thread) => { + if (spoonFilter !== 'all' && thread.spoonId !== spoonFilter) return false; + if (priorityFilter !== 'all' && thread.priority !== priorityFilter) { + return false; + } + if ( + outcomeFilter !== 'all' && + (thread.maintenanceOutcome ?? 'none') !== outcomeFilter + ) { + return false; + } + return true; + }); + + const updateFilter = (key: string, value: string) => { + const next = new URLSearchParams(params.toString()); + if (value === 'all') next.delete(key); + else next.set(key, value); + router.push(next.size ? `/threads?${next.toString()}` : '/threads'); + }; + + const submitThread = async (event: React.FormEvent) => { + event.preventDefault(); + if (!spoonId || !prompt.trim()) return; + setCreating(true); + try { + const threadId = await createThread({ + spoonId: spoonId as Id<'spoons'>, + title: title.trim() || undefined, + prompt, + materializeEnvFile, + envFilePath, + }); + toast.success('Thread created.'); + router.push(`/threads/${threadId}`); + } catch (error) { + console.error(error); + toast.error('Could not create thread.'); + } finally { + setCreating(false); + } + }; return (
@@ -46,20 +124,97 @@ const ThreadsPage = () => {

-
+ + + New thread + + +
+
+ + +
+
+ + setTitle(event.target.value)} + /> +
+
+ +