Add features & update project
Build and Push Spoon Images / quality (push) Successful in 1m41s
Build and Push Spoon Images / build-images (push) Successful in 7m4s

This commit is contained in:
Gabriel Brown
2026-06-23 02:06:58 -04:00
parent fe72fc2957
commit d207b8b0b8
26 changed files with 1257 additions and 231 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ jobs:
printf '%s\n' "$DOTENV_PROD" > "$env_file" printf '%s\n' "$DOTENV_PROD" > "$env_file"
CI_ENV_FILE="$env_file" ./scripts/build-next-app production CI_ENV_FILE="$env_file" ./scripts/build-next-app production
- name: Build agent images - name: Build agent images
run: ./scripts/build-agent-images run: SPOON_AGENT_CONTAINER_RUNTIME=docker ./scripts/build-agent-images
- name: Tag and push images - name: Tag and push images
run: | run: |
docker tag spoon-next:latest git.gbrown.org/gib/spoon-next:${{ gitea.sha }} docker tag spoon-next:latest git.gbrown.org/gib/spoon-next:${{ gitea.sha }}
+6 -2
View File
@@ -23,6 +23,8 @@
access to the host Docker socket. API-key provider jobs run through OpenCode; 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 ChatGPT login profiles run through the Codex CLI with an injected
`CODEX_HOME/.codex/auth.json` inside the isolated job workspace. `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 ## Protected and generated files
@@ -55,12 +57,14 @@
- Host-run worker dev uses `scripts/dev-agent-worker` after Infisical env - Host-run worker dev uses `scripts/dev-agent-worker` after Infisical env
loading. It prefers Podman, sets `SPOON_AGENT_CONTAINER_ACCESS=host_port`, loading. It prefers Podman, sets `SPOON_AGENT_CONTAINER_ACCESS=host_port`,
and expects `spoon-agent-job:latest` to exist locally. and expects `spoon-agent-job:latest` to exist locally.
- `bun smoke:agent-container` checks that the local job image has Node, Bun, - `bun smoke:agent-container` checks that the local job image has Node, npm,
git, ripgrep, jq, Python, OpenCode, and Codex available. Bun, pnpm, yarn, git, ripgrep, jq, Python, OpenCode, and Codex available.
- Old terminal workspaces can be deleted from `Settings -> Worker`; orphaned - Old terminal workspaces can be deleted from `Settings -> Worker`; orphaned
containers/workdirs are cleaned through the worker HTTP API, not from the containers/workdirs are cleaned through the worker HTTP API, not from the
browser directly. browser directly.
- CI uses Gitea-injected secrets or `CI_ENV_FILE` and must not call Infisical. - 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 - CI must provide Convex deployment env for codegen, either
`CONVEX_SELF_HOSTED_URL` plus `CONVEX_SELF_HOSTED_ADMIN_KEY`, or `CONVEX_SELF_HOSTED_URL` plus `CONVEX_SELF_HOSTED_ADMIN_KEY`, or
`CONVEX_DEPLOYMENT`. `CONVEX_DEPLOYMENT`.
+22 -2
View File
@@ -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" SPOON_AGENT_JOB_IMAGE="git.gbrown.org/gib/spoon-agent-job:latest"
``` ```
The job image includes Node 22, Bun, package managers through Corepack, git, The job image includes Node 22, Bun, pnpm and yarn through Corepack, npm, git,
ripgrep, Python, build tools, and the OpenCode CLI. It is not the forked ripgrep, Python, build tools, OpenCode, and the Codex CLI. It is not the forked
project's production runtime; it is the agent execution environment. project's production runtime; it is the agent execution environment.
Production worker runtime requirements: Production worker runtime requirements:
@@ -216,15 +216,35 @@ Production worker runtime requirements:
`SPOON_AGENT_WORKER_INTERNAL_TOKEN` so Next API routes can proxy workspace `SPOON_AGENT_WORKER_INTERNAL_TOKEN` so Next API routes can proxy workspace
file, diff, message, command, and draft PR actions. file, diff, message, command, and draft PR actions.
- `spoon-agent-worker` also needs `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`. - `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: Useful production checks:
```sh ```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 docker logs --tail=200 spoon-agent-worker
curl -H "Authorization: Bearer $SPOON_AGENT_WORKER_INTERNAL_TOKEN" \ curl -H "Authorization: Bearer $SPOON_AGENT_WORKER_INTERNAL_TOKEN" \
http://spoon-agent-worker:3921/health 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 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 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. the isolated job workspace as `CODEX_HOME/.codex/auth.json` before execution.
+7 -5
View File
@@ -11,18 +11,20 @@ import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui'; import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
const DashboardPage = () => { const DashboardPage = () => {
const spoons = useQuery(api.spoons.listMine, {}) ?? []; const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? []; const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? []; const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
const activeSpoons = spoons.filter( const activeSpoons = spoons.filter(
(spoon) => spoon.status === 'active', (spoon) => spoon.status === 'active',
).length; ).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( const diverged = spoons.filter(
(spoon) => spoon.syncStatus === 'diverged', (spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy > 0,
).length; ).length;
const openPullRequests = spoons.reduce( const openPullRequests = spoons.reduce(
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0), (total, spoon) => total + spoon.effectiveUpstreamAheadBy,
0, 0,
); );
@@ -70,7 +72,7 @@ const DashboardPage = () => {
<MetricCard <MetricCard
label='Upstream commits' label='Upstream commits'
value={openPullRequests} value={openPullRequests}
note='Waiting across Spoons' note='Actionable after ignores'
icon={ShieldCheck} icon={ShieldCheck}
/> />
</div> </div>
+12 -5
View File
@@ -32,7 +32,7 @@ const formatDate = (value?: number) =>
const SpoonsPage = () => { const SpoonsPage = () => {
const router = useRouter(); const router = useRouter();
const spoons = useQuery(api.spoons.listMine, {}) ?? []; const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? []; const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
const active = spoons.filter((spoon) => spoon.status === 'active').length; const active = spoons.filter((spoon) => spoon.status === 'active').length;
const needsReview = threads.filter( const needsReview = threads.filter(
@@ -41,7 +41,7 @@ const SpoonsPage = () => {
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status), !['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
).length; ).length;
const upstreamWaiting = spoons.reduce( const upstreamWaiting = spoons.reduce(
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0), (total, spoon) => total + spoon.effectiveUpstreamAheadBy,
0, 0,
); );
@@ -152,10 +152,16 @@ const SpoonsPage = () => {
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className='text-sm'> <div className='text-sm'>
<p>{spoon.upstreamAheadBy ?? 0} upstream</p> <p>{spoon.effectiveUpstreamAheadBy} actionable</p>
<p className='text-muted-foreground'> <p className='text-muted-foreground'>
{spoon.forkAheadBy ?? 0} fork-only {spoon.rawUpstreamAheadBy} raw upstream ·{' '}
{spoon.forkAheadBy} fork-only
</p> </p>
{spoon.ignoredUpstreamCount ? (
<p className='text-muted-foreground'>
{spoon.ignoredUpstreamCount} ignored
</p>
) : null}
</div> </div>
</TableCell> </TableCell>
<TableCell className='capitalize'> <TableCell className='capitalize'>
@@ -197,7 +203,8 @@ const SpoonsPage = () => {
{spoons.length ? ( {spoons.length ? (
<p className='text-muted-foreground text-sm'> <p className='text-muted-foreground text-sm'>
Raw upstream commits waiting across all Spoons: {upstreamWaiting} Actionable upstream commits waiting across all Spoons:{' '}
{upstreamWaiting}
</p> </p>
) : null} ) : null}
</main> </main>
@@ -1,9 +1,10 @@
'use client'; 'use client';
import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useMutation, useQuery } from 'convex/react'; 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 { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -23,28 +24,88 @@ const ThreadDetailPage = () => {
const threadId = params.threadId as Id<'threads'>; const threadId = params.threadId as Id<'threads'>;
const details = useQuery(api.threads.get, { threadId }); const details = useQuery(api.threads.get, { threadId });
const messages = useQuery(api.threads.listMessages, { 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 markResolved = useMutation(api.threads.markResolved);
const cancel = useMutation(api.threads.cancel); const cancel = useMutation(api.threads.cancel);
const [sending, setSending] = useState(false);
const [queueing, setQueueing] = useState(false);
if (details === undefined) { if (details === undefined) {
return <main className='text-muted-foreground p-6'>Loading thread...</main>; return <main className='text-muted-foreground p-6'>Loading thread...</main>;
} }
const { thread, spoon, latestJob } = details; 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<HTMLFormElement>) => { const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
const form = new FormData(event.currentTarget); const form = new FormData(event.currentTarget);
const value = form.get('message'); const value = form.get('message');
const content = typeof value === 'string' ? value : ''; const content = typeof value === 'string' ? value : '';
setSending(true);
try { 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(); event.currentTarget.reset();
toast.success('Message added.'); toast.success(activeJob ? 'Message sent to agent.' : 'Message added.');
} catch (error) { } catch (error) {
console.error(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 = () => {
</a> </a>
</Button> </Button>
) : null} ) : null}
<Button {canQueueRun ? (
variant='outline' <Button disabled={queueing} onClick={() => void startRun()}>
onClick={() => <Play className='size-4' />
markResolved({ threadId }).then(() => {latestJob ? 'Rerun' : 'Start workspace run'}
toast.success('Thread resolved.'), </Button>
) ) : null}
} {!terminalThread ? (
> <>
<CheckCircle2 className='size-4' /> <Button
Resolve variant='outline'
</Button> onClick={() => {
<Button if (!window.confirm('Mark this thread as resolved?')) return;
variant='outline' void markResolved({ threadId }).then(() =>
onClick={() => toast.success('Thread resolved.'),
cancel({ threadId }).then(() => );
toast.success('Thread cancelled.'), }}
) >
} <CheckCircle2 className='size-4' />
> Resolve
<XCircle className='size-4' /> </Button>
Cancel <Button
</Button> variant='outline'
onClick={() => {
if (!window.confirm('Cancel this thread?')) return;
void cancel({ threadId }).then(() =>
toast.success('Thread cancelled.'),
);
}}
>
<XCircle className='size-4' />
Cancel
</Button>
</>
) : null}
</div> </div>
</div> </div>
@@ -149,9 +222,28 @@ const ThreadDetailPage = () => {
name='message' name='message'
required required
minLength={2} 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}
/> />
<Button type='submit'>Add message</Button> <div className='flex flex-wrap items-center gap-2'>
<Button type='submit' disabled={sending || terminalThread}>
{sending
? 'Sending...'
: activeJob
? 'Send to agent'
: 'Add note'}
</Button>
{!activeJob ? (
<p className='text-muted-foreground text-xs'>
No active workspace is attached, so messages are saved as
thread notes until a run is started.
</p>
) : null}
</div>
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
+296 -39
View File
@@ -1,28 +1,52 @@
'use client'; 'use client';
import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useQuery } from 'convex/react'; import { useMutation, useQuery } from 'convex/react';
import { MessageSquare, Plus } from 'lucide-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 { api } from '@spoon/backend/convex/_generated/api.js';
import { import {
Badge, Badge,
Button, Button,
Card, Card,
CardContent, CardContent,
CardHeader,
CardTitle,
Input,
Label,
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
Switch,
Textarea,
} from '@spoon/ui'; } from '@spoon/ui';
const formatTime = (value: number) => new Date(value).toLocaleString(); const formatTime = (value: number) => new Date(value).toLocaleString();
const ThreadsPage = () => { const ThreadsPage = () => {
const router = useRouter();
const params = useSearchParams(); const params = useSearchParams();
const source = params.get('source') ?? 'all'; 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 = const threads =
useQuery(api.threads.listMine, { useQuery(api.threads.listMine, {
source: source as source: source as
@@ -32,8 +56,62 @@ const ThreadsPage = () => {
| 'merge_conflict' | 'merge_conflict'
| 'manual_review' | 'manual_review'
| 'system', | 'system',
status: status as
| 'all'
| 'open'
| 'queued'
| 'running'
| 'waiting_for_user'
| 'changes_ready'
| 'draft_pr_opened'
| 'resolved'
| 'ignored'
| 'failed'
| 'cancelled',
limit: 100, 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<HTMLFormElement>) => {
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 ( return (
<main className='space-y-6'> <main className='space-y-6'>
@@ -46,20 +124,97 @@ const ThreadsPage = () => {
</p> </p>
</div> </div>
<Button asChild> <Button asChild>
<Link href='/spoons'> <a href='#new-thread'>
<Plus className='size-4' /> <Plus className='size-4' />
New thread from Spoon New thread
</Link> </a>
</Button> </Button>
</div> </div>
<div className='flex flex-col gap-3 md:flex-row'> <Card id='new-thread' className='shadow-none'>
<CardHeader>
<CardTitle className='text-base'>New thread</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={submitThread} className='grid gap-4 lg:grid-cols-2'>
<div className='grid gap-2'>
<Label>Spoon</Label>
<Select value={spoonId} onValueChange={setSpoonId}>
<SelectTrigger>
<SelectValue placeholder='Choose a Spoon' />
</SelectTrigger>
<SelectContent>
{spoons.map((spoon) => (
<SelectItem key={spoon._id} value={spoon._id}>
{spoon.name} · {spoon.upstreamOwner}/{spoon.upstreamRepo}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label>Title</Label>
<Input
value={title}
placeholder='Optional'
onChange={(event) => setTitle(event.target.value)}
/>
</div>
<div className='grid gap-2 lg:col-span-2'>
<Label>Prompt</Label>
<Textarea
value={prompt}
placeholder='Describe the change, review, or maintenance task.'
required
minLength={4}
onChange={(event) => setPrompt(event.target.value)}
/>
</div>
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
<div>
<Label>Write Spoon secrets to env file</Label>
<p className='text-muted-foreground text-xs'>
All Spoon secrets are always available as process env.
</p>
</div>
<Switch
checked={materializeEnvFile}
onCheckedChange={setMaterializeEnvFile}
/>
</div>
<div className='grid gap-2'>
<Label>Env file path</Label>
<Input
value={envFilePath}
onChange={(event) => setEnvFilePath(event.target.value)}
/>
</div>
<div className='text-muted-foreground text-sm lg:col-span-2'>
Provider:{' '}
<span className='text-foreground font-medium'>
{defaultProfile
? `${defaultProfile.name} · ${defaultProfile.defaultModel}`
: 'Configure an AI provider in Settings'}
</span>
</div>
<div className='lg:col-span-2'>
<Button
type='submit'
disabled={
creating || !spoonId || !prompt.trim() || !defaultProfile
}
>
{creating ? 'Creating...' : 'Create thread'}
</Button>
</div>
</form>
</CardContent>
</Card>
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-5'>
<Select <Select
value={source} value={source}
onValueChange={(value) => { onValueChange={(value) => updateFilter('source', value)}
window.location.href =
value === 'all' ? '/threads' : `/threads?source=${value}`;
}}
> >
<SelectTrigger className='w-full md:w-56'> <SelectTrigger className='w-full md:w-56'>
<SelectValue /> <SelectValue />
@@ -73,43 +228,145 @@ const ThreadsPage = () => {
<SelectItem value='system'>System</SelectItem> <SelectItem value='system'>System</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Select
value={status}
onValueChange={(value) => updateFilter('status', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All statuses</SelectItem>
<SelectItem value='open'>Open</SelectItem>
<SelectItem value='queued'>Queued</SelectItem>
<SelectItem value='running'>Running</SelectItem>
<SelectItem value='waiting_for_user'>Waiting</SelectItem>
<SelectItem value='changes_ready'>Changes ready</SelectItem>
<SelectItem value='draft_pr_opened'>Draft PR opened</SelectItem>
<SelectItem value='resolved'>Resolved</SelectItem>
<SelectItem value='ignored'>Ignored</SelectItem>
<SelectItem value='failed'>Failed</SelectItem>
<SelectItem value='cancelled'>Cancelled</SelectItem>
</SelectContent>
</Select>
<Select value={spoonFilter} onValueChange={setSpoonFilter}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All Spoons</SelectItem>
{spoons.map((spoon) => (
<SelectItem key={spoon._id} value={spoon._id}>
{spoon.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All priorities</SelectItem>
<SelectItem value='low'>Low</SelectItem>
<SelectItem value='normal'>Normal</SelectItem>
<SelectItem value='high'>High</SelectItem>
</SelectContent>
</Select>
<Select value={outcomeFilter} onValueChange={setOutcomeFilter}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All outcomes</SelectItem>
<SelectItem value='none'>No outcome</SelectItem>
<SelectItem value='auto_synced'>Auto synced</SelectItem>
<SelectItem value='sync_recommended'>Sync recommended</SelectItem>
<SelectItem value='ignored'>Ignored</SelectItem>
<SelectItem value='review_pr_recommended'>Review PR</SelectItem>
<SelectItem value='manual_review_required'>
Manual review
</SelectItem>
<SelectItem value='conflict_resolution_required'>
Conflict
</SelectItem>
<SelectItem value='failed'>Failed</SelectItem>
<SelectItem value='unknown'>Unknown</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className='space-y-3'> <div className='space-y-3'>
{threads.length ? ( {visibleThreads.length ? (
threads.map((thread) => ( visibleThreads.map((thread) => (
<Link <Card
key={thread._id} key={thread._id}
href={`/threads/${thread._id}`} role='link'
className='block' tabIndex={0}
className='hover:border-primary/50 cursor-pointer shadow-none transition-colors'
onClick={() => router.push(`/threads/${thread._id}`)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
router.push(`/threads/${thread._id}`);
}
}}
> >
<Card className='hover:border-primary/50 shadow-none transition-colors'> <CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'> <div className='min-w-0'>
<div className='min-w-0'> <div className='flex flex-wrap items-center gap-2'>
<div className='flex flex-wrap items-center gap-2'> <h2 className='truncate font-medium'>{thread.title}</h2>
<h2 className='truncate font-medium'>{thread.title}</h2> {thread.spoonName ? (
<Badge variant='outline'> <Badge variant='outline'>{thread.spoonName}</Badge>
{thread.source.replaceAll('_', ' ')} ) : null}
<Badge variant='outline'>
{thread.source.replaceAll('_', ' ')}
</Badge>
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
{thread.maintenanceOutcome ? (
<Badge variant='secondary'>
{thread.maintenanceOutcome.replaceAll('_', ' ')}
</Badge> </Badge>
<Badge>{thread.status.replaceAll('_', ' ')}</Badge> ) : null}
{thread.maintenanceOutcome ? (
<Badge variant='secondary'>
{thread.maintenanceOutcome.replaceAll('_', ' ')}
</Badge>
) : null}
</div>
<p className='text-muted-foreground mt-1 line-clamp-2 text-sm'>
{thread.summary ??
'No summary has been recorded for this thread yet.'}
</p>
</div> </div>
<div className='text-muted-foreground text-xs md:text-right'> <p className='text-muted-foreground mt-1 line-clamp-2 text-sm'>
<p>{formatTime(thread.updatedAt)}</p> {thread.summary ??
<p className='capitalize'>{thread.priority} priority</p> 'No summary has been recorded for this thread yet.'}
</p>
</div>
<div className='text-muted-foreground text-xs md:text-right'>
<p>{formatTime(thread.updatedAt)}</p>
<p className='capitalize'>{thread.priority} priority</p>
{thread.latestJobStatus ? (
<p>{thread.latestJobStatus.replaceAll('_', ' ')}</p>
) : null}
<div className='mt-2 flex justify-start gap-2 md:justify-end'>
{thread.latestAgentJobId ? (
<Button size='sm' variant='outline' asChild>
<Link
href={`/spoons/${thread.spoonId}/agent/${thread.latestAgentJobId}`}
onClick={(event) => event.stopPropagation()}
>
Workspace
</Link>
</Button>
) : null}
{thread.latestJobPullRequestUrl ? (
<Button size='sm' asChild>
<a
href={thread.latestJobPullRequestUrl}
target='_blank'
rel='noreferrer'
onClick={(event) => event.stopPropagation()}
>
PR
</a>
</Button>
) : null}
</div> </div>
</CardContent> </div>
</Card> </CardContent>
</Link> </Card>
)) ))
) : ( ) : (
<Card className='shadow-none'> <Card className='shadow-none'>
@@ -0,0 +1,81 @@
import { NextResponse } from 'next/server';
import { proxyWorker } from '@/lib/agent-worker-proxy';
import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server';
import { fetchMutation, fetchQuery } from 'convex/nextjs';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
const activeJobStatuses = new Set([
'claimed',
'preparing',
'running',
'checks_running',
'changes_ready',
]);
const activeWorkspaceStatuses = new Set(['active', 'idle']);
export const POST = async (
request: Request,
context: { params: Promise<{ threadId: string }> },
) => {
try {
const token = await convexAuthNextjsToken();
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { threadId: rawThreadId } = await context.params;
const threadId = rawThreadId as Id<'threads'>;
const body = (await request.json()) as { content?: string };
const content = body.content?.trim() ?? '';
if (!content) {
return NextResponse.json(
{ error: 'Message is required.' },
{ status: 400 },
);
}
const details = await fetchQuery(api.threads.get, { threadId }, { token });
const latestJob = details.latestJob;
const canSendToWorker =
latestJob &&
activeJobStatuses.has(latestJob.status) &&
activeWorkspaceStatuses.has(latestJob.workspaceStatus ?? '');
if (!canSendToWorker) {
await fetchMutation(
api.threads.appendUserMessage,
{ threadId, content },
{ token },
);
return NextResponse.json({
success: true,
mode: 'note',
message: latestJob
? 'Message was added as a thread note because the latest workspace is not active.'
: 'Message was added as a thread note.',
});
}
const proxied = await proxyWorker(latestJob._id, 'message', {
method: 'POST',
body: JSON.stringify({ content }),
});
if (!proxied.ok) {
const text = await proxied.text();
return NextResponse.json(
{
error: text,
recoverable:
text.includes('workspace is not active') ||
text.includes('not active on this worker'),
},
{ status: proxied.status === 500 ? 409 : proxied.status },
);
}
return proxied;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: message }, { status: 500 });
}
};
@@ -13,12 +13,14 @@ export const AgentThread = ({
events, events,
interactions, interactions,
disabled, disabled,
agentTurnActive,
}: { }: {
jobId: string; jobId: string;
messages: Doc<'agentJobMessages'>[]; messages: Doc<'agentJobMessages'>[];
events: Doc<'agentJobEvents'>[]; events: Doc<'agentJobEvents'>[];
interactions: Doc<'agentInteractionRequests'>[]; interactions: Doc<'agentInteractionRequests'>[];
disabled: boolean; disabled: boolean;
agentTurnActive: boolean;
}) => { }) => {
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
@@ -94,7 +96,7 @@ export const AgentThread = ({
type='button' type='button'
variant='outline' variant='outline'
size='sm' size='sm'
disabled={disabled} disabled={disabled || !agentTurnActive}
onClick={abort} onClick={abort}
> >
<Ban className='size-3' /> <Ban className='size-3' />
@@ -6,7 +6,7 @@ import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js'; import { api } from '@spoon/backend/convex/_generated/api.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui'; import { Button, Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
import type { DiffResponse, FileResponse, FileTreeNode } from './types'; import type { DiffResponse, FileResponse, FileTreeNode } from './types';
import { AgentThread } from './agent-thread'; import { AgentThread } from './agent-thread';
@@ -40,6 +40,9 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
}) ?? []; }) ?? [];
const uiState = useQuery(api.agentJobs.getWorkspaceUiState, { jobId }); const uiState = useQuery(api.agentJobs.getWorkspaceUiState, { jobId });
const patchUiState = useMutation(api.agentJobs.patchWorkspaceUiState); const patchUiState = useMutation(api.agentJobs.patchWorkspaceUiState);
const createJobForThread = useMutation(api.agentJobs.createForThread);
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
const markWorkspaceLost = useMutation(api.agentJobs.markWorkspaceLost);
const [tree, setTree] = useState<FileTreeNode | null>(null); const [tree, setTree] = useState<FileTreeNode | null>(null);
const [files, setFiles] = useState<Record<string, OpenFileState>>({}); const [files, setFiles] = useState<Record<string, OpenFileState>>({});
const [openFilePaths, setOpenFilePaths] = useState<string[]>([]); const [openFilePaths, setOpenFilePaths] = useState<string[]>([]);
@@ -50,6 +53,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const [vimEnabled, setVimEnabled] = useState(false); const [vimEnabled, setVimEnabled] = useState(false);
const [hydratedUiState, setHydratedUiState] = useState(false); const [hydratedUiState, setHydratedUiState] = useState(false);
const [diff, setDiff] = useState(''); const [diff, setDiff] = useState('');
const [workspaceError, setWorkspaceError] = useState<string>();
const [agentTurnActive, setAgentTurnActive] = useState(false);
const workspaceDisabled = const workspaceDisabled =
!job || !job ||
@@ -62,6 +67,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const response = await fetch(`/api/agent-jobs/${jobId}/tree`); const response = await fetch(`/api/agent-jobs/${jobId}/tree`);
if (!response.ok) throw new Error(await response.text()); if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as { tree: FileTreeNode | null }; const data = (await response.json()) as { tree: FileTreeNode | null };
setWorkspaceError(undefined);
setTree(data.tree); setTree(data.tree);
}, [jobId]); }, [jobId]);
@@ -69,9 +75,20 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const response = await fetch(`/api/agent-jobs/${jobId}/diff`); const response = await fetch(`/api/agent-jobs/${jobId}/diff`);
if (!response.ok) throw new Error(await response.text()); if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as DiffResponse; const data = (await response.json()) as DiffResponse;
setWorkspaceError(undefined);
setDiff(data.diff); setDiff(data.diff);
}, [jobId]); }, [jobId]);
const loadAgentStatus = useCallback(async () => {
const response = await fetch(`/api/agent-jobs/${jobId}/agent/status`);
if (!response.ok) {
setAgentTurnActive(false);
return;
}
const data = (await response.json()) as { active?: boolean };
setAgentTurnActive(Boolean(data.active));
}, [jobId]);
const loadFile = useCallback( const loadFile = useCallback(
async (path: string) => { async (path: string) => {
setFiles((current) => ({ setFiles((current) => ({
@@ -132,13 +149,27 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
void loadTree().catch((error: unknown) => { void loadTree().catch((error: unknown) => {
console.error(error); console.error(error);
setWorkspaceError(
error instanceof Error ? error.message : String(error),
);
}); });
void loadDiff().catch((error: unknown) => { void loadDiff().catch((error: unknown) => {
console.error(error); console.error(error);
setWorkspaceError(
error instanceof Error ? error.message : String(error),
);
}); });
void loadAgentStatus();
}, 0); }, 0);
return () => window.clearTimeout(timeout); return () => window.clearTimeout(timeout);
}, [job, loadDiff, loadTree]); }, [job, loadAgentStatus, loadDiff, loadTree]);
useEffect(() => {
const interval = window.setInterval(() => {
void loadAgentStatus();
}, 5_000);
return () => window.clearInterval(interval);
}, [loadAgentStatus]);
useEffect(() => { useEffect(() => {
if (!uiState || hydratedUiState) return; if (!uiState || hydratedUiState) return;
@@ -197,6 +228,23 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
} }
const activeFile = activeFilePath ? files[activeFilePath] : undefined; const activeFile = activeFilePath ? files[activeFilePath] : undefined;
const recoverWorkspace = async () => {
if (!job.threadId) return;
const newJobId = await createJobForThread({
threadId: job.threadId,
jobType: job.jobType ?? 'user_change',
});
window.location.href = `/spoons/${job.spoonId}/agent/${newJobId}`;
};
const deleteStaleWorkspace = async () => {
if (!window.confirm('Delete this stale workspace record?')) return;
await markWorkspaceLost({ jobId });
await deleteWorkspace({ jobId });
window.location.href = job.threadId
? `/threads/${job.threadId}`
: `/spoons/${job.spoonId}`;
};
const saveFile = async (content: string) => { const saveFile = async (content: string) => {
if (!activeFilePath) return; if (!activeFilePath) return;
@@ -280,6 +328,35 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
return ( return (
<main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'> <main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'>
<JobStatusBar job={job} /> <JobStatusBar job={job} />
{workspaceError ? (
<div className='border-border bg-background border-b p-4'>
<div className='border-destructive/40 bg-destructive/5 rounded-md border p-4'>
<p className='font-medium'>Workspace not active on this worker</p>
<p className='text-muted-foreground mt-1 text-sm'>
{workspaceError}
</p>
<div className='mt-3 flex flex-wrap gap-2'>
{job.threadId ? (
<Button type='button' onClick={() => void recoverWorkspace()}>
Recreate workspace run
</Button>
) : null}
<Button
type='button'
variant='outline'
onClick={() => void deleteStaleWorkspace()}
>
Delete stale workspace
</Button>
{job.threadId ? (
<Button type='button' variant='outline' asChild>
<a href={`/threads/${job.threadId}`}>Open thread</a>
</Button>
) : null}
</div>
</div>
</div>
) : null}
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'> <div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
<WorkspaceActions job={job} disabled={workspaceDisabled} /> <WorkspaceActions job={job} disabled={workspaceDisabled} />
</div> </div>
@@ -362,6 +439,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
events={events} events={events}
interactions={interactions} interactions={interactions}
disabled={workspaceDisabled} disabled={workspaceDisabled}
agentTurnActive={agentTurnActive}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@@ -374,6 +452,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
events={events} events={events}
interactions={interactions} interactions={interactions}
disabled={workspaceDisabled} disabled={workspaceDisabled}
agentTurnActive={agentTurnActive}
/> />
</aside> </aside>
</div> </div>
@@ -1,8 +1,12 @@
'use client'; 'use client';
import type { ProviderModelOption } from '@/lib/models-dev'; import type { ProviderModelOption } from '@/lib/provider-model-options';
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { loadModelsDevOptions } from '@/lib/models-dev'; import {
modelOptionsFromIds,
suggestedModelOptions,
supportsCustomModelOptions,
} from '@/lib/provider-model-options';
import { useAction, useMutation, useQuery } from 'convex/react'; import { useAction, useMutation, useQuery } from 'convex/react';
import { makeFunctionReference } from 'convex/server'; import { makeFunctionReference } from 'convex/server';
import { KeyRound, Trash2 } from 'lucide-react'; import { KeyRound, Trash2 } from 'lucide-react';
@@ -11,6 +15,7 @@ import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js'; import { api } from '@spoon/backend/convex/_generated/api.js';
import { import {
Badge,
Button, Button,
Card, Card,
CardContent, CardContent,
@@ -50,6 +55,7 @@ const saveProfileRef = makeFunctionReference<
secret?: string; secret?: string;
baseUrl?: string; baseUrl?: string;
defaultModel: string; defaultModel: string;
modelOptions?: string[];
reasoningEffort: ReasoningEffort; reasoningEffort: ReasoningEffort;
enabled: boolean; enabled: boolean;
}, },
@@ -119,33 +125,24 @@ export const AiProviderProfilesPanel = () => {
); );
const [secret, setSecret] = useState(''); const [secret, setSecret] = useState('');
const [baseUrl, setBaseUrl] = useState(''); const [baseUrl, setBaseUrl] = useState('');
const [defaultModelValue, setDefaultModelValue] = useState(''); const [defaultModelValue, setDefaultModelValue] = useState(
const [modelOptions, setModelOptions] = useState<ProviderModelOption[]>([]); suggestedModelOptions('openai')[0]?.id ?? '',
);
const [modelOptions, setModelOptions] = useState<ProviderModelOption[]>(
suggestedModelOptions('openai'),
);
const [customModelId, setCustomModelId] = useState('');
const [reasoningEffort, setReasoningEffort] = const [reasoningEffort, setReasoningEffort] =
useState<ReasoningEffort>('medium'); useState<ReasoningEffort>('medium');
const [enabled, setEnabled] = useState(true); const [enabled, setEnabled] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { const resetModelOptions = (nextProvider: Provider) => {
let cancelled = false; const options = suggestedModelOptions(nextProvider);
loadModelsDevOptions(provider) setModelOptions(options);
.then((options) => { setDefaultModelValue(options[0]?.id ?? '');
if (cancelled) return; setCustomModelId('');
setModelOptions(options); };
setDefaultModelValue((current) =>
current && options.some((option) => option.id === current)
? current
: (options[0]?.id ?? ''),
);
})
.catch((error: unknown) => {
console.error(error);
if (!cancelled) setModelOptions([]);
});
return () => {
cancelled = true;
};
}, [provider]);
const reset = () => { const reset = () => {
setProfileId(undefined); setProfileId(undefined);
@@ -153,6 +150,8 @@ export const AiProviderProfilesPanel = () => {
setSecret(''); setSecret('');
setBaseUrl(''); setBaseUrl('');
setDefaultModelValue(''); setDefaultModelValue('');
setModelOptions(suggestedModelOptions('openai'));
setCustomModelId('');
setReasoningEffort('medium'); setReasoningEffort('medium');
setEnabled(true); setEnabled(true);
setName('OpenAI'); setName('OpenAI');
@@ -165,6 +164,14 @@ export const AiProviderProfilesPanel = () => {
setSecret(''); setSecret('');
setBaseUrl(profile.baseUrl ?? ''); setBaseUrl(profile.baseUrl ?? '');
setDefaultModelValue(profile.defaultModel); setDefaultModelValue(profile.defaultModel);
setModelOptions(
modelOptionsFromIds(
profile.modelOptions?.length
? profile.modelOptions
: [profile.defaultModel],
),
);
setCustomModelId('');
setReasoningEffort(profile.reasoningEffort as ReasoningEffort); setReasoningEffort(profile.reasoningEffort as ReasoningEffort);
setEnabled(profile.enabled); setEnabled(profile.enabled);
}; };
@@ -181,6 +188,7 @@ export const AiProviderProfilesPanel = () => {
secret: secret.trim() ? secret : undefined, secret: secret.trim() ? secret : undefined,
baseUrl: baseUrl.trim() || undefined, baseUrl: baseUrl.trim() || undefined,
defaultModel: defaultModelValue, defaultModel: defaultModelValue,
modelOptions: modelOptions.map((model) => model.id),
reasoningEffort, reasoningEffort,
enabled, enabled,
}); });
@@ -310,6 +318,7 @@ export const AiProviderProfilesPanel = () => {
onValueChange={(value) => { onValueChange={(value) => {
const nextProvider = value as Provider; const nextProvider = value as Provider;
setProvider(nextProvider); setProvider(nextProvider);
resetModelOptions(nextProvider);
setName( setName(
providerOptions providerOptions
.find((option) => option.value === nextProvider) .find((option) => option.value === nextProvider)
@@ -397,9 +406,47 @@ export const AiProviderProfilesPanel = () => {
</SelectContent> </SelectContent>
</Select> </Select>
<p className='text-muted-foreground text-xs'> <p className='text-muted-foreground text-xs'>
Models are loaded from Models.dev, the catalog OpenCode uses Saved model options are used by Spoons. Add custom model IDs
for provider/model metadata. for compatible provider gateways.
</p> </p>
<div className='rounded-md border p-2'>
<p className='text-muted-foreground mb-2 text-xs'>
Available model options
</p>
<div className='flex flex-wrap gap-2'>
{modelOptions.map((model) => (
<Badge key={model.id} variant='outline'>
{model.id}
</Badge>
))}
</div>
</div>
{supportsCustomModelOptions(provider) ? (
<div className='flex gap-2'>
<Input
value={customModelId}
placeholder='provider/model-id'
onChange={(event) => setCustomModelId(event.target.value)}
/>
<Button
type='button'
variant='outline'
onClick={() => {
const id = customModelId.trim();
if (!id) return;
setModelOptions((current) =>
current.some((model) => model.id === id)
? current
: [...current, ...modelOptionsFromIds([id])],
);
setDefaultModelValue((current) => current || id);
setCustomModelId('');
}}
>
Add
</Button>
</div>
) : null}
</div> </div>
<div className='grid gap-2'> <div className='grid gap-2'>
<Label>Thinking</Label> <Label>Thinking</Label>
@@ -75,7 +75,7 @@ const features = [
{ {
title: 'Provider-owned AI', title: 'Provider-owned AI',
description: description:
'Use encrypted provider profiles, OpenCode auth, or user-owned API keys rather than a shared application key.', 'Use encrypted provider profiles: API-key providers run through OpenCode, and Codex login profiles run through the Codex CLI.',
icon: KeyRound, icon: KeyRound,
}, },
{ {
@@ -119,7 +119,7 @@ const ownership = [
{ {
title: 'Your providers', title: 'Your providers',
description: description:
'AI provider profiles and Codex/OpenCode auth stay encrypted and selected by you.', 'AI provider profiles, API keys, and Codex auth JSON stay encrypted and selected by you.',
icon: ShieldCheck, icon: ShieldCheck,
}, },
{ {
@@ -63,6 +63,14 @@ export default function Footer() {
Integrations Integrations
</Link> </Link>
</li> </li>
<li>
<Link
href='/settings/worker'
className='text-muted-foreground hover:text-foreground transition-colors'
>
Worker
</Link>
</li>
<li> <li>
<Link <Link
href='https://git.gbrown.org/gib/spoon' href='https://git.gbrown.org/gib/spoon'
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useMutation, useQuery } from 'convex/react'; import { useMutation, useQuery } from 'convex/react';
import { RefreshCw, Trash2, Wrench } from 'lucide-react'; import { Copy, RefreshCw, Trash2, Wrench } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@spoon/backend/convex/_generated/api.js'; import { api } from '@spoon/backend/convex/_generated/api.js';
@@ -47,6 +47,25 @@ export const WorkerHealthPanel = () => {
useQuery(api.agentJobs.countOldWorkspaces, { olderThanDays }) ?? 0; useQuery(api.agentJobs.countOldWorkspaces, { olderThanDays }) ?? 0;
const deleteOldWorkspaces = useMutation(api.agentJobs.deleteOldWorkspaces); const deleteOldWorkspaces = useMutation(api.agentJobs.deleteOldWorkspaces);
const copy = async (value: string) => {
await navigator.clipboard.writeText(value);
toast.success('Copied.');
};
const DiagnosticValue = ({ value }: { value: string }) => (
<dd className='flex items-center gap-2 font-mono break-all'>
<span>{value}</span>
<Button
type='button'
variant='ghost'
size='icon'
onClick={() => void copy(value)}
>
<Copy className='size-3' />
</Button>
</dd>
);
const refreshHealth = async () => { const refreshHealth = async () => {
setLoadingHealth(true); setLoadingHealth(true);
setHealthError(undefined); setHealthError(undefined);
@@ -151,15 +170,15 @@ export const WorkerHealthPanel = () => {
<dl className='grid gap-3 text-sm md:grid-cols-2'> <dl className='grid gap-3 text-sm md:grid-cols-2'>
<div> <div>
<dt className='text-muted-foreground'>Convex</dt> <dt className='text-muted-foreground'>Convex</dt>
<dd className='font-mono break-all'>{health.convexUrl}</dd> <DiagnosticValue value={health.convexUrl} />
</div> </div>
<div> <div>
<dt className='text-muted-foreground'>Job image</dt> <dt className='text-muted-foreground'>Job image</dt>
<dd className='font-mono break-all'>{health.jobImage}</dd> <DiagnosticValue value={health.jobImage} />
</div> </div>
<div> <div>
<dt className='text-muted-foreground'>Workdir</dt> <dt className='text-muted-foreground'>Workdir</dt>
<dd className='font-mono break-all'>{health.workdir}</dd> <DiagnosticValue value={health.workdir} />
</div> </div>
<div> <div>
<dt className='text-muted-foreground'>Network</dt> <dt className='text-muted-foreground'>Network</dt>
@@ -1,8 +1,6 @@
'use client'; 'use client';
import type { ProviderModelOption } from '@/lib/models-dev'; import { useState } from 'react';
import { useEffect, useState } from 'react';
import { loadModelsDevOptions } from '@/lib/models-dev';
import { useMutation, useQuery } from 'convex/react'; import { useMutation, useQuery } from 'convex/react';
import { Bot } from 'lucide-react'; import { Bot } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -53,6 +51,7 @@ export const SpoonAgentSettingsForm = ({
}) => { }) => {
const update = useMutation(api.spoonAgentSettings.update); const update = useMutation(api.spoonAgentSettings.update);
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? []; const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
const modelCatalog = useQuery(api.aiProviderModels.listAvailableForUser);
const configuredProfiles = profiles.filter( const configuredProfiles = profiles.filter(
(profile) => profile.enabled && profile.configured, (profile) => profile.enabled && profile.configured,
); );
@@ -99,8 +98,12 @@ export const SpoonAgentSettingsForm = ({
? defaultProfile?._id ? defaultProfile?._id
: aiProviderProfileId), : aiProviderProfileId),
); );
const [availableModels, setAvailableModels] = useState<ProviderModelOption[]>( const selectedModelProfile = modelCatalog?.profiles.find(
[], (profile) =>
profile.profileId ===
(aiProviderProfileId === '__default'
? defaultProfile?._id
: aiProviderProfileId),
); );
const [agentModel, setAgentModel] = useState( const [agentModel, setAgentModel] = useState(
settings?.aiProviderProfileId ? settings.agentModel : '', settings?.aiProviderProfileId ? settings.agentModel : '',
@@ -115,42 +118,17 @@ export const SpoonAgentSettingsForm = ({
: settings.reasoningEffort, : settings.reasoningEffort,
); );
useEffect(() => { const selectableModels = selectedModelProfile?.configured
if (!selectedProfile?.configured) { ? selectedModelProfile.models
return; : [];
} const selectedAgentModel =
let cancelled = false; agentModel && selectableModels.some((model) => model.id === agentModel)
loadModelsDevOptions(selectedProfile.provider) ? agentModel
.then((models) => { : selectableModels.some(
if (cancelled) return; (model) => model.id === selectedModelProfile?.defaultModel,
setAvailableModels(models); )
setAgentModel((current) => ? (selectedModelProfile?.defaultModel ?? '')
current && models.some((model) => model.id === current) : (selectableModels[0]?.id ?? '');
? current
: models.some((model) => model.id === selectedProfile.defaultModel)
? selectedProfile.defaultModel
: (models[0]?.id ?? ''),
);
setReasoningEffort(
selectedProfile.reasoningEffort === 'none'
? 'minimal'
: selectedProfile.reasoningEffort,
);
})
.catch((error: unknown) => {
console.error(error);
if (!cancelled) setAvailableModels([]);
});
return () => {
cancelled = true;
};
}, [
selectedProfile?.configured,
selectedProfile?.defaultModel,
selectedProfile?.provider,
selectedProfile?.reasoningEffort,
]);
const selectableModels = selectedProfile?.configured ? availableModels : [];
const save = async () => { const save = async () => {
try { try {
@@ -163,9 +141,7 @@ export const SpoonAgentSettingsForm = ({
installCommand: installCommand || undefined, installCommand: installCommand || undefined,
checkCommand: checkCommand || undefined, checkCommand: checkCommand || undefined,
testCommand: testCommand || undefined, testCommand: testCommand || undefined,
agentModel: agentModel.trim() agentModel: selectedAgentModel || undefined,
? agentModel
: (selectableModels[0]?.id ?? undefined),
reasoningEffort, reasoningEffort,
envFilePath: envFilePath as envFilePath: envFilePath as
| '.env' | '.env'
@@ -249,7 +225,8 @@ export const SpoonAgentSettingsForm = ({
</SelectContent> </SelectContent>
</Select> </Select>
<p className='text-muted-foreground text-xs'> <p className='text-muted-foreground text-xs'>
OpenCode jobs and maintenance review threads use this profile. Workspaces use this profile. Use default resolves to your account
default provider.
</p> </p>
</div> </div>
<div className='grid gap-2'> <div className='grid gap-2'>
@@ -271,7 +248,7 @@ export const SpoonAgentSettingsForm = ({
<div className='grid gap-2'> <div className='grid gap-2'>
<Label htmlFor='agentModel'>Model</Label> <Label htmlFor='agentModel'>Model</Label>
<Select <Select
value={agentModel} value={selectedAgentModel}
onValueChange={setAgentModel} onValueChange={setAgentModel}
disabled={!selectableModels.length} disabled={!selectableModels.length}
> >
@@ -288,8 +265,8 @@ export const SpoonAgentSettingsForm = ({
</Select> </Select>
{!selectableModels.length ? ( {!selectableModels.length ? (
<p className='text-muted-foreground text-xs'> <p className='text-muted-foreground text-xs'>
Configure an enabled AI provider profile in Settings before Configure an enabled AI provider profile with saved model
choosing a model. options in Settings before choosing a model.
</p> </p>
) : null} ) : null}
</div> </div>
@@ -423,7 +400,7 @@ export const SpoonAgentSettingsForm = ({
onClick={save} onClick={save}
disabled={ disabled={
!selectedProfile?.configured || !selectedProfile?.configured ||
!selectableModels.some((model) => model.id === agentModel) !selectableModels.some((model) => model.id === selectedAgentModel)
} }
> >
Save agent settings Save agent settings
+16 -3
View File
@@ -9,7 +9,13 @@ const formatDate = (value?: number) =>
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value) ? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
: 'Never'; : 'Never';
export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => ( type SpoonCardData = Doc<'spoons'> & {
rawUpstreamAheadBy?: number;
effectiveUpstreamAheadBy?: number;
ignoredUpstreamCount?: number;
};
export const SpoonCard = ({ spoon }: { spoon: SpoonCardData }) => (
<Link href={`/spoons/${spoon._id}`} className='group/spoon-card block'> <Link href={`/spoons/${spoon._id}`} className='group/spoon-card block'>
<Card className='group-hover/spoon-card:border-primary/50 group-hover/spoon-card:bg-muted/20 shadow-none transition-colors'> <Card className='group-hover/spoon-card:border-primary/50 group-hover/spoon-card:bg-muted/20 shadow-none transition-colors'>
<CardHeader className='flex-row items-start justify-between gap-4'> <CardHeader className='flex-row items-start justify-between gap-4'>
@@ -45,8 +51,15 @@ export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
<p className='font-medium'>{formatDate(spoon.lastCheckedAt)}</p> <p className='font-medium'>{formatDate(spoon.lastCheckedAt)}</p>
</div> </div>
<div> <div>
<p className='text-muted-foreground'>Upstream waiting</p> <p className='text-muted-foreground'>Actionable upstream</p>
<p className='font-medium'>{spoon.upstreamAheadBy ?? 0}</p> <p className='font-medium'>
{spoon.effectiveUpstreamAheadBy ?? spoon.upstreamAheadBy ?? 0}
</p>
{spoon.ignoredUpstreamCount ? (
<p className='text-muted-foreground text-xs'>
{spoon.ignoredUpstreamCount} ignored
</p>
) : null}
</div> </div>
<div> <div>
<p className='text-muted-foreground'>Fork-only commits</p> <p className='text-muted-foreground'>Fork-only commits</p>
-56
View File
@@ -1,56 +0,0 @@
type ModelsDevModel = {
id?: string;
name?: string;
tool_call?: boolean;
reasoning?: boolean;
limit?: { context?: number };
};
type ModelsDevProvider = {
id?: string;
name?: string;
models?: Record<string, ModelsDevModel>;
};
const providerMap = {
openai: 'openai',
anthropic: 'anthropic',
google: 'google',
openrouter: 'openrouter',
requesty: 'requesty',
litellm: 'litellm',
cloudflare_ai_gateway: 'cloudflare',
custom_openai_compatible: '',
opencode_openai_login: 'openai',
} as const;
export type ProviderModelOption = {
id: string;
label: string;
reasoning: boolean;
toolCall: boolean;
context?: number;
};
export const loadModelsDevOptions = async (provider: string) => {
const mapped = providerMap[provider as keyof typeof providerMap];
if (!mapped) return [];
const response = await fetch('https://models.dev/api.json', {
cache: 'force-cache',
});
if (!response.ok) return [];
const catalog = (await response.json()) as Record<string, ModelsDevProvider>;
const providerCatalog = catalog[mapped];
return Object.entries(providerCatalog?.models ?? {})
.map(
([id, model]): ProviderModelOption => ({
id: model.id ?? id,
label: model.name ?? model.id ?? id,
reasoning: Boolean(model.reasoning),
toolCall: Boolean(model.tool_call),
context: model.limit?.context,
}),
)
.filter((model) => model.toolCall)
.sort((a, b) => a.label.localeCompare(b.label));
};
@@ -0,0 +1,72 @@
export type ProviderModelOption = {
id: string;
label: string;
reasoning: boolean;
toolCall: boolean;
context?: number;
};
const options = {
openai: ['gpt-5.1-codex', 'gpt-5.1', 'gpt-5', 'gpt-5-mini'],
opencode_openai_login: ['gpt-5.1-codex', 'gpt-5.1', 'gpt-5'],
anthropic: ['claude-sonnet-4-5', 'claude-opus-4-5', 'claude-haiku-4-5'],
google: ['gemini-3-pro', 'gemini-2.5-pro', 'gemini-2.5-flash'],
openrouter: ['openai/gpt-5.1-codex', 'anthropic/claude-sonnet-4-5'],
requesty: ['openai/gpt-5.1-codex', 'anthropic/claude-sonnet-4-5'],
litellm: ['openai/gpt-5.1-codex', 'anthropic/claude-sonnet-4-5'],
cloudflare_ai_gateway: ['openai/gpt-5.1-codex'],
custom_openai_compatible: ['gpt-5.1-codex'],
} as const;
export type ProviderModelKey = keyof typeof options;
const modelOptionsByProvider: Record<string, readonly string[]> = options;
const labelForModel = (id: string) => {
const label = id
.split('/')
.at(-1)
?.replaceAll('-', ' ')
.replace(/\b\w/g, (letter) => letter.toUpperCase());
return label ?? id;
};
export const suggestedModelOptions = (
provider: string,
): ProviderModelOption[] =>
(modelOptionsByProvider[provider] ?? []).map((id) => ({
id,
label: labelForModel(id),
reasoning: true,
toolCall: true,
}));
export const modelOptionsFromIds = (
ids: string[] | undefined,
): ProviderModelOption[] =>
(ids ?? [])
.map((id) => id.trim())
.filter(Boolean)
.filter((id, index, all) => all.indexOf(id) === index)
.map((id) => ({
id,
label: labelForModel(id),
reasoning: true,
toolCall: true,
}));
export const modelIdsForProfile = (profile?: {
defaultModel?: string;
modelOptions?: string[];
}) =>
[profile?.defaultModel, ...(profile?.modelOptions ?? [])]
.filter((model): model is string => Boolean(model?.trim()))
.filter((model, index, all) => all.indexOf(model) === index);
export const supportsCustomModelOptions = (provider: string) =>
[
'openrouter',
'requesty',
'litellm',
'cloudflare_ai_gateway',
'custom_openai_compatible',
].includes(provider);
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import {
modelIdsForProfile,
modelOptionsFromIds,
suggestedModelOptions,
supportsCustomModelOptions,
} from '../../src/lib/provider-model-options';
describe('provider model options', () => {
it('returns stored profile model ids without duplicates', () => {
expect(
modelIdsForProfile({
defaultModel: 'gpt-5.1-codex',
modelOptions: ['gpt-5.1-codex', 'gpt-5'],
}),
).toEqual(['gpt-5.1-codex', 'gpt-5']);
});
it('provides local suggestions for built-in providers', () => {
expect(
suggestedModelOptions('openai').some(
(model) => model.id === 'gpt-5.1-codex',
),
).toBe(true);
});
it('supports custom model ids only for gateway-style providers', () => {
expect(supportsCustomModelOptions('openrouter')).toBe(true);
expect(supportsCustomModelOptions('openai')).toBe(false);
});
it('normalizes model ids into select options', () => {
expect(modelOptionsFromIds(['openai/gpt-5.1-codex'])[0]).toMatchObject({
id: 'openai/gpt-5.1-codex',
label: 'Gpt 5.1 Codex',
});
});
});
+2
View File
@@ -15,6 +15,8 @@ RUN apt-get update \
python3 \ python3 \
ripgrep \ ripgrep \
&& corepack enable \ && corepack enable \
&& corepack prepare pnpm@latest --activate \
&& corepack prepare yarn@stable --activate \
&& npm install -g bun@1.3.10 opencode-ai@latest @openai/codex@latest \ && npm install -g bun@1.3.10 opencode-ai@latest @openai/codex@latest \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
+39 -2
View File
@@ -219,6 +219,11 @@ const isDeletableWorkspace = (job: Doc<'agentJobs'>) =>
['failed', 'cancelled', 'timed_out'].includes(job.status) || ['failed', 'cancelled', 'timed_out'].includes(job.status) ||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? ''); ['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
const isTerminalJob = (job: Doc<'agentJobs'>) =>
['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes(
job.status,
) || ['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
const deleteWorkspaceRows = async (ctx: MutationCtx, job: Doc<'agentJobs'>) => { const deleteWorkspaceRows = async (ctx: MutationCtx, job: Doc<'agentJobs'>) => {
const messages = await ctx.db const messages = await ctx.db
.query('agentJobMessages') .query('agentJobMessages')
@@ -546,7 +551,10 @@ export const createForThread = mutation({
throw new ConvexError('Thread not found.'); throw new ConvexError('Thread not found.');
} }
if (thread.latestAgentJobId) { if (thread.latestAgentJobId) {
throw new ConvexError('This thread already has an agent job.'); const latestJob = await ctx.db.get(thread.latestAgentJobId);
if (latestJob && !isTerminalJob(latestJob)) {
throw new ConvexError('This thread already has an active agent job.');
}
} }
const spoon = await getOwnedSpoon(ctx, thread.spoonId, ownerId); const spoon = await getOwnedSpoon(ctx, thread.spoonId, ownerId);
const promptMessage = await ctx.db const promptMessage = await ctx.db
@@ -609,7 +617,12 @@ export const createForThreadInternal = internalMutation({
if (thread?.ownerId !== args.ownerId || !thread.spoonId) { if (thread?.ownerId !== args.ownerId || !thread.spoonId) {
throw new ConvexError('Thread not found.'); throw new ConvexError('Thread not found.');
} }
if (thread.latestAgentJobId) return thread.latestAgentJobId; if (thread.latestAgentJobId) {
const latestJob = await ctx.db.get(thread.latestAgentJobId);
if (latestJob && !isTerminalJob(latestJob)) {
return thread.latestAgentJobId;
}
}
const spoon = await ctx.db.get(thread.spoonId); const spoon = await ctx.db.get(thread.spoonId);
if (spoon?.ownerId !== args.ownerId) { if (spoon?.ownerId !== args.ownerId) {
throw new ConvexError('Spoon not found.'); throw new ConvexError('Spoon not found.');
@@ -929,6 +942,30 @@ export const deleteWorkspace = mutation({
}, },
}); });
export const markWorkspaceLost = 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.');
const now = Date.now();
await ctx.db.patch(jobId, {
status: 'failed',
workspaceStatus: 'failed',
error: 'Workspace is not active on the configured worker.',
completedAt: job.completedAt ?? now,
updatedAt: now,
});
if (job.threadId) {
await ctx.db.patch(job.threadId, {
status: 'failed',
updatedAt: now,
});
}
return { success: true };
},
});
export const countOldWorkspaces = query({ export const countOldWorkspaces = query({
args: { olderThanDays: v.optional(v.number()) }, args: { olderThanDays: v.optional(v.number()) },
handler: async (ctx, { olderThanDays }) => { handler: async (ctx, { olderThanDays }) => {
@@ -0,0 +1,99 @@
import type { Doc } from './_generated/dataModel';
import { query } from './_generated/server';
import { getRequiredUserId } from './model';
type AiProviderProfileWithDefault = Doc<'aiProviderProfiles'> & {
isDefault?: boolean;
};
const labelForModel = (model: string): string => {
const parts = model.split('/');
const raw = parts[parts.length - 1] ?? model;
return raw
.replaceAll('-', ' ')
.replace(/\b\w/g, (letter: string) => letter.toUpperCase());
};
const recommendedFor = (model: string) => {
const lower = model.toLowerCase();
const tags: ('coding' | 'review' | 'fast' | 'large_context')[] = [];
if (
lower.includes('codex') ||
lower.includes('claude') ||
lower.includes('sonnet')
) {
tags.push('coding');
}
if (
lower.includes('mini') ||
lower.includes('haiku') ||
lower.includes('flash')
) {
tags.push('fast');
}
if (
lower.includes('200k') ||
lower.includes('1m') ||
lower.includes('large')
) {
tags.push('large_context');
}
if (!tags.length) tags.push('review');
return tags;
};
export const listAvailableForUser = query({
args: {},
handler: async (ctx) => {
const ownerId = await getRequiredUserId(ctx);
const profiles = await ctx.db
.query('aiProviderProfiles')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.order('desc')
.collect();
const configuredProfiles = profiles.filter(
(profile) =>
profile.enabled &&
(profile.authType === 'none' || Boolean(profile.encryptedSecret)),
);
const explicitDefault = configuredProfiles.find(
(profile) => (profile as AiProviderProfileWithDefault).isDefault,
);
const defaultProfileId =
explicitDefault?._id ??
(configuredProfiles.length === 1
? configuredProfiles[0]?._id
: undefined);
return {
profiles: profiles
.filter((profile) => profile.enabled)
.map((profile) => {
const configured =
profile.authType === 'none' || Boolean(profile.encryptedSecret);
const modelIds = [
profile.defaultModel,
...(profile.modelOptions ?? []),
]
.map((model) => model.trim())
.filter(Boolean)
.filter((model, index, all) => all.indexOf(model) === index);
return {
profileId: profile._id,
profileName: profile.name,
provider: profile.provider,
configured,
enabled: profile.enabled,
isDefault: profile._id === defaultProfileId,
defaultModel: profile.defaultModel,
reasoningEffort: profile.reasoningEffort,
models: modelIds.map((id) => ({
id,
label: labelForModel(id),
recommendedFor: recommendedFor(id),
})),
};
}),
};
},
});
+58
View File
@@ -87,6 +87,64 @@ export const listMine = query({
}, },
}); });
export const listMineWithState = query({
args: {},
handler: async (ctx) => {
const ownerId = await getRequiredUserId(ctx);
const spoons = (
await ctx.db
.query('spoons')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.order('desc')
.collect()
).filter((spoon) => spoon.status !== 'archived');
return await Promise.all(
spoons.map(async (spoon) => {
const [state, ignoredChanges, threads] = await Promise.all([
ctx.db
.query('spoonRepositoryStates')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
.first(),
ctx.db
.query('ignoredUpstreamChanges')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
.collect(),
ctx.db
.query('threads')
.withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
.order('desc')
.collect(),
]);
const ignoredShas = new Set(
ignoredChanges.flatMap((change) => change.commitShas),
);
const rawUpstreamAheadBy =
state?.upstreamAheadBy ?? spoon.upstreamAheadBy ?? 0;
const effectiveUpstreamAheadBy = Math.max(
0,
rawUpstreamAheadBy - ignoredShas.size,
);
const openThreads = threads.filter(
(thread) =>
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
thread.status,
),
);
return {
...spoon,
rawUpstreamAheadBy,
effectiveUpstreamAheadBy,
ignoredUpstreamCount: ignoredShas.size,
forkAheadBy: state?.forkAheadBy ?? spoon.forkAheadBy ?? 0,
openThreadCount: openThreads.length,
latestThreadStatus: threads[0]?.status,
};
}),
);
},
});
export const get = query({ export const get = query({
args: { spoonId: v.id('spoons') }, args: { spoonId: v.id('spoons') },
handler: async (ctx, { spoonId }) => { handler: async (ctx, { spoonId }) => {
+24 -2
View File
@@ -82,7 +82,7 @@ export const listMine = query({
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId)) .withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.order('desc') .order('desc')
.take(args.limit ?? 50); .take(args.limit ?? 50);
return threads.filter((thread) => { const filtered = threads.filter((thread) => {
if ( if (
args.status && args.status &&
args.status !== 'all' && args.status !== 'all' &&
@@ -100,6 +100,28 @@ export const listMine = query({
if (args.spoonId && thread.spoonId !== args.spoonId) return false; if (args.spoonId && thread.spoonId !== args.spoonId) return false;
return true; return true;
}); });
return await Promise.all(
filtered.map(async (thread) => {
const [spoon, latestJob] = await Promise.all([
thread.spoonId ? ctx.db.get(thread.spoonId) : null,
thread.latestAgentJobId ? ctx.db.get(thread.latestAgentJobId) : null,
]);
return {
...publicThread(thread),
spoonName: spoon?.ownerId === ownerId ? spoon.name : undefined,
latestJobStatus:
latestJob?.ownerId === ownerId ? latestJob.status : undefined,
latestJobWorkspaceStatus:
latestJob?.ownerId === ownerId
? latestJob.workspaceStatus
: undefined,
latestJobPullRequestUrl:
latestJob?.ownerId === ownerId
? latestJob.pullRequestUrl
: undefined,
};
}),
);
}, },
}); });
@@ -216,7 +238,7 @@ export const appendUserMessage = mutation({
spoonId: thread.spoonId, spoonId: thread.spoonId,
role: 'user', role: 'user',
content: requireText(content, 'Message'), content: requireText(content, 'Message'),
status: 'queued', status: 'completed',
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
+142
View File
@@ -34,6 +34,13 @@ const spoonInput = {
productionRefStrategy: 'default_branch' as const, productionRefStrategy: 'default_branch' as const,
}; };
const githubSpoonInput = {
...spoonInput,
provider: 'github' as const,
upstreamUrl: 'https://github.com/upstream/editor',
forkUrl: 'https://github.com/team/editor-spoon',
};
const createAgentJob = async ( const createAgentJob = async (
t: ReturnType<typeof convexTest>, t: ReturnType<typeof convexTest>,
args: { args: {
@@ -114,6 +121,54 @@ describe('convex-test harness', () => {
expect(spoons[0]?.ownerId).toBe(userId); expect(spoons[0]?.ownerId).toBe(userId);
}); });
test('lists effective drift after ignored upstream changes', async () => {
const t = convexTest(schema, modules);
const ownerId = await createUser(t, 'owner@example.com');
const spoonId = await authed(t, ownerId).mutation(
api.spoons.createManual,
githubSpoonInput,
);
await t.mutation(async (ctx) => {
const now = Date.now();
await ctx.db.insert('spoonRepositoryStates', {
spoonId,
ownerId: ownerId as Id<'users'>,
upstreamFullName: 'upstream/editor',
forkFullName: 'team/editor-spoon',
upstreamDefaultBranch: 'main',
forkDefaultBranch: 'main',
upstreamHeadSha: 'upstream-head',
forkHeadSha: 'fork-head',
upstreamAheadBy: 2,
forkAheadBy: 1,
status: 'diverged',
openForkPullRequestCount: 0,
openUpstreamPullRequestCount: 0,
refreshedAt: now,
createdAt: now,
updatedAt: now,
});
await ctx.db.insert('ignoredUpstreamChanges', {
spoonId,
ownerId: ownerId as Id<'users'>,
upstreamTo: 'upstream-head',
commitShas: ['abc123'],
reason: 'irrelevant',
decidedBy: 'user',
createdAt: now,
});
});
const spoons = await authed(t, ownerId).query(
api.spoons.listMineWithState,
{},
);
expect(spoons[0]?.rawUpstreamAheadBy).toBe(2);
expect(spoons[0]?.effectiveUpstreamAheadBy).toBe(1);
expect(spoons[0]?.ignoredUpstreamCount).toBe(1);
});
test('does not allow reading another users Spoon', async () => { test('does not allow reading another users Spoon', async () => {
const t = convexTest(schema, modules); const t = convexTest(schema, modules);
const ownerId = await createUser(t, 'owner@example.com'); const ownerId = await createUser(t, 'owner@example.com');
@@ -128,6 +183,38 @@ describe('convex-test harness', () => {
).rejects.toThrow('Spoon not found.'); ).rejects.toThrow('Spoon not found.');
}); });
test('thread notes are completed when no workspace handles them', 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,
githubSpoonInput,
);
const threadId = await t.mutation(async (ctx) => {
return await ctx.db.insert('threads', {
ownerId,
spoonId,
title: 'Manual note',
source: 'user_request',
status: 'open',
priority: 'normal',
createdAt: Date.now(),
updatedAt: Date.now(),
});
});
const messageId = await authed(t, ownerId).mutation(
api.threads.appendUserMessage,
{
threadId,
content: 'extra context',
},
);
const message = await t.run(async (ctx) => await ctx.db.get(messageId));
expect(message?.status).toBe('completed');
});
test('requires Spoon ownership for agent requests', async () => { test('requires Spoon ownership for agent requests', async () => {
const t = convexTest(schema, modules); const t = convexTest(schema, modules);
const ownerId = await createUser(t, 'owner@example.com'); const ownerId = await createUser(t, 'owner@example.com');
@@ -211,4 +298,59 @@ describe('convex-test harness', () => {
authed(t, otherId).mutation(api.agentJobs.deleteWorkspace, { jobId }), authed(t, otherId).mutation(api.agentJobs.deleteWorkspace, { jobId }),
).rejects.toThrow('Agent job not found.'); ).rejects.toThrow('Agent job not found.');
}); });
test('queues a new thread job after the previous job is terminal', 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,
githubSpoonInput,
);
await t.mutation(async (ctx) => {
await ctx.db.insert('aiProviderProfiles', {
ownerId,
name: 'Test provider',
provider: 'openai',
authType: 'none',
defaultModel: 'gpt-5.1-codex',
modelOptions: ['gpt-5.1-codex'],
reasoningEffort: 'medium',
enabled: true,
createdAt: Date.now(),
updatedAt: Date.now(),
});
});
const threadId = await t.mutation(async (ctx) => {
return await ctx.db.insert('threads', {
ownerId,
spoonId,
title: 'Retryable thread',
summary: 'try again',
source: 'user_request',
status: 'failed',
priority: 'normal',
createdAt: Date.now(),
updatedAt: Date.now(),
});
});
const failedJobId = await createAgentJob(t, {
ownerId,
spoonId,
status: 'failed',
workspaceStatus: 'failed',
});
await t.mutation(async (ctx) => {
await ctx.db.patch(threadId, { latestAgentJobId: failedJobId });
});
const newJobId = await authed(t, ownerId).mutation(
api.agentJobs.createForThread,
{
threadId,
jobType: 'user_change',
},
);
expect(newJobId).not.toBe(failedJobId);
});
}); });
+3
View File
@@ -19,6 +19,9 @@ fi
set -euo pipefail set -euo pipefail
node --version node --version
bun --version bun --version
pnpm --version
yarn --version
npm --version
git --version git --version
rg --version >/dev/null rg --version >/dev/null
jq --version jq --version