Add features & update project
This commit is contained in:
@@ -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 }}
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
{canQueueRun ? (
|
||||||
|
<Button disabled={queueing} onClick={() => void startRun()}>
|
||||||
|
<Play className='size-4' />
|
||||||
|
{latestJob ? 'Rerun' : 'Start workspace run'}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{!terminalThread ? (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
markResolved({ threadId }).then(() =>
|
if (!window.confirm('Mark this thread as resolved?')) return;
|
||||||
|
void markResolved({ threadId }).then(() =>
|
||||||
toast.success('Thread resolved.'),
|
toast.success('Thread resolved.'),
|
||||||
)
|
);
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
<CheckCircle2 className='size-4' />
|
<CheckCircle2 className='size-4' />
|
||||||
Resolve
|
Resolve
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
cancel({ threadId }).then(() =>
|
if (!window.confirm('Cancel this thread?')) return;
|
||||||
|
void cancel({ threadId }).then(() =>
|
||||||
toast.success('Thread cancelled.'),
|
toast.success('Thread cancelled.'),
|
||||||
)
|
);
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
<XCircle className='size-4' />
|
<XCircle className='size-4' />
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</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>
|
||||||
|
|||||||
@@ -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,21 +228,97 @@ 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'>{thread.spoonName}</Badge>
|
||||||
|
) : null}
|
||||||
<Badge variant='outline'>
|
<Badge variant='outline'>
|
||||||
{thread.source.replaceAll('_', ' ')}
|
{thread.source.replaceAll('_', ' ')}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -106,10 +337,36 @@ const ThreadsPage = () => {
|
|||||||
<div className='text-muted-foreground text-xs md:text-right'>
|
<div className='text-muted-foreground text-xs md:text-right'>
|
||||||
<p>{formatTime(thread.updatedAt)}</p>
|
<p>{formatTime(thread.updatedAt)}</p>
|
||||||
<p className='capitalize'>{thread.priority} priority</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<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)
|
|
||||||
.then((options) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setModelOptions(options);
|
setModelOptions(options);
|
||||||
setDefaultModelValue((current) =>
|
setDefaultModelValue(options[0]?.id ?? '');
|
||||||
current && options.some((option) => option.id === current)
|
setCustomModelId('');
|
||||||
? 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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 \
|
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/*
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 user’s Spoon', async () => {
|
test('does not allow reading another user’s 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user