Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d207b8b0b8 | |||
| fe72fc2957 |
@@ -53,7 +53,7 @@ jobs:
|
||||
printf '%s\n' "$DOTENV_PROD" > "$env_file"
|
||||
CI_ENV_FILE="$env_file" ./scripts/build-next-app production
|
||||
- name: Build agent images
|
||||
run: ./scripts/build-agent-images
|
||||
run: SPOON_AGENT_CONTAINER_RUNTIME=docker ./scripts/build-agent-images
|
||||
- name: Tag and push images
|
||||
run: |
|
||||
docker tag spoon-next:latest git.gbrown.org/gib/spoon-next:${{ gitea.sha }}
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
access to the host Docker socket. API-key provider jobs run through OpenCode;
|
||||
Codex ChatGPT login profiles run through the Codex CLI with an injected
|
||||
`CODEX_HOME/.codex/auth.json` inside the isolated job workspace.
|
||||
The job image must keep Node, npm, Bun, pnpm, yarn, git, ripgrep, jq,
|
||||
Python, OpenCode, and Codex available.
|
||||
|
||||
## Protected and generated files
|
||||
|
||||
@@ -52,7 +54,17 @@
|
||||
- Agent workspace proxy env uses `SPOON_AGENT_WORKER_URL`,
|
||||
`SPOON_AGENT_WORKER_HTTP_PORT`, and `SPOON_AGENT_WORKER_INTERNAL_TOKEN`.
|
||||
Keep these server-only; the browser must never receive worker tokens.
|
||||
- Host-run worker dev uses `scripts/dev-agent-worker` after Infisical env
|
||||
loading. It prefers Podman, sets `SPOON_AGENT_CONTAINER_ACCESS=host_port`,
|
||||
and expects `spoon-agent-job:latest` to exist locally.
|
||||
- `bun smoke:agent-container` checks that the local job image has Node, npm,
|
||||
Bun, pnpm, yarn, git, ripgrep, jq, Python, OpenCode, and Codex available.
|
||||
- Old terminal workspaces can be deleted from `Settings -> Worker`; orphaned
|
||||
containers/workdirs are cleaned through the worker HTTP API, not from the
|
||||
browser directly.
|
||||
- CI uses Gitea-injected secrets or `CI_ENV_FILE` and must not call Infisical.
|
||||
- Gitea image builds force `SPOON_AGENT_CONTAINER_RUNTIME=docker`; keep local
|
||||
Podman auto-detection out of CI image tagging/pushing.
|
||||
- CI must provide Convex deployment env for codegen, either
|
||||
`CONVEX_SELF_HOSTED_URL` plus `CONVEX_SELF_HOSTED_ADMIN_KEY`, or
|
||||
`CONVEX_DEPLOYMENT`.
|
||||
@@ -77,6 +89,7 @@
|
||||
bun db:up # start Postgres, Convex, and dashboard
|
||||
bun dev:next # host Next + deploy/watch local Convex functions
|
||||
bun dev:agent # run the optional coding-agent worker on the host
|
||||
bun dev:next:worker # run Next, backend, and agent worker together
|
||||
bun sync:convex # sync Infisical values into Convex
|
||||
bun db:down # stop and preserve local data
|
||||
bun db:down:wipe # remove local data volumes and generated admin key
|
||||
|
||||
@@ -154,6 +154,29 @@ Workspace capabilities:
|
||||
The browser never receives worker tokens and never talks directly to the worker
|
||||
or job container.
|
||||
|
||||
Worker cleanup is available in `Settings -> Worker`. It can delete old terminal
|
||||
workspace records and ask the active worker to remove orphaned job containers
|
||||
and inactive work directories.
|
||||
|
||||
Local worker development:
|
||||
|
||||
```sh
|
||||
scripts/build-agent-images
|
||||
bun smoke:agent-container
|
||||
bun dev:next:worker
|
||||
bun dev:next:worker:staging
|
||||
```
|
||||
|
||||
Local host-run worker commands still load env through Infisical, then
|
||||
`scripts/dev-agent-worker` selects Podman when available, falls back to Docker,
|
||||
and publishes the OpenCode server on a localhost port so the host worker can
|
||||
reach the job container. Override with:
|
||||
|
||||
```env
|
||||
SPOON_AGENT_CONTAINER_RUNTIME=podman
|
||||
SPOON_AGENT_CONTAINER_ACCESS=host_port
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -175,8 +198,8 @@ production should use the repo-provided JS/TS workbench image:
|
||||
SPOON_AGENT_JOB_IMAGE="git.gbrown.org/gib/spoon-agent-job:latest"
|
||||
```
|
||||
|
||||
The job image includes Node 22, Bun, package managers through Corepack, git,
|
||||
ripgrep, Python, build tools, and the OpenCode CLI. It is not the forked
|
||||
The job image includes Node 22, Bun, pnpm and yarn through Corepack, npm, git,
|
||||
ripgrep, Python, build tools, OpenCode, and the Codex CLI. It is not the forked
|
||||
project's production runtime; it is the agent execution environment.
|
||||
|
||||
Production worker runtime requirements:
|
||||
@@ -184,6 +207,8 @@ Production worker runtime requirements:
|
||||
- `spoon-agent-worker` must run as a separate service.
|
||||
- The worker needs `/var/run/docker.sock` mounted so it can launch job
|
||||
containers.
|
||||
- Production should keep `SPOON_AGENT_CONTAINER_RUNTIME=docker` and
|
||||
`SPOON_AGENT_CONTAINER_ACCESS=network`.
|
||||
- The production Docker host must be logged into `git.gbrown.org` so worker jobs
|
||||
can pull the private `spoon-agent-job` image.
|
||||
- `SPOON_WORKER_TOKEN` must match the value stored in Convex production env.
|
||||
@@ -191,15 +216,35 @@ Production worker runtime requirements:
|
||||
`SPOON_AGENT_WORKER_INTERNAL_TOKEN` so Next API routes can proxy workspace
|
||||
file, diff, message, command, and draft PR actions.
|
||||
- `spoon-agent-worker` also needs `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`.
|
||||
If the private key is stored in a single-line dotenv value, encode newlines as
|
||||
literal `\n` characters so the worker can restore the PEM before using it.
|
||||
|
||||
Useful production checks:
|
||||
|
||||
```sh
|
||||
docker login git.gbrown.org
|
||||
docker pull git.gbrown.org/gib/spoon-agent-worker:latest
|
||||
docker pull git.gbrown.org/gib/spoon-agent-job:latest
|
||||
docker logs --tail=200 spoon-agent-worker
|
||||
curl -H "Authorization: Bearer $SPOON_AGENT_WORKER_INTERNAL_TOKEN" \
|
||||
http://spoon-agent-worker:3921/health
|
||||
```
|
||||
|
||||
Deployment readiness checklist:
|
||||
|
||||
1. Production Convex env has `SPOON_WORKER_TOKEN`, `SPOON_ENCRYPTION_KEY`,
|
||||
GitHub App env, and Convex Auth signing keys.
|
||||
2. Compose env has `SPOON_AGENT_WORKER_URL`,
|
||||
`SPOON_AGENT_WORKER_INTERNAL_TOKEN`, `SPOON_AGENT_JOB_IMAGE`, and the GitHub
|
||||
App private key.
|
||||
3. The production Docker host can pull private images from `git.gbrown.org`.
|
||||
4. `Settings -> Worker` reports the expected job image, runtime, network, and
|
||||
active workspace count.
|
||||
5. The first test thread uses a configured API-key provider or a trusted Codex
|
||||
login profile.
|
||||
6. If a worker restart leaves stale workspace state, use the workspace recovery
|
||||
panel or `Settings -> Worker` cleanup.
|
||||
|
||||
API-key based AI provider profiles run through OpenCode. Codex ChatGPT login
|
||||
profiles run through the Codex CLI: Spoon writes the encrypted `auth.json` into
|
||||
the isolated job workspace as `CODEX_HOME/.codex/auth.json` before execution.
|
||||
@@ -437,6 +482,8 @@ not call Infisical.
|
||||
| `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | Server-only token for Next-to-worker proxy |
|
||||
| `SPOON_AGENT_JOB_IMAGE` | Agent job container image |
|
||||
| `SPOON_AGENT_RUNTIME` | Runtime mode, currently Docker/Podman-oriented |
|
||||
| `SPOON_AGENT_CONTAINER_RUNTIME` | Container CLI used by worker, `docker`/`podman` |
|
||||
| `SPOON_AGENT_CONTAINER_ACCESS` | `network` in prod, `host_port` for host dev |
|
||||
| `SPOON_AGENT_MAX_CONCURRENT_JOBS` | Worker concurrency limit |
|
||||
| `SPOON_AGENT_JOB_TIMEOUT_MS` | Job timeout |
|
||||
| `SPOON_AGENT_WORKDIR` | Worker work directory |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun with-env src/index.ts",
|
||||
"dev": "bun with-env bash ../../scripts/dev-agent-worker -- bun src/index.ts",
|
||||
"start": "bun src/index.ts",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
export type NormalizedAgentEvent =
|
||||
| { kind: 'assistant_delta'; content: string; externalMessageId?: string }
|
||||
| {
|
||||
kind: 'assistant_completed';
|
||||
content?: string;
|
||||
externalMessageId?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'tool_started';
|
||||
name: string;
|
||||
input?: string;
|
||||
externalMessageId?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'tool_completed';
|
||||
name: string;
|
||||
output?: string;
|
||||
externalMessageId?: string;
|
||||
}
|
||||
| { kind: 'file_edited'; path: string }
|
||||
| {
|
||||
kind: 'command_executed';
|
||||
command: string;
|
||||
exitCode?: number;
|
||||
output?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'permission_requested';
|
||||
externalRequestId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
metadata?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'question_requested';
|
||||
externalRequestId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
options?: string[];
|
||||
metadata?: string;
|
||||
}
|
||||
| { kind: 'session'; sessionId: string }
|
||||
| { kind: 'status'; status: string; metadata?: string }
|
||||
| { kind: 'error'; message: string; metadata?: string };
|
||||
|
||||
const stringify = (value: unknown) => {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value === undefined || value === null) return '';
|
||||
if (
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean' ||
|
||||
typeof value === 'bigint'
|
||||
) {
|
||||
return value.toString();
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
||||
value && typeof value === 'object'
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
const textFromPart = (part: Record<string, unknown>) => {
|
||||
const text = part.text ?? part.content ?? part.delta;
|
||||
return typeof text === 'string' ? text : '';
|
||||
};
|
||||
|
||||
export const normalizeCodexJsonLine = (
|
||||
line: string,
|
||||
): NormalizedAgentEvent[] => {
|
||||
if (!line.trim()) return [];
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line) as unknown;
|
||||
} catch {
|
||||
return [{ kind: 'status', status: line }];
|
||||
}
|
||||
const event = asRecord(parsed);
|
||||
if (!event) return [];
|
||||
const type = stringify(event.type ?? event.event);
|
||||
const id = event.id ?? event.session_id ?? event.sessionId;
|
||||
const sessionId =
|
||||
typeof id === 'string' && type.toLowerCase().includes('session')
|
||||
? id
|
||||
: undefined;
|
||||
const events: NormalizedAgentEvent[] = sessionId
|
||||
? [{ kind: 'session', sessionId }]
|
||||
: [];
|
||||
const message = asRecord(event.message);
|
||||
const item = asRecord(event.item);
|
||||
const data = asRecord(event.data);
|
||||
const part = asRecord(event.part);
|
||||
const delta = event.delta ?? data?.delta;
|
||||
if (typeof delta === 'string') {
|
||||
events.push({ kind: 'assistant_delta', content: delta });
|
||||
}
|
||||
const text =
|
||||
(part ? textFromPart(part) : '') ||
|
||||
(message ? stringify(message.content ?? message.text) : '') ||
|
||||
(item ? stringify(item.content ?? item.text) : '');
|
||||
if (
|
||||
text &&
|
||||
(type.includes('message') ||
|
||||
type.includes('response.output_text') ||
|
||||
type.includes('agent_message'))
|
||||
) {
|
||||
events.push({ kind: 'assistant_delta', content: text });
|
||||
}
|
||||
const command = event.command ?? data?.command;
|
||||
if (typeof command === 'string') {
|
||||
events.push({
|
||||
kind: 'command_executed',
|
||||
command,
|
||||
output: stringify(event.output ?? data?.output),
|
||||
});
|
||||
}
|
||||
const file = event.file ?? event.path ?? data?.file ?? data?.path;
|
||||
if (typeof file === 'string' && type.includes('file')) {
|
||||
events.push({ kind: 'file_edited', path: file });
|
||||
}
|
||||
if (type.includes('error')) {
|
||||
events.push({
|
||||
kind: 'error',
|
||||
message: stringify(event.message ?? event.error ?? data),
|
||||
});
|
||||
}
|
||||
if (type.includes('completed') || type.includes('turn.done')) {
|
||||
events.push({ kind: 'assistant_completed' });
|
||||
}
|
||||
if (events.length === 0) {
|
||||
events.push({ kind: 'status', status: type || 'codex_event' });
|
||||
}
|
||||
return events;
|
||||
};
|
||||
|
||||
export const normalizeOpenCodeEvent = (
|
||||
input: unknown,
|
||||
): NormalizedAgentEvent[] => {
|
||||
const event = asRecord(input);
|
||||
if (!event) return [];
|
||||
const type = stringify(event.type);
|
||||
const properties = asRecord(event.properties) ?? asRecord(event.data) ?? event;
|
||||
const events: NormalizedAgentEvent[] = [];
|
||||
const sessionId = properties.sessionID ?? properties.sessionId;
|
||||
if (typeof sessionId === 'string' && type.includes('session')) {
|
||||
events.push({ kind: 'session', sessionId });
|
||||
}
|
||||
if (type === 'message.part.delta') {
|
||||
const part = asRecord(properties.part) ?? properties;
|
||||
const text = textFromPart(part);
|
||||
if (text) {
|
||||
events.push({
|
||||
kind: 'assistant_delta',
|
||||
content: text,
|
||||
externalMessageId: stringify(properties.messageID),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (type === 'message.updated' || type === 'message.part.updated') {
|
||||
const part = asRecord(properties.part);
|
||||
const text = part ? textFromPart(part) : stringify(properties.message);
|
||||
if (text) {
|
||||
events.push({
|
||||
kind: 'assistant_delta',
|
||||
content: text,
|
||||
externalMessageId: stringify(properties.messageID),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (type.includes('tool.started')) {
|
||||
events.push({
|
||||
kind: 'tool_started',
|
||||
name: stringify(properties.tool ?? properties.name ?? 'tool'),
|
||||
input: stringify(properties.input),
|
||||
externalMessageId: stringify(properties.messageID),
|
||||
});
|
||||
}
|
||||
if (type.includes('tool.finished') || type.includes('tool.completed')) {
|
||||
events.push({
|
||||
kind: 'tool_completed',
|
||||
name: stringify(properties.tool ?? properties.name ?? 'tool'),
|
||||
output: stringify(properties.output ?? properties.result),
|
||||
externalMessageId: stringify(properties.messageID),
|
||||
});
|
||||
}
|
||||
if (type === 'file.edited') {
|
||||
const file = properties.file;
|
||||
if (typeof file === 'string') events.push({ kind: 'file_edited', path: file });
|
||||
}
|
||||
if (type === 'command.executed') {
|
||||
events.push({
|
||||
kind: 'command_executed',
|
||||
command: stringify(properties.command),
|
||||
output: stringify(properties.output),
|
||||
});
|
||||
}
|
||||
if (type.includes('permission') && type.includes('asked')) {
|
||||
events.push({
|
||||
kind: 'permission_requested',
|
||||
externalRequestId: stringify(properties.permissionID ?? properties.id),
|
||||
title: 'Permission requested',
|
||||
body: stringify(properties.permission ?? properties.message ?? properties),
|
||||
metadata: stringify(properties),
|
||||
});
|
||||
}
|
||||
if (type.includes('question') && type.includes('asked')) {
|
||||
events.push({
|
||||
kind: 'question_requested',
|
||||
externalRequestId: stringify(properties.requestID ?? properties.id),
|
||||
title: 'Agent question',
|
||||
body: stringify(properties.question ?? properties.message ?? properties),
|
||||
metadata: stringify(properties),
|
||||
});
|
||||
}
|
||||
if (type === 'session.idle') events.push({ kind: 'assistant_completed' });
|
||||
if (type === 'session.error') {
|
||||
events.push({
|
||||
kind: 'error',
|
||||
message: stringify(properties.error ?? properties.message ?? properties),
|
||||
});
|
||||
}
|
||||
if (events.length === 0 && type) {
|
||||
events.push({ kind: 'status', status: type, metadata: stringify(properties) });
|
||||
}
|
||||
return events;
|
||||
};
|
||||
@@ -19,6 +19,14 @@ export const env = {
|
||||
workerToken: requiredEnv('SPOON_WORKER_TOKEN'),
|
||||
workerId: process.env.SPOON_AGENT_WORKER_ID?.trim() ?? 'local-worker',
|
||||
runtime: process.env.SPOON_AGENT_RUNTIME?.trim() ?? 'docker',
|
||||
containerRuntime:
|
||||
process.env.SPOON_AGENT_CONTAINER_RUNTIME?.trim() ??
|
||||
process.env.SPOON_CONTAINER_RUNTIME?.trim() ??
|
||||
'docker',
|
||||
containerAccess:
|
||||
process.env.SPOON_AGENT_CONTAINER_ACCESS?.trim() === 'host_port'
|
||||
? 'host_port'
|
||||
: 'network',
|
||||
jobImage:
|
||||
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
|
||||
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { createOpencodeClient } from '@opencode-ai/sdk';
|
||||
import type { OpencodeClient } from '@opencode-ai/sdk';
|
||||
|
||||
import type { NormalizedAgentEvent } from './agent-events';
|
||||
import { normalizeOpenCodeEvent } from './agent-events';
|
||||
|
||||
export type OpenCodeSession = {
|
||||
client: OpencodeClient;
|
||||
sessionId: string;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
const basicAuth = (username: string, password: string) =>
|
||||
`Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
|
||||
const modelParts = (model: string) => {
|
||||
const [rawProviderId, ...rest] = model.split('/');
|
||||
const providerID =
|
||||
rawProviderId && rawProviderId.length > 0 ? rawProviderId : 'openai';
|
||||
const modelID = rest.length > 0 ? rest.join('/') : model;
|
||||
return {
|
||||
providerID,
|
||||
modelID,
|
||||
};
|
||||
};
|
||||
|
||||
export const createOpenCodeSession = async (args: {
|
||||
baseUrl: string;
|
||||
password: string;
|
||||
directory: string;
|
||||
title: string;
|
||||
onEvent: (event: NormalizedAgentEvent) => Promise<void>;
|
||||
}) => {
|
||||
const abortController = new AbortController();
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: args.baseUrl,
|
||||
directory: args.directory,
|
||||
headers: {
|
||||
authorization: basicAuth('opencode', args.password),
|
||||
},
|
||||
});
|
||||
const created = await client.session.create({
|
||||
query: { directory: args.directory },
|
||||
body: { title: args.title },
|
||||
});
|
||||
if (!created.data) {
|
||||
throw new Error('OpenCode session could not be created.');
|
||||
}
|
||||
const sessionId = created.data.id;
|
||||
void (async () => {
|
||||
const events = await client.event.subscribe({
|
||||
signal: abortController.signal,
|
||||
query: { directory: args.directory },
|
||||
onSseEvent: (event) => {
|
||||
for (const normalized of normalizeOpenCodeEvent(event.data)) {
|
||||
void args.onEvent(normalized);
|
||||
}
|
||||
},
|
||||
onSseError: (error) => {
|
||||
void args.onEvent({
|
||||
kind: 'error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
for await (const event of events.stream) {
|
||||
for (const normalized of normalizeOpenCodeEvent(event)) {
|
||||
await args.onEvent(normalized);
|
||||
}
|
||||
}
|
||||
})().catch((error: unknown) => {
|
||||
if (!abortController.signal.aborted) {
|
||||
void args.onEvent({
|
||||
kind: 'error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
return {
|
||||
client,
|
||||
sessionId,
|
||||
close: () => abortController.abort(),
|
||||
} satisfies OpenCodeSession;
|
||||
};
|
||||
|
||||
export const promptOpenCodeSession = async (args: {
|
||||
session: OpenCodeSession;
|
||||
prompt: string;
|
||||
model: string;
|
||||
directory: string;
|
||||
}) => {
|
||||
const model = modelParts(args.model);
|
||||
const result = await args.session.client.session.promptAsync({
|
||||
path: { id: args.session.sessionId },
|
||||
query: { directory: args.directory },
|
||||
body: {
|
||||
model,
|
||||
parts: [{ type: 'text', text: args.prompt }],
|
||||
},
|
||||
});
|
||||
if (result.error) {
|
||||
throw new Error('OpenCode prompt was rejected.');
|
||||
}
|
||||
};
|
||||
|
||||
export const abortOpenCodeSession = async (session: OpenCodeSession) => {
|
||||
await session.client.session.abort({
|
||||
path: { id: session.sessionId },
|
||||
});
|
||||
};
|
||||
|
||||
export const replyOpenCodePermission = async (args: {
|
||||
session: OpenCodeSession;
|
||||
permissionId: string;
|
||||
response: 'once' | 'always' | 'reject';
|
||||
directory: string;
|
||||
}) => {
|
||||
const result = await args.session.client.postSessionIdPermissionsPermissionId({
|
||||
path: { id: args.session.sessionId, permissionID: args.permissionId },
|
||||
query: { directory: args.directory },
|
||||
body: { response: args.response },
|
||||
});
|
||||
if (result.error) {
|
||||
throw new Error('OpenCode permission response was rejected.');
|
||||
}
|
||||
};
|
||||
@@ -2,20 +2,30 @@ import { execa } from 'execa';
|
||||
|
||||
import { env } from '../env';
|
||||
|
||||
type CommandResult = {
|
||||
exitCode: number;
|
||||
output: string;
|
||||
};
|
||||
|
||||
const environmentArgs = (environment: Record<string, string>) =>
|
||||
Object.entries(environment).flatMap(([name, value]) => [
|
||||
'-e',
|
||||
`${name}=${value}`,
|
||||
]);
|
||||
|
||||
const networkArgs = () => (env.network ? ['--network', env.network] : []);
|
||||
|
||||
const containerRuntime = () => env.containerRuntime;
|
||||
|
||||
export const runInJobContainer = async (args: {
|
||||
workdir: string;
|
||||
command: string[];
|
||||
environment: Record<string, string>;
|
||||
redact: (value: string) => string;
|
||||
timeoutMs: number;
|
||||
}) => {
|
||||
const envArgs = Object.entries(args.environment).flatMap(([name, value]) => [
|
||||
'-e',
|
||||
`${name}=${value}`,
|
||||
]);
|
||||
const networkArgs = env.network ? ['--network', env.network] : [];
|
||||
}): Promise<CommandResult> => {
|
||||
const result = await execa(
|
||||
'docker',
|
||||
containerRuntime(),
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
@@ -23,8 +33,8 @@ export const runInJobContainer = async (args: {
|
||||
'4g',
|
||||
'--cpus',
|
||||
'2',
|
||||
...networkArgs,
|
||||
...envArgs,
|
||||
...networkArgs(),
|
||||
...environmentArgs(args.environment),
|
||||
'-v',
|
||||
`${args.workdir}:/workspace`,
|
||||
'-w',
|
||||
@@ -43,3 +53,202 @@ export const runInJobContainer = async (args: {
|
||||
output: args.redact(result.all),
|
||||
};
|
||||
};
|
||||
|
||||
export const startWorkspaceContainer = async (args: {
|
||||
workdir: string;
|
||||
containerName: string;
|
||||
environment: Record<string, string>;
|
||||
command?: string[];
|
||||
publishTcpPort?: number;
|
||||
}) => {
|
||||
await execa(
|
||||
containerRuntime(),
|
||||
[
|
||||
'rm',
|
||||
'-f',
|
||||
args.containerName,
|
||||
],
|
||||
{ reject: false },
|
||||
);
|
||||
const result = await execa(
|
||||
containerRuntime(),
|
||||
[
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
args.containerName,
|
||||
'--memory',
|
||||
'4g',
|
||||
'--cpus',
|
||||
'2',
|
||||
...networkArgs(),
|
||||
...(args.publishTcpPort
|
||||
? ['-p', `127.0.0.1::${args.publishTcpPort}`]
|
||||
: []),
|
||||
...environmentArgs(args.environment),
|
||||
'-v',
|
||||
`${args.workdir}:/workspace`,
|
||||
'-w',
|
||||
'/workspace/repo',
|
||||
env.jobImage,
|
||||
...(args.command ?? ['sleep', 'infinity']),
|
||||
],
|
||||
{ all: true },
|
||||
);
|
||||
return {
|
||||
containerId: result.stdout.trim(),
|
||||
containerName: args.containerName,
|
||||
hostPort: args.publishTcpPort
|
||||
? await getPublishedPort(args.containerName, args.publishTcpPort)
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const getPublishedPort = async (containerName: string, containerPort: number) => {
|
||||
const result = await execa(
|
||||
containerRuntime(),
|
||||
['port', containerName, `${containerPort}/tcp`],
|
||||
{ all: true, reject: false },
|
||||
);
|
||||
const output = result.all.trim();
|
||||
const match = /:(\d+)\s*$/.exec(output);
|
||||
if (!match?.[1]) {
|
||||
throw new Error(
|
||||
`Could not determine published port for ${containerName}:${containerPort}.`,
|
||||
);
|
||||
}
|
||||
return match[1];
|
||||
};
|
||||
|
||||
export const execInWorkspaceContainer = async (args: {
|
||||
containerName: string;
|
||||
command: string[];
|
||||
environment?: Record<string, string>;
|
||||
redact: (value: string) => string;
|
||||
timeoutMs: number;
|
||||
}): Promise<CommandResult> => {
|
||||
const result = await execa(
|
||||
containerRuntime(),
|
||||
[
|
||||
'exec',
|
||||
...(args.environment ? environmentArgs(args.environment) : []),
|
||||
args.containerName,
|
||||
...args.command,
|
||||
],
|
||||
{
|
||||
all: true,
|
||||
reject: false,
|
||||
timeout: args.timeoutMs,
|
||||
},
|
||||
);
|
||||
return {
|
||||
exitCode: result.exitCode ?? 0,
|
||||
output: args.redact(result.all),
|
||||
};
|
||||
};
|
||||
|
||||
export const streamInJobContainer = async (args: {
|
||||
workdir: string;
|
||||
command: string[];
|
||||
environment: Record<string, string>;
|
||||
redact: (value: string) => string;
|
||||
timeoutMs: number;
|
||||
onStdoutLine?: (line: string) => Promise<void>;
|
||||
onStderrLine?: (line: string) => Promise<void>;
|
||||
}): Promise<CommandResult> => {
|
||||
const subprocess = execa(
|
||||
containerRuntime(),
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
'--memory',
|
||||
'4g',
|
||||
'--cpus',
|
||||
'2',
|
||||
...networkArgs(),
|
||||
...environmentArgs(args.environment),
|
||||
'-v',
|
||||
`${args.workdir}:/workspace`,
|
||||
'-w',
|
||||
'/workspace/repo',
|
||||
env.jobImage,
|
||||
...args.command,
|
||||
],
|
||||
{
|
||||
all: true,
|
||||
reject: false,
|
||||
timeout: args.timeoutMs,
|
||||
},
|
||||
);
|
||||
let stdoutBuffer = '';
|
||||
let stderrBuffer = '';
|
||||
const output: string[] = [];
|
||||
const consume = async (
|
||||
chunk: Buffer,
|
||||
source: 'stdout' | 'stderr',
|
||||
handler?: (line: string) => Promise<void>,
|
||||
) => {
|
||||
output.push(chunk.toString('utf8'));
|
||||
const next = `${source === 'stdout' ? stdoutBuffer : stderrBuffer}${chunk.toString('utf8')}`;
|
||||
const lines = next.split(/\r?\n/);
|
||||
const remainder = lines.pop() ?? '';
|
||||
if (source === 'stdout') stdoutBuffer = remainder;
|
||||
else stderrBuffer = remainder;
|
||||
for (const line of lines) {
|
||||
if (handler) {
|
||||
await handler(args.redact(line));
|
||||
}
|
||||
}
|
||||
};
|
||||
subprocess.stdout.on('data', (chunk: Buffer) => {
|
||||
void consume(chunk, 'stdout', args.onStdoutLine);
|
||||
});
|
||||
subprocess.stderr.on('data', (chunk: Buffer) => {
|
||||
void consume(chunk, 'stderr', args.onStderrLine);
|
||||
});
|
||||
const result = await subprocess;
|
||||
if (stdoutBuffer && args.onStdoutLine) {
|
||||
await args.onStdoutLine(args.redact(stdoutBuffer));
|
||||
}
|
||||
if (stderrBuffer && args.onStderrLine) {
|
||||
await args.onStderrLine(args.redact(stderrBuffer));
|
||||
}
|
||||
return {
|
||||
exitCode: result.exitCode ?? 0,
|
||||
output: args.redact(output.join('')),
|
||||
};
|
||||
};
|
||||
|
||||
export const stopWorkspaceContainer = async (containerName: string) => {
|
||||
await execa(containerRuntime(), ['rm', '-f', containerName], {
|
||||
reject: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const inspectWorkspaceContainer = async (containerName: string) => {
|
||||
const result = await execa(
|
||||
containerRuntime(),
|
||||
['inspect', containerName],
|
||||
{
|
||||
all: true,
|
||||
reject: false,
|
||||
},
|
||||
);
|
||||
return {
|
||||
exists: result.exitCode === 0,
|
||||
output: result.all,
|
||||
};
|
||||
};
|
||||
|
||||
export const listWorkspaceContainerNames = async (prefix: string) => {
|
||||
const result = await execa(
|
||||
containerRuntime(),
|
||||
['ps', '-a', '--format', '{{.Names}}'],
|
||||
{ all: true, reject: false },
|
||||
);
|
||||
if (result.exitCode !== 0) return [];
|
||||
return result.all
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith(prefix));
|
||||
};
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { createServer } from 'node:http';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
|
||||
import { env } from './env';
|
||||
import {
|
||||
abortWorkspaceAgent,
|
||||
cleanupOrphanedWorkspaces,
|
||||
getWorkerHealth,
|
||||
getWorkspaceAgentStatus,
|
||||
getWorkspaceDiff,
|
||||
listWorkspaceTree,
|
||||
openWorkspacePullRequest,
|
||||
readWorkspaceFile,
|
||||
replyToInteraction,
|
||||
runWorkspaceCommand,
|
||||
sendWorkspaceMessage,
|
||||
stopWorkspace,
|
||||
@@ -43,7 +50,7 @@ const requireAuth = (request: IncomingMessage) => {
|
||||
};
|
||||
|
||||
const jobRoute = (pathname: string) => {
|
||||
const match = /^\/jobs\/([^/]+)\/([^/]+)$/.exec(pathname);
|
||||
const match = /^\/jobs\/([^/]+)\/(.+)$/.exec(pathname);
|
||||
if (!match?.[1] || !match[2]) return null;
|
||||
return { jobId: decodeURIComponent(match[1]), action: match[2] };
|
||||
};
|
||||
@@ -57,8 +64,12 @@ export const startWorkerServer = () => {
|
||||
request.url ?? '/',
|
||||
`http://localhost:${env.httpPort}`,
|
||||
);
|
||||
if (url.pathname === '/health') {
|
||||
sendJson(response, 200, { ok: true, workerId: env.workerId });
|
||||
if (url.pathname === '/health' && request.method === 'GET') {
|
||||
sendJson(response, 200, await getWorkerHealth());
|
||||
return;
|
||||
}
|
||||
if (url.pathname === '/cleanup' && request.method === 'POST') {
|
||||
sendJson(response, 200, await cleanupOrphanedWorkspaces());
|
||||
return;
|
||||
}
|
||||
const route = jobRoute(url.pathname);
|
||||
@@ -108,6 +119,34 @@ export const startWorkerServer = () => {
|
||||
sendJson(response, 200, { success: true });
|
||||
return;
|
||||
}
|
||||
if (request.method === 'GET' && route.action === 'agent/status') {
|
||||
sendJson(response, 200, getWorkspaceAgentStatus(route.jobId));
|
||||
return;
|
||||
}
|
||||
if (request.method === 'POST' && route.action === 'agent/abort') {
|
||||
sendJson(response, 200, await abortWorkspaceAgent(route.jobId));
|
||||
return;
|
||||
}
|
||||
const interactionMatch =
|
||||
/^interactions\/([^/]+)\/reply$/.exec(route.action);
|
||||
if (request.method === 'POST' && interactionMatch?.[1]) {
|
||||
const body = await parseJson<{
|
||||
externalRequestId?: string;
|
||||
response?: string;
|
||||
}>(request);
|
||||
sendJson(
|
||||
response,
|
||||
200,
|
||||
await replyToInteraction(route.jobId, {
|
||||
interactionId: decodeURIComponent(
|
||||
interactionMatch[1],
|
||||
) as Id<'agentInteractionRequests'>,
|
||||
externalRequestId: body.externalRequestId ?? '',
|
||||
response: body.response ?? 'once',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (request.method === 'POST' && route.action === 'run-command') {
|
||||
const body = await parseJson<{ command?: string }>(request);
|
||||
sendJson(
|
||||
@@ -128,7 +167,13 @@ export const startWorkerServer = () => {
|
||||
sendJson(response, 404, { error: 'Not found' });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
sendJson(response, message === 'Unauthorized' ? 401 : 500, {
|
||||
const status =
|
||||
message === 'Unauthorized'
|
||||
? 401
|
||||
: message.includes('not supported')
|
||||
? 409
|
||||
: 500;
|
||||
sendJson(response, status, {
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
|
||||
+586
-43
@@ -7,12 +7,15 @@ import {
|
||||
stat,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { ConvexHttpClient } from 'convex/browser';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
|
||||
import type { NormalizedAgentEvent } from './agent-events';
|
||||
import { normalizeCodexJsonLine } from './agent-events';
|
||||
import { env } from './env';
|
||||
import {
|
||||
cloneRepository,
|
||||
@@ -22,8 +25,21 @@ import {
|
||||
run,
|
||||
} from './git';
|
||||
import { getInstallationToken, openDraftPullRequest } from './github';
|
||||
import type { OpenCodeSession } from './opencode-session';
|
||||
import {
|
||||
abortOpenCodeSession,
|
||||
createOpenCodeSession,
|
||||
promptOpenCodeSession,
|
||||
replyOpenCodePermission,
|
||||
} from './opencode-session';
|
||||
import { createRedactor, truncate } from './redact';
|
||||
import { runInJobContainer } from './runtime/docker';
|
||||
import {
|
||||
listWorkspaceContainerNames,
|
||||
runInJobContainer,
|
||||
startWorkspaceContainer,
|
||||
stopWorkspaceContainer,
|
||||
streamInJobContainer,
|
||||
} from './runtime/docker';
|
||||
|
||||
type Claim = {
|
||||
job: {
|
||||
@@ -81,6 +97,14 @@ type ActiveWorkspace = {
|
||||
repoDir: string;
|
||||
githubToken: string;
|
||||
redact: (value: string) => string;
|
||||
runtimeMode?: 'opencode_server' | 'codex_exec' | 'legacy_cli';
|
||||
containerName?: string;
|
||||
containerId?: string;
|
||||
opencodePassword?: string;
|
||||
opencodeSession?: OpenCodeSession;
|
||||
codexSessionId?: string;
|
||||
agentTurnActive?: boolean;
|
||||
resolveTurn?: () => void;
|
||||
};
|
||||
|
||||
type FileTreeNode = {
|
||||
@@ -225,6 +249,70 @@ const appendMessage = async (args: {
|
||||
...args,
|
||||
});
|
||||
|
||||
const updateMessage = async (args: {
|
||||
messageId: Id<'agentJobMessages'>;
|
||||
content?: string;
|
||||
status?: 'queued' | 'streaming' | 'completed' | 'failed';
|
||||
metadata?: string;
|
||||
}) =>
|
||||
await client.mutation(api.agentJobs.updateMessage, {
|
||||
workerToken: env.workerToken,
|
||||
workerId: env.workerId,
|
||||
...args,
|
||||
});
|
||||
|
||||
const setRuntimeSession = async (args: {
|
||||
jobId: Id<'agentJobs'>;
|
||||
agentRuntimeMode: 'opencode_server' | 'codex_exec' | 'legacy_cli';
|
||||
opencodeSessionId?: string;
|
||||
codexSessionId?: string;
|
||||
containerId?: string;
|
||||
}) =>
|
||||
await client.mutation(api.agentJobs.setRuntimeSession, {
|
||||
workerToken: env.workerToken,
|
||||
workerId: env.workerId,
|
||||
...args,
|
||||
});
|
||||
|
||||
const setCodexSessionId = async (
|
||||
jobId: Id<'agentJobs'>,
|
||||
codexSessionId: string,
|
||||
) =>
|
||||
await client.mutation(api.agentJobs.setCodexSessionId, {
|
||||
workerToken: env.workerToken,
|
||||
workerId: env.workerId,
|
||||
jobId,
|
||||
codexSessionId,
|
||||
});
|
||||
|
||||
const createInteractionRequest = async (args: {
|
||||
jobId: Id<'agentJobs'>;
|
||||
runtime: 'opencode' | 'codex';
|
||||
externalRequestId: string;
|
||||
kind: 'question' | 'permission' | 'tool_confirmation';
|
||||
title: string;
|
||||
body: string;
|
||||
options?: string[];
|
||||
metadata?: string;
|
||||
}) =>
|
||||
await client.mutation(api.agentJobs.createInteractionRequest, {
|
||||
workerToken: env.workerToken,
|
||||
workerId: env.workerId,
|
||||
...args,
|
||||
});
|
||||
|
||||
const patchInteractionRequest = async (args: {
|
||||
interactionId: Id<'agentInteractionRequests'>;
|
||||
status: 'pending' | 'answered' | 'approved' | 'rejected' | 'expired';
|
||||
response?: string;
|
||||
metadata?: string;
|
||||
}) =>
|
||||
await client.mutation(api.agentJobs.patchInteractionRequest, {
|
||||
workerToken: env.workerToken,
|
||||
workerId: env.workerId,
|
||||
...args,
|
||||
});
|
||||
|
||||
const recordWorkspaceChange = async (args: {
|
||||
jobId: Id<'agentJobs'>;
|
||||
path: string;
|
||||
@@ -240,6 +328,9 @@ const recordWorkspaceChange = async (args: {
|
||||
|
||||
const commandToShell = (command: string) => ['bash', '-lc', command];
|
||||
|
||||
const workspaceContainerName = (jobId: string) =>
|
||||
`spoon-agent-job-${jobId.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
|
||||
|
||||
const isCodexLoginProfile = (claim: Claim) =>
|
||||
claim.aiProviderProfile?.provider === 'opencode_openai_login' ||
|
||||
claim.aiProviderProfile?.authType === 'opencode_auth_json';
|
||||
@@ -373,20 +464,305 @@ const prepareCodexAuth = async (workspace: ActiveWorkspace) => {
|
||||
);
|
||||
};
|
||||
|
||||
const agentCommand = (claim: Claim, prompt: string) => {
|
||||
if (isCodexLoginProfile(claim)) {
|
||||
return commandToShell(
|
||||
`codex exec --model ${quoteShell(codexModel(claim))} --sandbox workspace-write ${quoteShell(prompt)}`,
|
||||
);
|
||||
}
|
||||
return commandToShell(
|
||||
`opencode run --model ${quoteShell(opencodeModel(claim))} ${quoteShell(prompt)}`,
|
||||
);
|
||||
};
|
||||
|
||||
const agentFailurePrefix = (claim: Claim) =>
|
||||
isCodexLoginProfile(claim) ? 'codex failed' : 'opencode failed';
|
||||
|
||||
const handleAgentEvent = async (args: {
|
||||
workspace: ActiveWorkspace;
|
||||
event: NormalizedAgentEvent;
|
||||
assistantMessageId: Id<'agentJobMessages'>;
|
||||
assistantContent: { value: string };
|
||||
}) => {
|
||||
const { workspace, event, assistantMessageId, assistantContent } = args;
|
||||
const jobId = workspace.claim.job._id;
|
||||
if (event.kind === 'assistant_delta') {
|
||||
assistantContent.value = truncate(
|
||||
`${assistantContent.value}${event.content}`,
|
||||
40_000,
|
||||
);
|
||||
await updateMessage({
|
||||
messageId: assistantMessageId,
|
||||
content: assistantContent.value,
|
||||
status: 'streaming',
|
||||
metadata: event.externalMessageId
|
||||
? JSON.stringify({ externalMessageId: event.externalMessageId })
|
||||
: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.kind === 'assistant_completed') {
|
||||
workspace.agentTurnActive = false;
|
||||
workspace.resolveTurn?.();
|
||||
workspace.resolveTurn = undefined;
|
||||
if (event.content) {
|
||||
assistantContent.value = truncate(
|
||||
`${assistantContent.value}${event.content}`,
|
||||
40_000,
|
||||
);
|
||||
}
|
||||
await updateMessage({
|
||||
messageId: assistantMessageId,
|
||||
content: assistantContent.value,
|
||||
status: 'completed',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.kind === 'session') {
|
||||
if (workspace.runtimeMode === 'codex_exec') {
|
||||
workspace.codexSessionId = event.sessionId;
|
||||
await setCodexSessionId(jobId, event.sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.kind === 'tool_started' || event.kind === 'tool_completed') {
|
||||
const detail =
|
||||
event.kind === 'tool_started' ? event.input : event.output;
|
||||
await appendMessage({
|
||||
jobId,
|
||||
role: 'tool',
|
||||
status: event.kind === 'tool_started' ? 'streaming' : 'completed',
|
||||
content: truncate(
|
||||
`${event.name}${detail ? `\n\n${detail}` : ''}`,
|
||||
20_000,
|
||||
),
|
||||
metadata: JSON.stringify({
|
||||
kind: event.kind,
|
||||
externalMessageId: event.externalMessageId,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.kind === 'file_edited') {
|
||||
const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact);
|
||||
await recordWorkspaceChange({
|
||||
jobId,
|
||||
path: event.path,
|
||||
source: 'agent',
|
||||
changeType: await fileChangedType(workspace.repoDir, event.path),
|
||||
diff: truncate(diff.output, 50_000),
|
||||
});
|
||||
await appendEvent(jobId, 'info', 'edit', `Agent edited ${event.path}.`);
|
||||
return;
|
||||
}
|
||||
if (event.kind === 'command_executed') {
|
||||
await appendEvent(
|
||||
jobId,
|
||||
event.exitCode && event.exitCode !== 0 ? 'warn' : 'info',
|
||||
'check',
|
||||
event.command,
|
||||
event.output ? truncate(event.output, 10_000) : undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.kind === 'permission_requested' ||
|
||||
event.kind === 'question_requested'
|
||||
) {
|
||||
await createInteractionRequest({
|
||||
jobId,
|
||||
runtime: workspace.runtimeMode === 'codex_exec' ? 'codex' : 'opencode',
|
||||
externalRequestId: event.externalRequestId,
|
||||
kind: event.kind === 'permission_requested' ? 'permission' : 'question',
|
||||
title: event.title,
|
||||
body: truncate(event.body, 20_000),
|
||||
options: event.kind === 'question_requested' ? event.options : undefined,
|
||||
metadata: event.metadata,
|
||||
});
|
||||
await appendMessage({
|
||||
jobId,
|
||||
role: 'system',
|
||||
status: 'completed',
|
||||
content: `${event.title}\n\n${truncate(event.body, 20_000)}`,
|
||||
metadata: JSON.stringify({ kind: event.kind }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.kind === 'status') {
|
||||
await appendEvent(
|
||||
jobId,
|
||||
'debug',
|
||||
'plan',
|
||||
event.status,
|
||||
event.metadata ? truncate(event.metadata, 10_000) : undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await appendEvent(jobId, 'error', 'plan', truncate(event.message, 20_000));
|
||||
};
|
||||
|
||||
const ensureOpenCodeSession = async (workspace: ActiveWorkspace) => {
|
||||
if (workspace.opencodeSession) return workspace.opencodeSession;
|
||||
const containerName = workspaceContainerName(workspace.claim.job._id);
|
||||
const password = randomBytes(24).toString('hex');
|
||||
const aiEnv = providerEnvironment(workspace.claim);
|
||||
const secretEnv = Object.fromEntries(
|
||||
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||
);
|
||||
const container = await startWorkspaceContainer({
|
||||
workdir: workspace.workdir,
|
||||
containerName,
|
||||
environment: {
|
||||
...aiEnv,
|
||||
...secretEnv,
|
||||
OPENCODE_SERVER_PASSWORD: password,
|
||||
OPENCODE_SERVER_USERNAME: 'opencode',
|
||||
},
|
||||
command: ['opencode', 'serve', '--hostname', '0.0.0.0', '--port', '4096'],
|
||||
publishTcpPort: env.containerAccess === 'host_port' ? 4096 : undefined,
|
||||
});
|
||||
const baseUrl =
|
||||
env.containerAccess === 'host_port'
|
||||
? `http://127.0.0.1:${container.hostPort}`
|
||||
: `http://${containerName}:4096`;
|
||||
workspace.containerName = container.containerName;
|
||||
workspace.containerId = container.containerId;
|
||||
workspace.opencodePassword = password;
|
||||
workspace.runtimeMode = 'opencode_server';
|
||||
await setRuntimeSession({
|
||||
jobId: workspace.claim.job._id,
|
||||
agentRuntimeMode: 'opencode_server',
|
||||
containerId: container.containerId,
|
||||
});
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
try {
|
||||
const session = await createOpenCodeSession({
|
||||
baseUrl,
|
||||
password,
|
||||
directory: '/workspace/repo',
|
||||
title: workspace.claim.job.prompt.slice(0, 80) || 'Spoon workspace',
|
||||
onEvent: async (event) => {
|
||||
const messageId = workspaceCurrentMessage.get(workspace.claim.job._id);
|
||||
if (!messageId) return;
|
||||
await handleAgentEvent({
|
||||
workspace,
|
||||
event,
|
||||
assistantMessageId: messageId,
|
||||
assistantContent:
|
||||
workspaceCurrentContent.get(workspace.claim.job._id) ?? {
|
||||
value: '',
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
workspace.opencodeSession = session;
|
||||
await setRuntimeSession({
|
||||
jobId: workspace.claim.job._id,
|
||||
agentRuntimeMode: 'opencode_server',
|
||||
opencodeSessionId: session.sessionId,
|
||||
containerId: container.containerId,
|
||||
});
|
||||
return session;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(500);
|
||||
}
|
||||
}
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error('OpenCode server did not become ready.');
|
||||
};
|
||||
|
||||
const workspaceCurrentMessage = new Map<string, Id<'agentJobMessages'>>();
|
||||
const workspaceCurrentContent = new Map<
|
||||
string,
|
||||
{
|
||||
value: string;
|
||||
}
|
||||
>();
|
||||
|
||||
const runCodexTurn = async (args: {
|
||||
workspace: ActiveWorkspace;
|
||||
prompt: string;
|
||||
assistantMessageId: Id<'agentJobMessages'>;
|
||||
assistantContent: { value: string };
|
||||
}) => {
|
||||
const { workspace, prompt, assistantMessageId, assistantContent } = args;
|
||||
workspace.runtimeMode = 'codex_exec';
|
||||
await setRuntimeSession({
|
||||
jobId: workspace.claim.job._id,
|
||||
agentRuntimeMode: 'codex_exec',
|
||||
codexSessionId: workspace.codexSessionId,
|
||||
});
|
||||
const command = workspace.codexSessionId
|
||||
? commandToShell(
|
||||
`codex exec resume --json --model ${quoteShell(
|
||||
codexModel(workspace.claim),
|
||||
)} ${quoteShell(workspace.codexSessionId)} ${quoteShell(prompt)}`,
|
||||
)
|
||||
: commandToShell(
|
||||
`codex exec --json --model ${quoteShell(
|
||||
codexModel(workspace.claim),
|
||||
)} --sandbox workspace-write ${quoteShell(prompt)}`,
|
||||
);
|
||||
const aiEnv = providerEnvironment(workspace.claim, jobContainerWorkspace);
|
||||
const secretEnv = Object.fromEntries(
|
||||
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||
);
|
||||
const result = await streamInJobContainer({
|
||||
workdir: workspace.workdir,
|
||||
command,
|
||||
environment: {
|
||||
...aiEnv,
|
||||
...secretEnv,
|
||||
},
|
||||
redact: workspace.redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
onStdoutLine: async (line) => {
|
||||
for (const event of normalizeCodexJsonLine(line)) {
|
||||
await handleAgentEvent({
|
||||
workspace,
|
||||
event,
|
||||
assistantMessageId,
|
||||
assistantContent,
|
||||
});
|
||||
}
|
||||
},
|
||||
onStderrLine: async (line) => {
|
||||
if (line.trim()) {
|
||||
await appendEvent(
|
||||
workspace.claim.job._id,
|
||||
'debug',
|
||||
'plan',
|
||||
truncate(line, 10_000),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`codex failed:\n${result.output}`);
|
||||
}
|
||||
};
|
||||
|
||||
const runOpenCodeTurn = async (args: {
|
||||
workspace: ActiveWorkspace;
|
||||
prompt: string;
|
||||
assistantMessageId: Id<'agentJobMessages'>;
|
||||
assistantContent: { value: string };
|
||||
}) => {
|
||||
const { workspace, prompt, assistantMessageId, assistantContent } = args;
|
||||
workspaceCurrentMessage.set(workspace.claim.job._id, assistantMessageId);
|
||||
workspaceCurrentContent.set(workspace.claim.job._id, assistantContent);
|
||||
const session = await ensureOpenCodeSession(workspace);
|
||||
const turnDone = new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
workspace.resolveTurn = undefined;
|
||||
reject(new Error('OpenCode turn timed out.'));
|
||||
}, env.jobTimeoutMs);
|
||||
workspace.resolveTurn = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
await promptOpenCodeSession({
|
||||
session,
|
||||
prompt,
|
||||
model: opencodeModel(workspace.claim),
|
||||
directory: '/workspace/repo',
|
||||
});
|
||||
await turnDone;
|
||||
};
|
||||
|
||||
const systemPromptForJob = (claim: Claim) => {
|
||||
const base = [
|
||||
`Spoon: ${claim.spoon.name}`,
|
||||
@@ -888,9 +1264,80 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => {
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const getWorkspaceAgentStatus = (jobId: string) => {
|
||||
const workspace = resolveWorkspace(jobId);
|
||||
return {
|
||||
runtimeMode: workspace.runtimeMode ?? 'legacy_cli',
|
||||
opencodeSessionId: workspace.opencodeSession?.sessionId,
|
||||
codexSessionId: workspace.codexSessionId,
|
||||
containerId: workspace.containerId,
|
||||
active: Boolean(workspace.agentTurnActive),
|
||||
};
|
||||
};
|
||||
|
||||
export const abortWorkspaceAgent = async (jobId: string) => {
|
||||
const workspace = resolveWorkspace(jobId);
|
||||
if (workspace.opencodeSession) {
|
||||
await abortOpenCodeSession(workspace.opencodeSession);
|
||||
workspace.agentTurnActive = false;
|
||||
workspace.resolveTurn?.();
|
||||
workspace.resolveTurn = undefined;
|
||||
await appendEvent(workspace.claim.job._id, 'warn', 'cleanup', 'Agent turn aborted.');
|
||||
return { success: true };
|
||||
}
|
||||
if (workspace.runtimeMode === 'codex_exec') {
|
||||
throw new Error('Codex agent turns cannot be aborted from Spoon yet.');
|
||||
}
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const replyToInteraction = async (
|
||||
jobId: string,
|
||||
args: {
|
||||
interactionId: Id<'agentInteractionRequests'>;
|
||||
externalRequestId: string;
|
||||
response: string;
|
||||
},
|
||||
) => {
|
||||
const workspace = resolveWorkspace(jobId);
|
||||
if (workspace.runtimeMode === 'codex_exec') {
|
||||
throw new Error('Codex interaction replies are not supported yet.');
|
||||
}
|
||||
if (!workspace.opencodeSession) {
|
||||
throw new Error('OpenCode session is not active.');
|
||||
}
|
||||
const mapped =
|
||||
args.response === 'reject'
|
||||
? 'reject'
|
||||
: args.response === 'always'
|
||||
? 'always'
|
||||
: 'once';
|
||||
await replyOpenCodePermission({
|
||||
session: workspace.opencodeSession,
|
||||
permissionId: args.externalRequestId,
|
||||
response: mapped,
|
||||
directory: '/workspace/repo',
|
||||
});
|
||||
await patchInteractionRequest({
|
||||
interactionId: args.interactionId,
|
||||
status: mapped === 'reject' ? 'rejected' : 'approved',
|
||||
response: mapped,
|
||||
});
|
||||
await appendMessage({
|
||||
jobId: workspace.claim.job._id,
|
||||
role: 'system',
|
||||
status: 'completed',
|
||||
content: `Interaction ${mapped === 'reject' ? 'rejected' : 'approved'}.`,
|
||||
});
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
|
||||
const workspace = resolveWorkspace(jobId);
|
||||
const { claim, repoDir, redact, workdir } = workspace;
|
||||
const { claim, redact } = workspace;
|
||||
if (workspace.agentTurnActive) {
|
||||
throw new Error('Wait for the current agent turn to finish or abort it.');
|
||||
}
|
||||
await appendMessage({
|
||||
jobId: claim.job._id,
|
||||
role: 'user',
|
||||
@@ -903,50 +1350,62 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
|
||||
if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
|
||||
throw new Error('Legacy OpenAI direct jobs are no longer supported.');
|
||||
}
|
||||
const aiEnv = providerEnvironment(
|
||||
claim,
|
||||
env.runtime === 'docker' ? jobContainerWorkspace : workdir,
|
||||
);
|
||||
workspace.agentTurnActive = true;
|
||||
const assistantMessageId = await appendMessage({
|
||||
jobId: claim.job._id,
|
||||
role: 'assistant',
|
||||
status: 'streaming',
|
||||
content: '',
|
||||
});
|
||||
const assistantContent = { value: '' };
|
||||
if (isCodexLoginProfile(claim)) {
|
||||
await runCodexTurn({
|
||||
workspace,
|
||||
prompt,
|
||||
assistantMessageId,
|
||||
assistantContent,
|
||||
});
|
||||
} else if (env.runtime === 'docker') {
|
||||
await runOpenCodeTurn({
|
||||
workspace,
|
||||
prompt,
|
||||
assistantMessageId,
|
||||
assistantContent,
|
||||
});
|
||||
} else {
|
||||
const aiEnv = providerEnvironment(claim);
|
||||
const secretEnv = Object.fromEntries(
|
||||
claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||
);
|
||||
const command = agentCommand(claim, prompt);
|
||||
const result =
|
||||
env.runtime === 'docker'
|
||||
? await runInJobContainer({
|
||||
workdir,
|
||||
command,
|
||||
environment: {
|
||||
...aiEnv,
|
||||
...secretEnv,
|
||||
},
|
||||
redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
})
|
||||
: await run(
|
||||
'bash',
|
||||
command.slice(1),
|
||||
{
|
||||
cwd: repoDir,
|
||||
const result = await run('bash', ['-lc', `opencode run --format json --model ${quoteShell(opencodeModel(claim))} ${quoteShell(prompt)}`], {
|
||||
cwd: workspace.repoDir,
|
||||
env: {
|
||||
...aiEnv,
|
||||
...secretEnv,
|
||||
},
|
||||
redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
},
|
||||
);
|
||||
await appendMessage({
|
||||
jobId: claim.job._id,
|
||||
role: 'assistant',
|
||||
});
|
||||
await updateMessage({
|
||||
messageId: assistantMessageId,
|
||||
status: result.exitCode === 0 ? 'completed' : 'failed',
|
||||
content: truncate(result.output, 40_000),
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`${agentFailurePrefix(claim)}:\n${result.output}`);
|
||||
}
|
||||
}
|
||||
if (isCodexLoginProfile(claim)) {
|
||||
await updateMessage({
|
||||
messageId: assistantMessageId,
|
||||
status: 'completed',
|
||||
content: assistantContent.value,
|
||||
});
|
||||
workspace.agentTurnActive = false;
|
||||
}
|
||||
workspace.agentTurnActive = false;
|
||||
if (claim.job.jobType === 'maintenance_review') {
|
||||
const decision = parseMaintenanceDecision(result.output);
|
||||
const decision = parseMaintenanceDecision(assistantContent.value);
|
||||
if (decision) {
|
||||
await addArtifact({
|
||||
jobId: claim.job._id,
|
||||
@@ -959,11 +1418,11 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
|
||||
} else {
|
||||
await updateStatus(claim.job._id, 'changes_ready', {
|
||||
summary:
|
||||
'OpenCode completed the review, but Spoon could not parse a structured maintenance decision.',
|
||||
'The agent completed the review, but Spoon could not parse a structured maintenance decision.',
|
||||
});
|
||||
}
|
||||
}
|
||||
const diff = await getWorktreeDiff(repoDir, redact);
|
||||
const diff = await getWorktreeDiff(workspace.repoDir, redact);
|
||||
await addArtifact({
|
||||
jobId: claim.job._id,
|
||||
kind: 'diff',
|
||||
@@ -979,6 +1438,9 @@ export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
|
||||
diff: truncate(diff.output, 50_000),
|
||||
});
|
||||
} catch (error) {
|
||||
workspace.agentTurnActive = false;
|
||||
workspace.resolveTurn?.();
|
||||
workspace.resolveTurn = undefined;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await appendEvent(
|
||||
claim.job._id,
|
||||
@@ -1059,6 +1521,10 @@ export const openWorkspacePullRequest = async (jobId: string) => {
|
||||
summary: 'Draft PR opened from interactive workspace.',
|
||||
});
|
||||
await markWorkspaceStopped(claim.job._id);
|
||||
workspace.opencodeSession?.close();
|
||||
if (workspace.containerName) {
|
||||
await stopWorkspaceContainer(workspace.containerName);
|
||||
}
|
||||
activeWorkspaces.delete(jobId);
|
||||
await rm(workspace.workdir, { recursive: true, force: true });
|
||||
return {
|
||||
@@ -1070,11 +1536,88 @@ export const openWorkspacePullRequest = async (jobId: string) => {
|
||||
export const stopWorkspace = async (jobId: string) => {
|
||||
const workspace = resolveWorkspace(jobId);
|
||||
await markWorkspaceStopped(workspace.claim.job._id);
|
||||
workspace.opencodeSession?.close();
|
||||
if (workspace.containerName) {
|
||||
await stopWorkspaceContainer(workspace.containerName);
|
||||
}
|
||||
activeWorkspaces.delete(jobId);
|
||||
await rm(workspace.workdir, { recursive: true, force: true });
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const getWorkerHealth = async () => {
|
||||
const active = [...activeWorkspaces.entries()].map(([jobId, workspace]) => ({
|
||||
jobId,
|
||||
runtimeMode: workspace.runtimeMode ?? 'legacy_cli',
|
||||
containerName: workspace.containerName,
|
||||
workdir: workspace.workdir,
|
||||
agentTurnActive: Boolean(workspace.agentTurnActive),
|
||||
}));
|
||||
const containerNames = await listWorkspaceContainerNames('spoon-agent-job-');
|
||||
return {
|
||||
ok: true,
|
||||
workerId: env.workerId,
|
||||
convexUrl: env.convexUrl,
|
||||
runtime: env.runtime,
|
||||
containerRuntime: env.containerRuntime,
|
||||
containerAccess: env.containerAccess,
|
||||
jobImage: env.jobImage,
|
||||
workdir: env.workdir,
|
||||
network: env.network,
|
||||
httpPort: env.httpPort,
|
||||
maxConcurrentJobs: env.maxConcurrentJobs,
|
||||
jobTimeoutMs: env.jobTimeoutMs,
|
||||
activeWorkspaceCount: active.length,
|
||||
activeWorkspaces: active,
|
||||
workspaceContainers: containerNames,
|
||||
};
|
||||
};
|
||||
|
||||
export const cleanupOrphanedWorkspaces = async () => {
|
||||
const activeContainers = new Set(
|
||||
[...activeWorkspaces.values()]
|
||||
.map((workspace) => workspace.containerName)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
);
|
||||
const activeWorkdirs = new Set(
|
||||
[...activeWorkspaces.values()].map((workspace) =>
|
||||
path.resolve(workspace.workdir),
|
||||
),
|
||||
);
|
||||
const removedContainers: string[] = [];
|
||||
for (const containerName of await listWorkspaceContainerNames(
|
||||
'spoon-agent-job-',
|
||||
)) {
|
||||
if (activeContainers.has(containerName)) continue;
|
||||
await stopWorkspaceContainer(containerName);
|
||||
removedContainers.push(containerName);
|
||||
}
|
||||
|
||||
const removedWorkdirs: string[] = [];
|
||||
const root = path.resolve(env.workdir);
|
||||
try {
|
||||
const entries = await readdir(root, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
||||
const target = path.resolve(root, entry.name);
|
||||
if (activeWorkdirs.has(target)) continue;
|
||||
await rm(target, { recursive: true, force: true });
|
||||
removedWorkdirs.push(target);
|
||||
}
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? 'code' in error : false;
|
||||
if (!code || (error as { code?: string }).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
removedContainers,
|
||||
removedWorkdirs,
|
||||
};
|
||||
};
|
||||
|
||||
export const startWorker = async () => {
|
||||
console.log(`Spoon agent worker ${env.workerId} polling ${env.convexUrl}`);
|
||||
for (;;) {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
normalizeCodexJsonLine,
|
||||
normalizeOpenCodeEvent,
|
||||
} from '../../src/agent-events';
|
||||
|
||||
describe('agent event normalization', () => {
|
||||
test('normalizes Codex assistant deltas and session ids', () => {
|
||||
expect(
|
||||
normalizeCodexJsonLine(
|
||||
JSON.stringify({
|
||||
type: 'session.created',
|
||||
session_id: 'codex-session-1',
|
||||
}),
|
||||
),
|
||||
).toContainEqual({ kind: 'session', sessionId: 'codex-session-1' });
|
||||
|
||||
expect(
|
||||
normalizeCodexJsonLine(
|
||||
JSON.stringify({
|
||||
type: 'response.output_text.delta',
|
||||
delta: 'hello',
|
||||
}),
|
||||
),
|
||||
).toContainEqual({ kind: 'assistant_delta', content: 'hello' });
|
||||
});
|
||||
|
||||
test('normalizes Codex command and file events', () => {
|
||||
expect(
|
||||
normalizeCodexJsonLine(
|
||||
JSON.stringify({
|
||||
type: 'command.completed',
|
||||
command: 'bun test',
|
||||
output: 'ok',
|
||||
}),
|
||||
),
|
||||
).toContainEqual({
|
||||
kind: 'command_executed',
|
||||
command: 'bun test',
|
||||
output: 'ok',
|
||||
});
|
||||
|
||||
expect(
|
||||
normalizeCodexJsonLine(
|
||||
JSON.stringify({
|
||||
type: 'file.edited',
|
||||
path: 'src/app.ts',
|
||||
}),
|
||||
),
|
||||
).toContainEqual({ kind: 'file_edited', path: 'src/app.ts' });
|
||||
});
|
||||
|
||||
test('normalizes OpenCode assistant, tool, and permission events', () => {
|
||||
expect(
|
||||
normalizeOpenCodeEvent({
|
||||
type: 'message.part.delta',
|
||||
properties: {
|
||||
part: { text: 'streamed' },
|
||||
messageID: 'message-1',
|
||||
},
|
||||
}),
|
||||
).toContainEqual({
|
||||
kind: 'assistant_delta',
|
||||
content: 'streamed',
|
||||
externalMessageId: 'message-1',
|
||||
});
|
||||
|
||||
expect(
|
||||
normalizeOpenCodeEvent({
|
||||
type: 'tool.started',
|
||||
properties: { tool: 'edit', input: { path: 'README.md' } },
|
||||
}),
|
||||
).toContainEqual({
|
||||
kind: 'tool_started',
|
||||
name: 'edit',
|
||||
input: '{\n "path": "README.md"\n}',
|
||||
externalMessageId: '',
|
||||
});
|
||||
|
||||
expect(
|
||||
normalizeOpenCodeEvent({
|
||||
type: 'permission.asked',
|
||||
properties: {
|
||||
permissionID: 'perm-1',
|
||||
message: 'Run bun test?',
|
||||
},
|
||||
}),
|
||||
).toContainEqual({
|
||||
kind: 'permission_requested',
|
||||
externalRequestId: 'perm-1',
|
||||
title: 'Permission requested',
|
||||
body: 'Run bun test?',
|
||||
metadata: '{\n "permissionID": "perm-1",\n "message": "Run bun test?"\n}',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,18 +11,20 @@ import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||
|
||||
const DashboardPage = () => {
|
||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
||||
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
|
||||
const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
|
||||
const activeSpoons = spoons.filter(
|
||||
(spoon) => spoon.status === 'active',
|
||||
).length;
|
||||
const behind = spoons.filter((spoon) => spoon.syncStatus === 'behind').length;
|
||||
const behind = spoons.filter(
|
||||
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy === 0,
|
||||
).length;
|
||||
const diverged = spoons.filter(
|
||||
(spoon) => spoon.syncStatus === 'diverged',
|
||||
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy > 0,
|
||||
).length;
|
||||
const openPullRequests = spoons.reduce(
|
||||
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
|
||||
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -70,7 +72,7 @@ const DashboardPage = () => {
|
||||
<MetricCard
|
||||
label='Upstream commits'
|
||||
value={openPullRequests}
|
||||
note='Waiting across Spoons'
|
||||
note='Actionable after ignores'
|
||||
icon={ShieldCheck}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Brain, Github, Shield, User } from 'lucide-react';
|
||||
import { Brain, Github, ServerCog, Shield, User } from 'lucide-react';
|
||||
|
||||
import { cn } from '@spoon/ui';
|
||||
|
||||
@@ -11,6 +11,7 @@ const settingsItems = [
|
||||
{ href: '/settings/profile', label: 'Profile', icon: User },
|
||||
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
|
||||
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
|
||||
{ href: '/settings/worker', label: 'Worker', icon: ServerCog },
|
||||
{ href: '/settings/security', label: 'Security', icon: Shield },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { WorkerHealthPanel } from '@/components/settings/worker-health-panel';
|
||||
|
||||
const WorkerSettingsPage = () => (
|
||||
<section className='max-w-5xl space-y-4'>
|
||||
<div>
|
||||
<h2 className='text-xl font-semibold'>Worker</h2>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Monitor the agent worker and clean up old workspace state.
|
||||
</p>
|
||||
</div>
|
||||
<WorkerHealthPanel />
|
||||
</section>
|
||||
);
|
||||
|
||||
export default WorkerSettingsPage;
|
||||
@@ -32,7 +32,7 @@ const formatDate = (value?: number) =>
|
||||
|
||||
const SpoonsPage = () => {
|
||||
const router = useRouter();
|
||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
||||
const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
|
||||
const active = spoons.filter((spoon) => spoon.status === 'active').length;
|
||||
const needsReview = threads.filter(
|
||||
@@ -41,7 +41,7 @@ const SpoonsPage = () => {
|
||||
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
|
||||
).length;
|
||||
const upstreamWaiting = spoons.reduce(
|
||||
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
|
||||
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -152,10 +152,16 @@ const SpoonsPage = () => {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className='text-sm'>
|
||||
<p>{spoon.upstreamAheadBy ?? 0} upstream</p>
|
||||
<p>{spoon.effectiveUpstreamAheadBy} actionable</p>
|
||||
<p className='text-muted-foreground'>
|
||||
{spoon.forkAheadBy ?? 0} fork-only
|
||||
{spoon.rawUpstreamAheadBy} raw upstream ·{' '}
|
||||
{spoon.forkAheadBy} fork-only
|
||||
</p>
|
||||
{spoon.ignoredUpstreamCount ? (
|
||||
<p className='text-muted-foreground'>
|
||||
{spoon.ignoredUpstreamCount} ignored
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='capitalize'>
|
||||
@@ -197,7 +203,8 @@ const SpoonsPage = () => {
|
||||
|
||||
{spoons.length ? (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Raw upstream commits waiting across all Spoons: {upstreamWaiting}
|
||||
Actionable upstream commits waiting across all Spoons:{' '}
|
||||
{upstreamWaiting}
|
||||
</p>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { ArrowUpRight, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { ArrowUpRight, CheckCircle2, Play, XCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
@@ -23,28 +24,88 @@ const ThreadDetailPage = () => {
|
||||
const threadId = params.threadId as Id<'threads'>;
|
||||
const details = useQuery(api.threads.get, { threadId });
|
||||
const messages = useQuery(api.threads.listMessages, { threadId }) ?? [];
|
||||
const appendMessage = useMutation(api.threads.appendUserMessage);
|
||||
const createJob = useMutation(api.agentJobs.createForThread);
|
||||
const markResolved = useMutation(api.threads.markResolved);
|
||||
const cancel = useMutation(api.threads.cancel);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [queueing, setQueueing] = useState(false);
|
||||
|
||||
if (details === undefined) {
|
||||
return <main className='text-muted-foreground p-6'>Loading thread...</main>;
|
||||
}
|
||||
|
||||
const { thread, spoon, latestJob } = details;
|
||||
const terminalThread = [
|
||||
'resolved',
|
||||
'ignored',
|
||||
'failed',
|
||||
'cancelled',
|
||||
].includes(thread.status);
|
||||
const activeJob =
|
||||
latestJob &&
|
||||
[
|
||||
'claimed',
|
||||
'preparing',
|
||||
'running',
|
||||
'checks_running',
|
||||
'changes_ready',
|
||||
].includes(latestJob.status) &&
|
||||
['active', 'idle'].includes(latestJob.workspaceStatus ?? '');
|
||||
const canQueueRun =
|
||||
spoon &&
|
||||
(!latestJob ||
|
||||
['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes(
|
||||
latestJob.status,
|
||||
) ||
|
||||
['stopped', 'expired', 'failed'].includes(
|
||||
latestJob.workspaceStatus ?? '',
|
||||
));
|
||||
const jobType =
|
||||
thread.source === 'merge_conflict'
|
||||
? ('conflict_resolution' as const)
|
||||
: thread.source === 'upstream_update'
|
||||
? ('maintenance_review' as const)
|
||||
: ('user_change' as const);
|
||||
|
||||
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const value = form.get('message');
|
||||
const content = typeof value === 'string' ? value : '';
|
||||
setSending(true);
|
||||
try {
|
||||
await appendMessage({ threadId, content });
|
||||
const response = await fetch(`/api/threads/${threadId}/message`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
recoverable?: boolean;
|
||||
} | null;
|
||||
throw new Error(payload?.error ?? (await response.text()));
|
||||
}
|
||||
event.currentTarget.reset();
|
||||
toast.success('Message added.');
|
||||
toast.success(activeJob ? 'Message sent to agent.' : 'Message added.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not add message.');
|
||||
toast.error('Could not send message.');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startRun = async () => {
|
||||
setQueueing(true);
|
||||
try {
|
||||
const jobId = await createJob({ threadId, jobType });
|
||||
toast.success('Workspace run queued.');
|
||||
window.location.href = `/spoons/${spoon?._id}/agent/${jobId}`;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not queue workspace run.');
|
||||
} finally {
|
||||
setQueueing(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,28 +160,40 @@ const ThreadDetailPage = () => {
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{canQueueRun ? (
|
||||
<Button disabled={queueing} onClick={() => void startRun()}>
|
||||
<Play className='size-4' />
|
||||
{latestJob ? 'Rerun' : 'Start workspace run'}
|
||||
</Button>
|
||||
) : null}
|
||||
{!terminalThread ? (
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
markResolved({ threadId }).then(() =>
|
||||
onClick={() => {
|
||||
if (!window.confirm('Mark this thread as resolved?')) return;
|
||||
void markResolved({ threadId }).then(() =>
|
||||
toast.success('Thread resolved.'),
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 className='size-4' />
|
||||
Resolve
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
cancel({ threadId }).then(() =>
|
||||
onClick={() => {
|
||||
if (!window.confirm('Cancel this thread?')) return;
|
||||
void cancel({ threadId }).then(() =>
|
||||
toast.success('Thread cancelled.'),
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<XCircle className='size-4' />
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,9 +222,28 @@ const ThreadDetailPage = () => {
|
||||
name='message'
|
||||
required
|
||||
minLength={2}
|
||||
placeholder='Add context or instructions for this thread.'
|
||||
placeholder={
|
||||
activeJob
|
||||
? 'Send instructions to the active agent workspace.'
|
||||
: 'Add context or instructions for the next run.'
|
||||
}
|
||||
disabled={sending || terminalThread}
|
||||
/>
|
||||
<Button type='submit'>Add message</Button>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Button type='submit' disabled={sending || terminalThread}>
|
||||
{sending
|
||||
? 'Sending...'
|
||||
: activeJob
|
||||
? 'Send to agent'
|
||||
: 'Add note'}
|
||||
</Button>
|
||||
{!activeJob ? (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
No active workspace is attached, so messages are saved as
|
||||
thread notes until a run is started.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,28 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { MessageSquare, Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
Textarea,
|
||||
} from '@spoon/ui';
|
||||
|
||||
const formatTime = (value: number) => new Date(value).toLocaleString();
|
||||
|
||||
const ThreadsPage = () => {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const source = params.get('source') ?? 'all';
|
||||
const status = params.get('status') ?? 'all';
|
||||
const [spoonFilter, setSpoonFilter] = useState('all');
|
||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
||||
const [outcomeFilter, setOutcomeFilter] = useState('all');
|
||||
const [spoonId, setSpoonId] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [materializeEnvFile, setMaterializeEnvFile] = useState(false);
|
||||
const [envFilePath, setEnvFilePath] = useState('.env.local');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const createThread = useMutation(api.threads.createUserThread);
|
||||
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
||||
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||
const defaultProfile = profiles.find((profile) => profile.isDefault);
|
||||
const threads =
|
||||
useQuery(api.threads.listMine, {
|
||||
source: source as
|
||||
@@ -32,8 +56,62 @@ const ThreadsPage = () => {
|
||||
| 'merge_conflict'
|
||||
| 'manual_review'
|
||||
| 'system',
|
||||
status: status as
|
||||
| 'all'
|
||||
| 'open'
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'waiting_for_user'
|
||||
| 'changes_ready'
|
||||
| 'draft_pr_opened'
|
||||
| 'resolved'
|
||||
| 'ignored'
|
||||
| 'failed'
|
||||
| 'cancelled',
|
||||
limit: 100,
|
||||
}) ?? [];
|
||||
const visibleThreads = threads.filter((thread) => {
|
||||
if (spoonFilter !== 'all' && thread.spoonId !== spoonFilter) return false;
|
||||
if (priorityFilter !== 'all' && thread.priority !== priorityFilter) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
outcomeFilter !== 'all' &&
|
||||
(thread.maintenanceOutcome ?? 'none') !== outcomeFilter
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const updateFilter = (key: string, value: string) => {
|
||||
const next = new URLSearchParams(params.toString());
|
||||
if (value === 'all') next.delete(key);
|
||||
else next.set(key, value);
|
||||
router.push(next.size ? `/threads?${next.toString()}` : '/threads');
|
||||
};
|
||||
|
||||
const submitThread = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!spoonId || !prompt.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const threadId = await createThread({
|
||||
spoonId: spoonId as Id<'spoons'>,
|
||||
title: title.trim() || undefined,
|
||||
prompt,
|
||||
materializeEnvFile,
|
||||
envFilePath,
|
||||
});
|
||||
toast.success('Thread created.');
|
||||
router.push(`/threads/${threadId}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not create thread.');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className='space-y-6'>
|
||||
@@ -46,20 +124,97 @@ const ThreadsPage = () => {
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href='/spoons'>
|
||||
<a href='#new-thread'>
|
||||
<Plus className='size-4' />
|
||||
New thread from Spoon
|
||||
</Link>
|
||||
New thread
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-3 md:flex-row'>
|
||||
<Card id='new-thread' className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>New thread</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={submitThread} className='grid gap-4 lg:grid-cols-2'>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Spoon</Label>
|
||||
<Select value={spoonId} onValueChange={setSpoonId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Choose a Spoon' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{spoons.map((spoon) => (
|
||||
<SelectItem key={spoon._id} value={spoon._id}>
|
||||
{spoon.name} · {spoon.upstreamOwner}/{spoon.upstreamRepo}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
value={title}
|
||||
placeholder='Optional'
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2 lg:col-span-2'>
|
||||
<Label>Prompt</Label>
|
||||
<Textarea
|
||||
value={prompt}
|
||||
placeholder='Describe the change, review, or maintenance task.'
|
||||
required
|
||||
minLength={4}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
|
||||
<div>
|
||||
<Label>Write Spoon secrets to env file</Label>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
All Spoon secrets are always available as process env.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={materializeEnvFile}
|
||||
onCheckedChange={setMaterializeEnvFile}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Env file path</Label>
|
||||
<Input
|
||||
value={envFilePath}
|
||||
onChange={(event) => setEnvFilePath(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='text-muted-foreground text-sm lg:col-span-2'>
|
||||
Provider:{' '}
|
||||
<span className='text-foreground font-medium'>
|
||||
{defaultProfile
|
||||
? `${defaultProfile.name} · ${defaultProfile.defaultModel}`
|
||||
: 'Configure an AI provider in Settings'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='lg:col-span-2'>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={
|
||||
creating || !spoonId || !prompt.trim() || !defaultProfile
|
||||
}
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create thread'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-5'>
|
||||
<Select
|
||||
value={source}
|
||||
onValueChange={(value) => {
|
||||
window.location.href =
|
||||
value === 'all' ? '/threads' : `/threads?source=${value}`;
|
||||
}}
|
||||
onValueChange={(value) => updateFilter('source', value)}
|
||||
>
|
||||
<SelectTrigger className='w-full md:w-56'>
|
||||
<SelectValue />
|
||||
@@ -73,21 +228,97 @@ const ThreadsPage = () => {
|
||||
<SelectItem value='system'>System</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(value) => updateFilter('status', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>All statuses</SelectItem>
|
||||
<SelectItem value='open'>Open</SelectItem>
|
||||
<SelectItem value='queued'>Queued</SelectItem>
|
||||
<SelectItem value='running'>Running</SelectItem>
|
||||
<SelectItem value='waiting_for_user'>Waiting</SelectItem>
|
||||
<SelectItem value='changes_ready'>Changes ready</SelectItem>
|
||||
<SelectItem value='draft_pr_opened'>Draft PR opened</SelectItem>
|
||||
<SelectItem value='resolved'>Resolved</SelectItem>
|
||||
<SelectItem value='ignored'>Ignored</SelectItem>
|
||||
<SelectItem value='failed'>Failed</SelectItem>
|
||||
<SelectItem value='cancelled'>Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={spoonFilter} onValueChange={setSpoonFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>All Spoons</SelectItem>
|
||||
{spoons.map((spoon) => (
|
||||
<SelectItem key={spoon._id} value={spoon._id}>
|
||||
{spoon.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>All priorities</SelectItem>
|
||||
<SelectItem value='low'>Low</SelectItem>
|
||||
<SelectItem value='normal'>Normal</SelectItem>
|
||||
<SelectItem value='high'>High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={outcomeFilter} onValueChange={setOutcomeFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='all'>All outcomes</SelectItem>
|
||||
<SelectItem value='none'>No outcome</SelectItem>
|
||||
<SelectItem value='auto_synced'>Auto synced</SelectItem>
|
||||
<SelectItem value='sync_recommended'>Sync recommended</SelectItem>
|
||||
<SelectItem value='ignored'>Ignored</SelectItem>
|
||||
<SelectItem value='review_pr_recommended'>Review PR</SelectItem>
|
||||
<SelectItem value='manual_review_required'>
|
||||
Manual review
|
||||
</SelectItem>
|
||||
<SelectItem value='conflict_resolution_required'>
|
||||
Conflict
|
||||
</SelectItem>
|
||||
<SelectItem value='failed'>Failed</SelectItem>
|
||||
<SelectItem value='unknown'>Unknown</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
{threads.length ? (
|
||||
threads.map((thread) => (
|
||||
<Link
|
||||
{visibleThreads.length ? (
|
||||
visibleThreads.map((thread) => (
|
||||
<Card
|
||||
key={thread._id}
|
||||
href={`/threads/${thread._id}`}
|
||||
className='block'
|
||||
role='link'
|
||||
tabIndex={0}
|
||||
className='hover:border-primary/50 cursor-pointer shadow-none transition-colors'
|
||||
onClick={() => router.push(`/threads/${thread._id}`)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
router.push(`/threads/${thread._id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Card className='hover:border-primary/50 shadow-none transition-colors'>
|
||||
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
|
||||
<div className='min-w-0'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<h2 className='truncate font-medium'>{thread.title}</h2>
|
||||
{thread.spoonName ? (
|
||||
<Badge variant='outline'>{thread.spoonName}</Badge>
|
||||
) : null}
|
||||
<Badge variant='outline'>
|
||||
{thread.source.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
@@ -106,10 +337,36 @@ const ThreadsPage = () => {
|
||||
<div className='text-muted-foreground text-xs md:text-right'>
|
||||
<p>{formatTime(thread.updatedAt)}</p>
|
||||
<p className='capitalize'>{thread.priority} priority</p>
|
||||
{thread.latestJobStatus ? (
|
||||
<p>{thread.latestJobStatus.replaceAll('_', ' ')}</p>
|
||||
) : null}
|
||||
<div className='mt-2 flex justify-start gap-2 md:justify-end'>
|
||||
{thread.latestAgentJobId ? (
|
||||
<Button size='sm' variant='outline' asChild>
|
||||
<Link
|
||||
href={`/spoons/${thread.spoonId}/agent/${thread.latestAgentJobId}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
Workspace
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{thread.latestJobPullRequestUrl ? (
|
||||
<Button size='sm' asChild>
|
||||
<a
|
||||
href={thread.latestJobPullRequestUrl}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
PR
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<Card className='shadow-none'>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
|
||||
|
||||
export const POST = async (
|
||||
_request: Request,
|
||||
context: { params: Promise<{ jobId: string }> },
|
||||
) =>
|
||||
await withOwnedJob(
|
||||
context,
|
||||
async (jobId) =>
|
||||
await proxyWorker(jobId, 'agent/abort', { method: 'POST' }),
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
|
||||
|
||||
export const GET = async (
|
||||
_request: Request,
|
||||
context: { params: Promise<{ jobId: string }> },
|
||||
) =>
|
||||
await withOwnedJob(
|
||||
context,
|
||||
async (jobId) =>
|
||||
await proxyWorker(jobId, 'agent/status', { method: 'GET' }),
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
proxyWorker,
|
||||
requireOwnedJob,
|
||||
routeJobId,
|
||||
} from '@/lib/agent-worker-proxy';
|
||||
|
||||
export const POST = async (
|
||||
request: Request,
|
||||
context: { params: Promise<{ jobId: string; interactionId: string }> },
|
||||
) => {
|
||||
const params = await context.params;
|
||||
const jobId = await routeJobId({ params });
|
||||
const owned = await requireOwnedJob(jobId);
|
||||
if (!owned.ok) return owned.response;
|
||||
return await proxyWorker(
|
||||
jobId,
|
||||
`interactions/${encodeURIComponent(params.interactionId)}/reply`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: await request.text(),
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import {
|
||||
proxyWorkerRoot,
|
||||
requireAuthenticatedUser,
|
||||
} from '@/lib/agent-worker-proxy';
|
||||
|
||||
export const POST = async () => {
|
||||
const authenticated = await requireAuthenticatedUser();
|
||||
if (!authenticated.ok) return authenticated.response;
|
||||
return await proxyWorkerRoot('/cleanup', { method: 'POST' });
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import {
|
||||
proxyWorkerRoot,
|
||||
requireAuthenticatedUser,
|
||||
} from '@/lib/agent-worker-proxy';
|
||||
|
||||
export const GET = async () => {
|
||||
const authenticated = await requireAuthenticatedUser();
|
||||
if (!authenticated.ok) return authenticated.response;
|
||||
return await proxyWorkerRoot('/health', { method: 'GET' });
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -1,23 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Send } from 'lucide-react';
|
||||
import { Ban, Send } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Button, Textarea } from '@spoon/ui';
|
||||
import { Badge, Button, Textarea } from '@spoon/ui';
|
||||
|
||||
export const AgentThread = ({
|
||||
jobId,
|
||||
messages,
|
||||
events,
|
||||
interactions,
|
||||
disabled,
|
||||
agentTurnActive,
|
||||
}: {
|
||||
jobId: string;
|
||||
messages: Doc<'agentJobMessages'>[];
|
||||
events: Doc<'agentJobEvents'>[];
|
||||
interactions: Doc<'agentInteractionRequests'>[];
|
||||
disabled: boolean;
|
||||
agentTurnActive: boolean;
|
||||
}) => {
|
||||
const [content, setContent] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [replying, setReplying] = useState<string>();
|
||||
|
||||
const send = async () => {
|
||||
if (!content.trim()) return;
|
||||
@@ -37,27 +44,141 @@ export const AgentThread = ({
|
||||
}
|
||||
};
|
||||
|
||||
const abort = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/agent/abort`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
toast.success('Agent turn aborted.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not abort agent.');
|
||||
}
|
||||
};
|
||||
|
||||
const reply = async (
|
||||
interaction: Doc<'agentInteractionRequests'>,
|
||||
responseValue: string,
|
||||
) => {
|
||||
setReplying(interaction._id);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/agent-jobs/${jobId}/interactions/${interaction._id}/reply`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
externalRequestId: interaction.externalRequestId,
|
||||
response: responseValue,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
toast.success('Response sent.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not answer interaction.');
|
||||
} finally {
|
||||
setReplying(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-[520px] flex-col'>
|
||||
<div className='border-border border-b p-3'>
|
||||
<div className='border-border flex items-start justify-between gap-3 border-b p-3'>
|
||||
<div>
|
||||
<h2 className='text-sm font-semibold'>Agent thread</h2>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Messages persist with this workspace.
|
||||
Messages, tool activity, and requests persist with this workspace.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={disabled || !agentTurnActive}
|
||||
onClick={abort}
|
||||
>
|
||||
<Ban className='size-3' />
|
||||
Abort
|
||||
</Button>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 space-y-3 overflow-auto p-3'>
|
||||
{interactions.map((interaction) => (
|
||||
<article
|
||||
key={interaction._id}
|
||||
className='border-primary/40 bg-primary/5 rounded-md border p-3 text-sm'
|
||||
>
|
||||
<div className='mb-2 flex items-center justify-between gap-2'>
|
||||
<span className='font-medium'>{interaction.title}</span>
|
||||
<Badge variant='outline' className='capitalize'>
|
||||
{interaction.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className='text-sm whitespace-pre-wrap'>{interaction.body}</p>
|
||||
{interaction.status === 'pending' ? (
|
||||
<div className='mt-3 flex gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
disabled={replying === interaction._id}
|
||||
onClick={() => void reply(interaction, 'once')}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
variant='outline'
|
||||
disabled={replying === interaction._id}
|
||||
onClick={() => void reply(interaction, 'reject')}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
{messages.map((message) => (
|
||||
<article
|
||||
key={message._id}
|
||||
className='border-border bg-background rounded-md border p-3 text-sm'
|
||||
className={
|
||||
message.role === 'user'
|
||||
? 'border-border bg-muted ml-6 rounded-md border p-3 text-sm'
|
||||
: message.status === 'failed'
|
||||
? 'border-destructive/40 bg-destructive/5 rounded-md border p-3 text-sm'
|
||||
: 'border-border bg-background rounded-md border p-3 text-sm'
|
||||
}
|
||||
>
|
||||
<div className='mb-2 flex items-center justify-between gap-2'>
|
||||
<span className='font-medium capitalize'>{message.role}</span>
|
||||
<span className='text-muted-foreground text-xs capitalize'>
|
||||
<Badge
|
||||
variant={
|
||||
message.status === 'failed' ? 'destructive' : 'outline'
|
||||
}
|
||||
className='capitalize'
|
||||
>
|
||||
{message.status}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<p className='whitespace-pre-wrap'>{message.content}</p>
|
||||
<p className='whitespace-pre-wrap'>
|
||||
{message.content ||
|
||||
(message.status === 'streaming' ? 'Working...' : '')}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
{events.slice(-20).map((event) => (
|
||||
<article
|
||||
key={event._id}
|
||||
className='border-border text-muted-foreground rounded-md border border-dashed p-2 text-xs'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<span className='font-medium capitalize'>
|
||||
{event.phase} / {event.level}
|
||||
</span>
|
||||
<span>{new Date(event.createdAt).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
<p className='mt-1 whitespace-pre-wrap'>{event.message}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
|
||||
import { Button, Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
|
||||
|
||||
import type { DiffResponse, FileResponse, FileTreeNode } from './types';
|
||||
import { AgentThread } from './agent-thread';
|
||||
import { CodeEditor } from './code-editor';
|
||||
import { CommandPanel } from './command-panel';
|
||||
import { DiffViewer } from './diff-viewer';
|
||||
import { FileTabs } from './file-tabs';
|
||||
import { FileTree } from './file-tree';
|
||||
import { JobStatusBar } from './job-status-bar';
|
||||
import { WorkspaceActions } from './workspace-actions';
|
||||
|
||||
type OpenFileState = {
|
||||
path: string;
|
||||
content: string;
|
||||
savedContent: string;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
const job = useQuery(api.agentJobs.get, { jobId });
|
||||
const messages =
|
||||
useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? [];
|
||||
const events =
|
||||
useQuery(api.agentJobs.listEvents, { jobId, limit: 200 }) ?? [];
|
||||
const interactions =
|
||||
useQuery(api.agentJobs.listInteractionRequests, {
|
||||
jobId,
|
||||
status: 'all',
|
||||
}) ?? [];
|
||||
const uiState = useQuery(api.agentJobs.getWorkspaceUiState, { jobId });
|
||||
const patchUiState = useMutation(api.agentJobs.patchWorkspaceUiState);
|
||||
const createJobForThread = useMutation(api.agentJobs.createForThread);
|
||||
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
|
||||
const markWorkspaceLost = useMutation(api.agentJobs.markWorkspaceLost);
|
||||
const [tree, setTree] = useState<FileTreeNode | null>(null);
|
||||
const [selectedPath, setSelectedPath] = useState<string>();
|
||||
const [fileContent, setFileContent] = useState('');
|
||||
const [files, setFiles] = useState<Record<string, OpenFileState>>({});
|
||||
const [openFilePaths, setOpenFilePaths] = useState<string[]>([]);
|
||||
const [activeFilePath, setActiveFilePath] = useState<string>();
|
||||
const [expandedDirectoryPaths, setExpandedDirectoryPaths] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [vimEnabled, setVimEnabled] = useState(false);
|
||||
const [hydratedUiState, setHydratedUiState] = useState(false);
|
||||
const [diff, setDiff] = useState('');
|
||||
const [workspaceError, setWorkspaceError] = useState<string>();
|
||||
const [agentTurnActive, setAgentTurnActive] = useState(false);
|
||||
|
||||
const workspaceDisabled =
|
||||
!job ||
|
||||
@@ -37,6 +67,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/tree`);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const data = (await response.json()) as { tree: FileTreeNode | null };
|
||||
setWorkspaceError(undefined);
|
||||
setTree(data.tree);
|
||||
}, [jobId]);
|
||||
|
||||
@@ -44,34 +75,151 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/diff`);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const data = (await response.json()) as DiffResponse;
|
||||
setWorkspaceError(undefined);
|
||||
setDiff(data.diff);
|
||||
}, [jobId]);
|
||||
|
||||
const loadAgentStatus = useCallback(async () => {
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/agent/status`);
|
||||
if (!response.ok) {
|
||||
setAgentTurnActive(false);
|
||||
return;
|
||||
}
|
||||
const data = (await response.json()) as { active?: boolean };
|
||||
setAgentTurnActive(Boolean(data.active));
|
||||
}, [jobId]);
|
||||
|
||||
const loadFile = useCallback(
|
||||
async (path: string) => {
|
||||
setFiles((current) => ({
|
||||
...current,
|
||||
[path]: current[path] ?? {
|
||||
path,
|
||||
content: '',
|
||||
savedContent: '',
|
||||
loading: true,
|
||||
saving: false,
|
||||
},
|
||||
}));
|
||||
const response = await fetch(
|
||||
`/api/agent-jobs/${jobId}/file?path=${encodeURIComponent(path)}`,
|
||||
);
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const data = (await response.json()) as FileResponse;
|
||||
setSelectedPath(data.path);
|
||||
setFileContent(data.content);
|
||||
setFiles((current) => ({
|
||||
...current,
|
||||
[data.path]: {
|
||||
path: data.path,
|
||||
content: data.content,
|
||||
savedContent: data.content,
|
||||
loading: false,
|
||||
saving: false,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[jobId],
|
||||
);
|
||||
|
||||
const openFile = useCallback(
|
||||
(path: string) => {
|
||||
setOpenFilePaths((current) =>
|
||||
current.includes(path) ? current : [...current, path],
|
||||
);
|
||||
setActiveFilePath(path);
|
||||
if (!files[path]) {
|
||||
void loadFile(path).catch((error) => {
|
||||
console.error(error);
|
||||
setFiles((current) => {
|
||||
const next = { ...current };
|
||||
delete next[path];
|
||||
return next;
|
||||
});
|
||||
setOpenFilePaths((current) =>
|
||||
current.filter((filePath) => filePath !== path),
|
||||
);
|
||||
toast.error('Could not load file.');
|
||||
});
|
||||
}
|
||||
},
|
||||
[files, loadFile],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!job) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
void loadTree().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
setWorkspaceError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
void loadDiff().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
setWorkspaceError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
void loadAgentStatus();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [job, loadDiff, loadTree]);
|
||||
}, [job, loadAgentStatus, loadDiff, loadTree]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
void loadAgentStatus();
|
||||
}, 5_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [loadAgentStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!uiState || hydratedUiState) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
setOpenFilePaths(uiState.openFilePaths);
|
||||
setActiveFilePath(uiState.activeFilePath);
|
||||
setExpandedDirectoryPaths(uiState.expandedDirectoryPaths);
|
||||
setVimEnabled(uiState.vimEnabled);
|
||||
setHydratedUiState(true);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [hydratedUiState, uiState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydratedUiState) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
void patchUiState({
|
||||
jobId,
|
||||
openFilePaths,
|
||||
activeFilePath,
|
||||
vimEnabled,
|
||||
expandedDirectoryPaths,
|
||||
}).catch((error: unknown) => {
|
||||
console.error(error);
|
||||
});
|
||||
}, 400);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [
|
||||
activeFilePath,
|
||||
expandedDirectoryPaths,
|
||||
hydratedUiState,
|
||||
jobId,
|
||||
openFilePaths,
|
||||
patchUiState,
|
||||
vimEnabled,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydratedUiState) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
for (const path of openFilePaths) {
|
||||
if (!files[path]) {
|
||||
void loadFile(path).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [files, hydratedUiState, loadFile, openFilePaths]);
|
||||
|
||||
if (job === undefined) {
|
||||
return (
|
||||
@@ -79,24 +227,136 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const activeFile = activeFilePath ? files[activeFilePath] : undefined;
|
||||
const recoverWorkspace = async () => {
|
||||
if (!job.threadId) return;
|
||||
const newJobId = await createJobForThread({
|
||||
threadId: job.threadId,
|
||||
jobType: job.jobType ?? 'user_change',
|
||||
});
|
||||
window.location.href = `/spoons/${job.spoonId}/agent/${newJobId}`;
|
||||
};
|
||||
|
||||
const deleteStaleWorkspace = async () => {
|
||||
if (!window.confirm('Delete this stale workspace record?')) return;
|
||||
await markWorkspaceLost({ jobId });
|
||||
await deleteWorkspace({ jobId });
|
||||
window.location.href = job.threadId
|
||||
? `/threads/${job.threadId}`
|
||||
: `/spoons/${job.spoonId}`;
|
||||
};
|
||||
|
||||
const saveFile = async (content: string) => {
|
||||
if (!selectedPath) return;
|
||||
if (!activeFilePath) return;
|
||||
setFiles((current) => ({
|
||||
...current,
|
||||
[activeFilePath]: {
|
||||
...(current[activeFilePath] ?? {
|
||||
path: activeFilePath,
|
||||
savedContent: '',
|
||||
loading: false,
|
||||
}),
|
||||
content,
|
||||
saving: true,
|
||||
},
|
||||
}));
|
||||
const response = await fetch(`/api/agent-jobs/${jobId}/file`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ path: selectedPath, content }),
|
||||
body: JSON.stringify({ path: activeFilePath, content }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
toast.error('Could not save file.');
|
||||
setFiles((current) => ({
|
||||
...current,
|
||||
[activeFilePath]: {
|
||||
...(current[activeFilePath] ?? {
|
||||
path: activeFilePath,
|
||||
content,
|
||||
savedContent: '',
|
||||
loading: false,
|
||||
}),
|
||||
saving: false,
|
||||
},
|
||||
}));
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
setFileContent(content);
|
||||
setFiles((current) => ({
|
||||
...current,
|
||||
[activeFilePath]: {
|
||||
...(current[activeFilePath] ?? {
|
||||
path: activeFilePath,
|
||||
loading: false,
|
||||
}),
|
||||
content,
|
||||
savedContent: content,
|
||||
saving: false,
|
||||
},
|
||||
}));
|
||||
await loadDiff();
|
||||
toast.success('File saved.');
|
||||
};
|
||||
|
||||
const closeFile = (path: string) => {
|
||||
const file = files[path];
|
||||
if (file && file.content !== file.savedContent) {
|
||||
const confirmed = window.confirm(
|
||||
`Close ${path} and discard unsaved changes?`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
const index = openFilePaths.indexOf(path);
|
||||
const nextOpen = openFilePaths.filter((filePath) => filePath !== path);
|
||||
setOpenFilePaths(nextOpen);
|
||||
setFiles((current) => {
|
||||
const next = { ...current };
|
||||
delete next[path];
|
||||
return next;
|
||||
});
|
||||
if (activeFilePath === path) {
|
||||
setActiveFilePath(nextOpen[index - 1] ?? nextOpen[index] ?? undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDirectory = (path: string) => {
|
||||
setExpandedDirectoryPaths((current) =>
|
||||
current.includes(path)
|
||||
? current.filter((directoryPath) => directoryPath !== path)
|
||||
: [...current, path],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'>
|
||||
<JobStatusBar job={job} />
|
||||
{workspaceError ? (
|
||||
<div className='border-border bg-background border-b p-4'>
|
||||
<div className='border-destructive/40 bg-destructive/5 rounded-md border p-4'>
|
||||
<p className='font-medium'>Workspace not active on this worker</p>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{workspaceError}
|
||||
</p>
|
||||
<div className='mt-3 flex flex-wrap gap-2'>
|
||||
{job.threadId ? (
|
||||
<Button type='button' onClick={() => void recoverWorkspace()}>
|
||||
Recreate workspace run
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => void deleteStaleWorkspace()}
|
||||
>
|
||||
Delete stale workspace
|
||||
</Button>
|
||||
{job.threadId ? (
|
||||
<Button type='button' variant='outline' asChild>
|
||||
<a href={`/threads/${job.threadId}`}>Open thread</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
|
||||
<WorkspaceActions job={job} disabled={workspaceDisabled} />
|
||||
</div>
|
||||
@@ -108,13 +368,10 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
</div>
|
||||
<FileTree
|
||||
tree={tree}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={(path) => {
|
||||
void loadFile(path).catch((error) => {
|
||||
console.error(error);
|
||||
toast.error('Could not load file.');
|
||||
});
|
||||
}}
|
||||
selectedPath={activeFilePath}
|
||||
expandedPaths={expandedDirectoryPaths}
|
||||
onSelect={openFile}
|
||||
onToggleDirectory={toggleDirectory}
|
||||
/>
|
||||
</aside>
|
||||
<section className='bg-background flex min-w-0 flex-col'>
|
||||
@@ -129,12 +386,44 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
Thread
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='editor' className='m-0 min-h-0 flex-1'>
|
||||
<TabsContent
|
||||
value='editor'
|
||||
className='m-0 flex min-h-0 flex-1 flex-col'
|
||||
>
|
||||
<FileTabs
|
||||
tabs={openFilePaths.map((path) => ({
|
||||
path,
|
||||
dirty: files[path]
|
||||
? files[path].content !== files[path].savedContent
|
||||
: false,
|
||||
}))}
|
||||
activePath={activeFilePath}
|
||||
onActivate={setActiveFilePath}
|
||||
onClose={closeFile}
|
||||
/>
|
||||
<CodeEditor
|
||||
path={selectedPath}
|
||||
content={fileContent}
|
||||
path={activeFilePath}
|
||||
content={activeFile?.content ?? ''}
|
||||
savedContent={activeFile?.savedContent ?? ''}
|
||||
readOnly={workspaceDisabled}
|
||||
vimEnabled={vimEnabled}
|
||||
onSave={saveFile}
|
||||
onVimEnabledChange={setVimEnabled}
|
||||
onChange={(content) => {
|
||||
if (!activeFilePath) return;
|
||||
setFiles((current) => ({
|
||||
...current,
|
||||
[activeFilePath]: {
|
||||
...(current[activeFilePath] ?? {
|
||||
path: activeFilePath,
|
||||
savedContent: '',
|
||||
loading: false,
|
||||
saving: false,
|
||||
}),
|
||||
content,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value='diff' className='m-0 min-h-0 flex-1'>
|
||||
@@ -147,7 +436,10 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
<AgentThread
|
||||
jobId={jobId}
|
||||
messages={messages}
|
||||
events={events}
|
||||
interactions={interactions}
|
||||
disabled={workspaceDisabled}
|
||||
agentTurnActive={agentTurnActive}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
@@ -157,7 +449,10 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||
<AgentThread
|
||||
jobId={jobId}
|
||||
messages={messages}
|
||||
events={events}
|
||||
interactions={interactions}
|
||||
disabled={workspaceDisabled}
|
||||
agentTurnActive={agentTurnActive}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@ import dynamic from 'next/dynamic';
|
||||
|
||||
import { Button, Switch } from '@spoon/ui';
|
||||
|
||||
import { languageForPath } from './languages';
|
||||
|
||||
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
||||
ssr: false,
|
||||
});
|
||||
@@ -20,27 +22,27 @@ type VimMode = {
|
||||
export const CodeEditor = ({
|
||||
path,
|
||||
content,
|
||||
savedContent,
|
||||
readOnly,
|
||||
vimEnabled,
|
||||
onSave,
|
||||
onChange,
|
||||
onVimEnabledChange,
|
||||
}: {
|
||||
path?: string;
|
||||
content: string;
|
||||
savedContent: string;
|
||||
readOnly: boolean;
|
||||
vimEnabled: boolean;
|
||||
onSave: (content: string) => Promise<void>;
|
||||
onChange: (content: string) => void;
|
||||
onVimEnabledChange: (enabled: boolean) => void;
|
||||
}) => {
|
||||
const [value, setValue] = useState(content);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [vimEnabled, setVimEnabled] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const editorRef = useRef<MonacoEditorInstance | null>(null);
|
||||
const vimRef = useRef<VimMode | null>(null);
|
||||
const statusRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(content);
|
||||
setDirty(false);
|
||||
}, [content, path]);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
@@ -71,13 +73,14 @@ export const CodeEditor = ({
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(value);
|
||||
setDirty(false);
|
||||
await onSave(content);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dirty = content !== savedContent;
|
||||
|
||||
return (
|
||||
<div className='flex h-full min-h-0 flex-col'>
|
||||
<div className='border-border flex h-11 items-center justify-between gap-3 border-b px-3'>
|
||||
@@ -90,7 +93,7 @@ export const CodeEditor = ({
|
||||
<div className='flex items-center gap-3'>
|
||||
<label className='flex items-center gap-2 text-xs'>
|
||||
Vim
|
||||
<Switch checked={vimEnabled} onCheckedChange={setVimEnabled} />
|
||||
<Switch checked={vimEnabled} onCheckedChange={onVimEnabledChange} />
|
||||
</label>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -107,7 +110,8 @@ export const CodeEditor = ({
|
||||
height='100%'
|
||||
width='100%'
|
||||
path={path}
|
||||
value={value}
|
||||
language={languageForPath(path)}
|
||||
value={content}
|
||||
theme='vs-dark'
|
||||
options={{
|
||||
readOnly,
|
||||
@@ -116,13 +120,20 @@ export const CodeEditor = ({
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
quickSuggestions: true,
|
||||
suggestOnTriggerCharacters: true,
|
||||
tabCompletion: 'on',
|
||||
wordBasedSuggestions: 'matchingDocuments',
|
||||
bracketPairColorization: { enabled: true },
|
||||
renderWhitespace: 'selection',
|
||||
}}
|
||||
onMount={(editor) => {
|
||||
editorRef.current = editor as MonacoEditorInstance;
|
||||
}}
|
||||
onChange={(next) => {
|
||||
setValue(next ?? '');
|
||||
setDirty((next ?? '') !== content);
|
||||
const nextValue = next ?? '';
|
||||
onChange(nextValue);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,18 +8,36 @@ const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const diffStats = (diff: string) => {
|
||||
const files = new Set<string>();
|
||||
let additions = 0;
|
||||
let removals = 0;
|
||||
for (const line of diff.split('\n')) {
|
||||
if (line.startsWith('diff --git ')) files.add(line);
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) additions += 1;
|
||||
if (line.startsWith('-') && !line.startsWith('---')) removals += 1;
|
||||
}
|
||||
return { files: files.size, additions, removals };
|
||||
};
|
||||
|
||||
export const DiffViewer = ({
|
||||
diff,
|
||||
onRefresh,
|
||||
}: {
|
||||
diff: string;
|
||||
onRefresh: () => Promise<void>;
|
||||
}) => (
|
||||
}) => {
|
||||
const stats = diffStats(diff);
|
||||
return (
|
||||
<div className='flex h-full min-h-0 flex-col'>
|
||||
<div className='border-border flex h-11 items-center justify-between border-b px-3'>
|
||||
<div>
|
||||
<div className='border-border flex h-12 items-center justify-between gap-3 border-b px-3'>
|
||||
<div className='min-w-0'>
|
||||
<p className='text-sm font-medium'>Workspace diff</p>
|
||||
<p className='text-muted-foreground text-xs'>Current git diff</p>
|
||||
<p className='text-muted-foreground truncate text-xs'>
|
||||
{diff.trim()
|
||||
? `${stats.files} files, +${stats.additions} -${stats.removals}`
|
||||
: 'Current git diff'}
|
||||
</p>
|
||||
</div>
|
||||
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
|
||||
Refresh
|
||||
@@ -38,6 +56,7 @@ export const DiffViewer = ({
|
||||
fontSize: 13,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -46,4 +65,5 @@ export const DiffViewer = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { Circle, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
import { basename } from './languages';
|
||||
|
||||
export type OpenFileTab = {
|
||||
path: string;
|
||||
dirty: boolean;
|
||||
};
|
||||
|
||||
export const FileTabs = ({
|
||||
tabs,
|
||||
activePath,
|
||||
onActivate,
|
||||
onClose,
|
||||
}: {
|
||||
tabs: OpenFileTab[];
|
||||
activePath?: string;
|
||||
onActivate: (path: string) => void;
|
||||
onClose: (path: string) => void;
|
||||
}) => {
|
||||
if (tabs.length === 0) return null;
|
||||
return (
|
||||
<div className='border-border bg-muted/30 flex h-10 flex-none items-stretch overflow-x-auto border-b'>
|
||||
{tabs.map((tab) => {
|
||||
const active = tab.path === activePath;
|
||||
return (
|
||||
<div
|
||||
key={tab.path}
|
||||
className={
|
||||
active
|
||||
? 'border-primary bg-background flex max-w-56 min-w-0 items-center border-t-2 border-r'
|
||||
: 'border-border flex max-w-56 min-w-0 items-center border-r'
|
||||
}
|
||||
title={tab.path}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-full min-w-0 flex-1 items-center gap-2 px-3 text-left text-xs'
|
||||
onClick={() => onActivate(tab.path)}
|
||||
>
|
||||
{tab.dirty ? (
|
||||
<Circle className='fill-primary text-primary size-2 flex-none' />
|
||||
) : null}
|
||||
<span className='truncate font-mono'>{basename(tab.path)}</span>
|
||||
</button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='mr-1 size-6 flex-none'
|
||||
aria-label={`Close ${tab.path}`}
|
||||
onClick={() => onClose(tab.path)}
|
||||
>
|
||||
<X className='size-3' />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronRight, FileCode, Folder } from 'lucide-react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileCode,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
@@ -9,38 +15,59 @@ import type { FileTreeNode } from './types';
|
||||
const TreeNode = ({
|
||||
node,
|
||||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelect,
|
||||
onToggle,
|
||||
depth = 0,
|
||||
}: {
|
||||
node: FileTreeNode;
|
||||
selectedPath?: string;
|
||||
expandedPaths: Set<string>;
|
||||
onSelect: (path: string) => void;
|
||||
onToggle: (path: string) => void;
|
||||
depth?: number;
|
||||
}) => {
|
||||
if (node.type === 'directory') {
|
||||
const isRoot = !node.path;
|
||||
const expanded = isRoot || expandedPaths.has(node.path);
|
||||
return (
|
||||
<div>
|
||||
{node.path ? (
|
||||
<div
|
||||
className='text-muted-foreground flex h-7 items-center gap-1 px-2 text-xs font-medium'
|
||||
{!isRoot ? (
|
||||
<button
|
||||
type='button'
|
||||
aria-expanded={expanded}
|
||||
className='text-muted-foreground hover:bg-muted flex h-7 w-full items-center gap-1 px-2 text-left text-xs font-medium'
|
||||
style={{ paddingLeft: depth * 12 + 8 }}
|
||||
onClick={() => onToggle(node.path)}
|
||||
>
|
||||
<ChevronRight className='size-3' />
|
||||
<Folder className='size-3' />
|
||||
{expanded ? (
|
||||
<ChevronDown className='size-3 flex-none' />
|
||||
) : (
|
||||
<ChevronRight className='size-3 flex-none' />
|
||||
)}
|
||||
{expanded ? (
|
||||
<FolderOpen className='size-3 flex-none' />
|
||||
) : (
|
||||
<Folder className='size-3 flex-none' />
|
||||
)}
|
||||
<span className='truncate'>{node.name}</span>
|
||||
</div>
|
||||
</button>
|
||||
) : null}
|
||||
{expanded ? (
|
||||
<div>
|
||||
{node.children?.map((child) => (
|
||||
<TreeNode
|
||||
key={`${child.type}:${child.path}`}
|
||||
node={child}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
onSelect={onSelect}
|
||||
onToggle={onToggle}
|
||||
depth={node.path ? depth + 1 : depth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,11 +89,15 @@ const TreeNode = ({
|
||||
export const FileTree = ({
|
||||
tree,
|
||||
selectedPath,
|
||||
expandedPaths,
|
||||
onSelect,
|
||||
onToggleDirectory,
|
||||
}: {
|
||||
tree: FileTreeNode | null;
|
||||
selectedPath?: string;
|
||||
expandedPaths: string[];
|
||||
onSelect: (path: string) => void;
|
||||
onToggleDirectory: (path: string) => void;
|
||||
}) => {
|
||||
if (!tree) {
|
||||
return (
|
||||
@@ -76,8 +107,14 @@ export const FileTree = ({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='overflow-auto py-2'>
|
||||
<TreeNode node={tree} selectedPath={selectedPath} onSelect={onSelect} />
|
||||
<div className='h-full overflow-auto py-2'>
|
||||
<TreeNode
|
||||
node={tree}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={new Set(expandedPaths)}
|
||||
onSelect={onSelect}
|
||||
onToggle={onToggleDirectory}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export const languageForPath = (path?: string) => {
|
||||
if (!path) return undefined;
|
||||
const name = path.toLowerCase().split('/').at(-1) ?? path.toLowerCase();
|
||||
if (name === '.env' || name.startsWith('.env.')) return 'plaintext';
|
||||
if (name.endsWith('.tsx') || name.endsWith('.ts')) return 'typescript';
|
||||
if (
|
||||
name.endsWith('.jsx') ||
|
||||
name.endsWith('.js') ||
|
||||
name.endsWith('.mjs') ||
|
||||
name.endsWith('.cjs')
|
||||
) {
|
||||
return 'javascript';
|
||||
}
|
||||
if (name.endsWith('.json')) return 'json';
|
||||
if (name.endsWith('.css')) return 'css';
|
||||
if (name.endsWith('.scss')) return 'scss';
|
||||
if (name.endsWith('.html')) return 'html';
|
||||
if (name.endsWith('.md') || name.endsWith('.mdx')) return 'markdown';
|
||||
if (name.endsWith('.yml') || name.endsWith('.yaml')) return 'yaml';
|
||||
if (name.endsWith('.sh') || name.endsWith('.bash')) return 'shell';
|
||||
if (name.endsWith('.py')) return 'python';
|
||||
if (name.endsWith('.rs')) return 'rust';
|
||||
if (name.endsWith('.go')) return 'go';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const basename = (path: string) => path.split('/').at(-1) ?? path;
|
||||
@@ -1,9 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { ExternalLink, GitPullRequestDraft, Square } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMutation } from 'convex/react';
|
||||
import {
|
||||
ExternalLink,
|
||||
GitPullRequestDraft,
|
||||
Square,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
export const WorkspaceActions = ({
|
||||
@@ -13,6 +21,12 @@ export const WorkspaceActions = ({
|
||||
job: Doc<'agentJobs'>;
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
|
||||
const canDelete =
|
||||
['failed', 'cancelled', 'timed_out'].includes(job.status) ||
|
||||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
|
||||
|
||||
const openPr = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/agent-jobs/${job._id}/open-pr`, {
|
||||
@@ -26,6 +40,24 @@ export const WorkspaceActions = ({
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
'Delete this workspace and its messages, events, artifacts, diffs, and UI state? This cannot be undone.',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteWorkspace({ jobId: job._id });
|
||||
toast.success('Workspace deleted.');
|
||||
router.push(`/spoons/${job.spoonId}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not delete workspace.');
|
||||
}
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/agent-jobs/${job._id}/stop`, {
|
||||
@@ -63,6 +95,12 @@ export const WorkspaceActions = ({
|
||||
<Square className='size-4' />
|
||||
Stop
|
||||
</Button>
|
||||
{canDelete ? (
|
||||
<Button type='button' variant='destructive' size='sm' onClick={remove}>
|
||||
<Trash2 className='size-4' />
|
||||
Delete workspace
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useMutation } from 'convex/react';
|
||||
import { ExternalLink, MonitorUp, XCircle } from 'lucide-react';
|
||||
import { ExternalLink, MonitorUp, Trash2, XCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
@@ -22,10 +22,17 @@ const formatTime = (value: number) =>
|
||||
|
||||
export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
|
||||
const cancel = useMutation(api.agentJobs.cancel);
|
||||
const deleteWorkspace = useMutation(api.agentJobs.deleteWorkspace);
|
||||
const [selectedJobId, setSelectedJobId] = useState<string | null>(
|
||||
jobs[0]?._id ?? null,
|
||||
);
|
||||
const selectedJob = jobs.find((job) => job._id === selectedJobId) ?? jobs[0];
|
||||
const selectedJobCanDelete = selectedJob
|
||||
? ['failed', 'cancelled', 'timed_out'].includes(selectedJob.status) ||
|
||||
['stopped', 'expired', 'failed'].includes(
|
||||
selectedJob.workspaceStatus ?? '',
|
||||
)
|
||||
: false;
|
||||
|
||||
if (!jobs.length) {
|
||||
return (
|
||||
@@ -110,6 +117,32 @@ export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
|
||||
Open workspace
|
||||
</Link>
|
||||
</Button>
|
||||
{selectedJobCanDelete ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
onClick={async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
'Delete this workspace and its messages, events, artifacts, diffs, and UI state? This cannot be undone.',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteWorkspace({ jobId: selectedJob._id });
|
||||
toast.success('Workspace deleted.');
|
||||
setSelectedJobId(null);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not delete workspace.');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
Delete workspace
|
||||
</Button>
|
||||
) : null}
|
||||
<AgentJobDetail job={selectedJob} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import type { ProviderModelOption } from '@/lib/models-dev';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { loadModelsDevOptions } from '@/lib/models-dev';
|
||||
import type { ProviderModelOption } from '@/lib/provider-model-options';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
modelOptionsFromIds,
|
||||
suggestedModelOptions,
|
||||
supportsCustomModelOptions,
|
||||
} from '@/lib/provider-model-options';
|
||||
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||
import { makeFunctionReference } from 'convex/server';
|
||||
import { KeyRound, Trash2 } from 'lucide-react';
|
||||
@@ -11,6 +15,7 @@ import { toast } from 'sonner';
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -50,6 +55,7 @@ const saveProfileRef = makeFunctionReference<
|
||||
secret?: string;
|
||||
baseUrl?: string;
|
||||
defaultModel: string;
|
||||
modelOptions?: string[];
|
||||
reasoningEffort: ReasoningEffort;
|
||||
enabled: boolean;
|
||||
},
|
||||
@@ -119,33 +125,24 @@ export const AiProviderProfilesPanel = () => {
|
||||
);
|
||||
const [secret, setSecret] = useState('');
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
const [defaultModelValue, setDefaultModelValue] = useState('');
|
||||
const [modelOptions, setModelOptions] = useState<ProviderModelOption[]>([]);
|
||||
const [defaultModelValue, setDefaultModelValue] = useState(
|
||||
suggestedModelOptions('openai')[0]?.id ?? '',
|
||||
);
|
||||
const [modelOptions, setModelOptions] = useState<ProviderModelOption[]>(
|
||||
suggestedModelOptions('openai'),
|
||||
);
|
||||
const [customModelId, setCustomModelId] = useState('');
|
||||
const [reasoningEffort, setReasoningEffort] =
|
||||
useState<ReasoningEffort>('medium');
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
loadModelsDevOptions(provider)
|
||||
.then((options) => {
|
||||
if (cancelled) return;
|
||||
const resetModelOptions = (nextProvider: Provider) => {
|
||||
const options = suggestedModelOptions(nextProvider);
|
||||
setModelOptions(options);
|
||||
setDefaultModelValue((current) =>
|
||||
current && options.some((option) => option.id === current)
|
||||
? current
|
||||
: (options[0]?.id ?? ''),
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error(error);
|
||||
if (!cancelled) setModelOptions([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
setDefaultModelValue(options[0]?.id ?? '');
|
||||
setCustomModelId('');
|
||||
};
|
||||
}, [provider]);
|
||||
|
||||
const reset = () => {
|
||||
setProfileId(undefined);
|
||||
@@ -153,6 +150,8 @@ export const AiProviderProfilesPanel = () => {
|
||||
setSecret('');
|
||||
setBaseUrl('');
|
||||
setDefaultModelValue('');
|
||||
setModelOptions(suggestedModelOptions('openai'));
|
||||
setCustomModelId('');
|
||||
setReasoningEffort('medium');
|
||||
setEnabled(true);
|
||||
setName('OpenAI');
|
||||
@@ -165,6 +164,14 @@ export const AiProviderProfilesPanel = () => {
|
||||
setSecret('');
|
||||
setBaseUrl(profile.baseUrl ?? '');
|
||||
setDefaultModelValue(profile.defaultModel);
|
||||
setModelOptions(
|
||||
modelOptionsFromIds(
|
||||
profile.modelOptions?.length
|
||||
? profile.modelOptions
|
||||
: [profile.defaultModel],
|
||||
),
|
||||
);
|
||||
setCustomModelId('');
|
||||
setReasoningEffort(profile.reasoningEffort as ReasoningEffort);
|
||||
setEnabled(profile.enabled);
|
||||
};
|
||||
@@ -181,6 +188,7 @@ export const AiProviderProfilesPanel = () => {
|
||||
secret: secret.trim() ? secret : undefined,
|
||||
baseUrl: baseUrl.trim() || undefined,
|
||||
defaultModel: defaultModelValue,
|
||||
modelOptions: modelOptions.map((model) => model.id),
|
||||
reasoningEffort,
|
||||
enabled,
|
||||
});
|
||||
@@ -310,6 +318,7 @@ export const AiProviderProfilesPanel = () => {
|
||||
onValueChange={(value) => {
|
||||
const nextProvider = value as Provider;
|
||||
setProvider(nextProvider);
|
||||
resetModelOptions(nextProvider);
|
||||
setName(
|
||||
providerOptions
|
||||
.find((option) => option.value === nextProvider)
|
||||
@@ -397,9 +406,47 @@ export const AiProviderProfilesPanel = () => {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Models are loaded from Models.dev, the catalog OpenCode uses
|
||||
for provider/model metadata.
|
||||
Saved model options are used by Spoons. Add custom model IDs
|
||||
for compatible provider gateways.
|
||||
</p>
|
||||
<div className='rounded-md border p-2'>
|
||||
<p className='text-muted-foreground mb-2 text-xs'>
|
||||
Available model options
|
||||
</p>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{modelOptions.map((model) => (
|
||||
<Badge key={model.id} variant='outline'>
|
||||
{model.id}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{supportsCustomModelOptions(provider) ? (
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
value={customModelId}
|
||||
placeholder='provider/model-id'
|
||||
onChange={(event) => setCustomModelId(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
const id = customModelId.trim();
|
||||
if (!id) return;
|
||||
setModelOptions((current) =>
|
||||
current.some((model) => model.id === id)
|
||||
? current
|
||||
: [...current, ...modelOptionsFromIds([id])],
|
||||
);
|
||||
setDefaultModelValue((current) => current || id);
|
||||
setCustomModelId('');
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Thinking</Label>
|
||||
|
||||
@@ -75,7 +75,7 @@ const features = [
|
||||
{
|
||||
title: 'Provider-owned AI',
|
||||
description:
|
||||
'Use encrypted provider profiles, OpenCode auth, or user-owned API keys rather than a shared application key.',
|
||||
'Use encrypted provider profiles: API-key providers run through OpenCode, and Codex login profiles run through the Codex CLI.',
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
@@ -119,7 +119,7 @@ const ownership = [
|
||||
{
|
||||
title: 'Your providers',
|
||||
description:
|
||||
'AI provider profiles and Codex/OpenCode auth stay encrypted and selected by you.',
|
||||
'AI provider profiles, API keys, and Codex auth JSON stay encrypted and selected by you.',
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -63,6 +63,14 @@ export default function Footer() {
|
||||
Integrations
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href='/settings/worker'
|
||||
className='text-muted-foreground hover:text-foreground transition-colors'
|
||||
>
|
||||
Worker
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href='https://git.gbrown.org/gib/spoon'
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { Copy, RefreshCw, Trash2, Wrench } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
} from '@spoon/ui';
|
||||
|
||||
type WorkerHealth = {
|
||||
ok: boolean;
|
||||
workerId: string;
|
||||
convexUrl: string;
|
||||
runtime: string;
|
||||
containerRuntime: string;
|
||||
containerAccess: string;
|
||||
jobImage: string;
|
||||
workdir: string;
|
||||
network?: string;
|
||||
httpPort: number;
|
||||
activeWorkspaceCount: number;
|
||||
workspaceContainers: string[];
|
||||
};
|
||||
|
||||
type CleanupResult = {
|
||||
removedContainers: string[];
|
||||
removedWorkdirs: string[];
|
||||
};
|
||||
|
||||
export const WorkerHealthPanel = () => {
|
||||
const [health, setHealth] = useState<WorkerHealth | null>(null);
|
||||
const [healthError, setHealthError] = useState<string>();
|
||||
const [loadingHealth, setLoadingHealth] = useState(false);
|
||||
const [cleaning, setCleaning] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [olderThanDays, setOlderThanDays] = useState(7);
|
||||
const deletableCount =
|
||||
useQuery(api.agentJobs.countOldWorkspaces, { olderThanDays }) ?? 0;
|
||||
const deleteOldWorkspaces = useMutation(api.agentJobs.deleteOldWorkspaces);
|
||||
|
||||
const copy = async (value: string) => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success('Copied.');
|
||||
};
|
||||
|
||||
const DiagnosticValue = ({ value }: { value: string }) => (
|
||||
<dd className='flex items-center gap-2 font-mono break-all'>
|
||||
<span>{value}</span>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => void copy(value)}
|
||||
>
|
||||
<Copy className='size-3' />
|
||||
</Button>
|
||||
</dd>
|
||||
);
|
||||
|
||||
const refreshHealth = async () => {
|
||||
setLoadingHealth(true);
|
||||
setHealthError(undefined);
|
||||
try {
|
||||
const response = await fetch('/api/agent-worker/health');
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
setHealth((await response.json()) as WorkerHealth);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setHealthError(message);
|
||||
setHealth(null);
|
||||
} finally {
|
||||
setLoadingHealth(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refreshHealth();
|
||||
}, []);
|
||||
|
||||
const cleanupOrphans = async () => {
|
||||
setCleaning(true);
|
||||
try {
|
||||
const response = await fetch('/api/agent-worker/cleanup', {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const result = (await response.json()) as CleanupResult;
|
||||
toast.success(
|
||||
`Cleaned ${result.removedContainers.length} containers and ${result.removedWorkdirs.length} workdirs.`,
|
||||
);
|
||||
await refreshHealth();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not clean worker resources.');
|
||||
} finally {
|
||||
setCleaning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteOld = async () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Delete up to 100 stopped, cancelled, failed, or expired workspaces older than ${olderThanDays} days?`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setDeleting(true);
|
||||
try {
|
||||
const result = await deleteOldWorkspaces({
|
||||
olderThanDays,
|
||||
limit: 100,
|
||||
});
|
||||
toast.success(`Deleted ${result.deleted} workspaces.`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not delete old workspaces.');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader className='flex flex-row items-start justify-between gap-4'>
|
||||
<div>
|
||||
<CardTitle>Worker health</CardTitle>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Runtime status for the server-side agent worker.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={loadingHealth}
|
||||
onClick={() => void refreshHealth()}
|
||||
>
|
||||
<RefreshCw className='size-4' />
|
||||
Refresh
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{healthError ? (
|
||||
<div className='border-destructive/40 bg-destructive/10 text-destructive rounded-md border p-3 text-sm'>
|
||||
{healthError}
|
||||
</div>
|
||||
) : null}
|
||||
{health ? (
|
||||
<>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Badge variant={health.ok ? 'secondary' : 'destructive'}>
|
||||
{health.ok ? 'healthy' : 'unhealthy'}
|
||||
</Badge>
|
||||
<Badge variant='outline'>{health.workerId}</Badge>
|
||||
<Badge variant='outline'>
|
||||
{health.containerRuntime} / {health.containerAccess}
|
||||
</Badge>
|
||||
</div>
|
||||
<dl className='grid gap-3 text-sm md:grid-cols-2'>
|
||||
<div>
|
||||
<dt className='text-muted-foreground'>Convex</dt>
|
||||
<DiagnosticValue value={health.convexUrl} />
|
||||
</div>
|
||||
<div>
|
||||
<dt className='text-muted-foreground'>Job image</dt>
|
||||
<DiagnosticValue value={health.jobImage} />
|
||||
</div>
|
||||
<div>
|
||||
<dt className='text-muted-foreground'>Workdir</dt>
|
||||
<DiagnosticValue value={health.workdir} />
|
||||
</div>
|
||||
<div>
|
||||
<dt className='text-muted-foreground'>Network</dt>
|
||||
<dd className='font-mono break-all'>
|
||||
{health.network ?? 'none'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className='text-muted-foreground'>HTTP port</dt>
|
||||
<dd>{health.httpPort}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className='text-muted-foreground'>Active workspaces</dt>
|
||||
<dd>{health.activeWorkspaceCount}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Workspace containers
|
||||
</p>
|
||||
<p className='mt-1 font-mono text-sm'>
|
||||
{health.workspaceContainers.length
|
||||
? health.workspaceContainers.join(', ')
|
||||
: 'none'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : !healthError ? (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{loadingHealth ? 'Checking worker...' : 'No worker response yet.'}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle>Cleanup</CardTitle>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Remove stopped workspace records and orphaned local worker
|
||||
resources.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='grid gap-3 md:grid-cols-[12rem_1fr_auto] md:items-end'>
|
||||
<label className='space-y-1'>
|
||||
<span className='text-sm font-medium'>Older than days</span>
|
||||
<Input
|
||||
type='number'
|
||||
min={0}
|
||||
value={olderThanDays}
|
||||
onChange={(event) =>
|
||||
setOlderThanDays(
|
||||
Math.max(Number.parseInt(event.target.value, 10) || 0, 0),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{deletableCount} stopped, cancelled, failed, timed out, or expired
|
||||
workspaces match this age filter.
|
||||
</p>
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
disabled={deleting || deletableCount === 0}
|
||||
onClick={() => void deleteOld()}
|
||||
>
|
||||
<Trash2 className='size-4' />
|
||||
Delete old
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='border-border flex flex-col justify-between gap-3 rounded-md border p-3 md:flex-row md:items-center'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>Orphaned worker resources</p>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Remove inactive Spoon job containers and inactive directories
|
||||
under the configured worker workdir.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={cleaning}
|
||||
onClick={() => void cleanupOrphans()}
|
||||
>
|
||||
<Wrench className='size-4' />
|
||||
Clean orphans
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type { ProviderModelOption } from '@/lib/models-dev';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { loadModelsDevOptions } from '@/lib/models-dev';
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -53,6 +51,7 @@ export const SpoonAgentSettingsForm = ({
|
||||
}) => {
|
||||
const update = useMutation(api.spoonAgentSettings.update);
|
||||
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||
const modelCatalog = useQuery(api.aiProviderModels.listAvailableForUser);
|
||||
const configuredProfiles = profiles.filter(
|
||||
(profile) => profile.enabled && profile.configured,
|
||||
);
|
||||
@@ -99,8 +98,12 @@ export const SpoonAgentSettingsForm = ({
|
||||
? defaultProfile?._id
|
||||
: aiProviderProfileId),
|
||||
);
|
||||
const [availableModels, setAvailableModels] = useState<ProviderModelOption[]>(
|
||||
[],
|
||||
const selectedModelProfile = modelCatalog?.profiles.find(
|
||||
(profile) =>
|
||||
profile.profileId ===
|
||||
(aiProviderProfileId === '__default'
|
||||
? defaultProfile?._id
|
||||
: aiProviderProfileId),
|
||||
);
|
||||
const [agentModel, setAgentModel] = useState(
|
||||
settings?.aiProviderProfileId ? settings.agentModel : '',
|
||||
@@ -115,42 +118,17 @@ export const SpoonAgentSettingsForm = ({
|
||||
: settings.reasoningEffort,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProfile?.configured) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
loadModelsDevOptions(selectedProfile.provider)
|
||||
.then((models) => {
|
||||
if (cancelled) return;
|
||||
setAvailableModels(models);
|
||||
setAgentModel((current) =>
|
||||
current && models.some((model) => model.id === current)
|
||||
? current
|
||||
: models.some((model) => model.id === selectedProfile.defaultModel)
|
||||
? selectedProfile.defaultModel
|
||||
: (models[0]?.id ?? ''),
|
||||
);
|
||||
setReasoningEffort(
|
||||
selectedProfile.reasoningEffort === 'none'
|
||||
? 'minimal'
|
||||
: selectedProfile.reasoningEffort,
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error(error);
|
||||
if (!cancelled) setAvailableModels([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
selectedProfile?.configured,
|
||||
selectedProfile?.defaultModel,
|
||||
selectedProfile?.provider,
|
||||
selectedProfile?.reasoningEffort,
|
||||
]);
|
||||
const selectableModels = selectedProfile?.configured ? availableModels : [];
|
||||
const selectableModels = selectedModelProfile?.configured
|
||||
? selectedModelProfile.models
|
||||
: [];
|
||||
const selectedAgentModel =
|
||||
agentModel && selectableModels.some((model) => model.id === agentModel)
|
||||
? agentModel
|
||||
: selectableModels.some(
|
||||
(model) => model.id === selectedModelProfile?.defaultModel,
|
||||
)
|
||||
? (selectedModelProfile?.defaultModel ?? '')
|
||||
: (selectableModels[0]?.id ?? '');
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
@@ -163,9 +141,7 @@ export const SpoonAgentSettingsForm = ({
|
||||
installCommand: installCommand || undefined,
|
||||
checkCommand: checkCommand || undefined,
|
||||
testCommand: testCommand || undefined,
|
||||
agentModel: agentModel.trim()
|
||||
? agentModel
|
||||
: (selectableModels[0]?.id ?? undefined),
|
||||
agentModel: selectedAgentModel || undefined,
|
||||
reasoningEffort,
|
||||
envFilePath: envFilePath as
|
||||
| '.env'
|
||||
@@ -249,7 +225,8 @@ export const SpoonAgentSettingsForm = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
OpenCode jobs and maintenance review threads use this profile.
|
||||
Workspaces use this profile. Use default resolves to your account
|
||||
default provider.
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
@@ -271,7 +248,7 @@ export const SpoonAgentSettingsForm = ({
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='agentModel'>Model</Label>
|
||||
<Select
|
||||
value={agentModel}
|
||||
value={selectedAgentModel}
|
||||
onValueChange={setAgentModel}
|
||||
disabled={!selectableModels.length}
|
||||
>
|
||||
@@ -288,8 +265,8 @@ export const SpoonAgentSettingsForm = ({
|
||||
</Select>
|
||||
{!selectableModels.length ? (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Configure an enabled AI provider profile in Settings before
|
||||
choosing a model.
|
||||
Configure an enabled AI provider profile with saved model
|
||||
options in Settings before choosing a model.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -423,7 +400,7 @@ export const SpoonAgentSettingsForm = ({
|
||||
onClick={save}
|
||||
disabled={
|
||||
!selectedProfile?.configured ||
|
||||
!selectableModels.some((model) => model.id === agentModel)
|
||||
!selectableModels.some((model) => model.id === selectedAgentModel)
|
||||
}
|
||||
>
|
||||
Save agent settings
|
||||
|
||||
@@ -9,7 +9,13 @@ const formatDate = (value?: number) =>
|
||||
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
|
||||
: 'Never';
|
||||
|
||||
export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
|
||||
type SpoonCardData = Doc<'spoons'> & {
|
||||
rawUpstreamAheadBy?: number;
|
||||
effectiveUpstreamAheadBy?: number;
|
||||
ignoredUpstreamCount?: number;
|
||||
};
|
||||
|
||||
export const SpoonCard = ({ spoon }: { spoon: SpoonCardData }) => (
|
||||
<Link href={`/spoons/${spoon._id}`} className='group/spoon-card block'>
|
||||
<Card className='group-hover/spoon-card:border-primary/50 group-hover/spoon-card:bg-muted/20 shadow-none transition-colors'>
|
||||
<CardHeader className='flex-row items-start justify-between gap-4'>
|
||||
@@ -45,8 +51,15 @@ export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
|
||||
<p className='font-medium'>{formatDate(spoon.lastCheckedAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Upstream waiting</p>
|
||||
<p className='font-medium'>{spoon.upstreamAheadBy ?? 0}</p>
|
||||
<p className='text-muted-foreground'>Actionable upstream</p>
|
||||
<p className='font-medium'>
|
||||
{spoon.effectiveUpstreamAheadBy ?? spoon.upstreamAheadBy ?? 0}
|
||||
</p>
|
||||
{spoon.ignoredUpstreamCount ? (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{spoon.ignoredUpstreamCount} ignored
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Fork-only commits</p>
|
||||
|
||||
@@ -32,6 +32,45 @@ export const requireOwnedJob = async (jobId: Id<'agentJobs'>) => {
|
||||
return { ok: true as const };
|
||||
};
|
||||
|
||||
export const requireAuthenticatedUser = async () => {
|
||||
const token = await convexAuthNextjsToken();
|
||||
if (!token) {
|
||||
return {
|
||||
ok: false as const,
|
||||
response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
};
|
||||
}
|
||||
await fetchQuery(api.auth.getUser, {}, { token });
|
||||
return { ok: true as const };
|
||||
};
|
||||
|
||||
export const proxyWorkerRoot = async (path: string, init?: RequestInit) => {
|
||||
const token = workerToken();
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'SPOON_AGENT_WORKER_INTERNAL_TOKEN is not configured.' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
const url = new URL(path, env.SPOON_AGENT_WORKER_URL);
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
const text = await response.text();
|
||||
return new NextResponse(text, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'content-type':
|
||||
response.headers.get('content-type') ?? 'application/json',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const proxyWorker = async (
|
||||
jobId: Id<'agentJobs'>,
|
||||
action: string,
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
basename,
|
||||
languageForPath,
|
||||
} from '../../src/components/agent-workspace/languages';
|
||||
|
||||
describe('workspace language helpers', () => {
|
||||
it('maps common code file extensions to Monaco languages', () => {
|
||||
expect(languageForPath('src/app.ts')).toBe('typescript');
|
||||
expect(languageForPath('src/app.tsx')).toBe('typescript');
|
||||
expect(languageForPath('src/app.js')).toBe('javascript');
|
||||
expect(languageForPath('package.json')).toBe('json');
|
||||
expect(languageForPath('README.md')).toBe('markdown');
|
||||
expect(languageForPath('.env.local')).toBe('plaintext');
|
||||
});
|
||||
|
||||
it('lets Monaco fall back for unknown paths', () => {
|
||||
expect(languageForPath('Gemfile')).toBeUndefined();
|
||||
expect(languageForPath()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns a useful basename for file tabs', () => {
|
||||
expect(basename('src/components/button.tsx')).toBe('button.tsx');
|
||||
expect(basename('README.md')).toBe('README.md');
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,8 @@ RUN apt-get update \
|
||||
python3 \
|
||||
ripgrep \
|
||||
&& corepack enable \
|
||||
&& corepack prepare pnpm@latest --activate \
|
||||
&& corepack prepare yarn@stable --activate \
|
||||
&& npm install -g bun@1.3.10 opencode-ai@latest @openai/codex@latest \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -71,6 +71,8 @@ services:
|
||||
- SPOON_AGENT_WORKER_ID=${SPOON_AGENT_WORKER_ID:-local-worker}
|
||||
- SPOON_AGENT_JOB_IMAGE=${SPOON_AGENT_JOB_IMAGE:-spoon-agent-job:latest}
|
||||
- SPOON_AGENT_RUNTIME=${SPOON_AGENT_RUNTIME:-docker}
|
||||
- SPOON_AGENT_CONTAINER_RUNTIME=${SPOON_AGENT_CONTAINER_RUNTIME:-docker}
|
||||
- SPOON_AGENT_CONTAINER_ACCESS=${SPOON_AGENT_CONTAINER_ACCESS:-network}
|
||||
- SPOON_AGENT_NETWORK=${SPOON_AGENT_NETWORK:-spoon-local_default}
|
||||
- SPOON_AGENT_MAX_CONCURRENT_JOBS=${SPOON_AGENT_MAX_CONCURRENT_JOBS:-1}
|
||||
- SPOON_AGENT_JOB_TIMEOUT_MS=${SPOON_AGENT_JOB_TIMEOUT_MS:-1800000}
|
||||
|
||||
@@ -102,6 +102,8 @@ services:
|
||||
- SPOON_AGENT_WORKER_ID=${SPOON_AGENT_WORKER_ID:-production-worker}
|
||||
- SPOON_AGENT_JOB_IMAGE=${SPOON_AGENT_JOB_IMAGE:-spoon-agent-job:latest}
|
||||
- SPOON_AGENT_RUNTIME=${SPOON_AGENT_RUNTIME:-docker}
|
||||
- SPOON_AGENT_CONTAINER_RUNTIME=${SPOON_AGENT_CONTAINER_RUNTIME:-docker}
|
||||
- SPOON_AGENT_CONTAINER_ACCESS=${SPOON_AGENT_CONTAINER_ACCESS:-network}
|
||||
- SPOON_AGENT_NETWORK=${SPOON_AGENT_NETWORK:-nginx-bridge}
|
||||
- SPOON_AGENT_MAX_CONCURRENT_JOBS=${SPOON_AGENT_MAX_CONCURRENT_JOBS:-1}
|
||||
- SPOON_AGENT_JOB_TIMEOUT_MS=${SPOON_AGENT_JOB_TIMEOUT_MS:-1800000}
|
||||
|
||||
+5
-2
@@ -53,8 +53,10 @@
|
||||
"dev:tunnel": "turbo run dev:tunnel",
|
||||
"dev:next": "turbo run dev -F @spoon/next -F @spoon/backend",
|
||||
"dev:next:staging": "INFISICAL_ENV=staging turbo run dev -F @spoon/next -F @spoon/backend",
|
||||
"dev:agent": "turbo run dev -F @spoon/agent-worker",
|
||||
"dev:agent:staging": "INFISICAL_ENV=staging turbo run dev -F @spoon/agent-worker",
|
||||
"dev:agent": "SPOON_AGENT_WORKER_URL=http://localhost:3921 SPOON_AGENT_CONTAINER_ACCESS=host_port turbo run dev -F @spoon/agent-worker",
|
||||
"dev:agent:staging": "INFISICAL_ENV=staging SPOON_AGENT_WORKER_URL=http://localhost:3921 SPOON_AGENT_CONTAINER_ACCESS=host_port turbo run dev -F @spoon/agent-worker",
|
||||
"dev:next:worker": "SPOON_AGENT_WORKER_URL=http://localhost:3921 SPOON_AGENT_CONTAINER_ACCESS=host_port turbo run dev -F @spoon/next -F @spoon/backend -F @spoon/agent-worker",
|
||||
"dev:next:worker:staging": "INFISICAL_ENV=staging SPOON_AGENT_WORKER_URL=http://localhost:3921 SPOON_AGENT_CONTAINER_ACCESS=host_port turbo run dev -F @spoon/next -F @spoon/backend -F @spoon/agent-worker",
|
||||
"dev:next:web": "turbo run dev:web -F @spoon/next -F @spoon/backend",
|
||||
"dev:next:web:staging": "INFISICAL_ENV=staging turbo run dev:web -F @spoon/next -F @spoon/backend",
|
||||
"dev:expo": "turbo run dev -F @spoon/expo -F @spoon/backend",
|
||||
@@ -73,6 +75,7 @@
|
||||
"sync:convex:production": "scripts/sync-convex-env production",
|
||||
"sync:convex:prod": "scripts/sync-convex-env prod",
|
||||
"auth:keys": "node scripts/generate-convex-auth-keys.mjs",
|
||||
"smoke:agent-container": "scripts/smoke-agent-container",
|
||||
"db:up": "bash scripts/db/up",
|
||||
"db:down": "bash scripts/db/down",
|
||||
"db:down:wipe": "bash scripts/db/down --wipe",
|
||||
|
||||
@@ -36,6 +36,12 @@ const workspaceStatus = v.union(
|
||||
v.literal('failed'),
|
||||
);
|
||||
|
||||
const agentRuntimeMode = v.union(
|
||||
v.literal('opencode_server'),
|
||||
v.literal('codex_exec'),
|
||||
v.literal('legacy_cli'),
|
||||
);
|
||||
|
||||
const messageRole = v.union(
|
||||
v.literal('user'),
|
||||
v.literal('assistant'),
|
||||
@@ -100,6 +106,22 @@ const artifactContentType = v.union(
|
||||
v.literal('text/x-diff'),
|
||||
);
|
||||
|
||||
const interactionRuntime = v.union(v.literal('opencode'), v.literal('codex'));
|
||||
|
||||
const interactionKind = v.union(
|
||||
v.literal('question'),
|
||||
v.literal('permission'),
|
||||
v.literal('tool_confirmation'),
|
||||
);
|
||||
|
||||
const interactionStatus = v.union(
|
||||
v.literal('pending'),
|
||||
v.literal('answered'),
|
||||
v.literal('approved'),
|
||||
v.literal('rejected'),
|
||||
v.literal('expired'),
|
||||
);
|
||||
|
||||
const maintenanceDecision = v.union(
|
||||
v.literal('sync'),
|
||||
v.literal('ignore'),
|
||||
@@ -172,6 +194,84 @@ const normalizeEnvFilePath = (value?: string) => {
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const normalizeWorkspacePath = (value: string) => {
|
||||
const trimmed = optionalText(value);
|
||||
if (!trimmed) throw new ConvexError('Workspace path is required.');
|
||||
if (
|
||||
trimmed.startsWith('/') ||
|
||||
trimmed.includes('\0') ||
|
||||
trimmed.split('/').includes('..') ||
|
||||
trimmed === '.git' ||
|
||||
trimmed.startsWith('.git/')
|
||||
) {
|
||||
throw new ConvexError('Workspace path must stay inside the repository.');
|
||||
}
|
||||
return trimmed.replace(/^\.\/+/, '');
|
||||
};
|
||||
|
||||
const normalizeWorkspacePaths = (values: string[] | undefined, max: number) =>
|
||||
values
|
||||
?.map(normalizeWorkspacePath)
|
||||
.filter((value, index, all) => all.indexOf(value) === index)
|
||||
.slice(0, max);
|
||||
|
||||
const isDeletableWorkspace = (job: Doc<'agentJobs'>) =>
|
||||
['failed', 'cancelled', 'timed_out'].includes(job.status) ||
|
||||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
|
||||
|
||||
const isTerminalJob = (job: Doc<'agentJobs'>) =>
|
||||
['failed', 'cancelled', 'timed_out', 'draft_pr_opened'].includes(
|
||||
job.status,
|
||||
) || ['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
|
||||
|
||||
const deleteWorkspaceRows = async (ctx: MutationCtx, job: Doc<'agentJobs'>) => {
|
||||
const messages = await ctx.db
|
||||
.query('agentJobMessages')
|
||||
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
||||
.collect();
|
||||
const events = await ctx.db
|
||||
.query('agentJobEvents')
|
||||
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
||||
.collect();
|
||||
const artifacts = await ctx.db
|
||||
.query('agentJobArtifacts')
|
||||
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
||||
.collect();
|
||||
const changes = await ctx.db
|
||||
.query('agentWorkspaceChanges')
|
||||
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
||||
.collect();
|
||||
const uiStates = await ctx.db
|
||||
.query('agentWorkspaceUiStates')
|
||||
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
||||
.collect();
|
||||
const interactions = await ctx.db
|
||||
.query('agentInteractionRequests')
|
||||
.withIndex('by_job', (q) => q.eq('jobId', job._id))
|
||||
.collect();
|
||||
|
||||
for (const row of [
|
||||
...messages,
|
||||
...events,
|
||||
...artifacts,
|
||||
...changes,
|
||||
...uiStates,
|
||||
...interactions,
|
||||
]) {
|
||||
await ctx.db.delete(row._id);
|
||||
}
|
||||
if (job.threadId) {
|
||||
const thread = await ctx.db.get(job.threadId);
|
||||
if (thread?.latestAgentJobId === job._id) {
|
||||
await ctx.db.patch(job.threadId, {
|
||||
latestAgentJobId: undefined,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
await ctx.db.delete(job._id);
|
||||
};
|
||||
|
||||
const getAgentSettings = async (ctx: MutationCtx, spoon: Doc<'spoons'>) => {
|
||||
const settings = await ctx.db
|
||||
.query('spoonAgentSettings')
|
||||
@@ -451,7 +551,10 @@ export const createForThread = mutation({
|
||||
throw new ConvexError('Thread not found.');
|
||||
}
|
||||
if (thread.latestAgentJobId) {
|
||||
throw new ConvexError('This thread already has an agent job.');
|
||||
const latestJob = await ctx.db.get(thread.latestAgentJobId);
|
||||
if (latestJob && !isTerminalJob(latestJob)) {
|
||||
throw new ConvexError('This thread already has an active agent job.');
|
||||
}
|
||||
}
|
||||
const spoon = await getOwnedSpoon(ctx, thread.spoonId, ownerId);
|
||||
const promptMessage = await ctx.db
|
||||
@@ -514,7 +617,12 @@ export const createForThreadInternal = internalMutation({
|
||||
if (thread?.ownerId !== args.ownerId || !thread.spoonId) {
|
||||
throw new ConvexError('Thread not found.');
|
||||
}
|
||||
if (thread.latestAgentJobId) return thread.latestAgentJobId;
|
||||
if (thread.latestAgentJobId) {
|
||||
const latestJob = await ctx.db.get(thread.latestAgentJobId);
|
||||
if (latestJob && !isTerminalJob(latestJob)) {
|
||||
return thread.latestAgentJobId;
|
||||
}
|
||||
}
|
||||
const spoon = await ctx.db.get(thread.spoonId);
|
||||
if (spoon?.ownerId !== args.ownerId) {
|
||||
throw new ConvexError('Spoon not found.');
|
||||
@@ -609,6 +717,115 @@ export const listMessages = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const getWorkspaceUiState = query({
|
||||
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 state = await ctx.db
|
||||
.query('agentWorkspaceUiStates')
|
||||
.withIndex('by_job', (q) => q.eq('jobId', jobId))
|
||||
.first();
|
||||
return (
|
||||
state ?? {
|
||||
jobId,
|
||||
spoonId: job.spoonId,
|
||||
ownerId,
|
||||
openFilePaths: [],
|
||||
activeFilePath: undefined,
|
||||
vimEnabled: false,
|
||||
expandedDirectoryPaths: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const patchWorkspaceUiState = mutation({
|
||||
args: {
|
||||
jobId: v.id('agentJobs'),
|
||||
openFilePaths: v.optional(v.array(v.string())),
|
||||
activeFilePath: v.optional(v.string()),
|
||||
vimEnabled: v.optional(v.boolean()),
|
||||
expandedDirectoryPaths: v.optional(v.array(v.string())),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const job = await ctx.db.get(args.jobId);
|
||||
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
|
||||
const now = Date.now();
|
||||
const existing = await ctx.db
|
||||
.query('agentWorkspaceUiStates')
|
||||
.withIndex('by_job', (q) => q.eq('jobId', args.jobId))
|
||||
.first();
|
||||
const patch = {
|
||||
...(args.openFilePaths !== undefined
|
||||
? { openFilePaths: normalizeWorkspacePaths(args.openFilePaths, 40) }
|
||||
: {}),
|
||||
...(args.activeFilePath !== undefined
|
||||
? {
|
||||
activeFilePath: args.activeFilePath
|
||||
? normalizeWorkspacePath(args.activeFilePath)
|
||||
: undefined,
|
||||
}
|
||||
: {}),
|
||||
...(args.vimEnabled !== undefined ? { vimEnabled: args.vimEnabled } : {}),
|
||||
...(args.expandedDirectoryPaths !== undefined
|
||||
? {
|
||||
expandedDirectoryPaths: normalizeWorkspacePaths(
|
||||
args.expandedDirectoryPaths,
|
||||
500,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
updatedAt: now,
|
||||
};
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, patch);
|
||||
return existing._id;
|
||||
}
|
||||
return await ctx.db.insert('agentWorkspaceUiStates', {
|
||||
jobId: args.jobId,
|
||||
spoonId: job.spoonId,
|
||||
ownerId,
|
||||
openFilePaths: patch.openFilePaths ?? [],
|
||||
activeFilePath: patch.activeFilePath,
|
||||
vimEnabled: patch.vimEnabled ?? false,
|
||||
expandedDirectoryPaths: patch.expandedDirectoryPaths ?? [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const listInteractionRequests = query({
|
||||
args: {
|
||||
jobId: v.id('agentJobs'),
|
||||
status: v.optional(v.union(v.literal('pending'), v.literal('all'))),
|
||||
},
|
||||
handler: async (ctx, { jobId, status }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const job = await ctx.db.get(jobId);
|
||||
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
|
||||
if (status === 'pending') {
|
||||
return await ctx.db
|
||||
.query('agentInteractionRequests')
|
||||
.withIndex('by_job_status', (q) =>
|
||||
q.eq('jobId', jobId).eq('status', 'pending'),
|
||||
)
|
||||
.order('asc')
|
||||
.collect();
|
||||
}
|
||||
return await ctx.db
|
||||
.query('agentInteractionRequests')
|
||||
.withIndex('by_job', (q) => q.eq('jobId', jobId))
|
||||
.order('asc')
|
||||
.collect();
|
||||
},
|
||||
});
|
||||
|
||||
export const appendUserMessage = mutation({
|
||||
args: { jobId: v.id('agentJobs'), content: v.string() },
|
||||
handler: async (ctx, { jobId, content }) => {
|
||||
@@ -709,6 +926,91 @@ export const cancel = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteWorkspace = 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.');
|
||||
if (!isDeletableWorkspace(job)) {
|
||||
throw new ConvexError(
|
||||
'Only stopped, cancelled, failed, or expired workspaces can be deleted.',
|
||||
);
|
||||
}
|
||||
await deleteWorkspaceRows(ctx, job);
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const markWorkspaceLost = mutation({
|
||||
args: { jobId: v.id('agentJobs') },
|
||||
handler: async (ctx, { jobId }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const job = await ctx.db.get(jobId);
|
||||
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
|
||||
const now = Date.now();
|
||||
await ctx.db.patch(jobId, {
|
||||
status: 'failed',
|
||||
workspaceStatus: 'failed',
|
||||
error: 'Workspace is not active on the configured worker.',
|
||||
completedAt: job.completedAt ?? now,
|
||||
updatedAt: now,
|
||||
});
|
||||
if (job.threadId) {
|
||||
await ctx.db.patch(job.threadId, {
|
||||
status: 'failed',
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const countOldWorkspaces = query({
|
||||
args: { olderThanDays: v.optional(v.number()) },
|
||||
handler: async (ctx, { olderThanDays }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const cutoff =
|
||||
olderThanDays && olderThanDays > 0
|
||||
? Date.now() - olderThanDays * 24 * 60 * 60 * 1000
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const jobs = await ctx.db
|
||||
.query('agentJobs')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||
.collect();
|
||||
return jobs.filter(
|
||||
(job) => isDeletableWorkspace(job) && job.updatedAt <= cutoff,
|
||||
).length;
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteOldWorkspaces = mutation({
|
||||
args: {
|
||||
olderThanDays: v.optional(v.number()),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, { olderThanDays, limit }) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const cutoff =
|
||||
olderThanDays && olderThanDays > 0
|
||||
? Date.now() - olderThanDays * 24 * 60 * 60 * 1000
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const max = Math.min(Math.max(limit ?? 50, 1), 100);
|
||||
const jobs = await ctx.db
|
||||
.query('agentJobs')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||
.collect();
|
||||
const deletable = jobs
|
||||
.filter((job) => isDeletableWorkspace(job) && job.updatedAt <= cutoff)
|
||||
.sort((a, b) => a.updatedAt - b.updatedAt)
|
||||
.slice(0, max);
|
||||
for (const job of deletable) {
|
||||
await deleteWorkspaceRows(ctx, job);
|
||||
}
|
||||
return { deleted: deletable.length };
|
||||
},
|
||||
});
|
||||
|
||||
export const claimNextInternal = internalMutation({
|
||||
args: { workerId: v.string() },
|
||||
handler: async (ctx, { workerId }) => {
|
||||
@@ -867,6 +1169,138 @@ export const markWorkspaceActive = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const setRuntimeSession = mutation({
|
||||
args: {
|
||||
workerToken: v.string(),
|
||||
workerId: v.string(),
|
||||
jobId: v.id('agentJobs'),
|
||||
agentRuntimeMode,
|
||||
opencodeSessionId: v.optional(v.string()),
|
||||
codexSessionId: v.optional(v.string()),
|
||||
containerId: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireWorkerToken(args.workerToken);
|
||||
const job = await ctx.db.get(args.jobId);
|
||||
if (job?.claimedBy !== args.workerId) {
|
||||
throw new ConvexError('Agent job not claimed by this worker.');
|
||||
}
|
||||
await ctx.db.patch(args.jobId, {
|
||||
agentRuntimeMode: args.agentRuntimeMode,
|
||||
opencodeSessionId: optionalText(args.opencodeSessionId),
|
||||
codexSessionId: optionalText(args.codexSessionId),
|
||||
containerId: optionalText(args.containerId),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const setCodexSessionId = mutation({
|
||||
args: {
|
||||
workerToken: v.string(),
|
||||
workerId: v.string(),
|
||||
jobId: v.id('agentJobs'),
|
||||
codexSessionId: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireWorkerToken(args.workerToken);
|
||||
const job = await ctx.db.get(args.jobId);
|
||||
if (job?.claimedBy !== args.workerId) {
|
||||
throw new ConvexError('Agent job not claimed by this worker.');
|
||||
}
|
||||
await ctx.db.patch(args.jobId, {
|
||||
codexSessionId: optionalText(args.codexSessionId),
|
||||
agentRuntimeMode: 'codex_exec',
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const createInteractionRequest = mutation({
|
||||
args: {
|
||||
workerToken: v.string(),
|
||||
workerId: v.string(),
|
||||
jobId: v.id('agentJobs'),
|
||||
runtime: interactionRuntime,
|
||||
externalRequestId: v.string(),
|
||||
kind: interactionKind,
|
||||
title: v.string(),
|
||||
body: v.string(),
|
||||
options: v.optional(v.array(v.string())),
|
||||
metadata: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireWorkerToken(args.workerToken);
|
||||
const job = await ctx.db.get(args.jobId);
|
||||
if (job?.claimedBy !== args.workerId) {
|
||||
throw new ConvexError('Agent job not claimed by this worker.');
|
||||
}
|
||||
const now = Date.now();
|
||||
const existing = (
|
||||
await ctx.db
|
||||
.query('agentInteractionRequests')
|
||||
.withIndex('by_job', (q) => q.eq('jobId', args.jobId))
|
||||
.collect()
|
||||
).find((request) => request.externalRequestId === args.externalRequestId);
|
||||
const record = {
|
||||
runtime: args.runtime,
|
||||
externalRequestId: args.externalRequestId,
|
||||
kind: args.kind,
|
||||
title: args.title,
|
||||
body: args.body,
|
||||
options: args.options,
|
||||
metadata: args.metadata,
|
||||
status: 'pending' as const,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, record);
|
||||
return existing._id;
|
||||
}
|
||||
const requestId = await ctx.db.insert('agentInteractionRequests', {
|
||||
jobId: args.jobId,
|
||||
spoonId: job.spoonId,
|
||||
ownerId: job.ownerId,
|
||||
...record,
|
||||
createdAt: now,
|
||||
});
|
||||
await ctx.db.patch(args.jobId, {
|
||||
status: 'running',
|
||||
updatedAt: now,
|
||||
});
|
||||
return requestId;
|
||||
},
|
||||
});
|
||||
|
||||
export const patchInteractionRequest = mutation({
|
||||
args: {
|
||||
workerToken: v.string(),
|
||||
workerId: v.string(),
|
||||
interactionId: v.id('agentInteractionRequests'),
|
||||
status: interactionStatus,
|
||||
response: v.optional(v.string()),
|
||||
metadata: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
requireWorkerToken(args.workerToken);
|
||||
const interaction = await ctx.db.get(args.interactionId);
|
||||
if (!interaction) throw new ConvexError('Interaction request not found.');
|
||||
const job = await ctx.db.get(interaction.jobId);
|
||||
if (job?.claimedBy !== args.workerId) {
|
||||
throw new ConvexError('Agent job not claimed by this worker.');
|
||||
}
|
||||
await ctx.db.patch(args.interactionId, {
|
||||
status: args.status,
|
||||
response: optionalText(args.response),
|
||||
metadata: args.metadata,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const markWorkspaceStopped = mutation({
|
||||
args: {
|
||||
workerToken: v.string(),
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -524,6 +524,14 @@ const applicationTables = {
|
||||
baseBranch: v.string(),
|
||||
workBranch: v.string(),
|
||||
opencodeSessionId: v.optional(v.string()),
|
||||
codexSessionId: v.optional(v.string()),
|
||||
agentRuntimeMode: v.optional(
|
||||
v.union(
|
||||
v.literal('opencode_server'),
|
||||
v.literal('codex_exec'),
|
||||
v.literal('legacy_cli'),
|
||||
),
|
||||
),
|
||||
containerId: v.optional(v.string()),
|
||||
workspaceUrl: v.optional(v.string()),
|
||||
workspaceExpiresAt: v.optional(v.number()),
|
||||
@@ -587,6 +595,48 @@ const applicationTables = {
|
||||
})
|
||||
.index('by_job', ['jobId'])
|
||||
.index('by_owner', ['ownerId']),
|
||||
agentWorkspaceUiStates: defineTable({
|
||||
jobId: v.id('agentJobs'),
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
openFilePaths: v.array(v.string()),
|
||||
activeFilePath: v.optional(v.string()),
|
||||
vimEnabled: v.boolean(),
|
||||
expandedDirectoryPaths: v.array(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_job', ['jobId'])
|
||||
.index('by_owner', ['ownerId']),
|
||||
agentInteractionRequests: defineTable({
|
||||
jobId: v.id('agentJobs'),
|
||||
spoonId: v.id('spoons'),
|
||||
ownerId: v.id('users'),
|
||||
runtime: v.union(v.literal('opencode'), v.literal('codex')),
|
||||
externalRequestId: v.string(),
|
||||
kind: v.union(
|
||||
v.literal('question'),
|
||||
v.literal('permission'),
|
||||
v.literal('tool_confirmation'),
|
||||
),
|
||||
title: v.string(),
|
||||
body: v.string(),
|
||||
options: v.optional(v.array(v.string())),
|
||||
status: v.union(
|
||||
v.literal('pending'),
|
||||
v.literal('answered'),
|
||||
v.literal('approved'),
|
||||
v.literal('rejected'),
|
||||
v.literal('expired'),
|
||||
),
|
||||
response: v.optional(v.string()),
|
||||
metadata: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_job', ['jobId'])
|
||||
.index('by_job_status', ['jobId', 'status'])
|
||||
.index('by_owner', ['ownerId']),
|
||||
agentWorkspaceChanges: defineTable({
|
||||
jobId: v.id('agentJobs'),
|
||||
spoonId: v.id('spoons'),
|
||||
|
||||
@@ -87,6 +87,64 @@ export const listMine = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const listMineWithState = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const spoons = (
|
||||
await ctx.db
|
||||
.query('spoons')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||
.order('desc')
|
||||
.collect()
|
||||
).filter((spoon) => spoon.status !== 'archived');
|
||||
|
||||
return await Promise.all(
|
||||
spoons.map(async (spoon) => {
|
||||
const [state, ignoredChanges, threads] = await Promise.all([
|
||||
ctx.db
|
||||
.query('spoonRepositoryStates')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
|
||||
.first(),
|
||||
ctx.db
|
||||
.query('ignoredUpstreamChanges')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
|
||||
.collect(),
|
||||
ctx.db
|
||||
.query('threads')
|
||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoon._id))
|
||||
.order('desc')
|
||||
.collect(),
|
||||
]);
|
||||
const ignoredShas = new Set(
|
||||
ignoredChanges.flatMap((change) => change.commitShas),
|
||||
);
|
||||
const rawUpstreamAheadBy =
|
||||
state?.upstreamAheadBy ?? spoon.upstreamAheadBy ?? 0;
|
||||
const effectiveUpstreamAheadBy = Math.max(
|
||||
0,
|
||||
rawUpstreamAheadBy - ignoredShas.size,
|
||||
);
|
||||
const openThreads = threads.filter(
|
||||
(thread) =>
|
||||
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
|
||||
thread.status,
|
||||
),
|
||||
);
|
||||
return {
|
||||
...spoon,
|
||||
rawUpstreamAheadBy,
|
||||
effectiveUpstreamAheadBy,
|
||||
ignoredUpstreamCount: ignoredShas.size,
|
||||
forkAheadBy: state?.forkAheadBy ?? spoon.forkAheadBy ?? 0,
|
||||
openThreadCount: openThreads.length,
|
||||
latestThreadStatus: threads[0]?.status,
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const get = query({
|
||||
args: { spoonId: v.id('spoons') },
|
||||
handler: async (ctx, { spoonId }) => {
|
||||
|
||||
@@ -82,7 +82,7 @@ export const listMine = query({
|
||||
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||
.order('desc')
|
||||
.take(args.limit ?? 50);
|
||||
return threads.filter((thread) => {
|
||||
const filtered = threads.filter((thread) => {
|
||||
if (
|
||||
args.status &&
|
||||
args.status !== 'all' &&
|
||||
@@ -100,6 +100,28 @@ export const listMine = query({
|
||||
if (args.spoonId && thread.spoonId !== args.spoonId) return false;
|
||||
return true;
|
||||
});
|
||||
return await Promise.all(
|
||||
filtered.map(async (thread) => {
|
||||
const [spoon, latestJob] = await Promise.all([
|
||||
thread.spoonId ? ctx.db.get(thread.spoonId) : null,
|
||||
thread.latestAgentJobId ? ctx.db.get(thread.latestAgentJobId) : null,
|
||||
]);
|
||||
return {
|
||||
...publicThread(thread),
|
||||
spoonName: spoon?.ownerId === ownerId ? spoon.name : undefined,
|
||||
latestJobStatus:
|
||||
latestJob?.ownerId === ownerId ? latestJob.status : undefined,
|
||||
latestJobWorkspaceStatus:
|
||||
latestJob?.ownerId === ownerId
|
||||
? latestJob.workspaceStatus
|
||||
: undefined,
|
||||
latestJobPullRequestUrl:
|
||||
latestJob?.ownerId === ownerId
|
||||
? latestJob.pullRequestUrl
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -216,7 +238,7 @@ export const appendUserMessage = mutation({
|
||||
spoonId: thread.spoonId,
|
||||
role: 'user',
|
||||
content: requireText(content, 'Message'),
|
||||
status: 'queued',
|
||||
status: 'completed',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { convexTest } from 'convex-test';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { Id } from '../../convex/_generated/dataModel.js';
|
||||
import { api } from '../../convex/_generated/api.js';
|
||||
import schema from '../../convex/schema';
|
||||
|
||||
@@ -33,6 +34,67 @@ const spoonInput = {
|
||||
productionRefStrategy: 'default_branch' as const,
|
||||
};
|
||||
|
||||
const githubSpoonInput = {
|
||||
...spoonInput,
|
||||
provider: 'github' as const,
|
||||
upstreamUrl: 'https://github.com/upstream/editor',
|
||||
forkUrl: 'https://github.com/team/editor-spoon',
|
||||
};
|
||||
|
||||
const createAgentJob = async (
|
||||
t: ReturnType<typeof convexTest>,
|
||||
args: {
|
||||
ownerId: Id<'users'>;
|
||||
spoonId: Id<'spoons'>;
|
||||
status: 'running' | 'failed' | 'cancelled';
|
||||
workspaceStatus?: 'active' | 'stopped' | 'failed' | 'expired';
|
||||
},
|
||||
) =>
|
||||
await t.mutation(async (ctx) => {
|
||||
const now = Date.now();
|
||||
const requestId = await ctx.db.insert('agentRequests', {
|
||||
spoonId: args.spoonId,
|
||||
ownerId: args.ownerId,
|
||||
prompt: 'Clean this workspace',
|
||||
status: 'running',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const jobId = await ctx.db.insert('agentJobs', {
|
||||
spoonId: args.spoonId,
|
||||
ownerId: args.ownerId,
|
||||
agentRequestId: requestId,
|
||||
status: args.status,
|
||||
prompt: 'Clean this workspace',
|
||||
runtime: 'opencode',
|
||||
workspaceStatus: args.workspaceStatus,
|
||||
baseBranch: 'main',
|
||||
workBranch: 'spoon/test',
|
||||
forkOwner: 'team',
|
||||
forkRepo: 'editor-spoon',
|
||||
forkUrl: 'https://git.example.com/team/editor-spoon',
|
||||
upstreamOwner: 'upstream',
|
||||
upstreamRepo: 'editor',
|
||||
selectedSecretIds: [],
|
||||
model: 'openai/gpt-5.1-codex',
|
||||
reasoningEffort: 'medium',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.patch(requestId, { agentJobId: jobId });
|
||||
await ctx.db.insert('agentJobMessages', {
|
||||
jobId,
|
||||
spoonId: args.spoonId,
|
||||
ownerId: args.ownerId,
|
||||
role: 'assistant',
|
||||
content: 'done',
|
||||
status: 'completed',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return jobId;
|
||||
});
|
||||
|
||||
describe('convex-test harness', () => {
|
||||
test('boots and executes against the project schema', async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
@@ -59,6 +121,54 @@ describe('convex-test harness', () => {
|
||||
expect(spoons[0]?.ownerId).toBe(userId);
|
||||
});
|
||||
|
||||
test('lists effective drift after ignored upstream changes', async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const ownerId = await createUser(t, 'owner@example.com');
|
||||
const spoonId = await authed(t, ownerId).mutation(
|
||||
api.spoons.createManual,
|
||||
githubSpoonInput,
|
||||
);
|
||||
await t.mutation(async (ctx) => {
|
||||
const now = Date.now();
|
||||
await ctx.db.insert('spoonRepositoryStates', {
|
||||
spoonId,
|
||||
ownerId: ownerId as Id<'users'>,
|
||||
upstreamFullName: 'upstream/editor',
|
||||
forkFullName: 'team/editor-spoon',
|
||||
upstreamDefaultBranch: 'main',
|
||||
forkDefaultBranch: 'main',
|
||||
upstreamHeadSha: 'upstream-head',
|
||||
forkHeadSha: 'fork-head',
|
||||
upstreamAheadBy: 2,
|
||||
forkAheadBy: 1,
|
||||
status: 'diverged',
|
||||
openForkPullRequestCount: 0,
|
||||
openUpstreamPullRequestCount: 0,
|
||||
refreshedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await ctx.db.insert('ignoredUpstreamChanges', {
|
||||
spoonId,
|
||||
ownerId: ownerId as Id<'users'>,
|
||||
upstreamTo: 'upstream-head',
|
||||
commitShas: ['abc123'],
|
||||
reason: 'irrelevant',
|
||||
decidedBy: 'user',
|
||||
createdAt: now,
|
||||
});
|
||||
});
|
||||
|
||||
const spoons = await authed(t, ownerId).query(
|
||||
api.spoons.listMineWithState,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(spoons[0]?.rawUpstreamAheadBy).toBe(2);
|
||||
expect(spoons[0]?.effectiveUpstreamAheadBy).toBe(1);
|
||||
expect(spoons[0]?.ignoredUpstreamCount).toBe(1);
|
||||
});
|
||||
|
||||
test('does not allow reading another user’s Spoon', async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const ownerId = await createUser(t, 'owner@example.com');
|
||||
@@ -73,6 +183,38 @@ describe('convex-test harness', () => {
|
||||
).rejects.toThrow('Spoon not found.');
|
||||
});
|
||||
|
||||
test('thread notes are completed when no workspace handles them', async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
|
||||
const spoonId = await authed(t, ownerId).mutation(
|
||||
api.spoons.createManual,
|
||||
githubSpoonInput,
|
||||
);
|
||||
const threadId = await t.mutation(async (ctx) => {
|
||||
return await ctx.db.insert('threads', {
|
||||
ownerId,
|
||||
spoonId,
|
||||
title: 'Manual note',
|
||||
source: 'user_request',
|
||||
status: 'open',
|
||||
priority: 'normal',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
const messageId = await authed(t, ownerId).mutation(
|
||||
api.threads.appendUserMessage,
|
||||
{
|
||||
threadId,
|
||||
content: 'extra context',
|
||||
},
|
||||
);
|
||||
const message = await t.run(async (ctx) => await ctx.db.get(messageId));
|
||||
|
||||
expect(message?.status).toBe('completed');
|
||||
});
|
||||
|
||||
test('requires Spoon ownership for agent requests', async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const ownerId = await createUser(t, 'owner@example.com');
|
||||
@@ -89,4 +231,126 @@ describe('convex-test harness', () => {
|
||||
}),
|
||||
).rejects.toThrow('Spoon not found.');
|
||||
});
|
||||
|
||||
test('deletes terminal workspaces and associated rows', 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,
|
||||
spoonInput,
|
||||
);
|
||||
const jobId = await createAgentJob(t, {
|
||||
ownerId,
|
||||
spoonId,
|
||||
status: 'failed',
|
||||
workspaceStatus: 'failed',
|
||||
});
|
||||
|
||||
await authed(t, ownerId).mutation(api.agentJobs.deleteWorkspace, { jobId });
|
||||
|
||||
const job = await t.run(async (ctx) => await ctx.db.get(jobId));
|
||||
const messages = await t.run(
|
||||
async (ctx) =>
|
||||
await ctx.db
|
||||
.query('agentJobMessages')
|
||||
.withIndex('by_job', (q) => q.eq('jobId', jobId))
|
||||
.collect(),
|
||||
);
|
||||
expect(job).toBeNull();
|
||||
expect(messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does not delete active workspaces', 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,
|
||||
spoonInput,
|
||||
);
|
||||
const jobId = await createAgentJob(t, {
|
||||
ownerId,
|
||||
spoonId,
|
||||
status: 'running',
|
||||
workspaceStatus: 'active',
|
||||
});
|
||||
|
||||
await expect(
|
||||
authed(t, ownerId).mutation(api.agentJobs.deleteWorkspace, { jobId }),
|
||||
).rejects.toThrow('Only stopped, cancelled, failed, or expired workspaces');
|
||||
});
|
||||
|
||||
test('does not delete another user’s workspace', async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
|
||||
const otherId = (await createUser(t, 'other@example.com')) as Id<'users'>;
|
||||
const spoonId = await authed(t, ownerId).mutation(
|
||||
api.spoons.createManual,
|
||||
spoonInput,
|
||||
);
|
||||
const jobId = await createAgentJob(t, {
|
||||
ownerId,
|
||||
spoonId,
|
||||
status: 'cancelled',
|
||||
workspaceStatus: 'stopped',
|
||||
});
|
||||
|
||||
await expect(
|
||||
authed(t, otherId).mutation(api.agentJobs.deleteWorkspace, { jobId }),
|
||||
).rejects.toThrow('Agent job not found.');
|
||||
});
|
||||
|
||||
test('queues a new thread job after the previous job is terminal', async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const ownerId = (await createUser(t, 'owner@example.com')) as Id<'users'>;
|
||||
const spoonId = await authed(t, ownerId).mutation(
|
||||
api.spoons.createManual,
|
||||
githubSpoonInput,
|
||||
);
|
||||
await t.mutation(async (ctx) => {
|
||||
await ctx.db.insert('aiProviderProfiles', {
|
||||
ownerId,
|
||||
name: 'Test provider',
|
||||
provider: 'openai',
|
||||
authType: 'none',
|
||||
defaultModel: 'gpt-5.1-codex',
|
||||
modelOptions: ['gpt-5.1-codex'],
|
||||
reasoningEffort: 'medium',
|
||||
enabled: true,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
});
|
||||
const threadId = await t.mutation(async (ctx) => {
|
||||
return await ctx.db.insert('threads', {
|
||||
ownerId,
|
||||
spoonId,
|
||||
title: 'Retryable thread',
|
||||
summary: 'try again',
|
||||
source: 'user_request',
|
||||
status: 'failed',
|
||||
priority: 'normal',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
});
|
||||
const failedJobId = await createAgentJob(t, {
|
||||
ownerId,
|
||||
spoonId,
|
||||
status: 'failed',
|
||||
workspaceStatus: 'failed',
|
||||
});
|
||||
await t.mutation(async (ctx) => {
|
||||
await ctx.db.patch(threadId, { latestAgentJobId: failedJobId });
|
||||
});
|
||||
|
||||
const newJobId = await authed(t, ownerId).mutation(
|
||||
api.agentJobs.createForThread,
|
||||
{
|
||||
threadId,
|
||||
jobType: 'user_change',
|
||||
},
|
||||
);
|
||||
|
||||
expect(newJobId).not.toBe(failedJobId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
RUNTIME="${SPOON_AGENT_CONTAINER_RUNTIME:-}"
|
||||
|
||||
docker build -f "$ROOT_DIR/docker/agent-worker.Dockerfile" -t spoon-agent-worker:latest "$ROOT_DIR"
|
||||
docker build -f "$ROOT_DIR/docker/agent-job.Dockerfile" -t spoon-agent-job:latest "$ROOT_DIR"
|
||||
if [[ -z "$RUNTIME" ]]; then
|
||||
if command -v podman >/dev/null 2>&1; then
|
||||
RUNTIME=podman
|
||||
elif command -v docker >/dev/null 2>&1; then
|
||||
RUNTIME=docker
|
||||
else
|
||||
printf 'build-agent-images: podman or docker is required.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
"$RUNTIME" build -f "$ROOT_DIR/docker/agent-worker.Dockerfile" -t spoon-agent-worker:latest "$ROOT_DIR"
|
||||
"$RUNTIME" build -f "$ROOT_DIR/docker/agent-job.Dockerfile" -t spoon-agent-job:latest "$ROOT_DIR"
|
||||
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${1:-}" == "--" ]]; then
|
||||
shift
|
||||
fi
|
||||
|
||||
if [[ "$#" -eq 0 ]]; then
|
||||
printf 'usage: dev-agent-worker -- <command> [args...]\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -z "${SPOON_AGENT_CONTAINER_RUNTIME:-}" ]]; then
|
||||
if command -v podman >/dev/null 2>&1; then
|
||||
export SPOON_AGENT_CONTAINER_RUNTIME=podman
|
||||
elif command -v docker >/dev/null 2>&1; then
|
||||
export SPOON_AGENT_CONTAINER_RUNTIME=docker
|
||||
else
|
||||
printf 'dev-agent-worker: podman or docker is required for container-backed jobs.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
export SPOON_AGENT_RUNTIME="${SPOON_AGENT_RUNTIME:-docker}"
|
||||
export SPOON_AGENT_CONTAINER_ACCESS="${SPOON_AGENT_CONTAINER_ACCESS:-host_port}"
|
||||
export SPOON_AGENT_WORKER_URL="${SPOON_AGENT_WORKER_URL:-http://localhost:${SPOON_AGENT_WORKER_HTTP_PORT:-3921}}"
|
||||
export SPOON_AGENT_WORKER_INTERNAL_TOKEN="${SPOON_AGENT_WORKER_INTERNAL_TOKEN:-${SPOON_WORKER_TOKEN:-}}"
|
||||
export SPOON_AGENT_WORKDIR="${SPOON_AGENT_LOCAL_WORKDIR:-.local/agent-work/${WITH_ENV_ENVIRONMENT:-dev}}"
|
||||
export SPOON_AGENT_JOB_IMAGE="${SPOON_AGENT_LOCAL_JOB_IMAGE:-spoon-agent-job:latest}"
|
||||
|
||||
if [[ "$SPOON_AGENT_CONTAINER_ACCESS" == "host_port" && -z "${SPOON_AGENT_KEEP_NETWORK:-}" ]]; then
|
||||
unset SPOON_AGENT_NETWORK
|
||||
fi
|
||||
|
||||
if ! "$SPOON_AGENT_CONTAINER_RUNTIME" image inspect "$SPOON_AGENT_JOB_IMAGE" >/dev/null 2>&1; then
|
||||
printf 'dev-agent-worker: job image %s is not present locally.\n' "$SPOON_AGENT_JOB_IMAGE" >&2
|
||||
printf 'Build it with: scripts/build-agent-images\n' >&2
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
RUNTIME="${SPOON_AGENT_CONTAINER_RUNTIME:-}"
|
||||
IMAGE="${SPOON_AGENT_LOCAL_JOB_IMAGE:-${SPOON_AGENT_JOB_IMAGE:-spoon-agent-job:latest}}"
|
||||
|
||||
if [[ -z "$RUNTIME" ]]; then
|
||||
if command -v podman >/dev/null 2>&1; then
|
||||
RUNTIME=podman
|
||||
elif command -v docker >/dev/null 2>&1; then
|
||||
RUNTIME=docker
|
||||
else
|
||||
printf 'smoke-agent-container: podman or docker is required.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
"$RUNTIME" run --rm "$IMAGE" bash -lc '
|
||||
set -euo pipefail
|
||||
node --version
|
||||
bun --version
|
||||
pnpm --version
|
||||
yarn --version
|
||||
npm --version
|
||||
git --version
|
||||
rg --version >/dev/null
|
||||
jq --version
|
||||
python3 --version
|
||||
opencode --version
|
||||
codex --version
|
||||
'
|
||||
@@ -38,6 +38,12 @@
|
||||
"SPOON_AGENT_WORKER_ID",
|
||||
"SPOON_AGENT_JOB_IMAGE",
|
||||
"SPOON_AGENT_RUNTIME",
|
||||
"SPOON_AGENT_CONTAINER_RUNTIME",
|
||||
"SPOON_CONTAINER_RUNTIME",
|
||||
"SPOON_AGENT_CONTAINER_ACCESS",
|
||||
"SPOON_AGENT_LOCAL_WORKDIR",
|
||||
"SPOON_AGENT_LOCAL_JOB_IMAGE",
|
||||
"SPOON_AGENT_KEEP_NETWORK",
|
||||
"SPOON_AGENT_MAX_CONCURRENT_JOBS",
|
||||
"SPOON_AGENT_JOB_TIMEOUT_MS",
|
||||
"SPOON_AGENT_WORKDIR",
|
||||
|
||||
Reference in New Issue
Block a user