Add features & update project
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = () => {
|
||||
<MetricCard
|
||||
label='Upstream commits'
|
||||
value={openPullRequests}
|
||||
note='Waiting across Spoons'
|
||||
note='Actionable after ignores'
|
||||
icon={ShieldCheck}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 = () => {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='text-sm'>
|
||||
<p>{spoon.upstreamAheadBy ?? 0} upstream</p>
|
||||
<p>{spoon.effectiveUpstreamAheadBy} actionable</p>
|
||||
<p className='text-muted-foreground'>
|
||||
{spoon.forkAheadBy ?? 0} fork-only
|
||||
{spoon.rawUpstreamAheadBy} raw upstream ·{' '}
|
||||
{spoon.forkAheadBy} fork-only
|
||||
</p>
|
||||
{spoon.ignoredUpstreamCount ? (
|
||||
<p className='text-muted-foreground'>
|
||||
{spoon.ignoredUpstreamCount} ignored
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='capitalize'>
|
||||
@@ -197,7 +203,8 @@ const SpoonsPage = () => {
|
||||
|
||||
{spoons.length ? (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Raw upstream commits waiting across all Spoons: {upstreamWaiting}
|
||||
Actionable upstream commits waiting across all Spoons:{' '}
|
||||
{upstreamWaiting}
|
||||
</p>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
@@ -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 <main className='text-muted-foreground p-6'>Loading thread...</main>;
|
||||
}
|
||||
|
||||
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>) => {
|
||||
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 = () => {
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
markResolved({ threadId }).then(() =>
|
||||
toast.success('Thread resolved.'),
|
||||
)
|
||||
}
|
||||
>
|
||||
<CheckCircle2 className='size-4' />
|
||||
Resolve
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
cancel({ threadId }).then(() =>
|
||||
toast.success('Thread cancelled.'),
|
||||
)
|
||||
}
|
||||
>
|
||||
<XCircle className='size-4' />
|
||||
Cancel
|
||||
</Button>
|
||||
{canQueueRun ? (
|
||||
<Button disabled={queueing} onClick={() => void startRun()}>
|
||||
<Play className='size-4' />
|
||||
{latestJob ? 'Rerun' : 'Start workspace run'}
|
||||
</Button>
|
||||
) : null}
|
||||
{!terminalThread ? (
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
if (!window.confirm('Mark this thread as resolved?')) return;
|
||||
void markResolved({ threadId }).then(() =>
|
||||
toast.success('Thread resolved.'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 className='size-4' />
|
||||
Resolve
|
||||
</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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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<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 (
|
||||
<main className='space-y-6'>
|
||||
@@ -46,20 +124,97 @@ const ThreadsPage = () => {
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href='/spoons'>
|
||||
<a href='#new-thread'>
|
||||
<Plus className='size-4' />
|
||||
New thread from Spoon
|
||||
</Link>
|
||||
New thread
|
||||
</a>
|
||||
</Button>
|
||||
</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
|
||||
value={source}
|
||||
onValueChange={(value) => {
|
||||
window.location.href =
|
||||
value === 'all' ? '/threads' : `/threads?source=${value}`;
|
||||
}}
|
||||
onValueChange={(value) => updateFilter('source', value)}
|
||||
>
|
||||
<SelectTrigger className='w-full md:w-56'>
|
||||
<SelectValue />
|
||||
@@ -73,43 +228,145 @@ const ThreadsPage = () => {
|
||||
<SelectItem value='system'>System</SelectItem>
|
||||
</SelectContent>
|
||||
</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 className='space-y-3'>
|
||||
{threads.length ? (
|
||||
threads.map((thread) => (
|
||||
<Link
|
||||
{visibleThreads.length ? (
|
||||
visibleThreads.map((thread) => (
|
||||
<Card
|
||||
key={thread._id}
|
||||
href={`/threads/${thread._id}`}
|
||||
className='block'
|
||||
role='link'
|
||||
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'>
|
||||
<div className='min-w-0'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<h2 className='truncate font-medium'>{thread.title}</h2>
|
||||
<Badge variant='outline'>
|
||||
{thread.source.replaceAll('_', ' ')}
|
||||
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
|
||||
<div className='min-w-0'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<h2 className='truncate font-medium'>{thread.title}</h2>
|
||||
{thread.spoonName ? (
|
||||
<Badge variant='outline'>{thread.spoonName}</Badge>
|
||||
) : null}
|
||||
<Badge variant='outline'>
|
||||
{thread.source.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
|
||||
{thread.maintenanceOutcome ? (
|
||||
<Badge variant='secondary'>
|
||||
{thread.maintenanceOutcome.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
|
||||
{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>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs md:text-right'>
|
||||
<p>{formatTime(thread.updatedAt)}</p>
|
||||
<p className='capitalize'>{thread.priority} priority</p>
|
||||
<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 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<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,
|
||||
interactions,
|
||||
disabled,
|
||||
agentTurnActive,
|
||||
}: {
|
||||
jobId: string;
|
||||
messages: Doc<'agentJobMessages'>[];
|
||||
events: Doc<'agentJobEvents'>[];
|
||||
interactions: Doc<'agentInteractionRequests'>[];
|
||||
disabled: boolean;
|
||||
agentTurnActive: boolean;
|
||||
}) => {
|
||||
const [content, setContent] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
@@ -94,7 +96,7 @@ export const AgentThread = ({
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={disabled}
|
||||
disabled={disabled || !agentTurnActive}
|
||||
onClick={abort}
|
||||
>
|
||||
<Ban className='size-3' />
|
||||
|
||||
@@ -6,7 +6,7 @@ import { toast } from 'sonner';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.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 { AgentThread } from './agent-thread';
|
||||
@@ -40,6 +40,9 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
}) ?? [];
|
||||
const uiState = useQuery(api.agentJobs.getWorkspaceUiState, { jobId });
|
||||
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 [files, setFiles] = useState<Record<string, OpenFileState>>({});
|
||||
const [openFilePaths, setOpenFilePaths] = useState<string[]>([]);
|
||||
@@ -50,6 +53,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
const [vimEnabled, setVimEnabled] = useState(false);
|
||||
const [hydratedUiState, setHydratedUiState] = useState(false);
|
||||
const [diff, setDiff] = useState('');
|
||||
const [workspaceError, setWorkspaceError] = useState<string>();
|
||||
const [agentTurnActive, setAgentTurnActive] = useState(false);
|
||||
|
||||
const workspaceDisabled =
|
||||
!job ||
|
||||
@@ -62,6 +67,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/tree`);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const data = (await response.json()) as { tree: FileTreeNode | null };
|
||||
setWorkspaceError(undefined);
|
||||
setTree(data.tree);
|
||||
}, [jobId]);
|
||||
|
||||
@@ -69,9 +75,20 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/diff`);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const data = (await response.json()) as DiffResponse;
|
||||
setWorkspaceError(undefined);
|
||||
setDiff(data.diff);
|
||||
}, [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(
|
||||
async (path: string) => {
|
||||
setFiles((current) => ({
|
||||
@@ -132,13 +149,27 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
void loadTree().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
setWorkspaceError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
void loadDiff().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
setWorkspaceError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
void loadAgentStatus();
|
||||
}, 0);
|
||||
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(() => {
|
||||
if (!uiState || hydratedUiState) return;
|
||||
@@ -197,6 +228,23 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (!activeFilePath) return;
|
||||
@@ -280,6 +328,35 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
return (
|
||||
<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} />
|
||||
{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'>
|
||||
<WorkspaceActions job={job} disabled={workspaceDisabled} />
|
||||
</div>
|
||||
@@ -362,6 +439,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
events={events}
|
||||
interactions={interactions}
|
||||
disabled={workspaceDisabled}
|
||||
agentTurnActive={agentTurnActive}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
@@ -374,6 +452,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
events={events}
|
||||
interactions={interactions}
|
||||
disabled={workspaceDisabled}
|
||||
agentTurnActive={agentTurnActive}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import type { ProviderModelOption } from '@/lib/models-dev';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { loadModelsDevOptions } from '@/lib/models-dev';
|
||||
import type { ProviderModelOption } from '@/lib/provider-model-options';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
modelOptionsFromIds,
|
||||
suggestedModelOptions,
|
||||
supportsCustomModelOptions,
|
||||
} from '@/lib/provider-model-options';
|
||||
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||
import { makeFunctionReference } from 'convex/server';
|
||||
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 { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -50,6 +55,7 @@ const saveProfileRef = makeFunctionReference<
|
||||
secret?: string;
|
||||
baseUrl?: string;
|
||||
defaultModel: string;
|
||||
modelOptions?: string[];
|
||||
reasoningEffort: ReasoningEffort;
|
||||
enabled: boolean;
|
||||
},
|
||||
@@ -119,33 +125,24 @@ export const AiProviderProfilesPanel = () => {
|
||||
);
|
||||
const [secret, setSecret] = useState('');
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
const [defaultModelValue, setDefaultModelValue] = useState('');
|
||||
const [modelOptions, setModelOptions] = useState<ProviderModelOption[]>([]);
|
||||
const [defaultModelValue, setDefaultModelValue] = useState(
|
||||
suggestedModelOptions('openai')[0]?.id ?? '',
|
||||
);
|
||||
const [modelOptions, setModelOptions] = useState<ProviderModelOption[]>(
|
||||
suggestedModelOptions('openai'),
|
||||
);
|
||||
const [customModelId, setCustomModelId] = useState('');
|
||||
const [reasoningEffort, setReasoningEffort] =
|
||||
useState<ReasoningEffort>('medium');
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadModelsDevOptions(provider)
|
||||
.then((options) => {
|
||||
if (cancelled) return;
|
||||
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 resetModelOptions = (nextProvider: Provider) => {
|
||||
const options = suggestedModelOptions(nextProvider);
|
||||
setModelOptions(options);
|
||||
setDefaultModelValue(options[0]?.id ?? '');
|
||||
setCustomModelId('');
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setProfileId(undefined);
|
||||
@@ -153,6 +150,8 @@ export const AiProviderProfilesPanel = () => {
|
||||
setSecret('');
|
||||
setBaseUrl('');
|
||||
setDefaultModelValue('');
|
||||
setModelOptions(suggestedModelOptions('openai'));
|
||||
setCustomModelId('');
|
||||
setReasoningEffort('medium');
|
||||
setEnabled(true);
|
||||
setName('OpenAI');
|
||||
@@ -165,6 +164,14 @@ export const AiProviderProfilesPanel = () => {
|
||||
setSecret('');
|
||||
setBaseUrl(profile.baseUrl ?? '');
|
||||
setDefaultModelValue(profile.defaultModel);
|
||||
setModelOptions(
|
||||
modelOptionsFromIds(
|
||||
profile.modelOptions?.length
|
||||
? profile.modelOptions
|
||||
: [profile.defaultModel],
|
||||
),
|
||||
);
|
||||
setCustomModelId('');
|
||||
setReasoningEffort(profile.reasoningEffort as ReasoningEffort);
|
||||
setEnabled(profile.enabled);
|
||||
};
|
||||
@@ -181,6 +188,7 @@ export const AiProviderProfilesPanel = () => {
|
||||
secret: secret.trim() ? secret : undefined,
|
||||
baseUrl: baseUrl.trim() || undefined,
|
||||
defaultModel: defaultModelValue,
|
||||
modelOptions: modelOptions.map((model) => model.id),
|
||||
reasoningEffort,
|
||||
enabled,
|
||||
});
|
||||
@@ -310,6 +318,7 @@ export const AiProviderProfilesPanel = () => {
|
||||
onValueChange={(value) => {
|
||||
const nextProvider = value as Provider;
|
||||
setProvider(nextProvider);
|
||||
resetModelOptions(nextProvider);
|
||||
setName(
|
||||
providerOptions
|
||||
.find((option) => option.value === nextProvider)
|
||||
@@ -397,9 +406,47 @@ export const AiProviderProfilesPanel = () => {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Models are loaded from Models.dev, the catalog OpenCode uses
|
||||
for provider/model metadata.
|
||||
Saved model options are used by Spoons. Add custom model IDs
|
||||
for compatible provider gateways.
|
||||
</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 className='grid gap-2'>
|
||||
<Label>Thinking</Label>
|
||||
|
||||
@@ -75,7 +75,7 @@ const features = [
|
||||
{
|
||||
title: 'Provider-owned AI',
|
||||
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,
|
||||
},
|
||||
{
|
||||
@@ -119,7 +119,7 @@ const ownership = [
|
||||
{
|
||||
title: 'Your providers',
|
||||
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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -63,6 +63,14 @@ export default function Footer() {
|
||||
Integrations
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href='/settings/worker'
|
||||
className='text-muted-foreground hover:text-foreground transition-colors'
|
||||
>
|
||||
Worker
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href='https://git.gbrown.org/gib/spoon'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from '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 { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
@@ -47,6 +47,25 @@ export const WorkerHealthPanel = () => {
|
||||
useQuery(api.agentJobs.countOldWorkspaces, { olderThanDays }) ?? 0;
|
||||
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 () => {
|
||||
setLoadingHealth(true);
|
||||
setHealthError(undefined);
|
||||
@@ -151,15 +170,15 @@ export const WorkerHealthPanel = () => {
|
||||
<dl className='grid gap-3 text-sm md:grid-cols-2'>
|
||||
<div>
|
||||
<dt className='text-muted-foreground'>Convex</dt>
|
||||
<dd className='font-mono break-all'>{health.convexUrl}</dd>
|
||||
<DiagnosticValue value={health.convexUrl} />
|
||||
</div>
|
||||
<div>
|
||||
<dt className='text-muted-foreground'>Job image</dt>
|
||||
<dd className='font-mono break-all'>{health.jobImage}</dd>
|
||||
<DiagnosticValue value={health.jobImage} />
|
||||
</div>
|
||||
<div>
|
||||
<dt className='text-muted-foreground'>Workdir</dt>
|
||||
<dd className='font-mono break-all'>{health.workdir}</dd>
|
||||
<DiagnosticValue value={health.workdir} />
|
||||
</div>
|
||||
<div>
|
||||
<dt className='text-muted-foreground'>Network</dt>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type { ProviderModelOption } from '@/lib/models-dev';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { loadModelsDevOptions } from '@/lib/models-dev';
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -53,6 +51,7 @@ export const SpoonAgentSettingsForm = ({
|
||||
}) => {
|
||||
const update = useMutation(api.spoonAgentSettings.update);
|
||||
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||
const modelCatalog = useQuery(api.aiProviderModels.listAvailableForUser);
|
||||
const configuredProfiles = profiles.filter(
|
||||
(profile) => profile.enabled && profile.configured,
|
||||
);
|
||||
@@ -99,8 +98,12 @@ export const SpoonAgentSettingsForm = ({
|
||||
? defaultProfile?._id
|
||||
: aiProviderProfileId),
|
||||
);
|
||||
const [availableModels, setAvailableModels] = useState<ProviderModelOption[]>(
|
||||
[],
|
||||
const selectedModelProfile = modelCatalog?.profiles.find(
|
||||
(profile) =>
|
||||
profile.profileId ===
|
||||
(aiProviderProfileId === '__default'
|
||||
? defaultProfile?._id
|
||||
: aiProviderProfileId),
|
||||
);
|
||||
const [agentModel, setAgentModel] = useState(
|
||||
settings?.aiProviderProfileId ? settings.agentModel : '',
|
||||
@@ -115,42 +118,17 @@ export const SpoonAgentSettingsForm = ({
|
||||
: settings.reasoningEffort,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProfile?.configured) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
loadModelsDevOptions(selectedProfile.provider)
|
||||
.then((models) => {
|
||||
if (cancelled) return;
|
||||
setAvailableModels(models);
|
||||
setAgentModel((current) =>
|
||||
current && models.some((model) => model.id === current)
|
||||
? 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 selectableModels = selectedModelProfile?.configured
|
||||
? selectedModelProfile.models
|
||||
: [];
|
||||
const selectedAgentModel =
|
||||
agentModel && selectableModels.some((model) => model.id === agentModel)
|
||||
? agentModel
|
||||
: selectableModels.some(
|
||||
(model) => model.id === selectedModelProfile?.defaultModel,
|
||||
)
|
||||
? (selectedModelProfile?.defaultModel ?? '')
|
||||
: (selectableModels[0]?.id ?? '');
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
@@ -163,9 +141,7 @@ export const SpoonAgentSettingsForm = ({
|
||||
installCommand: installCommand || undefined,
|
||||
checkCommand: checkCommand || undefined,
|
||||
testCommand: testCommand || undefined,
|
||||
agentModel: agentModel.trim()
|
||||
? agentModel
|
||||
: (selectableModels[0]?.id ?? undefined),
|
||||
agentModel: selectedAgentModel || undefined,
|
||||
reasoningEffort,
|
||||
envFilePath: envFilePath as
|
||||
| '.env'
|
||||
@@ -249,7 +225,8 @@ export const SpoonAgentSettingsForm = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
@@ -271,7 +248,7 @@ export const SpoonAgentSettingsForm = ({
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='agentModel'>Model</Label>
|
||||
<Select
|
||||
value={agentModel}
|
||||
value={selectedAgentModel}
|
||||
onValueChange={setAgentModel}
|
||||
disabled={!selectableModels.length}
|
||||
>
|
||||
@@ -288,8 +265,8 @@ export const SpoonAgentSettingsForm = ({
|
||||
</Select>
|
||||
{!selectableModels.length ? (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Configure an enabled AI provider profile in Settings before
|
||||
choosing a model.
|
||||
Configure an enabled AI provider profile with saved model
|
||||
options in Settings before choosing a model.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -423,7 +400,7 @@ export const SpoonAgentSettingsForm = ({
|
||||
onClick={save}
|
||||
disabled={
|
||||
!selectedProfile?.configured ||
|
||||
!selectableModels.some((model) => model.id === agentModel)
|
||||
!selectableModels.some((model) => model.id === selectedAgentModel)
|
||||
}
|
||||
>
|
||||
Save agent settings
|
||||
|
||||
@@ -9,7 +9,13 @@ const formatDate = (value?: number) =>
|
||||
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
|
||||
: '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'>
|
||||
<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'>
|
||||
@@ -45,8 +51,15 @@ export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
|
||||
<p className='font-medium'>{formatDate(spoon.lastCheckedAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Upstream waiting</p>
|
||||
<p className='font-medium'>{spoon.upstreamAheadBy ?? 0}</p>
|
||||
<p className='text-muted-foreground'>Actionable upstream</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>
|
||||
<p className='text-muted-foreground'>Fork-only commits</p>
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,8 @@ RUN apt-get update \
|
||||
python3 \
|
||||
ripgrep \
|
||||
&& 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 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -219,6 +219,11 @@ const isDeletableWorkspace = (job: Doc<'agentJobs'>) =>
|
||||
['failed', 'cancelled', 'timed_out'].includes(job.status) ||
|
||||
['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 messages = await ctx.db
|
||||
.query('agentJobMessages')
|
||||
@@ -546,7 +551,10 @@ export const createForThread = mutation({
|
||||
throw new ConvexError('Thread not found.');
|
||||
}
|
||||
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 promptMessage = await ctx.db
|
||||
@@ -609,7 +617,12 @@ export const createForThreadInternal = internalMutation({
|
||||
if (thread?.ownerId !== args.ownerId || !thread.spoonId) {
|
||||
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);
|
||||
if (spoon?.ownerId !== args.ownerId) {
|
||||
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({
|
||||
args: { olderThanDays: v.optional(v.number()) },
|
||||
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),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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({
|
||||
args: { spoonId: v.id('spoons') },
|
||||
handler: async (ctx, { spoonId }) => {
|
||||
|
||||
@@ -82,7 +82,7 @@ export const listMine = query({
|
||||
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||
.order('desc')
|
||||
.take(args.limit ?? 50);
|
||||
return threads.filter((thread) => {
|
||||
const filtered = threads.filter((thread) => {
|
||||
if (
|
||||
args.status &&
|
||||
args.status !== 'all' &&
|
||||
@@ -100,6 +100,28 @@ export const listMine = query({
|
||||
if (args.spoonId && thread.spoonId !== args.spoonId) return false;
|
||||
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,
|
||||
role: 'user',
|
||||
content: requireText(content, 'Message'),
|
||||
status: 'queued',
|
||||
status: 'completed',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
@@ -34,6 +34,13 @@ const spoonInput = {
|
||||
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 (
|
||||
t: ReturnType<typeof convexTest>,
|
||||
args: {
|
||||
@@ -114,6 +121,54 @@ describe('convex-test harness', () => {
|
||||
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 user’s Spoon', async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const ownerId = await createUser(t, 'owner@example.com');
|
||||
@@ -128,6 +183,38 @@ describe('convex-test harness', () => {
|
||||
).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 () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const ownerId = await createUser(t, 'owner@example.com');
|
||||
@@ -211,4 +298,59 @@ describe('convex-test harness', () => {
|
||||
authed(t, otherId).mutation(api.agentJobs.deleteWorkspace, { jobId }),
|
||||
).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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,9 @@ fi
|
||||
set -euo pipefail
|
||||
node --version
|
||||
bun --version
|
||||
pnpm --version
|
||||
yarn --version
|
||||
npm --version
|
||||
git --version
|
||||
rg --version >/dev/null
|
||||
jq --version
|
||||
|
||||
Reference in New Issue
Block a user