Compare commits
2 Commits
8ae6c4b533
...
ddce5efb13
| Author | SHA1 | Date | |
|---|---|---|---|
| ddce5efb13 | |||
| 206b64176b |
@@ -5,7 +5,9 @@
|
|||||||
- `apps/next`: Next.js 16 frontend.
|
- `apps/next`: Next.js 16 frontend.
|
||||||
- `apps/agent-worker`: optional server-side coding-agent worker. It polls
|
- `apps/agent-worker`: optional server-side coding-agent worker. It polls
|
||||||
Convex for queued jobs and may control Docker/Podman to run ephemeral job
|
Convex for queued jobs and may control Docker/Podman to run ephemeral job
|
||||||
containers.
|
containers. It also exposes a server-only HTTP API, defaulting to port 3921,
|
||||||
|
that Next route handlers proxy to for active workspace files, diffs,
|
||||||
|
messages, commands, and draft PR actions.
|
||||||
- `apps/expo`: Expo scaffold; only work here when explicitly requested.
|
- `apps/expo`: Expo scaffold; only work here when explicitly requested.
|
||||||
- `packages/backend/convex`: self-hosted Convex functions, schema, and auth.
|
- `packages/backend/convex`: self-hosted Convex functions, schema, and auth.
|
||||||
- `packages/ui`: shared shadcn-based UI components.
|
- `packages/ui`: shared shadcn-based UI components.
|
||||||
@@ -37,6 +39,9 @@
|
|||||||
UseSend, `SITE_URL`, `SPOON_WORKER_TOKEN`, encryption, and Convex Auth signing
|
UseSend, `SITE_URL`, `SPOON_WORKER_TOKEN`, encryption, and Convex Auth signing
|
||||||
variables from Infisical into the selected Convex deployment. Backend
|
variables from Infisical into the selected Convex deployment. Backend
|
||||||
dev/setup scripts run it before `convex dev`.
|
dev/setup scripts run it before `convex dev`.
|
||||||
|
- 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.
|
||||||
- CI uses Gitea-injected secrets or `CI_ENV_FILE` and must not call Infisical.
|
- CI uses Gitea-injected secrets or `CI_ENV_FILE` and must not call Infisical.
|
||||||
- CI must provide Convex deployment env for codegen, either
|
- CI must provide Convex deployment env for codegen, either
|
||||||
`CONVEX_SELF_HOSTED_URL` plus `CONVEX_SELF_HOSTED_ADMIN_KEY`, or
|
`CONVEX_SELF_HOSTED_URL` plus `CONVEX_SELF_HOSTED_ADMIN_KEY`, or
|
||||||
|
|||||||
@@ -1,70 +1,102 @@
|
|||||||
# Spoon
|
# Spoon
|
||||||
|
|
||||||
Spoon is a self-hostable fork maintenance dashboard.
|
Spoon is a self-hostable fork maintenance cockpit.
|
||||||
|
|
||||||
The product goal is simple: make it practical to fork a project, customize it,
|
Forking a project should not mean supporting it alone. Spoon tracks managed
|
||||||
and still stay close to upstream. Spoon tracks managed forks, called
|
forks, called **Spoons**, watches upstream for drift, automatically syncs clean
|
||||||
**Spoons**, and lays the foundation for upstream update checks, AI-assisted
|
forks when it can, and opens durable **Threads** when upstream changes need
|
||||||
change review, and agent-authored merge requests.
|
review, context, or code.
|
||||||
|
|
||||||
This repository is the Spoon application itself, not a generic starter.
|
This repository is the Spoon application itself, not a generic starter.
|
||||||
|
|
||||||
## Current scope
|
## What Spoon Does
|
||||||
|
|
||||||
|
- Tracks GitHub-backed managed forks and their upstream projects.
|
||||||
|
- Shows raw and effective drift, fork-only commits, pull requests, clone URLs,
|
||||||
|
additional remotes, sync history, and open maintenance work.
|
||||||
|
- Uses Threads as the product center for upstream reviews, merge conflicts,
|
||||||
|
ignored commits, user-requested changes, worker logs, and draft PR handoff.
|
||||||
|
- Auto-syncs clean behind forks when there are no fork-only commits.
|
||||||
|
- Creates maintenance threads when custom fork work means upstream changes need
|
||||||
|
a decision.
|
||||||
|
- Runs optional OpenCode-backed workspaces in isolated agent-job containers.
|
||||||
|
- Lets users configure encrypted AI provider profiles, Codex/OpenCode auth,
|
||||||
|
per-Spoon secrets, commands, and agent settings.
|
||||||
|
- Opens draft PRs for code changes instead of auto-merging custom forks.
|
||||||
|
|
||||||
|
## Current Scope
|
||||||
|
|
||||||
Implemented today:
|
Implemented today:
|
||||||
|
|
||||||
- Public Spoon landing page in Next.js.
|
- Public Next.js landing page for Spoon's thread-first maintenance model.
|
||||||
- Authenticated web dashboard routes:
|
- Authenticated web routes:
|
||||||
- `/dashboard`
|
- `/dashboard`
|
||||||
- `/spoons`
|
- `/spoons`
|
||||||
- `/spoons/new`
|
- `/spoons/new`
|
||||||
- `/updates`
|
|
||||||
- `/spoons/[spoonId]`
|
- `/spoons/[spoonId]`
|
||||||
- `/settings`
|
- `/spoons/[spoonId]/agent/[jobId]`
|
||||||
- Manual and GitHub-created Spoon records stored in Convex.
|
- `/threads`
|
||||||
|
- `/threads/[threadId]`
|
||||||
|
- `/settings/profile`
|
||||||
|
- `/settings/integrations`
|
||||||
|
- `/settings/ai-providers`
|
||||||
|
- Legacy `/updates` and `/agents` routes redirect into Threads.
|
||||||
- GitHub App connection, repository listing, fork creation, drift refresh,
|
- GitHub App connection, repository listing, fork creation, drift refresh,
|
||||||
commit/PR cache, and safe manual sync foundation.
|
commit/PR cache, and safe sync foundation.
|
||||||
- Per-user OpenAI settings for upstream compatibility review.
|
- Thread-first maintenance model with ignored upstream changes and effective
|
||||||
- Per-Spoon encrypted project secrets and agent runtime settings.
|
drift.
|
||||||
- Optional `apps/agent-worker` service that can claim queued jobs, clone the
|
- Optional `apps/agent-worker` service that claims queued jobs, clones the
|
||||||
GitHub fork, ask OpenAI for bounded file edits, run checks, push a branch, and
|
current GitHub fork, starts an isolated workspace, exposes file browsing and
|
||||||
open a draft PR.
|
edits through server-side Next proxies, runs commands, and opens draft PRs.
|
||||||
- Password auth and Authentik OAuth through Convex Auth.
|
- Browser workspace with persisted thread messages, file tree, Monaco editor
|
||||||
|
with optional Vim mode, diff view, command panel, logs, artifacts, and draft
|
||||||
|
PR actions.
|
||||||
|
- Encrypted per-user AI provider profiles and per-Spoon project secrets.
|
||||||
|
- Password auth and Authentik/GitHub OAuth through Convex Auth.
|
||||||
- Expo companion app shell with password and Authentik sign-in.
|
- Expo companion app shell with password and Authentik sign-in.
|
||||||
- Self-hosted local Convex using Postgres storage.
|
- Self-hosted local Convex using Postgres storage.
|
||||||
|
|
||||||
Not implemented yet:
|
Not implemented yet:
|
||||||
|
|
||||||
- Browser IDE/editor.
|
- Automatic merge of custom/diverged forks.
|
||||||
- Automatic merge.
|
- Git provider automation beyond GitHub.
|
||||||
- Additional Git provider automation beyond preserving provider-neutral fields.
|
|
||||||
- Additional remotes as push targets.
|
- Additional remotes as push targets.
|
||||||
- Long-running service-stack orchestration inside agent jobs.
|
- Long-running service-stack orchestration inside agent jobs.
|
||||||
|
- Direct browser access to worker containers.
|
||||||
- Production mobile build/release setup.
|
- Production mobile build/release setup.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- `apps/next`: Next.js 16 web app and primary product UI.
|
- `apps/next`: Next.js 16 web app and primary product UI.
|
||||||
- `apps/agent-worker`: optional server-side worker for queued coding-agent jobs.
|
- `apps/agent-worker`: optional server-side worker for OpenCode workspaces and
|
||||||
|
draft PR jobs.
|
||||||
- `apps/expo`: Expo companion app.
|
- `apps/expo`: Expo companion app.
|
||||||
- `packages/backend/convex`: self-hosted Convex schema, functions, auth, and
|
- `packages/backend/convex`: self-hosted Convex schema, functions, auth, and
|
||||||
HTTP routes.
|
HTTP routes.
|
||||||
- `packages/ui`: shared shadcn-based UI components.
|
- `packages/ui`: shared shadcn-based UI components.
|
||||||
- `tools`: shared ESLint, Prettier, Tailwind, TypeScript, and Vitest config.
|
- `tools`: shared ESLint, Prettier, Tailwind, TypeScript, and Vitest config.
|
||||||
- `docker`: local and production Compose files.
|
- `docker`: local and production Compose files.
|
||||||
- `scripts`: environment, database, and CI helpers.
|
- `scripts`: environment, database, codegen, and CI helpers.
|
||||||
|
|
||||||
The core domain objects are:
|
Core domain objects:
|
||||||
|
|
||||||
- `spoons`: managed fork records.
|
- `spoons`: managed fork records.
|
||||||
- `gitConnections`: future Git provider connection metadata.
|
- `threads`: durable maintenance and work conversations.
|
||||||
- `syncRuns`: future upstream checks, merge attempts, and AI reviews.
|
- `threadMessages`: persisted thread messages.
|
||||||
- `agentRequests`: prompt-driven agent work requests.
|
- `syncRuns`: upstream checks, sync attempts, and maintenance decisions.
|
||||||
- `agentJobs`: worker-executed coding-agent jobs and their PR lifecycle.
|
- `ignoredUpstreamChanges`: intentional ignore decisions that affect effective
|
||||||
|
drift.
|
||||||
|
- `gitConnections`: Git provider connection metadata.
|
||||||
|
- `agentJobs`: worker-executed workspace jobs and PR lifecycle.
|
||||||
|
- `agentJobEvents` and `agentJobArtifacts`: logs and structured job outputs.
|
||||||
|
- `agentWorkspaceChanges`: recorded file changes from user, agent, or command
|
||||||
|
activity.
|
||||||
- `spoonSecrets`: encrypted per-Spoon environment variables.
|
- `spoonSecrets`: encrypted per-Spoon environment variables.
|
||||||
- `spoonAgentSettings`: per-Spoon agent model, branch, and command settings.
|
- `spoonAgentSettings`: per-Spoon runtime, branch, command, and env-file
|
||||||
|
settings.
|
||||||
|
- `aiProviderProfiles`: encrypted provider/auth profiles used by OpenCode.
|
||||||
|
|
||||||
## Local setup
|
## Local Setup
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
|
|
||||||
@@ -92,8 +124,8 @@ Local services:
|
|||||||
Next and Expo run on the host. Local Convex runs in containers with Postgres
|
Next and Expo run on the host. Local Convex runs in containers with Postgres
|
||||||
storage. Normal `bun db:up` never contacts staging; it starts local Postgres,
|
storage. Normal `bun db:up` never contacts staging; it starts local Postgres,
|
||||||
Convex, and the dashboard, generates a machine-local Convex admin key in
|
Convex, and the dashboard, generates a machine-local Convex admin key in
|
||||||
`.local/dev.generated.env` when needed, deploys functions/schema, and
|
`.local/dev.generated.env` when needed, deploys functions/schema, and configures
|
||||||
configures local Convex Auth keys.
|
local Convex Auth keys.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bun db:down # stop; preserve local data
|
bun db:down # stop; preserve local data
|
||||||
@@ -112,6 +144,10 @@ Run the optional local agent worker in a separate terminal:
|
|||||||
bun dev:agent
|
bun dev:agent
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The worker starts an internal HTTP API, defaulting to `http://localhost:3921`,
|
||||||
|
for server-side Next route handlers. The browser never receives the worker token
|
||||||
|
or talks to this API directly.
|
||||||
|
|
||||||
The Docker Compose local worker service is disabled by default behind the
|
The Docker Compose local worker service is disabled by default behind the
|
||||||
`agent` profile. Build the job image before using Docker-backed jobs:
|
`agent` profile. Build the job image before using Docker-backed jobs:
|
||||||
|
|
||||||
@@ -120,10 +156,13 @@ docker build -f docker/agent-job.Dockerfile -t spoon-agent-job:latest .
|
|||||||
docker compose -f docker/compose.local.yml --profile agent up spoon-agent-worker
|
docker compose -f docker/compose.local.yml --profile agent up spoon-agent-worker
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment model
|
The job image includes the OpenCode CLI. Rebuild it after changes to
|
||||||
|
`docker/agent-job.Dockerfile`.
|
||||||
|
|
||||||
Local `dev` and `staging` values come from Infisical through `scripts/with-env`.
|
## Environment Model
|
||||||
App commands do not fall back to root `.env` files.
|
|
||||||
|
Local `dev` and `staging` values come from Infisical through
|
||||||
|
`scripts/with-env`. App commands do not fall back to root `.env` files.
|
||||||
|
|
||||||
Generated local state belongs in:
|
Generated local state belongs in:
|
||||||
|
|
||||||
@@ -139,17 +178,19 @@ Useful helpers:
|
|||||||
sh scripts/with-env dev -- <command>
|
sh scripts/with-env dev -- <command>
|
||||||
sh scripts/export-env dev
|
sh scripts/export-env dev
|
||||||
bun sync:convex
|
bun sync:convex
|
||||||
|
bun sync:convex:staging
|
||||||
```
|
```
|
||||||
|
|
||||||
### Convex deployment env
|
### Convex Deployment Env
|
||||||
|
|
||||||
Convex functions and HTTP actions read environment variables from the Convex
|
Convex functions and HTTP actions read environment variables from the Convex
|
||||||
deployment environment, not directly from the host process. For OAuth providers,
|
deployment environment, not directly from the host process. OAuth providers,
|
||||||
that means Infisical values must also be present in local Convex env.
|
GitHub App credentials, UseSend, encryption keys, worker tokens, and Convex Auth
|
||||||
|
signing keys must be synced into the selected Convex deployment.
|
||||||
|
|
||||||
`packages/backend` runs `scripts/sync-convex-env` before `convex dev`, so
|
`packages/backend` runs `scripts/sync-convex-env` before `convex dev`, so
|
||||||
`bun dev:next`, `bun dev:backend`, and `bun db:up` sync the relevant Infisical
|
`bun dev:next`, `bun dev:backend`, and `bun db:up` sync the relevant Infisical
|
||||||
values into the selected Convex deployment first. Run it manually when needed:
|
values into local Convex first. Run it manually when needed:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sh scripts/sync-convex-env dev
|
sh scripts/sync-convex-env dev
|
||||||
@@ -157,38 +198,12 @@ sh scripts/sync-convex-env staging
|
|||||||
INFISICAL_ENV=staging bun sync:convex
|
INFISICAL_ENV=staging bun sync:convex
|
||||||
```
|
```
|
||||||
|
|
||||||
The sync includes:
|
For local `dev`, `JWT_PRIVATE_KEY`, `JWKS`, `SPOON_ENCRYPTION_KEY`,
|
||||||
|
`SPOON_WORKER_TOKEN`, and related generated values are created automatically if
|
||||||
|
they are not already present. The generated Convex admin key remains
|
||||||
|
machine-local in `.local/dev.generated.env`; do not put it in Infisical.
|
||||||
|
|
||||||
```txt
|
Local OAuth callback URLs:
|
||||||
AUTH_AUTHENTIK_ID
|
|
||||||
AUTH_AUTHENTIK_SECRET
|
|
||||||
AUTH_AUTHENTIK_ISSUER
|
|
||||||
AUTH_GITHUB_ID
|
|
||||||
AUTH_GITHUB_SECRET
|
|
||||||
GITHUB_APP_ID
|
|
||||||
GITHUB_APP_CLIENT_ID
|
|
||||||
GITHUB_APP_CLIENT_SECRET
|
|
||||||
GITHUB_APP_PRIVATE_KEY
|
|
||||||
GITHUB_APP_WEBHOOK_SECRET
|
|
||||||
GITHUB_APP_SLUG
|
|
||||||
GITHUB_APP_INSTALLATION_ID
|
|
||||||
GITHUB_APP_OWNER
|
|
||||||
SPOON_ENCRYPTION_KEY
|
|
||||||
SPOON_WORKER_TOKEN
|
|
||||||
USESEND_API_KEY
|
|
||||||
USESEND_URL
|
|
||||||
USESEND_FROM_EMAIL
|
|
||||||
JWT_PRIVATE_KEY
|
|
||||||
JWKS
|
|
||||||
SITE_URL
|
|
||||||
```
|
|
||||||
|
|
||||||
For local `dev`, `JWT_PRIVATE_KEY`, `JWKS`, `SPOON_ENCRYPTION_KEY`, and
|
|
||||||
`SPOON_WORKER_TOKEN` are generated automatically if they are not already present
|
|
||||||
in Convex. The generated Convex admin key remains machine-local in
|
|
||||||
`.local/dev.generated.env`; do not put it in Infisical.
|
|
||||||
|
|
||||||
The local OAuth callback URLs are:
|
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
http://localhost:3211/api/auth/callback/authentik
|
http://localhost:3211/api/auth/callback/authentik
|
||||||
@@ -204,6 +219,7 @@ sync command.
|
|||||||
```sh
|
```sh
|
||||||
bun dev:next
|
bun dev:next
|
||||||
bun dev:expo
|
bun dev:expo
|
||||||
|
bun dev:agent
|
||||||
```
|
```
|
||||||
|
|
||||||
Physical devices cannot resolve their own `localhost`; override the public
|
Physical devices cannot resolve their own `localhost`; override the public
|
||||||
@@ -244,9 +260,10 @@ test runner instead of the repo's Turbo/Vitest test script.
|
|||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Production Compose keeps the self-hosted Convex backend/dashboard and expects
|
Production Compose runs the Next image, self-hosted Convex backend/dashboard,
|
||||||
`POSTGRES_URL` to be a database-cluster URL without a database path.
|
and Postgres. The deployed Next image is expected to be named
|
||||||
|
`spoon-next:latest` in the Gitea registry.
|
||||||
|
|
||||||
Gitea runs the quality gate first, builds the Next image from a temporary
|
Gitea runs the quality gate first, runs Convex codegen with deployment env,
|
||||||
Gitea-secret env file, then pushes SHA and `latest` tags. CI never installs or
|
builds the Next image from injected secrets or `CI_ENV_FILE`, then pushes SHA
|
||||||
invokes Infisical.
|
and `latest` tags. CI never installs or invokes Infisical.
|
||||||
|
|||||||
@@ -17,10 +17,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "^8.2.0",
|
"@octokit/auth-app": "^8.2.0",
|
||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
"@openai/agents": "latest",
|
"@opencode-ai/sdk": "latest",
|
||||||
"convex": "catalog:convex",
|
"convex": "catalog:convex",
|
||||||
"execa": "latest",
|
"execa": "latest",
|
||||||
"openai": "^6.44.0",
|
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { execa } from 'execa';
|
|
||||||
import OpenAI from 'openai';
|
|
||||||
|
|
||||||
const editSchema = {
|
|
||||||
type: 'object',
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
summary: { type: 'string' },
|
|
||||||
files: {
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
path: { type: 'string' },
|
|
||||||
content: { type: 'string' },
|
|
||||||
},
|
|
||||||
required: ['path', 'content'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
commands: {
|
|
||||||
type: 'array',
|
|
||||||
items: { type: 'string' },
|
|
||||||
},
|
|
||||||
limitations: {
|
|
||||||
type: 'array',
|
|
||||||
items: { type: 'string' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ['summary', 'files', 'commands', 'limitations'],
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type AgentEdit = {
|
|
||||||
summary: string;
|
|
||||||
files: { path: string; content: string }[];
|
|
||||||
commands: string[];
|
|
||||||
limitations: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const maxContextFiles = 40;
|
|
||||||
const maxFileBytes = 12_000;
|
|
||||||
|
|
||||||
const safeContextFile = (file: string) =>
|
|
||||||
!file.includes('node_modules/') &&
|
|
||||||
!file.includes('.git/') &&
|
|
||||||
!file.includes('dist/') &&
|
|
||||||
!file.includes('build/') &&
|
|
||||||
!file.includes('.next/') &&
|
|
||||||
!file.endsWith('.lock') &&
|
|
||||||
!file.endsWith('.png') &&
|
|
||||||
!file.endsWith('.jpg') &&
|
|
||||||
!file.endsWith('.jpeg') &&
|
|
||||||
!file.endsWith('.webp') &&
|
|
||||||
!file.endsWith('.gif') &&
|
|
||||||
!file.endsWith('.pdf');
|
|
||||||
|
|
||||||
const listFiles = async (repoDir: string) => {
|
|
||||||
const result = await execa('git', ['ls-files'], {
|
|
||||||
cwd: repoDir,
|
|
||||||
all: true,
|
|
||||||
reject: false,
|
|
||||||
});
|
|
||||||
return result.all
|
|
||||||
.split('\n')
|
|
||||||
.map((file) => file.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.filter(safeContextFile);
|
|
||||||
};
|
|
||||||
|
|
||||||
const chooseContextFiles = (files: string[], prompt: string) => {
|
|
||||||
const promptWords = new Set(
|
|
||||||
prompt
|
|
||||||
.toLowerCase()
|
|
||||||
.split(/[^a-z0-9]+/)
|
|
||||||
.filter((word) => word.length > 3),
|
|
||||||
);
|
|
||||||
const scored = files.map((file) => {
|
|
||||||
const lower = file.toLowerCase();
|
|
||||||
const score = [...promptWords].reduce(
|
|
||||||
(sum, word) => sum + (lower.includes(word) ? 2 : 0),
|
|
||||||
/(readme|package\.json|auth|env|config|route|provider)/i.exec(file)
|
|
||||||
? 3
|
|
||||||
: 0,
|
|
||||||
);
|
|
||||||
return { file, score };
|
|
||||||
});
|
|
||||||
return scored
|
|
||||||
.sort((a, b) => b.score - a.score)
|
|
||||||
.slice(0, maxContextFiles)
|
|
||||||
.map((item) => item.file);
|
|
||||||
};
|
|
||||||
|
|
||||||
const readContext = async (repoDir: string, files: string[]) => {
|
|
||||||
const chunks = [];
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
const content = await readFile(path.join(repoDir, file), 'utf8');
|
|
||||||
chunks.push({
|
|
||||||
path: file,
|
|
||||||
content:
|
|
||||||
content.length > maxFileBytes
|
|
||||||
? `${content.slice(0, maxFileBytes)}\n[truncated]`
|
|
||||||
: content,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Ignore files that disappeared while context was being gathered.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return chunks;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseEdit = (value: string): AgentEdit => {
|
|
||||||
const parsed = JSON.parse(value) as AgentEdit;
|
|
||||||
if (!Array.isArray(parsed.files)) {
|
|
||||||
throw new Error('OpenAI returned an edit without a files array.');
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
};
|
|
||||||
|
|
||||||
const safePath = (repoDir: string, filePath: string) => {
|
|
||||||
const resolved = path.resolve(repoDir, filePath);
|
|
||||||
if (!resolved.startsWith(path.resolve(repoDir))) {
|
|
||||||
throw new Error(`Refusing to write outside the repository: ${filePath}`);
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const runOpenAiEdit = async (args: {
|
|
||||||
repoDir: string;
|
|
||||||
apiKey: string;
|
|
||||||
model: string;
|
|
||||||
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
|
||||||
prompt: string;
|
|
||||||
secretNames: string[];
|
|
||||||
spoonName: string;
|
|
||||||
upstreamFullName: string;
|
|
||||||
forkFullName: string;
|
|
||||||
}) => {
|
|
||||||
const files = await listFiles(args.repoDir);
|
|
||||||
const selectedFiles = chooseContextFiles(files, args.prompt);
|
|
||||||
const contextFiles = await readContext(args.repoDir, selectedFiles);
|
|
||||||
const response = await new OpenAI({ apiKey: args.apiKey }).responses.create({
|
|
||||||
model: args.model,
|
|
||||||
store: false,
|
|
||||||
reasoning:
|
|
||||||
args.reasoningEffort === 'none'
|
|
||||||
? undefined
|
|
||||||
: { effort: args.reasoningEffort },
|
|
||||||
input: [
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content:
|
|
||||||
'You are a conservative coding agent working in a fork. Return complete replacement contents only for files that must change. Keep the diff minimal. Do not include secrets. Do not claim commands passed unless they are listed for the worker to run. If the context is insufficient, make the safest small change and describe limitations.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: JSON.stringify(
|
|
||||||
{
|
|
||||||
task: args.prompt,
|
|
||||||
spoon: args.spoonName,
|
|
||||||
upstream: args.upstreamFullName,
|
|
||||||
fork: args.forkFullName,
|
|
||||||
availableSecretNames: args.secretNames,
|
|
||||||
repositoryFiles: files.slice(0, 500),
|
|
||||||
contextFiles,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
text: {
|
|
||||||
format: {
|
|
||||||
type: 'json_schema',
|
|
||||||
name: 'spoon_agent_file_edits',
|
|
||||||
strict: true,
|
|
||||||
schema: editSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const edit = parseEdit(response.output_text);
|
|
||||||
for (const file of edit.files) {
|
|
||||||
const target = safePath(args.repoDir, file.path);
|
|
||||||
await mkdir(path.dirname(target), { recursive: true });
|
|
||||||
await writeFile(target, file.content);
|
|
||||||
}
|
|
||||||
return edit;
|
|
||||||
};
|
|
||||||
@@ -24,6 +24,11 @@ export const env = {
|
|||||||
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
|
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
|
||||||
network: process.env.SPOON_AGENT_NETWORK?.trim(),
|
network: process.env.SPOON_AGENT_NETWORK?.trim(),
|
||||||
pollMs: intEnv('SPOON_AGENT_POLL_MS', 5_000),
|
pollMs: intEnv('SPOON_AGENT_POLL_MS', 5_000),
|
||||||
|
httpPort: intEnv('SPOON_AGENT_WORKER_HTTP_PORT', 3921),
|
||||||
|
internalToken:
|
||||||
|
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN?.trim() ??
|
||||||
|
process.env.SPOON_WORKER_TOKEN?.trim() ??
|
||||||
|
'',
|
||||||
maxConcurrentJobs: intEnv('SPOON_AGENT_MAX_CONCURRENT_JOBS', 1),
|
maxConcurrentJobs: intEnv('SPOON_AGENT_MAX_CONCURRENT_JOBS', 1),
|
||||||
jobTimeoutMs: intEnv('SPOON_AGENT_JOB_TIMEOUT_MS', 1_800_000),
|
jobTimeoutMs: intEnv('SPOON_AGENT_JOB_TIMEOUT_MS', 1_800_000),
|
||||||
githubAppId: requiredEnv('GITHUB_APP_ID'),
|
githubAppId: requiredEnv('GITHUB_APP_ID'),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { startWorkerServer } from './server';
|
||||||
import { startWorker } from './worker';
|
import { startWorker } from './worker';
|
||||||
|
|
||||||
|
startWorkerServer();
|
||||||
await startWorker();
|
await startWorker();
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { createServer } from 'node:http';
|
||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import { env } from './env';
|
||||||
|
import {
|
||||||
|
getWorkspaceDiff,
|
||||||
|
listWorkspaceTree,
|
||||||
|
openWorkspacePullRequest,
|
||||||
|
readWorkspaceFile,
|
||||||
|
runWorkspaceCommand,
|
||||||
|
sendWorkspaceMessage,
|
||||||
|
stopWorkspace,
|
||||||
|
writeWorkspaceFile,
|
||||||
|
} from './worker';
|
||||||
|
|
||||||
|
const sendJson = (response: ServerResponse, status: number, body: unknown) => {
|
||||||
|
response.writeHead(status, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify(body));
|
||||||
|
};
|
||||||
|
|
||||||
|
const readBody = async (request: IncomingMessage) =>
|
||||||
|
await new Promise<string>((resolve, reject) => {
|
||||||
|
let body = '';
|
||||||
|
request.on('data', (chunk: Buffer) => {
|
||||||
|
body += chunk.toString('utf8');
|
||||||
|
});
|
||||||
|
request.on('end', () => resolve(body));
|
||||||
|
request.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
const parseJson = async <T>(request: IncomingMessage) => {
|
||||||
|
const body = await readBody(request);
|
||||||
|
if (!body.trim()) return {} as T;
|
||||||
|
return JSON.parse(body) as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireAuth = (request: IncomingMessage) => {
|
||||||
|
const header = request.headers.authorization;
|
||||||
|
const token = header?.startsWith('Bearer ') ? header.slice(7) : '';
|
||||||
|
if (!env.internalToken || token !== env.internalToken) {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const jobRoute = (pathname: string) => {
|
||||||
|
const match = /^\/jobs\/([^/]+)\/([^/]+)$/.exec(pathname);
|
||||||
|
if (!match?.[1] || !match[2]) return null;
|
||||||
|
return { jobId: decodeURIComponent(match[1]), action: match[2] };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const startWorkerServer = () => {
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
requireAuth(request);
|
||||||
|
const url = new URL(
|
||||||
|
request.url ?? '/',
|
||||||
|
`http://localhost:${env.httpPort}`,
|
||||||
|
);
|
||||||
|
if (url.pathname === '/health') {
|
||||||
|
sendJson(response, 200, { ok: true, workerId: env.workerId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const route = jobRoute(url.pathname);
|
||||||
|
if (!route) {
|
||||||
|
sendJson(response, 404, { error: 'Not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'GET' && route.action === 'tree') {
|
||||||
|
sendJson(response, 200, {
|
||||||
|
tree: await listWorkspaceTree(route.jobId),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'GET' && route.action === 'file') {
|
||||||
|
const filePath = url.searchParams.get('path') ?? '';
|
||||||
|
sendJson(response, 200, {
|
||||||
|
path: filePath,
|
||||||
|
content: await readWorkspaceFile(route.jobId, filePath),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'PUT' && route.action === 'file') {
|
||||||
|
const body = await parseJson<{ path?: string; content?: string }>(
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
sendJson(
|
||||||
|
response,
|
||||||
|
200,
|
||||||
|
await writeWorkspaceFile(
|
||||||
|
route.jobId,
|
||||||
|
body.path ?? '',
|
||||||
|
body.content ?? '',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'GET' && route.action === 'diff') {
|
||||||
|
sendJson(response, 200, {
|
||||||
|
diff: await getWorkspaceDiff(route.jobId),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && route.action === 'message') {
|
||||||
|
const body = await parseJson<{ content?: string }>(request);
|
||||||
|
await sendWorkspaceMessage(route.jobId, body.content ?? '');
|
||||||
|
sendJson(response, 200, { success: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && route.action === 'run-command') {
|
||||||
|
const body = await parseJson<{ command?: string }>(request);
|
||||||
|
sendJson(
|
||||||
|
response,
|
||||||
|
200,
|
||||||
|
await runWorkspaceCommand(route.jobId, body.command ?? ''),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && route.action === 'open-pr') {
|
||||||
|
sendJson(response, 200, await openWorkspacePullRequest(route.jobId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && route.action === 'stop') {
|
||||||
|
sendJson(response, 200, await stopWorkspace(route.jobId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(response, 404, { error: 'Not found' });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
sendJson(response, message === 'Unauthorized' ? 401 : 500, {
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
server.listen(env.httpPort, () => {
|
||||||
|
console.log(
|
||||||
|
`Spoon agent worker HTTP server listening on port ${env.httpPort}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
+675
-113
@@ -1,11 +1,18 @@
|
|||||||
import { access, readFile, rm } from 'node:fs/promises';
|
import {
|
||||||
|
access,
|
||||||
|
mkdir,
|
||||||
|
readdir,
|
||||||
|
readFile,
|
||||||
|
rm,
|
||||||
|
stat,
|
||||||
|
writeFile,
|
||||||
|
} from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { ConvexHttpClient } from 'convex/browser';
|
import { ConvexHttpClient } from 'convex/browser';
|
||||||
|
|
||||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
import { runOpenAiEdit } from './agent';
|
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import {
|
import {
|
||||||
cloneRepository,
|
cloneRepository,
|
||||||
@@ -22,6 +29,10 @@ type Claim = {
|
|||||||
job: {
|
job: {
|
||||||
_id: Id<'agentJobs'>;
|
_id: Id<'agentJobs'>;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
|
runtime?: 'openai_direct' | 'opencode';
|
||||||
|
jobType?: 'user_change' | 'maintenance_review' | 'conflict_resolution';
|
||||||
|
envFilePath?: string;
|
||||||
|
materializeEnvFile?: boolean;
|
||||||
baseBranch: string;
|
baseBranch: string;
|
||||||
workBranch: string;
|
workBranch: string;
|
||||||
forkOwner: string;
|
forkOwner: string;
|
||||||
@@ -31,7 +42,26 @@ type Claim = {
|
|||||||
};
|
};
|
||||||
spoon: { name: string };
|
spoon: { name: string };
|
||||||
openai: {
|
openai: {
|
||||||
apiKey: string;
|
apiKey?: string;
|
||||||
|
model: string;
|
||||||
|
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||||
|
};
|
||||||
|
aiProviderProfile?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
provider:
|
||||||
|
| 'openai'
|
||||||
|
| 'anthropic'
|
||||||
|
| 'google'
|
||||||
|
| 'openrouter'
|
||||||
|
| 'requesty'
|
||||||
|
| 'litellm'
|
||||||
|
| 'cloudflare_ai_gateway'
|
||||||
|
| 'custom_openai_compatible'
|
||||||
|
| 'opencode_openai_login';
|
||||||
|
authType: 'api_key' | 'opencode_auth_json' | 'none';
|
||||||
|
secret?: string;
|
||||||
|
baseUrl?: string;
|
||||||
model: string;
|
model: string;
|
||||||
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||||
};
|
};
|
||||||
@@ -40,13 +70,30 @@ type Claim = {
|
|||||||
installCommand?: string;
|
installCommand?: string;
|
||||||
checkCommand?: string;
|
checkCommand?: string;
|
||||||
testCommand?: string;
|
testCommand?: string;
|
||||||
|
autoDetectCommands?: boolean;
|
||||||
} | null;
|
} | null;
|
||||||
secrets: { name: string; value: string }[];
|
secrets: { name: string; value: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ActiveWorkspace = {
|
||||||
|
claim: Claim;
|
||||||
|
workdir: string;
|
||||||
|
repoDir: string;
|
||||||
|
githubToken: string;
|
||||||
|
redact: (value: string) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FileTreeNode = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: 'file' | 'directory';
|
||||||
|
children?: FileTreeNode[];
|
||||||
|
};
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
const client = new ConvexHttpClient(env.convexUrl);
|
const client = new ConvexHttpClient(env.convexUrl);
|
||||||
|
const activeWorkspaces = new Map<string, ActiveWorkspace>();
|
||||||
|
|
||||||
const appendEvent = async (
|
const appendEvent = async (
|
||||||
jobId: Id<'agentJobs'>,
|
jobId: Id<'agentJobs'>,
|
||||||
@@ -129,8 +176,232 @@ const completeWithDraftPr = async (args: {
|
|||||||
...args,
|
...args,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const applyMaintenanceDecision = async (
|
||||||
|
jobId: Id<'agentJobs'>,
|
||||||
|
decision: MaintenanceDecision,
|
||||||
|
) =>
|
||||||
|
await client.mutation(api.agentJobs.applyMaintenanceDecision, {
|
||||||
|
workerToken: env.workerToken,
|
||||||
|
workerId: env.workerId,
|
||||||
|
jobId,
|
||||||
|
...decision,
|
||||||
|
});
|
||||||
|
|
||||||
|
const markWorkspaceActive = async (args: {
|
||||||
|
jobId: Id<'agentJobs'>;
|
||||||
|
opencodeSessionId?: string;
|
||||||
|
containerId?: string;
|
||||||
|
workspaceUrl?: string;
|
||||||
|
}) =>
|
||||||
|
await client.mutation(api.agentJobs.markWorkspaceActive, {
|
||||||
|
workerToken: env.workerToken,
|
||||||
|
workerId: env.workerId,
|
||||||
|
workspaceExpiresAt: Date.now() + 2 * 60 * 60 * 1000,
|
||||||
|
...args,
|
||||||
|
});
|
||||||
|
|
||||||
|
const markWorkspaceStopped = async (
|
||||||
|
jobId: Id<'agentJobs'>,
|
||||||
|
workspaceStatus: 'stopped' | 'expired' | 'failed' = 'stopped',
|
||||||
|
) =>
|
||||||
|
await client.mutation(api.agentJobs.markWorkspaceStopped, {
|
||||||
|
workerToken: env.workerToken,
|
||||||
|
workerId: env.workerId,
|
||||||
|
jobId,
|
||||||
|
workspaceStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
const appendMessage = async (args: {
|
||||||
|
jobId: Id<'agentJobs'>;
|
||||||
|
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||||
|
content: string;
|
||||||
|
status: 'queued' | 'streaming' | 'completed' | 'failed';
|
||||||
|
metadata?: string;
|
||||||
|
}) =>
|
||||||
|
await client.mutation(api.agentJobs.appendMessage, {
|
||||||
|
workerToken: env.workerToken,
|
||||||
|
workerId: env.workerId,
|
||||||
|
...args,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recordWorkspaceChange = async (args: {
|
||||||
|
jobId: Id<'agentJobs'>;
|
||||||
|
path: string;
|
||||||
|
source: 'user' | 'agent' | 'command';
|
||||||
|
changeType: 'added' | 'modified' | 'deleted' | 'renamed';
|
||||||
|
diff?: string;
|
||||||
|
}) =>
|
||||||
|
await client.mutation(api.agentJobs.recordWorkspaceChange, {
|
||||||
|
workerToken: env.workerToken,
|
||||||
|
workerId: env.workerId,
|
||||||
|
...args,
|
||||||
|
});
|
||||||
|
|
||||||
const commandToShell = (command: string) => ['bash', '-lc', command];
|
const commandToShell = (command: string) => ['bash', '-lc', command];
|
||||||
|
|
||||||
|
const providerEnvironment = (claim: Claim): Record<string, string> => {
|
||||||
|
const profile = claim.aiProviderProfile;
|
||||||
|
const secret = profile?.secret ?? claim.openai.apiKey;
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('No AI provider credential is configured for this job.');
|
||||||
|
}
|
||||||
|
const baseUrl: Record<string, string> = profile?.baseUrl
|
||||||
|
? { OPENAI_BASE_URL: profile.baseUrl }
|
||||||
|
: {};
|
||||||
|
if (!profile || profile.provider === 'openai') {
|
||||||
|
return { OPENAI_API_KEY: secret, ...baseUrl };
|
||||||
|
}
|
||||||
|
if (profile.provider === 'anthropic') return { ANTHROPIC_API_KEY: secret };
|
||||||
|
if (profile.provider === 'google') return { GOOGLE_API_KEY: secret };
|
||||||
|
if (profile.provider === 'openrouter') {
|
||||||
|
return { OPENROUTER_API_KEY: secret, ...baseUrl };
|
||||||
|
}
|
||||||
|
if (profile.provider === 'requesty') {
|
||||||
|
return { REQUESTY_API_KEY: secret, ...baseUrl };
|
||||||
|
}
|
||||||
|
if (profile.provider === 'cloudflare_ai_gateway') {
|
||||||
|
return { CLOUDFLARE_API_KEY: secret, ...baseUrl };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
profile.provider === 'litellm' ||
|
||||||
|
profile.provider === 'custom_openai_compatible'
|
||||||
|
) {
|
||||||
|
return { OPENAI_API_KEY: secret, ...baseUrl };
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
'OpenCode login profiles are saved but need auth-file injection before execution.',
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const opencodeModel = (claim: Claim) => {
|
||||||
|
const profile = claim.aiProviderProfile;
|
||||||
|
const model = profile?.model ?? claim.openai.model;
|
||||||
|
if (model.includes('/')) return model;
|
||||||
|
if (!profile) return `openai/${model}`;
|
||||||
|
if (
|
||||||
|
profile.provider === 'custom_openai_compatible' ||
|
||||||
|
profile.provider === 'cloudflare_ai_gateway'
|
||||||
|
) {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
if (profile.provider === 'opencode_openai_login') return `openai/${model}`;
|
||||||
|
return `${profile.provider}/${model}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const systemPromptForJob = (claim: Claim) => {
|
||||||
|
const base = [
|
||||||
|
`Spoon: ${claim.spoon.name}`,
|
||||||
|
`Fork: ${claim.job.forkOwner}/${claim.job.forkRepo}`,
|
||||||
|
`Upstream: ${claim.job.upstreamOwner}/${claim.job.upstreamRepo}`,
|
||||||
|
`Selected secret names: ${claim.secrets.map((secret) => secret.name).join(', ') || 'none'}`,
|
||||||
|
].join('\n');
|
||||||
|
if (claim.job.jobType === 'maintenance_review') {
|
||||||
|
return `${base}
|
||||||
|
|
||||||
|
You are reviewing upstream changes for a maintained fork.
|
||||||
|
Determine whether the upstream commits can be safely applied.
|
||||||
|
If the fork has no relevant customizations, recommend sync.
|
||||||
|
If upstream changes are irrelevant to this fork, recommend ignore and list commit SHAs.
|
||||||
|
If changes may affect custom fork commits, explain risks and recommend review PR or manual review.
|
||||||
|
Do not claim tests passed unless commands were run.
|
||||||
|
End with a JSON maintenance decision in this exact shape:
|
||||||
|
{
|
||||||
|
"decision": "sync" | "ignore" | "open_review_pr" | "manual_review" | "conflict_resolution" | "unknown",
|
||||||
|
"risk": "low" | "medium" | "high" | "unknown",
|
||||||
|
"summary": "string",
|
||||||
|
"ignoredCommitShas": ["string"],
|
||||||
|
"ignoredReason": "string",
|
||||||
|
"recommendedAction": "string",
|
||||||
|
"requiresUserApproval": true
|
||||||
|
}
|
||||||
|
|
||||||
|
User/system request:
|
||||||
|
${claim.job.prompt}`;
|
||||||
|
}
|
||||||
|
if (claim.job.jobType === 'conflict_resolution') {
|
||||||
|
return `${base}
|
||||||
|
|
||||||
|
You are resolving upstream merge conflicts in a maintained fork.
|
||||||
|
Preserve fork customizations unless the user explicitly removed that upstream behavior.
|
||||||
|
Prefer small, reviewable changes.
|
||||||
|
Produce a draft PR rather than committing to main.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
${claim.job.prompt}`;
|
||||||
|
}
|
||||||
|
return `${base}
|
||||||
|
|
||||||
|
You are working on a maintained fork managed by Spoon.
|
||||||
|
Make the requested change only.
|
||||||
|
Preserve fork-specific customizations.
|
||||||
|
Do not commit secrets.
|
||||||
|
Use selected environment variables only for running/building/testing.
|
||||||
|
Open a draft PR only when instructed by Spoon.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
${claim.job.prompt}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MaintenanceDecision = {
|
||||||
|
decision:
|
||||||
|
| 'sync'
|
||||||
|
| 'ignore'
|
||||||
|
| 'open_review_pr'
|
||||||
|
| 'manual_review'
|
||||||
|
| 'conflict_resolution'
|
||||||
|
| 'unknown';
|
||||||
|
risk: 'low' | 'medium' | 'high' | 'unknown';
|
||||||
|
summary: string;
|
||||||
|
ignoredCommitShas: string[];
|
||||||
|
ignoredReason: string;
|
||||||
|
recommendedAction: string;
|
||||||
|
requiresUserApproval: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseMaintenanceDecision = (
|
||||||
|
output: string,
|
||||||
|
): MaintenanceDecision | null => {
|
||||||
|
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(output)?.[1];
|
||||||
|
const candidates = [fenced, output.slice(output.indexOf('{'))].filter(
|
||||||
|
Boolean,
|
||||||
|
) as string[];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(
|
||||||
|
candidate.trim(),
|
||||||
|
) as Partial<MaintenanceDecision>;
|
||||||
|
if (
|
||||||
|
parsed.decision &&
|
||||||
|
parsed.risk &&
|
||||||
|
typeof parsed.summary === 'string'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
decision: parsed.decision,
|
||||||
|
risk: parsed.risk,
|
||||||
|
summary: parsed.summary,
|
||||||
|
ignoredCommitShas: Array.isArray(parsed.ignoredCommitShas)
|
||||||
|
? parsed.ignoredCommitShas.filter(
|
||||||
|
(sha): sha is string => typeof sha === 'string',
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
ignoredReason:
|
||||||
|
typeof parsed.ignoredReason === 'string'
|
||||||
|
? parsed.ignoredReason
|
||||||
|
: '',
|
||||||
|
recommendedAction:
|
||||||
|
typeof parsed.recommendedAction === 'string'
|
||||||
|
? parsed.recommendedAction
|
||||||
|
: parsed.summary,
|
||||||
|
requiresUserApproval: parsed.requiresUserApproval ?? true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Try the next candidate.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const fileExists = async (filePath: string) => {
|
const fileExists = async (filePath: string) => {
|
||||||
try {
|
try {
|
||||||
await access(filePath);
|
await access(filePath);
|
||||||
@@ -180,28 +451,91 @@ const runProjectCommand = async (args: {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const quoteShell = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||||
|
|
||||||
|
const resolveWorkspace = (jobId: string) => {
|
||||||
|
const workspace = activeWorkspaces.get(jobId);
|
||||||
|
if (!workspace) {
|
||||||
|
throw new Error('Agent workspace is not active on this worker.');
|
||||||
|
}
|
||||||
|
return workspace;
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeWorkspacePath = (repoDir: string, filePath: string) => {
|
||||||
|
const resolved = path.resolve(repoDir, filePath);
|
||||||
|
const root = path.resolve(repoDir);
|
||||||
|
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
|
||||||
|
throw new Error(`Refusing to access path outside repository: ${filePath}`);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileChangedType = async (repoDir: string, filePath: string) => {
|
||||||
|
const status = await run('git', ['status', '--short', '--', filePath], {
|
||||||
|
cwd: repoDir,
|
||||||
|
redact: (value) => value,
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
});
|
||||||
|
const code = status.output.trim().slice(0, 2);
|
||||||
|
if (code.includes('D')) return 'deleted' as const;
|
||||||
|
if (code.includes('A') || code.includes('?')) return 'added' as const;
|
||||||
|
if (code.includes('R')) return 'renamed' as const;
|
||||||
|
return 'modified' as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
const materializeEnvFile = async (workspace: ActiveWorkspace) => {
|
||||||
|
const { claim, repoDir } = workspace;
|
||||||
|
if (!claim.job.materializeEnvFile || !claim.job.envFilePath) return;
|
||||||
|
const envPath = safeWorkspacePath(repoDir, claim.job.envFilePath);
|
||||||
|
await mkdir(path.dirname(envPath), { recursive: true });
|
||||||
|
const content = `${claim.secrets
|
||||||
|
.map((secret) => `${secret.name}=${JSON.stringify(secret.value)}`)
|
||||||
|
.join('\n')}\n`;
|
||||||
|
await writeFile(envPath, content);
|
||||||
|
await appendEvent(
|
||||||
|
claim.job._id,
|
||||||
|
'info',
|
||||||
|
'clone',
|
||||||
|
`Materialized selected secrets into ${claim.job.envFilePath}.`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const detectPackageCommands = async (
|
const detectPackageCommands = async (
|
||||||
repoDir: string,
|
repoDir: string,
|
||||||
): Promise<{ install?: string; check?: string; test?: string }> => {
|
): Promise<{
|
||||||
|
packageManager?: string;
|
||||||
|
scripts?: string[];
|
||||||
|
install?: string;
|
||||||
|
check?: string;
|
||||||
|
test?: string;
|
||||||
|
build?: string;
|
||||||
|
}> => {
|
||||||
const packageJsonPath = path.join(repoDir, 'package.json');
|
const packageJsonPath = path.join(repoDir, 'package.json');
|
||||||
try {
|
try {
|
||||||
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as {
|
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as {
|
||||||
|
packageManager?: string;
|
||||||
scripts?: Record<string, string>;
|
scripts?: Record<string, string>;
|
||||||
};
|
};
|
||||||
const scripts = packageJson.scripts ?? {};
|
const scripts = packageJson.scripts ?? {};
|
||||||
const packageManager = (await fileExists(path.join(repoDir, 'bun.lock')))
|
const declaredPackageManager = packageJson.packageManager?.split('@')[0];
|
||||||
|
const detectedPackageManager = (await fileExists(
|
||||||
|
path.join(repoDir, 'bun.lock'),
|
||||||
|
))
|
||||||
? 'bun'
|
? 'bun'
|
||||||
: (await fileExists(path.join(repoDir, 'pnpm-lock.yaml')))
|
: (await fileExists(path.join(repoDir, 'pnpm-lock.yaml')))
|
||||||
? 'pnpm'
|
? 'pnpm'
|
||||||
: (await fileExists(path.join(repoDir, 'yarn.lock')))
|
: (await fileExists(path.join(repoDir, 'yarn.lock')))
|
||||||
? 'yarn'
|
? 'yarn'
|
||||||
: 'npm';
|
: 'npm';
|
||||||
|
const packageManager = declaredPackageManager ?? detectedPackageManager;
|
||||||
const runScript = (script: string) =>
|
const runScript = (script: string) =>
|
||||||
packageManager === 'npm'
|
packageManager === 'npm'
|
||||||
? `npm run ${script}`
|
? `npm run ${script}`
|
||||||
: `${packageManager} run ${script}`;
|
: `${packageManager} run ${script}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
packageManager,
|
||||||
|
scripts: Object.keys(scripts).sort(),
|
||||||
install: `${packageManager} install`,
|
install: `${packageManager} install`,
|
||||||
check: scripts.typecheck
|
check: scripts.typecheck
|
||||||
? runScript('typecheck')
|
? runScript('typecheck')
|
||||||
@@ -213,6 +547,7 @@ const detectPackageCommands = async (
|
|||||||
? 'npm test'
|
? 'npm test'
|
||||||
: `${packageManager} test`
|
: `${packageManager} test`
|
||||||
: undefined,
|
: undefined,
|
||||||
|
build: scripts.build ? runScript('build') : undefined,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
@@ -250,13 +585,28 @@ ${
|
|||||||
|
|
||||||
Generated by Spoon.`;
|
Generated by Spoon.`;
|
||||||
|
|
||||||
|
const ensureNoEnvFilesStaged = async (workspace: ActiveWorkspace) => {
|
||||||
|
const status = await run('git', ['status', '--short'], {
|
||||||
|
cwd: workspace.repoDir,
|
||||||
|
redact: workspace.redact,
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
});
|
||||||
|
const envLine = status.output
|
||||||
|
.split('\n')
|
||||||
|
.find((line) => /\s\.env(?:$|[./-])/.test(line));
|
||||||
|
if (envLine) {
|
||||||
|
throw new Error(`Refusing to commit env file changes: ${envLine.trim()}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const runClaim = async (claim: Claim) => {
|
const runClaim = async (claim: Claim) => {
|
||||||
const jobId = claim.job._id;
|
const jobId = claim.job._id;
|
||||||
const workdir = path.resolve(env.workdir, jobId);
|
const workdir = path.resolve(env.workdir, jobId);
|
||||||
const secretValues = [
|
const secretValues = [
|
||||||
claim.openai.apiKey,
|
claim.openai.apiKey ?? '',
|
||||||
|
claim.aiProviderProfile?.secret ?? '',
|
||||||
...claim.secrets.map((secret) => secret.value),
|
...claim.secrets.map((secret) => secret.value),
|
||||||
];
|
].filter(Boolean);
|
||||||
const redact = createRedactor(secretValues);
|
const redact = createRedactor(secretValues);
|
||||||
try {
|
try {
|
||||||
await updateStatus(jobId, 'preparing');
|
await updateStatus(jobId, 'preparing');
|
||||||
@@ -275,118 +625,37 @@ const runClaim = async (claim: Claim) => {
|
|||||||
redact,
|
redact,
|
||||||
timeoutMs: env.jobTimeoutMs,
|
timeoutMs: env.jobTimeoutMs,
|
||||||
});
|
});
|
||||||
await updateStatus(jobId, 'running');
|
const workspace: ActiveWorkspace = {
|
||||||
await appendEvent(jobId, 'info', 'plan', 'Gathering repo context.');
|
claim,
|
||||||
const edit = await runOpenAiEdit({
|
workdir,
|
||||||
repoDir,
|
repoDir,
|
||||||
apiKey: claim.openai.apiKey,
|
githubToken,
|
||||||
model: claim.openai.model,
|
redact,
|
||||||
reasoningEffort: claim.openai.reasoningEffort,
|
};
|
||||||
prompt: claim.job.prompt,
|
await materializeEnvFile(workspace);
|
||||||
secretNames: claim.secrets.map((secret) => secret.name),
|
|
||||||
spoonName: claim.spoon.name,
|
|
||||||
upstreamFullName: `${claim.job.upstreamOwner}/${claim.job.upstreamRepo}`,
|
|
||||||
forkFullName: `${claim.job.forkOwner}/${claim.job.forkRepo}`,
|
|
||||||
});
|
|
||||||
await addArtifact({
|
|
||||||
jobId,
|
|
||||||
kind: 'plan',
|
|
||||||
title: 'Agent plan',
|
|
||||||
content: edit.summary,
|
|
||||||
contentType: 'text/markdown',
|
|
||||||
});
|
|
||||||
const status = await getStatus(repoDir, redact);
|
|
||||||
if (!status.output.trim()) {
|
|
||||||
throw new Error('No changes produced by the agent.');
|
|
||||||
}
|
|
||||||
const diff = await getWorktreeDiff(repoDir, redact);
|
|
||||||
await addArtifact({
|
|
||||||
jobId,
|
|
||||||
kind: 'diff',
|
|
||||||
title: 'Git diff',
|
|
||||||
content: truncate(diff.output, 200_000),
|
|
||||||
contentType: 'text/x-diff',
|
|
||||||
});
|
|
||||||
await updateStatus(jobId, 'checks_running');
|
|
||||||
const detected = await detectPackageCommands(repoDir);
|
const detected = await detectPackageCommands(repoDir);
|
||||||
const settings = claim.agentSettings;
|
|
||||||
const installCommand = settings?.installCommand ?? detected.install;
|
|
||||||
const checkCommand = settings?.checkCommand ?? detected.check;
|
|
||||||
const testCommand = settings?.testCommand ?? detected.test;
|
|
||||||
if (installCommand) {
|
|
||||||
await runProjectCommand({
|
|
||||||
command: installCommand,
|
|
||||||
phase: 'install',
|
|
||||||
claim,
|
|
||||||
workdir,
|
|
||||||
repoDir,
|
|
||||||
redact,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (checkCommand) {
|
|
||||||
await runProjectCommand({
|
|
||||||
command: checkCommand,
|
|
||||||
phase: 'check',
|
|
||||||
claim,
|
|
||||||
workdir,
|
|
||||||
repoDir,
|
|
||||||
redact,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (testCommand) {
|
|
||||||
await runProjectCommand({
|
|
||||||
command: testCommand,
|
|
||||||
phase: 'test',
|
|
||||||
claim,
|
|
||||||
workdir,
|
|
||||||
repoDir,
|
|
||||||
redact,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await appendEvent(jobId, 'info', 'commit', 'Committing changes.');
|
|
||||||
const commitSha = await commitAndPush({
|
|
||||||
repoDir,
|
|
||||||
workBranch: claim.job.workBranch,
|
|
||||||
message: `Agent: ${claim.job.prompt.slice(0, 72)}`,
|
|
||||||
redact,
|
|
||||||
timeoutMs: env.jobTimeoutMs,
|
|
||||||
});
|
|
||||||
const prBody = buildPrBody({
|
|
||||||
prompt: claim.job.prompt,
|
|
||||||
summary: edit.summary,
|
|
||||||
commands: [
|
|
||||||
installCommand,
|
|
||||||
checkCommand,
|
|
||||||
testCommand,
|
|
||||||
...edit.commands,
|
|
||||||
].filter((command): command is string => Boolean(command)),
|
|
||||||
limitations: edit.limitations,
|
|
||||||
});
|
|
||||||
await addArtifact({
|
await addArtifact({
|
||||||
jobId,
|
jobId,
|
||||||
kind: 'pr_body',
|
kind: 'summary',
|
||||||
title: 'Draft PR body',
|
title: 'Detected project commands',
|
||||||
content: prBody,
|
content: JSON.stringify(detected, null, 2),
|
||||||
contentType: 'text/markdown',
|
contentType: 'application/json',
|
||||||
});
|
});
|
||||||
await appendEvent(jobId, 'info', 'pr', 'Opening draft pull request.');
|
activeWorkspaces.set(jobId, workspace);
|
||||||
const pullRequest = await openDraftPullRequest({
|
await markWorkspaceActive({ jobId });
|
||||||
installationId: claim.github.installationId,
|
await updateStatus(jobId, 'running', {
|
||||||
forkOwner: claim.job.forkOwner,
|
summary: 'Workspace is active.',
|
||||||
forkRepo: claim.job.forkRepo,
|
|
||||||
baseBranch: claim.job.baseBranch,
|
|
||||||
workBranch: claim.job.workBranch,
|
|
||||||
title: `Agent: ${claim.job.prompt.slice(0, 64)}`,
|
|
||||||
body: prBody,
|
|
||||||
});
|
});
|
||||||
await completeWithDraftPr({
|
await appendMessage({
|
||||||
jobId,
|
jobId,
|
||||||
commitSha,
|
role: 'system',
|
||||||
pullRequestUrl: pullRequest.html_url,
|
status: 'completed',
|
||||||
pullRequestNumber: pullRequest.number,
|
content:
|
||||||
summary: edit.summary,
|
'Workspace is ready. You can browse files, edit manually, run commands, or send messages to the agent.',
|
||||||
});
|
});
|
||||||
await appendEvent(jobId, 'info', 'cleanup', 'Agent job completed.');
|
await appendEvent(jobId, 'info', 'plan', 'Interactive workspace is ready.');
|
||||||
|
|
||||||
|
await sendWorkspaceMessage(jobId, systemPromptForJob(claim));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
await appendEvent(
|
await appendEvent(
|
||||||
@@ -407,11 +676,304 @@ const runClaim = async (claim: Claim) => {
|
|||||||
message.toLowerCase().includes('timed out') ? 'timed_out' : 'failed',
|
message.toLowerCase().includes('timed out') ? 'timed_out' : 'failed',
|
||||||
{ error: truncate(redact(message), 10_000) },
|
{ error: truncate(redact(message), 10_000) },
|
||||||
);
|
);
|
||||||
} finally {
|
await markWorkspaceStopped(
|
||||||
await rm(workdir, { recursive: true, force: true });
|
jobId,
|
||||||
|
message.toLowerCase().includes('timed out') ? 'expired' : 'failed',
|
||||||
|
).catch((stopError: unknown) => {
|
||||||
|
console.error(stopError);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const listWorkspaceTree = async (jobId: string) => {
|
||||||
|
const workspace = resolveWorkspace(jobId);
|
||||||
|
const buildNode = async (
|
||||||
|
absolutePath: string,
|
||||||
|
relativePath: string,
|
||||||
|
): Promise<FileTreeNode | null> => {
|
||||||
|
const basename = path.basename(absolutePath);
|
||||||
|
if (['.git', 'node_modules', '.next', 'dist', 'build'].includes(basename)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const stats = await stat(absolutePath);
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
const entries = await readdir(absolutePath);
|
||||||
|
const children = (
|
||||||
|
await Promise.all(
|
||||||
|
entries
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.map((entry) =>
|
||||||
|
buildNode(
|
||||||
|
path.join(absolutePath, entry),
|
||||||
|
path.join(relativePath, entry),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).filter((node): node is FileTreeNode => Boolean(node));
|
||||||
|
return {
|
||||||
|
name: relativePath ? basename : workspace.claim.job.forkRepo,
|
||||||
|
path: relativePath,
|
||||||
|
type: 'directory',
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { name: basename, path: relativePath, type: 'file' };
|
||||||
|
};
|
||||||
|
return await buildNode(workspace.repoDir, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readWorkspaceFile = async (jobId: string, filePath: string) => {
|
||||||
|
const workspace = resolveWorkspace(jobId);
|
||||||
|
const target = safeWorkspacePath(workspace.repoDir, filePath);
|
||||||
|
return await readFile(target, 'utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeWorkspaceFile = async (
|
||||||
|
jobId: string,
|
||||||
|
filePath: string,
|
||||||
|
content: string,
|
||||||
|
) => {
|
||||||
|
const workspace = resolveWorkspace(jobId);
|
||||||
|
const target = safeWorkspacePath(workspace.repoDir, filePath);
|
||||||
|
await mkdir(path.dirname(target), { recursive: true });
|
||||||
|
await writeFile(target, content);
|
||||||
|
const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact);
|
||||||
|
await recordWorkspaceChange({
|
||||||
|
jobId: workspace.claim.job._id,
|
||||||
|
path: filePath,
|
||||||
|
source: 'user',
|
||||||
|
changeType: await fileChangedType(workspace.repoDir, filePath),
|
||||||
|
diff: truncate(diff.output, 50_000),
|
||||||
|
});
|
||||||
|
await appendEvent(
|
||||||
|
workspace.claim.job._id,
|
||||||
|
'info',
|
||||||
|
'edit',
|
||||||
|
`Saved ${filePath}.`,
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getWorkspaceDiff = async (jobId: string) => {
|
||||||
|
const workspace = resolveWorkspace(jobId);
|
||||||
|
const diff = await getWorktreeDiff(workspace.repoDir, workspace.redact);
|
||||||
|
return diff.output;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runWorkspaceCommand = async (jobId: string, command: string) => {
|
||||||
|
const workspace = resolveWorkspace(jobId);
|
||||||
|
await updateStatus(workspace.claim.job._id, 'checks_running');
|
||||||
|
await runProjectCommand({
|
||||||
|
command,
|
||||||
|
phase: command.includes('test') ? 'test' : 'check',
|
||||||
|
claim: workspace.claim,
|
||||||
|
workdir: workspace.workdir,
|
||||||
|
repoDir: workspace.repoDir,
|
||||||
|
redact: workspace.redact,
|
||||||
|
});
|
||||||
|
await updateStatus(workspace.claim.job._id, 'running');
|
||||||
|
await recordWorkspaceChange({
|
||||||
|
jobId: workspace.claim.job._id,
|
||||||
|
path: '.',
|
||||||
|
source: 'command',
|
||||||
|
changeType: 'modified',
|
||||||
|
diff: truncate(
|
||||||
|
(await getWorktreeDiff(workspace.repoDir, workspace.redact)).output,
|
||||||
|
50_000,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendWorkspaceMessage = async (jobId: string, prompt: string) => {
|
||||||
|
const workspace = resolveWorkspace(jobId);
|
||||||
|
const { claim, repoDir, redact, workdir } = workspace;
|
||||||
|
await appendMessage({
|
||||||
|
jobId: claim.job._id,
|
||||||
|
role: 'user',
|
||||||
|
status: 'completed',
|
||||||
|
content: prompt,
|
||||||
|
});
|
||||||
|
await appendEvent(claim.job._id, 'info', 'plan', 'Sending message to agent.');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
|
||||||
|
throw new Error('Legacy OpenAI direct jobs are no longer supported.');
|
||||||
|
}
|
||||||
|
const model = opencodeModel(claim);
|
||||||
|
const aiEnv = providerEnvironment(claim);
|
||||||
|
const secretEnv = Object.fromEntries(
|
||||||
|
claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||||
|
);
|
||||||
|
const result =
|
||||||
|
env.runtime === 'docker'
|
||||||
|
? await runInJobContainer({
|
||||||
|
workdir,
|
||||||
|
command: commandToShell(
|
||||||
|
`opencode run --model ${quoteShell(model)} ${quoteShell(prompt)}`,
|
||||||
|
),
|
||||||
|
environment: {
|
||||||
|
...aiEnv,
|
||||||
|
...secretEnv,
|
||||||
|
},
|
||||||
|
redact,
|
||||||
|
timeoutMs: env.jobTimeoutMs,
|
||||||
|
})
|
||||||
|
: await run(
|
||||||
|
'bash',
|
||||||
|
[
|
||||||
|
'-lc',
|
||||||
|
`opencode run --model ${quoteShell(model)} ${quoteShell(prompt)}`,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: repoDir,
|
||||||
|
env: {
|
||||||
|
...aiEnv,
|
||||||
|
...secretEnv,
|
||||||
|
},
|
||||||
|
redact,
|
||||||
|
timeoutMs: env.jobTimeoutMs,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await appendMessage({
|
||||||
|
jobId: claim.job._id,
|
||||||
|
role: 'assistant',
|
||||||
|
status: result.exitCode === 0 ? 'completed' : 'failed',
|
||||||
|
content: truncate(result.output, 40_000),
|
||||||
|
});
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error(`opencode failed:\n${result.output}`);
|
||||||
|
}
|
||||||
|
if (claim.job.jobType === 'maintenance_review') {
|
||||||
|
const decision = parseMaintenanceDecision(result.output);
|
||||||
|
if (decision) {
|
||||||
|
await addArtifact({
|
||||||
|
jobId: claim.job._id,
|
||||||
|
kind: 'summary',
|
||||||
|
title: 'Maintenance decision',
|
||||||
|
content: JSON.stringify(decision, null, 2),
|
||||||
|
contentType: 'application/json',
|
||||||
|
});
|
||||||
|
await applyMaintenanceDecision(claim.job._id, decision);
|
||||||
|
} else {
|
||||||
|
await updateStatus(claim.job._id, 'changes_ready', {
|
||||||
|
summary:
|
||||||
|
'OpenCode completed the review, but Spoon could not parse a structured maintenance decision.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const diff = await getWorktreeDiff(repoDir, redact);
|
||||||
|
await addArtifact({
|
||||||
|
jobId: claim.job._id,
|
||||||
|
kind: 'diff',
|
||||||
|
title: 'Git diff',
|
||||||
|
content: truncate(diff.output, 200_000),
|
||||||
|
contentType: 'text/x-diff',
|
||||||
|
});
|
||||||
|
await recordWorkspaceChange({
|
||||||
|
jobId: claim.job._id,
|
||||||
|
path: '.',
|
||||||
|
source: 'agent',
|
||||||
|
changeType: 'modified',
|
||||||
|
diff: truncate(diff.output, 50_000),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
await appendEvent(
|
||||||
|
claim.job._id,
|
||||||
|
'error',
|
||||||
|
'cleanup',
|
||||||
|
truncate(redact(message), 20_000),
|
||||||
|
);
|
||||||
|
await appendMessage({
|
||||||
|
jobId: claim.job._id,
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'failed',
|
||||||
|
content: truncate(redact(message), 40_000),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const openWorkspacePullRequest = async (jobId: string) => {
|
||||||
|
const workspace = resolveWorkspace(jobId);
|
||||||
|
const { claim, repoDir, redact } = workspace;
|
||||||
|
await ensureNoEnvFilesStaged(workspace);
|
||||||
|
const status = await getStatus(repoDir, redact);
|
||||||
|
if (!status.output.trim()) {
|
||||||
|
throw new Error('No changes are ready for a draft PR.');
|
||||||
|
}
|
||||||
|
const diff = await getWorktreeDiff(repoDir, redact);
|
||||||
|
const settings = claim.agentSettings;
|
||||||
|
const detected = await detectPackageCommands(repoDir);
|
||||||
|
const installCommand = settings?.installCommand ?? detected.install;
|
||||||
|
const checkCommand = settings?.checkCommand ?? detected.check;
|
||||||
|
const testCommand = settings?.testCommand ?? detected.test;
|
||||||
|
const prBody = buildPrBody({
|
||||||
|
prompt: claim.job.prompt,
|
||||||
|
summary: 'Interactive Spoon agent workspace changes.',
|
||||||
|
commands: [installCommand, checkCommand, testCommand].filter(
|
||||||
|
(command): command is string => Boolean(command),
|
||||||
|
),
|
||||||
|
limitations: ['Review the draft PR before merging.'],
|
||||||
|
});
|
||||||
|
await addArtifact({
|
||||||
|
jobId: claim.job._id,
|
||||||
|
kind: 'diff',
|
||||||
|
title: 'Final Git diff',
|
||||||
|
content: truncate(diff.output, 200_000),
|
||||||
|
contentType: 'text/x-diff',
|
||||||
|
});
|
||||||
|
await addArtifact({
|
||||||
|
jobId: claim.job._id,
|
||||||
|
kind: 'pr_body',
|
||||||
|
title: 'Draft PR body',
|
||||||
|
content: prBody,
|
||||||
|
contentType: 'text/markdown',
|
||||||
|
});
|
||||||
|
const commitSha = await commitAndPush({
|
||||||
|
repoDir,
|
||||||
|
workBranch: claim.job.workBranch,
|
||||||
|
message: `Agent: ${claim.job.prompt.slice(0, 72)}`,
|
||||||
|
redact,
|
||||||
|
timeoutMs: env.jobTimeoutMs,
|
||||||
|
});
|
||||||
|
if (!claim.github.installationId) {
|
||||||
|
throw new Error('GitHub installation ID is missing.');
|
||||||
|
}
|
||||||
|
const pullRequest = await openDraftPullRequest({
|
||||||
|
installationId: claim.github.installationId,
|
||||||
|
forkOwner: claim.job.forkOwner,
|
||||||
|
forkRepo: claim.job.forkRepo,
|
||||||
|
baseBranch: claim.job.baseBranch,
|
||||||
|
workBranch: claim.job.workBranch,
|
||||||
|
title: `Agent: ${claim.job.prompt.slice(0, 64)}`,
|
||||||
|
body: prBody,
|
||||||
|
});
|
||||||
|
await completeWithDraftPr({
|
||||||
|
jobId: claim.job._id,
|
||||||
|
commitSha,
|
||||||
|
pullRequestUrl: pullRequest.html_url,
|
||||||
|
pullRequestNumber: pullRequest.number,
|
||||||
|
summary: 'Draft PR opened from interactive workspace.',
|
||||||
|
});
|
||||||
|
await markWorkspaceStopped(claim.job._id);
|
||||||
|
activeWorkspaces.delete(jobId);
|
||||||
|
await rm(workspace.workdir, { recursive: true, force: true });
|
||||||
|
return {
|
||||||
|
pullRequestUrl: pullRequest.html_url,
|
||||||
|
pullRequestNumber: pullRequest.number,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stopWorkspace = async (jobId: string) => {
|
||||||
|
const workspace = resolveWorkspace(jobId);
|
||||||
|
await markWorkspaceStopped(workspace.claim.job._id);
|
||||||
|
activeWorkspaces.delete(jobId);
|
||||||
|
await rm(workspace.workdir, { recursive: true, force: true });
|
||||||
|
return { success: true };
|
||||||
|
};
|
||||||
|
|
||||||
export const startWorker = async () => {
|
export const startWorker = async () => {
|
||||||
console.log(`Spoon agent worker ${env.workerId} polling ${env.convexUrl}`);
|
console.log(`Spoon agent worker ${env.workerId} polling ${env.convexUrl}`);
|
||||||
for (;;) {
|
for (;;) {
|
||||||
|
|||||||
@@ -29,11 +29,9 @@ const Index = () => {
|
|||||||
api.syncRuns.listRecent,
|
api.syncRuns.listRecent,
|
||||||
isAuthenticated ? { limit: 5 } : 'skip',
|
isAuthenticated ? { limit: 5 } : 'skip',
|
||||||
) ?? [];
|
) ?? [];
|
||||||
const agentRequests =
|
const threads =
|
||||||
useQuery(
|
useQuery(api.threads.listMine, isAuthenticated ? { limit: 5 } : 'skip') ??
|
||||||
api.agentRequests.listRecent,
|
[];
|
||||||
isAuthenticated ? { limit: 5 } : 'skip',
|
|
||||||
) ?? [];
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@@ -101,8 +99,8 @@ const Index = () => {
|
|||||||
</View>
|
</View>
|
||||||
<View className='flex-row gap-3'>
|
<View className='flex-row gap-3'>
|
||||||
<Stat label='Spoons' value={spoons.length} />
|
<Stat label='Spoons' value={spoons.length} />
|
||||||
<Stat label='Updates' value={syncRuns.length} />
|
<Stat label='Checks' value={syncRuns.length} />
|
||||||
<Stat label='Agents' value={agentRequests.length} />
|
<Stat label='Threads' value={threads.length} />
|
||||||
</View>
|
</View>
|
||||||
<View className='border-border bg-card rounded-lg border p-4'>
|
<View className='border-border bg-card rounded-lg border p-4'>
|
||||||
<Text className='text-foreground font-semibold'>
|
<Text className='text-foreground font-semibold'>
|
||||||
|
|||||||
@@ -21,11 +21,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@convex-dev/auth": "catalog:convex",
|
"@convex-dev/auth": "catalog:convex",
|
||||||
|
"@monaco-editor/react": "latest",
|
||||||
"@sentry/nextjs": "^10.46.0",
|
"@sentry/nextjs": "^10.46.0",
|
||||||
"@spoon/backend": "workspace:*",
|
"@spoon/backend": "workspace:*",
|
||||||
"@spoon/ui": "workspace:*",
|
"@spoon/ui": "workspace:*",
|
||||||
"@t3-oss/env-nextjs": "^0.13.11",
|
"@t3-oss/env-nextjs": "^0.13.11",
|
||||||
"convex": "catalog:convex",
|
"convex": "catalog:convex",
|
||||||
|
"monaco-editor": "latest",
|
||||||
|
"monaco-vim": "latest",
|
||||||
"next": "^16.2.1",
|
"next": "^16.2.1",
|
||||||
"next-plausible": "^3.12.5",
|
"next-plausible": "^3.12.5",
|
||||||
"react": "catalog:react19",
|
"react": "catalog:react19",
|
||||||
|
|||||||
@@ -1,146 +1,7 @@
|
|||||||
'use client';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { useState } from 'react';
|
const AgentsRedirectPage = () => {
|
||||||
import { useMutation, useQuery } from 'convex/react';
|
redirect('/threads?source=user_request');
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
Input,
|
|
||||||
Label,
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
Textarea,
|
|
||||||
} from '@spoon/ui';
|
|
||||||
|
|
||||||
const AgentsPage = () => {
|
|
||||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
|
||||||
const requests = useQuery(api.agentRequests.listRecent, { limit: 50 }) ?? [];
|
|
||||||
const createRequest = useMutation(api.agentRequests.create);
|
|
||||||
const [spoonId, setSpoonId] = useState('');
|
|
||||||
const [targetBranch, setTargetBranch] = useState('');
|
|
||||||
const [prompt, setPrompt] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!spoonId) {
|
|
||||||
toast.error('Choose a Spoon first.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
await createRequest({
|
|
||||||
spoonId: spoonId as Id<'spoons'>,
|
|
||||||
prompt,
|
|
||||||
targetBranch: targetBranch || undefined,
|
|
||||||
});
|
|
||||||
setPrompt('');
|
|
||||||
setTargetBranch('');
|
|
||||||
toast.success('Agent request queued.');
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
toast.error('Could not queue agent request.');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className='space-y-6'>
|
|
||||||
<div>
|
|
||||||
<h1 className='text-3xl font-semibold tracking-normal'>Agents</h1>
|
|
||||||
<p className='text-muted-foreground mt-2'>
|
|
||||||
Queue prompt-driven work for future AI merge request automation.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className='grid gap-6 xl:grid-cols-[0.9fr_1.1fr]'>
|
|
||||||
<Card className='shadow-none'>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Request work</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={handleSubmit} className='space-y-4'>
|
|
||||||
<div className='grid gap-2'>
|
|
||||||
<Label>Spoon</Label>
|
|
||||||
<Select value={spoonId} onValueChange={setSpoonId}>
|
|
||||||
<SelectTrigger className='w-full'>
|
|
||||||
<SelectValue placeholder='Choose a Spoon' />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{spoons.map((spoon) => (
|
|
||||||
<SelectItem key={spoon._id} value={spoon._id}>
|
|
||||||
{spoon.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className='grid gap-2'>
|
|
||||||
<Label htmlFor='targetBranch'>Target branch</Label>
|
|
||||||
<Input
|
|
||||||
id='targetBranch'
|
|
||||||
value={targetBranch}
|
|
||||||
placeholder='feature/my-change'
|
|
||||||
onChange={(event) => setTargetBranch(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='grid gap-2'>
|
|
||||||
<Label htmlFor='prompt'>Prompt</Label>
|
|
||||||
<Textarea
|
|
||||||
id='prompt'
|
|
||||||
value={prompt}
|
|
||||||
required
|
|
||||||
onChange={(event) => setPrompt(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button type='submit' disabled={submitting || !spoons.length}>
|
|
||||||
{submitting ? 'Queueing...' : 'Queue request'}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className='shadow-none'>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Recent requests</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{requests.length ? (
|
|
||||||
<div className='space-y-3'>
|
|
||||||
{requests.map((request) => (
|
|
||||||
<div key={request._id} className='border-border border p-4'>
|
|
||||||
<p className='font-medium'>{request.prompt}</p>
|
|
||||||
<p className='text-muted-foreground mt-1 text-sm'>
|
|
||||||
{request.status.replaceAll('_', ' ')} ·{' '}
|
|
||||||
{(request.requestType ?? 'future_code_change').replaceAll(
|
|
||||||
'_',
|
|
||||||
' ',
|
|
||||||
)}{' '}
|
|
||||||
· {request.source ?? 'user'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className='text-muted-foreground'>
|
|
||||||
Agent requests will appear here after you create a Spoon and
|
|
||||||
queue work.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AgentsPage;
|
export default AgentsRedirectPage;
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { MetricCard } from '@/components/dashboard/metric-card';
|
import { MetricCard } from '@/components/dashboard/metric-card';
|
||||||
import { SpoonCard } from '@/components/spoons/spoon-card';
|
import { SpoonCard } from '@/components/spoons/spoon-card';
|
||||||
import { MaintenanceQueue } from '@/components/updates/maintenance-queue';
|
import { MaintenanceQueue } from '@/components/threads/maintenance-queue';
|
||||||
import { useQuery } from 'convex/react';
|
import { useQuery } from 'convex/react';
|
||||||
import { Bot, GitBranch, RefreshCw, ShieldCheck } from 'lucide-react';
|
import { GitBranch, MessageSquare, RefreshCw, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||||
@@ -13,9 +13,7 @@ import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
|||||||
const DashboardPage = () => {
|
const DashboardPage = () => {
|
||||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||||
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
|
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
|
||||||
const agentRequests =
|
const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
|
||||||
useQuery(api.agentRequests.listRecent, { limit: 5 }) ?? [];
|
|
||||||
const aiReviews = useQuery(api.aiReviews.listRecent, { limit: 5 }) ?? [];
|
|
||||||
const activeSpoons = spoons.filter(
|
const activeSpoons = spoons.filter(
|
||||||
(spoon) => spoon.status === 'active',
|
(spoon) => spoon.status === 'active',
|
||||||
).length;
|
).length;
|
||||||
@@ -34,7 +32,8 @@ const DashboardPage = () => {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className='text-3xl font-semibold tracking-normal'>Dashboard</h1>
|
<h1 className='text-3xl font-semibold tracking-normal'>Dashboard</h1>
|
||||||
<p className='text-muted-foreground mt-2'>
|
<p className='text-muted-foreground mt-2'>
|
||||||
Monitor managed forks, upstream activity, and queued agent work.
|
Monitor managed forks, upstream activity, and open maintenance
|
||||||
|
threads.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
@@ -56,10 +55,17 @@ const DashboardPage = () => {
|
|||||||
icon={RefreshCw}
|
icon={RefreshCw}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label='Agent requests'
|
label='Open threads'
|
||||||
value={agentRequests.length}
|
value={
|
||||||
note='Queued and recent'
|
threads.filter(
|
||||||
icon={Bot}
|
(thread) =>
|
||||||
|
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
|
||||||
|
thread.status,
|
||||||
|
),
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
note='Across all Spoons'
|
||||||
|
icon={MessageSquare}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label='Upstream commits'
|
label='Upstream commits'
|
||||||
@@ -71,7 +77,7 @@ const DashboardPage = () => {
|
|||||||
|
|
||||||
<section className='space-y-3'>
|
<section className='space-y-3'>
|
||||||
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
|
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
|
||||||
<MaintenanceQueue spoons={spoons} />
|
<MaintenanceQueue threads={threads} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className='grid gap-6 xl:grid-cols-2'>
|
<div className='grid gap-6 xl:grid-cols-2'>
|
||||||
@@ -126,29 +132,28 @@ const DashboardPage = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card className='mt-4 shadow-none'>
|
<Card className='mt-4 shadow-none'>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className='text-base'>AI reviews</CardTitle>
|
<CardTitle className='text-base'>Recent threads</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{aiReviews.length ? (
|
{threads.length ? (
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
{aiReviews.map((review) => (
|
{threads.slice(0, 5).map((thread) => (
|
||||||
<div
|
<div
|
||||||
key={review._id}
|
key={thread._id}
|
||||||
className='border-border border p-3 text-sm'
|
className='border-border border p-3 text-sm'
|
||||||
>
|
>
|
||||||
<p className='font-medium capitalize'>
|
<p className='font-medium'>{thread.title}</p>
|
||||||
{review.risk} risk
|
|
||||||
</p>
|
|
||||||
<p className='text-muted-foreground'>
|
<p className='text-muted-foreground'>
|
||||||
{review.outputSummary ?? review.inputSummary}
|
{thread.status.replaceAll('_', ' ')} ·{' '}
|
||||||
|
{thread.source.replaceAll('_', ' ')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className='text-muted-foreground text-sm'>
|
<p className='text-muted-foreground text-sm'>
|
||||||
OpenAI compatibility reviews will appear here after you run
|
Threads appear when you request work or upstream changes need
|
||||||
them on a Spoon.
|
review.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { AiProviderProfilesPanel } from '@/components/integrations/ai-provider-profiles-panel';
|
||||||
|
|
||||||
|
const AiProvidersPage = () => (
|
||||||
|
<section className='max-w-5xl space-y-4'>
|
||||||
|
<div>
|
||||||
|
<h2 className='text-xl font-semibold'>AI providers</h2>
|
||||||
|
<p className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
Configure encrypted API-key profiles and OpenCode auth profiles for
|
||||||
|
agent workspaces.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AiProviderProfilesPanel />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AiProvidersPage;
|
||||||
@@ -1,16 +1,5 @@
|
|||||||
import { OpenAiStatusPanel } from '@/components/integrations/openai-status-panel';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
const AiSettingsPage = () => (
|
const AiSettingsPage = () => redirect('/settings/ai-providers');
|
||||||
<section className='max-w-3xl space-y-4'>
|
|
||||||
<div>
|
|
||||||
<h2 className='text-xl font-semibold'>AI</h2>
|
|
||||||
<p className='text-muted-foreground mt-1 text-sm'>
|
|
||||||
Configure the OpenAI key, review model, and thinking level used for
|
|
||||||
compatibility reviews.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<OpenAiStatusPanel />
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default AiSettingsPage;
|
export default AiSettingsPage;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { cn } from '@spoon/ui';
|
|||||||
const settingsItems = [
|
const settingsItems = [
|
||||||
{ href: '/settings/profile', label: 'Profile', icon: User },
|
{ href: '/settings/profile', label: 'Profile', icon: User },
|
||||||
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
|
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
|
||||||
{ href: '/settings/ai', label: 'AI', icon: Brain },
|
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
|
||||||
{ href: '/settings/security', label: 'Security', icon: Shield },
|
{ href: '/settings/security', label: 'Security', icon: Shield },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { AgentWorkspaceShell } from '@/components/agent-workspace/agent-workspace-shell';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { Button } from '@spoon/ui';
|
||||||
|
|
||||||
|
const AgentWorkspacePage = () => {
|
||||||
|
const params = useParams<{ spoonId: string; jobId: string }>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className='space-y-4'>
|
||||||
|
<Button asChild variant='ghost' size='sm'>
|
||||||
|
<Link href={`/spoons/${params.spoonId}`}>
|
||||||
|
<ArrowLeft className='size-4' />
|
||||||
|
Back to Spoon
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<AgentWorkspaceShell jobId={params.jobId as Id<'agentJobs'>} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AgentWorkspacePage;
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { AgentJobList } from '@/components/agents/agent-job-list';
|
import { AgentJobList } from '@/components/agents/agent-job-list';
|
||||||
import { AgentRequestForm } from '@/components/agents/agent-request-form';
|
import { AgentRequestForm } from '@/components/agents/agent-request-form';
|
||||||
import { SpoonActivityTimeline } from '@/components/spoons/spoon-activity-timeline';
|
import { SpoonActivityTimeline } from '@/components/spoons/spoon-activity-timeline';
|
||||||
import { SpoonAgentSettingsForm } from '@/components/spoons/spoon-agent-settings-form';
|
import { SpoonAgentSettingsForm } from '@/components/spoons/spoon-agent-settings-form';
|
||||||
import { SpoonAiReviewPanel } from '@/components/spoons/spoon-ai-review-panel';
|
|
||||||
import { SpoonClonePanel } from '@/components/spoons/spoon-clone-panel';
|
import { SpoonClonePanel } from '@/components/spoons/spoon-clone-panel';
|
||||||
import { SpoonCommitList } from '@/components/spoons/spoon-commit-list';
|
import { SpoonCommitList } from '@/components/spoons/spoon-commit-list';
|
||||||
import { SpoonDetailHeader } from '@/components/spoons/spoon-detail-header';
|
import { SpoonDetailHeader } from '@/components/spoons/spoon-detail-header';
|
||||||
@@ -46,12 +46,10 @@ const SpoonDetailPage = () => {
|
|||||||
}) ?? [];
|
}) ?? [];
|
||||||
const pullRequests =
|
const pullRequests =
|
||||||
useQuery(api.spoonPullRequests.listForSpoon, { spoonId, limit: 100 }) ?? [];
|
useQuery(api.spoonPullRequests.listForSpoon, { spoonId, limit: 100 }) ?? [];
|
||||||
const reviews =
|
|
||||||
useQuery(api.aiReviews.listForSpoon, { spoonId, limit: 25 }) ?? [];
|
|
||||||
const syncRuns =
|
const syncRuns =
|
||||||
useQuery(api.syncRuns.listForSpoon, { spoonId, limit: 25 }) ?? [];
|
useQuery(api.syncRuns.listForSpoon, { spoonId, limit: 25 }) ?? [];
|
||||||
const agentRequests =
|
const threads =
|
||||||
useQuery(api.agentRequests.listForSpoon, { spoonId, limit: 25 }) ?? [];
|
useQuery(api.threads.listForSpoon, { spoonId, limit: 25 }) ?? [];
|
||||||
const agentSettings = useQuery(api.spoonAgentSettings.getForSpoon, {
|
const agentSettings = useQuery(api.spoonAgentSettings.getForSpoon, {
|
||||||
spoonId,
|
spoonId,
|
||||||
});
|
});
|
||||||
@@ -68,7 +66,7 @@ const SpoonDetailPage = () => {
|
|||||||
<SpoonMetrics
|
<SpoonMetrics
|
||||||
spoon={details.spoon}
|
spoon={details.spoon}
|
||||||
state={details.state}
|
state={details.state}
|
||||||
latestReview={details.latestReview}
|
latestThread={threads[0]}
|
||||||
/>
|
/>
|
||||||
{details.spoon.lastError ? (
|
{details.spoon.lastError ? (
|
||||||
<Card className='border-destructive shadow-none'>
|
<Card className='border-destructive shadow-none'>
|
||||||
@@ -95,11 +93,8 @@ const SpoonDetailPage = () => {
|
|||||||
<TabsTrigger className='h-9 flex-none px-3' value='pulls'>
|
<TabsTrigger className='h-9 flex-none px-3' value='pulls'>
|
||||||
Pull requests
|
Pull requests
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger className='h-9 flex-none px-3' value='ai'>
|
<TabsTrigger className='h-9 flex-none px-3' value='threads'>
|
||||||
AI review
|
Threads
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger className='h-9 flex-none px-3' value='agent'>
|
|
||||||
Agent work
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger className='h-9 flex-none px-3' value='activity'>
|
<TabsTrigger className='h-9 flex-none px-3' value='activity'>
|
||||||
Activity
|
Activity
|
||||||
@@ -125,6 +120,17 @@ const SpoonDetailPage = () => {
|
|||||||
'unknown'
|
'unknown'
|
||||||
).replaceAll('_', ' ')}
|
).replaceAll('_', ' ')}
|
||||||
</p>
|
</p>
|
||||||
|
{details.effectiveUpstreamAheadBy === 0 &&
|
||||||
|
(details.state?.upstreamAheadBy ??
|
||||||
|
details.spoon.upstreamAheadBy ??
|
||||||
|
0) > 0 ? (
|
||||||
|
<p className='text-muted-foreground mt-1 text-xs'>
|
||||||
|
Up to date after ignored upstream changes. Raw upstream
|
||||||
|
ahead:{' '}
|
||||||
|
{details.state?.upstreamAheadBy ??
|
||||||
|
details.spoon.upstreamAheadBy}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-muted-foreground'>Default branches</p>
|
<p className='text-muted-foreground'>Default branches</p>
|
||||||
@@ -155,37 +161,34 @@ const SpoonDetailPage = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card className='shadow-none'>
|
<Card className='shadow-none'>
|
||||||
<CardHeader className='pb-3'>
|
<CardHeader className='pb-3'>
|
||||||
<CardTitle className='text-base'>Latest AI review</CardTitle>
|
<CardTitle className='text-base'>Latest thread</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-3 text-sm'>
|
<CardContent className='space-y-3 text-sm'>
|
||||||
{details.latestReview ? (
|
{threads[0] ? (
|
||||||
<>
|
<>
|
||||||
<div className='grid grid-cols-2 gap-3'>
|
<div className='grid grid-cols-2 gap-3'>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-muted-foreground'>Risk</p>
|
<p className='text-muted-foreground'>Status</p>
|
||||||
<p className='mt-1 font-semibold capitalize'>
|
<p className='mt-1 font-semibold capitalize'>
|
||||||
{details.latestReview.risk}
|
{threads[0].status.replaceAll('_', ' ')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-muted-foreground'>Action</p>
|
<p className='text-muted-foreground'>Source</p>
|
||||||
<p className='mt-1 font-semibold capitalize'>
|
<p className='mt-1 font-semibold capitalize'>
|
||||||
{details.latestReview.recommendedAction.replaceAll(
|
{threads[0].source.replaceAll('_', ' ')}
|
||||||
'_',
|
|
||||||
' ',
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className='text-muted-foreground'>
|
<p className='text-muted-foreground'>
|
||||||
{details.latestReview.outputSummary ??
|
{threads[0].summary ??
|
||||||
details.latestReview.inputSummary}
|
'Open the thread to continue maintenance work.'}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className='text-muted-foreground'>
|
<p className='text-muted-foreground'>
|
||||||
Run a refresh and AI review to get a compatibility summary
|
Refresh GitHub state or create a thread to start maintenance
|
||||||
for upstream changes.
|
work for this Spoon.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -239,26 +242,45 @@ const SpoonDetailPage = () => {
|
|||||||
<SpoonPrList pullRequests={pullRequests} />
|
<SpoonPrList pullRequests={pullRequests} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value='ai' className='space-y-4'>
|
<TabsContent value='threads' className='space-y-4'>
|
||||||
<SpoonAiReviewPanel
|
|
||||||
latestReview={details.latestReview}
|
|
||||||
reviews={reviews}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value='agent' className='space-y-4'>
|
|
||||||
<AgentRequestForm
|
<AgentRequestForm
|
||||||
spoon={details.spoon}
|
spoon={details.spoon}
|
||||||
agentSettings={agentSettings}
|
agentSettings={agentSettings}
|
||||||
/>
|
/>
|
||||||
|
<Card className='shadow-none'>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='text-base'>Spoon threads</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-3'>
|
||||||
|
{threads.length ? (
|
||||||
|
threads.map((thread) => (
|
||||||
|
<Link
|
||||||
|
key={thread._id}
|
||||||
|
href={`/threads/${thread._id}`}
|
||||||
|
className='border-border hover:border-primary/50 block rounded-md border p-3 transition-colors'
|
||||||
|
>
|
||||||
|
<p className='font-medium'>{thread.title}</p>
|
||||||
|
<p className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
{thread.status.replaceAll('_', ' ')} ·{' '}
|
||||||
|
{thread.source.replaceAll('_', ' ')}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className='text-muted-foreground text-sm'>
|
||||||
|
No threads exist for this Spoon yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
<AgentJobList jobs={agentJobs} />
|
<AgentJobList jobs={agentJobs} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value='activity'>
|
<TabsContent value='activity'>
|
||||||
<SpoonActivityTimeline
|
<SpoonActivityTimeline
|
||||||
syncRuns={syncRuns}
|
syncRuns={syncRuns}
|
||||||
reviews={reviews}
|
threads={threads}
|
||||||
requests={agentRequests}
|
jobs={agentJobs}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,185 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { SpoonCard } from '@/components/spoons/spoon-card';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
|
||||||
import { useQuery } from 'convex/react';
|
import { useQuery } from 'convex/react';
|
||||||
|
import {
|
||||||
|
ArrowUpRight,
|
||||||
|
GitBranch,
|
||||||
|
MessageSquare,
|
||||||
|
RefreshCw,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
import { Button, Card, CardContent } from '@spoon/ui';
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@spoon/ui';
|
||||||
|
|
||||||
|
const formatDate = (value?: number) =>
|
||||||
|
value
|
||||||
|
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
|
||||||
|
: 'Never';
|
||||||
|
|
||||||
const SpoonsPage = () => {
|
const SpoonsPage = () => {
|
||||||
|
const router = useRouter();
|
||||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||||
|
const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
|
||||||
|
const active = spoons.filter((spoon) => spoon.status === 'active').length;
|
||||||
|
const needsReview = threads.filter(
|
||||||
|
(thread) =>
|
||||||
|
thread.spoonId &&
|
||||||
|
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
|
||||||
|
).length;
|
||||||
|
const upstreamWaiting = spoons.reduce(
|
||||||
|
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className='space-y-6'>
|
<main className='space-y-6'>
|
||||||
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
|
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
|
||||||
<div>
|
<div>
|
||||||
<h1 className='text-3xl font-semibold tracking-normal'>Spoons</h1>
|
<h1 className='text-3xl font-semibold tracking-normal'>Spoons</h1>
|
||||||
<p className='text-muted-foreground mt-2'>
|
<p className='text-muted-foreground mt-2'>
|
||||||
Managed forks you want to keep close to their upstream projects.
|
Managed forks, upstream drift, active maintenance threads, and fork
|
||||||
|
metadata in one place.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href='/spoons/new'>New Spoon</Link>
|
<Link href='/spoons/new'>New Spoon</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{spoons.length ? (
|
|
||||||
<div className='grid gap-4 xl:grid-cols-2'>
|
<div className='grid gap-3 md:grid-cols-3'>
|
||||||
{spoons.map((spoon) => (
|
<Card className='shadow-none'>
|
||||||
<SpoonCard key={spoon._id} spoon={spoon} />
|
<CardContent className='flex items-center justify-between p-4'>
|
||||||
))}
|
<div>
|
||||||
|
<p className='text-muted-foreground text-sm'>Managed</p>
|
||||||
|
<p className='text-2xl font-semibold'>{spoons.length}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<GitBranch className='text-muted-foreground size-5' />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className='shadow-none'>
|
||||||
|
<CardContent className='flex items-center justify-between p-4'>
|
||||||
|
<div>
|
||||||
|
<p className='text-muted-foreground text-sm'>Active</p>
|
||||||
|
<p className='text-2xl font-semibold'>{active}</p>
|
||||||
|
</div>
|
||||||
|
<RefreshCw className='text-muted-foreground size-5' />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className='shadow-none'>
|
||||||
|
<CardContent className='flex items-center justify-between p-4'>
|
||||||
|
<div>
|
||||||
|
<p className='text-muted-foreground text-sm'>Open threads</p>
|
||||||
|
<p className='text-2xl font-semibold'>{needsReview}</p>
|
||||||
|
</div>
|
||||||
|
<MessageSquare className='text-muted-foreground size-5' />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{spoons.length ? (
|
||||||
|
<Card className='shadow-none'>
|
||||||
|
<CardContent className='p-0'>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className='pl-4'>Spoon</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Fork</TableHead>
|
||||||
|
<TableHead>Drift</TableHead>
|
||||||
|
<TableHead>Cadence</TableHead>
|
||||||
|
<TableHead>Last checked</TableHead>
|
||||||
|
<TableHead className='pr-4 text-right'>Action</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{spoons.map((spoon) => {
|
||||||
|
const href = `/spoons/${spoon._id}`;
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={spoon._id}
|
||||||
|
role='link'
|
||||||
|
tabIndex={0}
|
||||||
|
className='hover:bg-muted/50 cursor-pointer'
|
||||||
|
onClick={() => router.push(href)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
router.push(href);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableCell className='pl-4'>
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className='group inline-flex min-w-0 flex-col'
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span className='group-hover:text-primary font-medium transition-colors'>
|
||||||
|
{spoon.name}
|
||||||
|
</span>
|
||||||
|
<span className='text-muted-foreground text-xs'>
|
||||||
|
{spoon.upstreamOwner}/{spoon.upstreamRepo}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<SpoonStatusBadge
|
||||||
|
status={spoon.syncStatus ?? spoon.status}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{spoon.forkOwner && spoon.forkRepo ? (
|
||||||
|
<span className='font-medium'>
|
||||||
|
{spoon.forkOwner}/{spoon.forkRepo}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Badge variant='outline'>Missing metadata</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className='text-sm'>
|
||||||
|
<p>{spoon.upstreamAheadBy ?? 0} upstream</p>
|
||||||
|
<p className='text-muted-foreground'>
|
||||||
|
{spoon.forkAheadBy ?? 0} fork-only
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className='capitalize'>
|
||||||
|
{spoon.syncCadence}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(spoon.lastCheckedAt)}</TableCell>
|
||||||
|
<TableCell className='pr-4 text-right'>
|
||||||
|
<Button size='sm' variant='outline' asChild>
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
<ArrowUpRight className='size-3' />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card className='shadow-none'>
|
<Card className='shadow-none'>
|
||||||
<CardContent className='p-8'>
|
<CardContent className='p-8'>
|
||||||
@@ -42,6 +194,12 @@ const SpoonsPage = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{spoons.length ? (
|
||||||
|
<p className='text-muted-foreground text-sm'>
|
||||||
|
Raw upstream commits waiting across all Spoons: {upstreamWaiting}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
|
import { ArrowUpRight, CheckCircle2, XCircle } 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,
|
||||||
|
Textarea,
|
||||||
|
} from '@spoon/ui';
|
||||||
|
|
||||||
|
const ThreadDetailPage = () => {
|
||||||
|
const params = useParams<{ threadId: string }>();
|
||||||
|
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 markResolved = useMutation(api.threads.markResolved);
|
||||||
|
const cancel = useMutation(api.threads.cancel);
|
||||||
|
|
||||||
|
if (details === undefined) {
|
||||||
|
return <main className='text-muted-foreground p-6'>Loading thread...</main>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { thread, spoon, latestJob } = details;
|
||||||
|
|
||||||
|
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 : '';
|
||||||
|
try {
|
||||||
|
await appendMessage({ threadId, content });
|
||||||
|
event.currentTarget.reset();
|
||||||
|
toast.success('Message added.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('Could not add message.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className='space-y-6'>
|
||||||
|
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-start'>
|
||||||
|
<div>
|
||||||
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
|
<h1 className='text-3xl font-semibold tracking-normal'>
|
||||||
|
{thread.title}
|
||||||
|
</h1>
|
||||||
|
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
|
||||||
|
<Badge variant='outline'>
|
||||||
|
{thread.source.replaceAll('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
{thread.maintenanceOutcome ? (
|
||||||
|
<Badge variant='secondary'>
|
||||||
|
{thread.maintenanceOutcome.replaceAll('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className='text-muted-foreground mt-2 max-w-3xl'>
|
||||||
|
{thread.summary ?? 'No summary has been recorded yet.'}
|
||||||
|
</p>
|
||||||
|
{spoon ? (
|
||||||
|
<Button variant='link' className='mt-2 h-auto p-0' asChild>
|
||||||
|
<Link href={`/spoons/${spoon._id}`}>
|
||||||
|
{spoon.name}
|
||||||
|
<ArrowUpRight className='size-3' />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
{latestJob ? (
|
||||||
|
<Button variant='outline' asChild>
|
||||||
|
<Link
|
||||||
|
href={`/spoons/${latestJob.spoonId}/agent/${latestJob._id}`}
|
||||||
|
>
|
||||||
|
Open workspace
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{latestJob?.pullRequestUrl ? (
|
||||||
|
<Button asChild>
|
||||||
|
<a
|
||||||
|
href={latestJob.pullRequestUrl}
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
>
|
||||||
|
Open PR
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
onClick={() =>
|
||||||
|
markResolved({ threadId }).then(() =>
|
||||||
|
toast.success('Thread resolved.'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className='size-4' />
|
||||||
|
Resolve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
onClick={() =>
|
||||||
|
cancel({ threadId }).then(() =>
|
||||||
|
toast.success('Thread cancelled.'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XCircle className='size-4' />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid gap-6 xl:grid-cols-[1fr_320px]'>
|
||||||
|
<Card className='shadow-none'>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Conversation</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
{messages.map((message) => (
|
||||||
|
<div
|
||||||
|
key={message._id}
|
||||||
|
className='border-border rounded-md border p-3'
|
||||||
|
>
|
||||||
|
<div className='mb-2 flex items-center justify-between gap-2'>
|
||||||
|
<Badge variant='outline'>{message.role}</Badge>
|
||||||
|
<span className='text-muted-foreground text-xs'>
|
||||||
|
{new Date(message.createdAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className='text-sm whitespace-pre-wrap'>{message.content}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<form onSubmit={submit} className='space-y-3'>
|
||||||
|
<Textarea
|
||||||
|
name='message'
|
||||||
|
required
|
||||||
|
minLength={2}
|
||||||
|
placeholder='Add context or instructions for this thread.'
|
||||||
|
/>
|
||||||
|
<Button type='submit'>Add message</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className='shadow-none'>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Thread state</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-3 text-sm'>
|
||||||
|
<div>
|
||||||
|
<p className='text-muted-foreground'>Priority</p>
|
||||||
|
<p className='font-medium capitalize'>{thread.priority}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='text-muted-foreground'>Upstream range</p>
|
||||||
|
<p className='font-mono text-xs break-all'>
|
||||||
|
{thread.upstreamFrom ?? 'unknown'} →{' '}
|
||||||
|
{thread.upstreamTo ?? 'unknown'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='text-muted-foreground'>Latest job</p>
|
||||||
|
<p className='font-medium'>
|
||||||
|
{latestJob?.status.replaceAll('_', ' ') ?? 'No job queued'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThreadDetailPage;
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { useQuery } from 'convex/react';
|
||||||
|
import { MessageSquare, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@spoon/ui';
|
||||||
|
|
||||||
|
const formatTime = (value: number) => new Date(value).toLocaleString();
|
||||||
|
|
||||||
|
const ThreadsPage = () => {
|
||||||
|
const params = useSearchParams();
|
||||||
|
const source = params.get('source') ?? 'all';
|
||||||
|
const threads =
|
||||||
|
useQuery(api.threads.listMine, {
|
||||||
|
source: source as
|
||||||
|
| 'all'
|
||||||
|
| 'user_request'
|
||||||
|
| 'upstream_update'
|
||||||
|
| 'merge_conflict'
|
||||||
|
| 'manual_review'
|
||||||
|
| 'system',
|
||||||
|
limit: 100,
|
||||||
|
}) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className='space-y-6'>
|
||||||
|
<div className='flex flex-col justify-between gap-4 md:flex-row md:items-end'>
|
||||||
|
<div>
|
||||||
|
<h1 className='text-3xl font-semibold tracking-normal'>Threads</h1>
|
||||||
|
<p className='text-muted-foreground mt-2'>
|
||||||
|
Maintenance reviews, upstream decisions, and user-requested fork
|
||||||
|
work across all Spoons.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href='/spoons'>
|
||||||
|
<Plus className='size-4' />
|
||||||
|
New thread from Spoon
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-3 md:flex-row'>
|
||||||
|
<Select
|
||||||
|
value={source}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
window.location.href =
|
||||||
|
value === 'all' ? '/threads' : `/threads?source=${value}`;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className='w-full md:w-56'>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='all'>All sources</SelectItem>
|
||||||
|
<SelectItem value='user_request'>User requests</SelectItem>
|
||||||
|
<SelectItem value='upstream_update'>Upstream updates</SelectItem>
|
||||||
|
<SelectItem value='merge_conflict'>Merge conflicts</SelectItem>
|
||||||
|
<SelectItem value='manual_review'>Manual review</SelectItem>
|
||||||
|
<SelectItem value='system'>System</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{threads.length ? (
|
||||||
|
threads.map((thread) => (
|
||||||
|
<Link
|
||||||
|
key={thread._id}
|
||||||
|
href={`/threads/${thread._id}`}
|
||||||
|
className='block'
|
||||||
|
>
|
||||||
|
<Card className='hover:border-primary/50 shadow-none transition-colors'>
|
||||||
|
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
|
||||||
|
<div className='min-w-0'>
|
||||||
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
|
<h2 className='truncate font-medium'>{thread.title}</h2>
|
||||||
|
<Badge variant='outline'>
|
||||||
|
{thread.source.replaceAll('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
|
||||||
|
{thread.maintenanceOutcome ? (
|
||||||
|
<Badge variant='secondary'>
|
||||||
|
{thread.maintenanceOutcome.replaceAll('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className='text-muted-foreground mt-1 line-clamp-2 text-sm'>
|
||||||
|
{thread.summary ??
|
||||||
|
'No summary has been recorded for this thread yet.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='text-muted-foreground text-xs md:text-right'>
|
||||||
|
<p>{formatTime(thread.updatedAt)}</p>
|
||||||
|
<p className='capitalize'>{thread.priority} priority</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Card className='shadow-none'>
|
||||||
|
<CardContent className='text-muted-foreground flex items-center gap-3 p-6 text-sm'>
|
||||||
|
<MessageSquare className='size-4' />
|
||||||
|
Threads appear when you ask Spoon to change a fork or when
|
||||||
|
upstream changes need review.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThreadsPage;
|
||||||
@@ -1,88 +1,7 @@
|
|||||||
'use client';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { MaintenanceQueue } from '@/components/updates/maintenance-queue';
|
const UpdatesRedirectPage = () => {
|
||||||
import { useQuery } from 'convex/react';
|
redirect('/threads?source=upstream_update');
|
||||||
|
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@spoon/ui';
|
|
||||||
|
|
||||||
const UpdatesPage = () => {
|
|
||||||
const runs = useQuery(api.syncRuns.listRecent, { limit: 50 }) ?? [];
|
|
||||||
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
|
||||||
return (
|
|
||||||
<main className='space-y-6'>
|
|
||||||
<div>
|
|
||||||
<h1 className='text-3xl font-semibold tracking-normal'>Updates</h1>
|
|
||||||
<p className='text-muted-foreground mt-2'>
|
|
||||||
Upstream checks, merge attempts, and AI reviews will appear here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-col gap-3 md:flex-row'>
|
|
||||||
<Select defaultValue='all'>
|
|
||||||
<SelectTrigger className='w-full md:w-48'>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value='all'>All statuses</SelectItem>
|
|
||||||
<SelectItem value='needs_review'>Needs review</SelectItem>
|
|
||||||
<SelectItem value='conflict'>Conflict</SelectItem>
|
|
||||||
<SelectItem value='clean'>Clean</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select defaultValue='all'>
|
|
||||||
<SelectTrigger className='w-full md:w-64'>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value='all'>All Spoons</SelectItem>
|
|
||||||
{spoons.map((spoon) => (
|
|
||||||
<SelectItem key={spoon._id} value={spoon._id}>
|
|
||||||
{spoon.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<Card className='shadow-none'>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Recent sync runs</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{runs.length ? (
|
|
||||||
<div className='space-y-3'>
|
|
||||||
{runs.map((run) => (
|
|
||||||
<div key={run._id} className='border-border border p-4'>
|
|
||||||
<p className='font-medium'>{run.kind.replaceAll('_', ' ')}</p>
|
|
||||||
<p className='text-muted-foreground text-sm'>
|
|
||||||
{run.status.replaceAll('_', ' ')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className='text-muted-foreground'>
|
|
||||||
Scheduled upstream checks will appear here once provider
|
|
||||||
connections and workers are added.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<section className='space-y-3'>
|
|
||||||
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
|
|
||||||
<MaintenanceQueue spoons={spoons} />
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default UpdatesPage;
|
export default UpdatesRedirectPage;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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, 'run-command', {
|
||||||
|
method: 'POST',
|
||||||
|
body: await request.text(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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, 'diff', { method: 'GET' }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { proxyWorker, withOwnedJob } from '@/lib/agent-worker-proxy';
|
||||||
|
|
||||||
|
export const GET = async (
|
||||||
|
request: Request,
|
||||||
|
context: { params: Promise<{ jobId: string }> },
|
||||||
|
) =>
|
||||||
|
await withOwnedJob(context, async (jobId) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
return await proxyWorker(
|
||||||
|
jobId,
|
||||||
|
'file',
|
||||||
|
{ method: 'GET' },
|
||||||
|
new URLSearchParams({ path: url.searchParams.get('path') ?? '' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PUT = async (
|
||||||
|
request: Request,
|
||||||
|
context: { params: Promise<{ jobId: string }> },
|
||||||
|
) =>
|
||||||
|
await withOwnedJob(
|
||||||
|
context,
|
||||||
|
async (jobId) =>
|
||||||
|
await proxyWorker(jobId, 'file', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: await request.text(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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, 'message', {
|
||||||
|
method: 'POST',
|
||||||
|
body: await request.text(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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, 'open-pr', { method: 'POST' }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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, 'stop', { method: 'POST' }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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, 'tree', { method: 'GET' }),
|
||||||
|
);
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
Agents,
|
|
||||||
CTA,
|
CTA,
|
||||||
Features,
|
Features,
|
||||||
Hero,
|
Hero,
|
||||||
|
MaintenanceDecisions,
|
||||||
Security,
|
Security,
|
||||||
|
ThreadedWork,
|
||||||
Workflow,
|
Workflow,
|
||||||
|
WorkspaceShowcase,
|
||||||
} from '@/components/landing';
|
} from '@/components/landing';
|
||||||
|
|
||||||
const Home = () => (
|
const Home = () => (
|
||||||
@@ -12,7 +14,9 @@ const Home = () => (
|
|||||||
<Hero />
|
<Hero />
|
||||||
<Workflow />
|
<Workflow />
|
||||||
<Features />
|
<Features />
|
||||||
<Agents />
|
<MaintenanceDecisions />
|
||||||
|
<ThreadedWork />
|
||||||
|
<WorkspaceShowcase />
|
||||||
<Security />
|
<Security />
|
||||||
<CTA />
|
<CTA />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Send } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { Button, Textarea } from '@spoon/ui';
|
||||||
|
|
||||||
|
export const AgentThread = ({
|
||||||
|
jobId,
|
||||||
|
messages,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
jobId: string;
|
||||||
|
messages: Doc<'agentJobMessages'>[];
|
||||||
|
disabled: boolean;
|
||||||
|
}) => {
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
if (!content.trim()) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/agent-jobs/${jobId}/message`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
setContent('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('Could not send message.');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex h-full min-h-[520px] flex-col'>
|
||||||
|
<div className='border-border border-b p-3'>
|
||||||
|
<h2 className='text-sm font-semibold'>Agent thread</h2>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Messages persist with this workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='min-h-0 flex-1 space-y-3 overflow-auto p-3'>
|
||||||
|
{messages.map((message) => (
|
||||||
|
<article
|
||||||
|
key={message._id}
|
||||||
|
className='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'>
|
||||||
|
{message.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className='whitespace-pre-wrap'>{message.content}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className='border-border space-y-2 border-t p-3'>
|
||||||
|
<Textarea
|
||||||
|
value={content}
|
||||||
|
placeholder='Ask the agent to inspect, explain, or change this fork.'
|
||||||
|
disabled={disabled || sending}
|
||||||
|
onChange={(event) => setContent(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
className='w-full'
|
||||||
|
disabled={disabled || sending || !content.trim()}
|
||||||
|
onClick={send}
|
||||||
|
>
|
||||||
|
<Send className='size-4' />
|
||||||
|
{sending ? 'Sending...' : 'Send'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { 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 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 { FileTree } from './file-tree';
|
||||||
|
import { JobStatusBar } from './job-status-bar';
|
||||||
|
import { WorkspaceActions } from './workspace-actions';
|
||||||
|
|
||||||
|
export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
|
||||||
|
const job = useQuery(api.agentJobs.get, { jobId });
|
||||||
|
const messages =
|
||||||
|
useQuery(api.agentJobs.listMessages, { jobId, limit: 200 }) ?? [];
|
||||||
|
const [tree, setTree] = useState<FileTreeNode | null>(null);
|
||||||
|
const [selectedPath, setSelectedPath] = useState<string>();
|
||||||
|
const [fileContent, setFileContent] = useState('');
|
||||||
|
const [diff, setDiff] = useState('');
|
||||||
|
|
||||||
|
const workspaceDisabled =
|
||||||
|
!job ||
|
||||||
|
['draft_pr_opened', 'failed', 'cancelled', 'timed_out'].includes(
|
||||||
|
job.status,
|
||||||
|
) ||
|
||||||
|
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
|
||||||
|
|
||||||
|
const loadTree = useCallback(async () => {
|
||||||
|
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 };
|
||||||
|
setTree(data.tree);
|
||||||
|
}, [jobId]);
|
||||||
|
|
||||||
|
const loadDiff = useCallback(async () => {
|
||||||
|
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;
|
||||||
|
setDiff(data.diff);
|
||||||
|
}, [jobId]);
|
||||||
|
|
||||||
|
const loadFile = useCallback(
|
||||||
|
async (path: string) => {
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
[jobId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!job) return;
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
void loadTree().catch((error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
void loadDiff().catch((error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [job, loadDiff, loadTree]);
|
||||||
|
|
||||||
|
if (job === undefined) {
|
||||||
|
return (
|
||||||
|
<main className='text-muted-foreground p-6'>Loading workspace...</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveFile = async (content: string) => {
|
||||||
|
if (!selectedPath) return;
|
||||||
|
const response = await fetch(`/api/agent-jobs/${jobId}/file`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ path: selectedPath, content }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
toast.error('Could not save file.');
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
setFileContent(content);
|
||||||
|
await loadDiff();
|
||||||
|
toast.success('File saved.');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className='border-border bg-muted/20 min-h-[calc(100vh-5rem)] overflow-hidden rounded-md border'>
|
||||||
|
<JobStatusBar job={job} />
|
||||||
|
<div className='border-border bg-background flex items-center justify-end border-b px-4 py-2'>
|
||||||
|
<WorkspaceActions job={job} disabled={workspaceDisabled} />
|
||||||
|
</div>
|
||||||
|
<div className='grid min-h-[680px] grid-cols-1 xl:grid-cols-[260px_minmax(0,1fr)_360px]'>
|
||||||
|
<aside className='border-border bg-background min-h-[260px] border-r'>
|
||||||
|
<div className='border-border border-b p-3'>
|
||||||
|
<h2 className='text-sm font-semibold'>Files</h2>
|
||||||
|
<p className='text-muted-foreground text-xs'>Current workspace</p>
|
||||||
|
</div>
|
||||||
|
<FileTree
|
||||||
|
tree={tree}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
onSelect={(path) => {
|
||||||
|
void loadFile(path).catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('Could not load file.');
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
<section className='bg-background min-w-0'>
|
||||||
|
<Tabs defaultValue='editor' className='h-full'>
|
||||||
|
<TabsList
|
||||||
|
variant='line'
|
||||||
|
className='border-border h-11 w-full justify-start rounded-none border-b px-3'
|
||||||
|
>
|
||||||
|
<TabsTrigger value='editor'>Editor</TabsTrigger>
|
||||||
|
<TabsTrigger value='diff'>Diff</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value='editor' className='m-0'>
|
||||||
|
<CodeEditor
|
||||||
|
path={selectedPath}
|
||||||
|
content={fileContent}
|
||||||
|
readOnly={workspaceDisabled}
|
||||||
|
onSave={saveFile}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value='diff' className='m-0'>
|
||||||
|
<DiffViewer diff={diff} onRefresh={loadDiff} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
<CommandPanel jobId={jobId} disabled={workspaceDisabled} />
|
||||||
|
</section>
|
||||||
|
<aside className='border-border bg-muted/20 min-w-0 border-l'>
|
||||||
|
<AgentThread
|
||||||
|
jobId={jobId}
|
||||||
|
messages={messages}
|
||||||
|
disabled={workspaceDisabled}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
import { Button, Switch } from '@spoon/ui';
|
||||||
|
|
||||||
|
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
type MonacoEditorInstance = {
|
||||||
|
getModel?: () => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VimMode = {
|
||||||
|
dispose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CodeEditor = ({
|
||||||
|
path,
|
||||||
|
content,
|
||||||
|
readOnly,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
path?: string;
|
||||||
|
content: string;
|
||||||
|
readOnly: boolean;
|
||||||
|
onSave: (content: string) => Promise<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;
|
||||||
|
vimRef.current?.dispose();
|
||||||
|
vimRef.current = null;
|
||||||
|
if (!vimEnabled) return;
|
||||||
|
void import('monaco-vim').then((module) => {
|
||||||
|
const initVimMode = module.initVimMode as unknown as (
|
||||||
|
editor: MonacoEditorInstance,
|
||||||
|
statusNode?: HTMLElement | null,
|
||||||
|
) => VimMode;
|
||||||
|
vimRef.current = initVimMode(editor, statusRef.current);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
vimRef.current?.dispose();
|
||||||
|
vimRef.current = null;
|
||||||
|
};
|
||||||
|
}, [vimEnabled, path]);
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
return (
|
||||||
|
<div className='text-muted-foreground flex h-full items-center justify-center text-sm'>
|
||||||
|
Select a file to inspect or edit.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave(value);
|
||||||
|
setDirty(false);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex h-full min-h-[520px] flex-col'>
|
||||||
|
<div className='border-border flex h-11 items-center justify-between gap-3 border-b px-3'>
|
||||||
|
<div className='min-w-0'>
|
||||||
|
<p className='truncate font-mono text-xs'>{path}</p>
|
||||||
|
{dirty ? (
|
||||||
|
<p className='text-muted-foreground text-xs'>Unsaved changes</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<label className='flex items-center gap-2 text-xs'>
|
||||||
|
Vim
|
||||||
|
<Switch checked={vimEnabled} onCheckedChange={setVimEnabled} />
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
size='sm'
|
||||||
|
disabled={readOnly || saving || !dirty}
|
||||||
|
onClick={save}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='min-h-0 flex-1'>
|
||||||
|
<MonacoEditor
|
||||||
|
height='520px'
|
||||||
|
path={path}
|
||||||
|
value={value}
|
||||||
|
theme='vs-dark'
|
||||||
|
options={{
|
||||||
|
readOnly,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 13,
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
wordWrap: 'on',
|
||||||
|
}}
|
||||||
|
onMount={(editor) => {
|
||||||
|
editorRef.current = editor as MonacoEditorInstance;
|
||||||
|
}}
|
||||||
|
onChange={(next) => {
|
||||||
|
setValue(next ?? '');
|
||||||
|
setDirty((next ?? '') !== content);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={statusRef}
|
||||||
|
className='border-border text-muted-foreground h-6 border-t px-3 py-1 font-mono text-xs'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Terminal } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button, Input } from '@spoon/ui';
|
||||||
|
|
||||||
|
export const CommandPanel = ({
|
||||||
|
jobId,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
jobId: string;
|
||||||
|
disabled: boolean;
|
||||||
|
}) => {
|
||||||
|
const [command, setCommand] = useState('');
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
setRunning(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/agent-jobs/${jobId}/command`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ command }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
toast.success('Command completed.');
|
||||||
|
setCommand('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('Command failed.');
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='border-border flex items-center gap-2 border-t p-3'>
|
||||||
|
<Terminal className='text-muted-foreground size-4' />
|
||||||
|
<Input
|
||||||
|
value={command}
|
||||||
|
placeholder='bun test, pnpm lint, npm run typecheck...'
|
||||||
|
disabled={disabled || running}
|
||||||
|
onChange={(event) => setCommand(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
disabled={disabled || running || !command.trim()}
|
||||||
|
onClick={run}
|
||||||
|
>
|
||||||
|
{running ? 'Running...' : 'Run'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
import { Button } from '@spoon/ui';
|
||||||
|
|
||||||
|
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DiffViewer = ({
|
||||||
|
diff,
|
||||||
|
onRefresh,
|
||||||
|
}: {
|
||||||
|
diff: string;
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
|
}) => (
|
||||||
|
<div className='flex h-full min-h-[520px] flex-col'>
|
||||||
|
<div className='border-border flex h-11 items-center justify-between border-b px-3'>
|
||||||
|
<div>
|
||||||
|
<p className='text-sm font-medium'>Workspace diff</p>
|
||||||
|
<p className='text-muted-foreground text-xs'>Current git diff</p>
|
||||||
|
</div>
|
||||||
|
<Button type='button' variant='outline' size='sm' onClick={onRefresh}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{diff.trim() ? (
|
||||||
|
<MonacoEditor
|
||||||
|
height='520px'
|
||||||
|
language='diff'
|
||||||
|
theme='vs-dark'
|
||||||
|
value={diff}
|
||||||
|
options={{
|
||||||
|
readOnly: true,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 13,
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'>
|
||||||
|
No workspace diff yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ChevronRight, FileCode, Folder } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@spoon/ui';
|
||||||
|
|
||||||
|
import type { FileTreeNode } from './types';
|
||||||
|
|
||||||
|
const TreeNode = ({
|
||||||
|
node,
|
||||||
|
selectedPath,
|
||||||
|
onSelect,
|
||||||
|
depth = 0,
|
||||||
|
}: {
|
||||||
|
node: FileTreeNode;
|
||||||
|
selectedPath?: string;
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
depth?: number;
|
||||||
|
}) => {
|
||||||
|
if (node.type === 'directory') {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{node.path ? (
|
||||||
|
<div
|
||||||
|
className='text-muted-foreground flex h-7 items-center gap-1 px-2 text-xs font-medium'
|
||||||
|
style={{ paddingLeft: depth * 12 + 8 }}
|
||||||
|
>
|
||||||
|
<ChevronRight className='size-3' />
|
||||||
|
<Folder className='size-3' />
|
||||||
|
<span className='truncate'>{node.name}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
{node.children?.map((child) => (
|
||||||
|
<TreeNode
|
||||||
|
key={`${child.type}:${child.path}`}
|
||||||
|
node={child}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
onSelect={onSelect}
|
||||||
|
depth={node.path ? depth + 1 : depth}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant={selectedPath === node.path ? 'secondary' : 'ghost'}
|
||||||
|
className='h-7 w-full justify-start gap-2 rounded-none px-2 text-left text-xs font-normal'
|
||||||
|
style={{ paddingLeft: depth * 12 + 8 }}
|
||||||
|
onClick={() => onSelect(node.path)}
|
||||||
|
>
|
||||||
|
<FileCode className='size-3 flex-none' />
|
||||||
|
<span className='truncate'>{node.name}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FileTree = ({
|
||||||
|
tree,
|
||||||
|
selectedPath,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
tree: FileTreeNode | null;
|
||||||
|
selectedPath?: string;
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
}) => {
|
||||||
|
if (!tree) {
|
||||||
|
return (
|
||||||
|
<p className='text-muted-foreground p-3 text-sm'>
|
||||||
|
Workspace files are not available yet.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className='overflow-auto py-2'>
|
||||||
|
<TreeNode node={tree} selectedPath={selectedPath} onSelect={onSelect} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { Badge } from '@spoon/ui';
|
||||||
|
|
||||||
|
export const JobStatusBar = ({ job }: { job: Doc<'agentJobs'> }) => (
|
||||||
|
<div className='border-border bg-background flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3'>
|
||||||
|
<div className='min-w-0'>
|
||||||
|
<h1 className='truncate text-base font-semibold'>{job.forkRepo}</h1>
|
||||||
|
<p className='text-muted-foreground truncate font-mono text-xs'>
|
||||||
|
{job.baseBranch} {'->'} {job.workBranch}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Badge variant='outline' className='capitalize'>
|
||||||
|
{job.status.replaceAll('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant='secondary' className='capitalize'>
|
||||||
|
{(job.workspaceStatus ?? 'not_started').replaceAll('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant='outline'>{job.runtime ?? 'opencode'}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export type FileTreeNode = {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: 'file' | 'directory';
|
||||||
|
children?: FileTreeNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FileResponse = {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DiffResponse = {
|
||||||
|
diff: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ExternalLink, GitPullRequestDraft, Square } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { Button } from '@spoon/ui';
|
||||||
|
|
||||||
|
export const WorkspaceActions = ({
|
||||||
|
job,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
job: Doc<'agentJobs'>;
|
||||||
|
disabled: boolean;
|
||||||
|
}) => {
|
||||||
|
const openPr = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/agent-jobs/${job._id}/open-pr`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
toast.success('Draft PR opened.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('Could not open draft PR.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/agent-jobs/${job._id}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
toast.success('Workspace stopped.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('Could not stop workspace.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
|
{job.pullRequestUrl ? (
|
||||||
|
<Button asChild variant='outline' size='sm'>
|
||||||
|
<a href={job.pullRequestUrl} target='_blank' rel='noreferrer'>
|
||||||
|
<ExternalLink className='size-4' />
|
||||||
|
Open PR
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button type='button' size='sm' disabled={disabled} onClick={openPr}>
|
||||||
|
<GitPullRequestDraft className='size-4' />
|
||||||
|
Open draft PR
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={stop}
|
||||||
|
>
|
||||||
|
<Square className='size-4' />
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
import { useMutation } from 'convex/react';
|
import { useMutation } from 'convex/react';
|
||||||
import { ExternalLink, XCircle } from 'lucide-react';
|
import { ExternalLink, MonitorUp, XCircle } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
@@ -101,6 +102,14 @@ export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
|
|||||||
Cancel job
|
Cancel job
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Button asChild>
|
||||||
|
<Link
|
||||||
|
href={`/spoons/${selectedJob.spoonId}/agent/${selectedJob._id}`}
|
||||||
|
>
|
||||||
|
<MonitorUp className='size-4' />
|
||||||
|
Open workspace
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
<AgentJobDetail job={selectedJob} />
|
<AgentJobDetail job={selectedJob} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -15,15 +15,24 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
Input,
|
Input,
|
||||||
Label,
|
Label,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
Switch,
|
||||||
Textarea,
|
Textarea,
|
||||||
} from '@spoon/ui';
|
} from '@spoon/ui';
|
||||||
|
|
||||||
import { SecretSelector } from './secret-selector';
|
|
||||||
|
|
||||||
type AgentSettings = {
|
type AgentSettings = {
|
||||||
defaultBaseBranch?: string;
|
defaultBaseBranch?: string;
|
||||||
|
runtime?: 'opencode' | 'openai_direct';
|
||||||
agentModel: string;
|
agentModel: string;
|
||||||
reasoningEffort: string;
|
reasoningEffort: string;
|
||||||
|
envFilePath?: string;
|
||||||
|
customEnvFilePath?: string;
|
||||||
|
materializeEnvFileByDefault?: boolean;
|
||||||
|
aiProviderProfileId?: Id<'aiProviderProfiles'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AgentRequestForm = ({
|
export const AgentRequestForm = ({
|
||||||
@@ -33,11 +42,16 @@ export const AgentRequestForm = ({
|
|||||||
spoon: Doc<'spoons'>;
|
spoon: Doc<'spoons'>;
|
||||||
agentSettings?: AgentSettings | null;
|
agentSettings?: AgentSettings | null;
|
||||||
}) => {
|
}) => {
|
||||||
const secrets = useQuery(api.spoonSecrets.listForSpoon, {
|
const secrets =
|
||||||
|
useQuery(api.spoonSecrets.listForSpoon, {
|
||||||
spoonId: spoon._id,
|
spoonId: spoon._id,
|
||||||
});
|
}) ?? [];
|
||||||
const createRequest = useMutation(api.agentRequests.create);
|
const profiles =
|
||||||
const createJob = useMutation(api.agentJobs.createFromRequest);
|
useQuery(api.aiProviderProfiles.listMine, {})?.filter(
|
||||||
|
(profile) => profile.enabled && profile.configured,
|
||||||
|
) ?? [];
|
||||||
|
const defaultProfile = profiles.find((profile) => profile.isDefault);
|
||||||
|
const createThread = useMutation(api.threads.createUserThread);
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
const [baseBranch, setBaseBranch] = useState(
|
const [baseBranch, setBaseBranch] = useState(
|
||||||
agentSettings?.defaultBaseBranch ??
|
agentSettings?.defaultBaseBranch ??
|
||||||
@@ -45,30 +59,52 @@ export const AgentRequestForm = ({
|
|||||||
spoon.upstreamDefaultBranch,
|
spoon.upstreamDefaultBranch,
|
||||||
);
|
);
|
||||||
const [requestedBranchName, setRequestedBranchName] = useState('');
|
const [requestedBranchName, setRequestedBranchName] = useState('');
|
||||||
const [selectedSecretIds, setSelectedSecretIds] = useState<
|
const [materializeEnvFile, setMaterializeEnvFile] = useState(
|
||||||
Id<'spoonSecrets'>[]
|
agentSettings?.materializeEnvFileByDefault ?? false,
|
||||||
>([]);
|
);
|
||||||
|
const [envFilePath, setEnvFilePath] = useState(
|
||||||
|
agentSettings?.envFilePath === 'custom'
|
||||||
|
? (agentSettings.customEnvFilePath ?? '.env.local')
|
||||||
|
: (agentSettings?.envFilePath ?? '.env.local'),
|
||||||
|
);
|
||||||
|
const [aiProviderProfileId, setAiProviderProfileId] = useState(
|
||||||
|
agentSettings?.aiProviderProfileId ?? '__settings',
|
||||||
|
);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const effectiveProviderProfileId =
|
||||||
|
aiProviderProfileId === '__settings'
|
||||||
|
? (agentSettings?.aiProviderProfileId ?? defaultProfile?._id)
|
||||||
|
: aiProviderProfileId;
|
||||||
|
const hasProvider = Boolean(
|
||||||
|
effectiveProviderProfileId &&
|
||||||
|
profiles.some((profile) => profile._id === effectiveProviderProfileId),
|
||||||
|
);
|
||||||
|
const selectedProfile = profiles.find((profile) =>
|
||||||
|
aiProviderProfileId === '__settings'
|
||||||
|
? profile._id ===
|
||||||
|
(agentSettings?.aiProviderProfileId ?? defaultProfile?._id)
|
||||||
|
: profile._id === aiProviderProfileId,
|
||||||
|
);
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const requestId = await createRequest({
|
await createThread({
|
||||||
spoonId: spoon._id,
|
spoonId: spoon._id,
|
||||||
prompt,
|
prompt,
|
||||||
targetBranch: baseBranch,
|
|
||||||
});
|
|
||||||
await createJob({
|
|
||||||
requestId,
|
|
||||||
selectedSecretIds,
|
|
||||||
baseBranch,
|
baseBranch,
|
||||||
requestedBranchName: requestedBranchName || undefined,
|
requestedBranchName: requestedBranchName || undefined,
|
||||||
|
materializeEnvFile,
|
||||||
|
envFilePath: materializeEnvFile ? envFilePath : undefined,
|
||||||
|
aiProviderProfileId:
|
||||||
|
aiProviderProfileId === '__settings'
|
||||||
|
? undefined
|
||||||
|
: (aiProviderProfileId as Id<'aiProviderProfiles'>),
|
||||||
});
|
});
|
||||||
setPrompt('');
|
setPrompt('');
|
||||||
setRequestedBranchName('');
|
setRequestedBranchName('');
|
||||||
setSelectedSecretIds([]);
|
toast.success('Thread created.');
|
||||||
toast.success('Agent job queued.');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error('Could not queue agent job.');
|
toast.error('Could not queue agent job.');
|
||||||
@@ -99,6 +135,32 @@ export const AgentRequestForm = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='grid gap-3 md:grid-cols-2'>
|
<div className='grid gap-3 md:grid-cols-2'>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label>Workspace runtime</Label>
|
||||||
|
<Input value='OpenCode workspace' disabled />
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label>AI provider</Label>
|
||||||
|
<Select
|
||||||
|
value={aiProviderProfileId}
|
||||||
|
onValueChange={setAiProviderProfileId}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='__settings'>
|
||||||
|
Use default
|
||||||
|
{defaultProfile ? ` (${defaultProfile.name})` : ''}
|
||||||
|
</SelectItem>
|
||||||
|
{profiles.map((profile) => (
|
||||||
|
<SelectItem key={profile._id} value={profile._id}>
|
||||||
|
{profile.name} · {profile.provider.replaceAll('_', ' ')}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
<div className='grid gap-2'>
|
<div className='grid gap-2'>
|
||||||
<Label htmlFor='baseBranch'>Base branch</Label>
|
<Label htmlFor='baseBranch'>Base branch</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -117,26 +179,45 @@ export const AgentRequestForm = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='grid gap-2'>
|
<div className='grid gap-3 md:grid-cols-[1fr_1fr]'>
|
||||||
<Label>Secrets exposed to this job</Label>
|
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
|
||||||
<SecretSelector
|
<div>
|
||||||
secrets={secrets ?? []}
|
<Label>Write Spoon secrets to env file</Label>
|
||||||
selectedSecretIds={selectedSecretIds}
|
<p className='text-muted-foreground text-xs'>
|
||||||
onChange={setSelectedSecretIds}
|
All {secrets.length} Spoon secret(s) are available as process
|
||||||
|
env. When enabled, Spoon also writes them to this file and
|
||||||
|
refuses to commit .env files.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={materializeEnvFile}
|
||||||
|
onCheckedChange={setMaterializeEnvFile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor='envFilePath'>Env file path</Label>
|
||||||
|
<Input
|
||||||
|
id='envFilePath'
|
||||||
|
value={envFilePath}
|
||||||
|
disabled={!materializeEnvFile}
|
||||||
|
onChange={(event) => setEnvFilePath(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className='bg-muted/40 grid gap-1 rounded-md p-3 text-xs'>
|
<div className='bg-muted/40 grid gap-1 rounded-md p-3 text-xs'>
|
||||||
<span>
|
<span>
|
||||||
Model:{' '}
|
Model:{' '}
|
||||||
<strong>{agentSettings?.agentModel ?? 'gpt-5.1-codex'}</strong>
|
<strong>
|
||||||
|
{selectedProfile?.defaultModel ?? 'Configure an AI provider'}
|
||||||
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Reasoning:{' '}
|
Reasoning:{' '}
|
||||||
<strong>{agentSettings?.reasoningEffort ?? 'high'}</strong>
|
<strong>{selectedProfile?.reasoningEffort ?? 'medium'}</strong>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button type='submit' disabled={submitting}>
|
<Button type='submit' disabled={submitting || !hasProvider}>
|
||||||
{submitting ? 'Queueing...' : 'Queue agent job'}
|
{submitting ? 'Creating...' : 'Create thread'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
|
||||||
import { Checkbox, Label } from '@spoon/ui';
|
|
||||||
|
|
||||||
type Secret = {
|
|
||||||
_id: Id<'spoonSecrets'>;
|
|
||||||
name: string;
|
|
||||||
valuePreview?: string;
|
|
||||||
description?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SecretSelector = ({
|
|
||||||
secrets,
|
|
||||||
selectedSecretIds,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
secrets: Secret[];
|
|
||||||
selectedSecretIds: Id<'spoonSecrets'>[];
|
|
||||||
onChange: (secretIds: Id<'spoonSecrets'>[]) => void;
|
|
||||||
}) => {
|
|
||||||
const toggle = (secretId: Id<'spoonSecrets'>, checked: boolean) => {
|
|
||||||
onChange(
|
|
||||||
checked
|
|
||||||
? [...selectedSecretIds, secretId]
|
|
||||||
: selectedSecretIds.filter((id) => id !== secretId),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!secrets.length) {
|
|
||||||
return (
|
|
||||||
<p className='text-muted-foreground text-sm'>
|
|
||||||
No Spoon secrets saved. Add project secrets in Settings when a job needs
|
|
||||||
environment variables.
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='grid gap-2'>
|
|
||||||
{secrets.map((secret) => (
|
|
||||||
<label
|
|
||||||
key={secret._id}
|
|
||||||
className='border-border flex items-start gap-3 rounded-md border p-3'
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedSecretIds.includes(secret._id)}
|
|
||||||
onCheckedChange={(checked) => toggle(secret._id, checked === true)}
|
|
||||||
/>
|
|
||||||
<span className='grid gap-1'>
|
|
||||||
<Label className='font-mono text-xs'>{secret.name}</Label>
|
|
||||||
<span className='text-muted-foreground text-xs'>
|
|
||||||
{secret.description ?? secret.valuePreview ?? 'Configured'}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ProviderModelOption } from '@/lib/models-dev';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { loadModelsDevOptions } from '@/lib/models-dev';
|
||||||
|
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||||
|
import { makeFunctionReference } from 'convex/server';
|
||||||
|
import { KeyRound, Trash2 } 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 {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
Switch,
|
||||||
|
Textarea,
|
||||||
|
} from '@spoon/ui';
|
||||||
|
|
||||||
|
type Provider =
|
||||||
|
| 'openai'
|
||||||
|
| 'anthropic'
|
||||||
|
| 'google'
|
||||||
|
| 'openrouter'
|
||||||
|
| 'requesty'
|
||||||
|
| 'litellm'
|
||||||
|
| 'cloudflare_ai_gateway'
|
||||||
|
| 'custom_openai_compatible'
|
||||||
|
| 'opencode_openai_login';
|
||||||
|
type AuthType = 'api_key' | 'opencode_auth_json' | 'none';
|
||||||
|
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||||
|
|
||||||
|
const saveProfileRef = makeFunctionReference<
|
||||||
|
'action',
|
||||||
|
{
|
||||||
|
profileId?: Id<'aiProviderProfiles'>;
|
||||||
|
name: string;
|
||||||
|
provider: Provider;
|
||||||
|
authType: AuthType;
|
||||||
|
secret?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
defaultModel: string;
|
||||||
|
reasoningEffort: ReasoningEffort;
|
||||||
|
enabled: boolean;
|
||||||
|
},
|
||||||
|
Id<'aiProviderProfiles'>
|
||||||
|
>('aiProviderProfilesNode:save');
|
||||||
|
|
||||||
|
const setDefaultProfileRef = makeFunctionReference<
|
||||||
|
'mutation',
|
||||||
|
{ profileId: Id<'aiProviderProfiles'> },
|
||||||
|
{ success: true }
|
||||||
|
>('aiProviderProfiles:setDefault');
|
||||||
|
|
||||||
|
const providerOptions: {
|
||||||
|
value: Provider;
|
||||||
|
label: string;
|
||||||
|
authType: AuthType;
|
||||||
|
}[] = [
|
||||||
|
{ value: 'openai', label: 'OpenAI API key', authType: 'api_key' },
|
||||||
|
{ value: 'anthropic', label: 'Anthropic API key', authType: 'api_key' },
|
||||||
|
{ value: 'google', label: 'Google Gemini API key', authType: 'api_key' },
|
||||||
|
{ value: 'openrouter', label: 'OpenRouter', authType: 'api_key' },
|
||||||
|
{ value: 'requesty', label: 'Requesty', authType: 'api_key' },
|
||||||
|
{ value: 'litellm', label: 'LiteLLM', authType: 'api_key' },
|
||||||
|
{
|
||||||
|
value: 'cloudflare_ai_gateway',
|
||||||
|
label: 'Cloudflare AI Gateway',
|
||||||
|
authType: 'api_key',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'custom_openai_compatible',
|
||||||
|
label: 'Custom OpenAI-compatible',
|
||||||
|
authType: 'api_key',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'opencode_openai_login',
|
||||||
|
label: 'OpenCode OpenAI login',
|
||||||
|
authType: 'opencode_auth_json',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const reasoningOptions: ReasoningEffort[] = [
|
||||||
|
'none',
|
||||||
|
'minimal',
|
||||||
|
'low',
|
||||||
|
'medium',
|
||||||
|
'high',
|
||||||
|
'xhigh',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AiProviderProfilesPanel = () => {
|
||||||
|
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||||
|
const saveProfile = useAction(saveProfileRef);
|
||||||
|
const setDefaultProfile = useMutation(setDefaultProfileRef);
|
||||||
|
const removeProfile = useMutation(api.aiProviderProfiles.remove);
|
||||||
|
const [profileId, setProfileId] = useState<Id<'aiProviderProfiles'>>();
|
||||||
|
const [name, setName] = useState('OpenAI');
|
||||||
|
const [provider, setProvider] = useState<Provider>('openai');
|
||||||
|
const selectedProvider = useMemo(
|
||||||
|
() =>
|
||||||
|
providerOptions.find((option) => option.value === provider) ??
|
||||||
|
({
|
||||||
|
value: 'openai',
|
||||||
|
label: 'OpenAI API key',
|
||||||
|
authType: 'api_key',
|
||||||
|
} satisfies (typeof providerOptions)[number]),
|
||||||
|
[provider],
|
||||||
|
);
|
||||||
|
const [secret, setSecret] = useState('');
|
||||||
|
const [baseUrl, setBaseUrl] = useState('');
|
||||||
|
const [defaultModelValue, setDefaultModelValue] = useState('');
|
||||||
|
const [modelOptions, setModelOptions] = useState<ProviderModelOption[]>([]);
|
||||||
|
const [reasoningEffort, setReasoningEffort] =
|
||||||
|
useState<ReasoningEffort>('medium');
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
loadModelsDevOptions(provider)
|
||||||
|
.then((options) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setModelOptions(options);
|
||||||
|
setDefaultModelValue((current) =>
|
||||||
|
current && options.some((option) => option.id === current)
|
||||||
|
? current
|
||||||
|
: (options[0]?.id ?? ''),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
if (!cancelled) setModelOptions([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [provider]);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setProfileId(undefined);
|
||||||
|
setProvider('openai');
|
||||||
|
setSecret('');
|
||||||
|
setBaseUrl('');
|
||||||
|
setDefaultModelValue('');
|
||||||
|
setReasoningEffort('medium');
|
||||||
|
setEnabled(true);
|
||||||
|
setName('OpenAI');
|
||||||
|
};
|
||||||
|
|
||||||
|
const edit = (profile: (typeof profiles)[number]) => {
|
||||||
|
setProfileId(profile._id);
|
||||||
|
setName(profile.name);
|
||||||
|
setProvider(profile.provider as Provider);
|
||||||
|
setSecret('');
|
||||||
|
setBaseUrl(profile.baseUrl ?? '');
|
||||||
|
setDefaultModelValue(profile.defaultModel);
|
||||||
|
setReasoningEffort(profile.reasoningEffort as ReasoningEffort);
|
||||||
|
setEnabled(profile.enabled);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await saveProfile({
|
||||||
|
profileId,
|
||||||
|
name,
|
||||||
|
provider,
|
||||||
|
authType: selectedProvider.authType,
|
||||||
|
secret: secret.trim() ? secret : undefined,
|
||||||
|
baseUrl: baseUrl.trim() || undefined,
|
||||||
|
defaultModel: defaultModelValue,
|
||||||
|
reasoningEffort,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
toast.success('AI provider saved.');
|
||||||
|
reset();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('Could not save AI provider.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedProfile = profileId
|
||||||
|
? profiles.find((profile) => profile._id === profileId)
|
||||||
|
: undefined;
|
||||||
|
const hasCredential =
|
||||||
|
selectedProvider.authType === 'none' ||
|
||||||
|
Boolean(secret.trim()) ||
|
||||||
|
Boolean(selectedProfile?.configured);
|
||||||
|
const canSelectModel = hasCredential && modelOptions.length > 0;
|
||||||
|
const configuredProfiles = profiles.filter(
|
||||||
|
(profile) => profile.enabled && profile.configured,
|
||||||
|
);
|
||||||
|
const defaultProfile = configuredProfiles.find(
|
||||||
|
(profile) => profile.isDefault,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='grid gap-4 xl:grid-cols-[1fr_0.9fr]'>
|
||||||
|
<Card className='shadow-none'>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='flex items-center gap-2 text-base'>
|
||||||
|
<KeyRound className='size-4' />
|
||||||
|
Provider profiles
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-3'>
|
||||||
|
{configuredProfiles.length > 1 ? (
|
||||||
|
<div className='grid gap-2 rounded-md border p-3'>
|
||||||
|
<Label>Default provider</Label>
|
||||||
|
<Select
|
||||||
|
value={defaultProfile?._id ?? ''}
|
||||||
|
onValueChange={async (value) => {
|
||||||
|
await setDefaultProfile({
|
||||||
|
profileId: value as Id<'aiProviderProfiles'>,
|
||||||
|
});
|
||||||
|
toast.success('Default AI provider updated.');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Choose default provider' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{configuredProfiles.map((profile) => (
|
||||||
|
<SelectItem key={profile._id} value={profile._id}>
|
||||||
|
{profile.name} · {profile.defaultModel}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Spoons using account default will use this provider.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{profiles.length ? (
|
||||||
|
profiles.map((profile) => (
|
||||||
|
<div
|
||||||
|
key={profile._id}
|
||||||
|
className='border-border flex items-center justify-between gap-3 rounded-md border p-3'
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='min-w-0 text-left'
|
||||||
|
onClick={() => edit(profile)}
|
||||||
|
>
|
||||||
|
<p className='truncate text-sm font-medium'>{profile.name}</p>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
{profile.provider.replaceAll('_', ' ')} ·{' '}
|
||||||
|
{profile.secretPreview ?? 'not configured'} ·{' '}
|
||||||
|
{profile.defaultModel}
|
||||||
|
{profile.isDefault ? ' · default' : ''}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
aria-label='Remove provider'
|
||||||
|
onClick={async () => {
|
||||||
|
await removeProfile({ profileId: profile._id });
|
||||||
|
toast.success('AI provider removed.');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className='size-4' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className='text-muted-foreground text-sm'>
|
||||||
|
Add API-key providers for OpenCode, or store an OpenCode OpenAI
|
||||||
|
login profile for the next auth-file injection pass.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className='shadow-none'>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='text-base'>
|
||||||
|
{profileId ? 'Edit provider' : 'Add provider'}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={save} className='space-y-4'>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label>Provider</Label>
|
||||||
|
<Select
|
||||||
|
value={provider}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const nextProvider = value as Provider;
|
||||||
|
setProvider(nextProvider);
|
||||||
|
setName(
|
||||||
|
providerOptions
|
||||||
|
.find((option) => option.value === nextProvider)
|
||||||
|
?.label.replace(' API key', '') ?? 'AI provider',
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{providerOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label>
|
||||||
|
{selectedProvider.authType === 'opencode_auth_json'
|
||||||
|
? 'OpenCode auth JSON'
|
||||||
|
: 'API key'}
|
||||||
|
</Label>
|
||||||
|
{selectedProvider.authType === 'opencode_auth_json' ? (
|
||||||
|
<>
|
||||||
|
<Textarea
|
||||||
|
value={secret}
|
||||||
|
placeholder='Paste the full auth.json contents.'
|
||||||
|
onChange={(event) => setSecret(event.target.value)}
|
||||||
|
/>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Copy your Codex auth file from{' '}
|
||||||
|
<code className='bg-muted rounded px-1 py-0.5'>
|
||||||
|
~/.codex/auth.json
|
||||||
|
</code>
|
||||||
|
. It is stored encrypted and should be treated like a
|
||||||
|
password.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
type='password'
|
||||||
|
value={secret}
|
||||||
|
placeholder={
|
||||||
|
profileId ? 'Leave blank to keep current secret' : 'sk-...'
|
||||||
|
}
|
||||||
|
onChange={(event) => setSecret(event.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label>Base URL</Label>
|
||||||
|
<Input
|
||||||
|
value={baseUrl}
|
||||||
|
placeholder='Optional for LiteLLM, Requesty, custom providers'
|
||||||
|
onChange={(event) => setBaseUrl(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2 md:grid-cols-2'>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label>Default model</Label>
|
||||||
|
<Select
|
||||||
|
value={defaultModelValue}
|
||||||
|
onValueChange={setDefaultModelValue}
|
||||||
|
disabled={!canSelectModel}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
hasCredential
|
||||||
|
? 'Choose a model'
|
||||||
|
: 'Add credentials first'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{modelOptions.map((model) => (
|
||||||
|
<SelectItem key={model.id} value={model.id}>
|
||||||
|
{model.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Models are loaded from Models.dev, the catalog OpenCode uses
|
||||||
|
for provider/model metadata.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label>Thinking</Label>
|
||||||
|
<Select
|
||||||
|
value={reasoningEffort}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setReasoningEffort(value as ReasoningEffort)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{reasoningOptions.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
|
||||||
|
<div>
|
||||||
|
<Label>Enabled</Label>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Disabled profiles cannot be selected for new jobs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
disabled={saving || !hasCredential || !defaultModelValue}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save provider'}
|
||||||
|
</Button>
|
||||||
|
{profileId ? (
|
||||||
|
<Button type='button' variant='outline' onClick={reset}>
|
||||||
|
New provider
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useAction, useMutation, useQuery } from 'convex/react';
|
|
||||||
import { makeFunctionReference } from 'convex/server';
|
|
||||||
import { Brain } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
Input,
|
|
||||||
Label,
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@spoon/ui';
|
|
||||||
|
|
||||||
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
|
||||||
|
|
||||||
const saveOpenAiSettingsRef = makeFunctionReference<
|
|
||||||
'action',
|
|
||||||
{ apiKey: string; model: string; reasoningEffort: ReasoningEffort },
|
|
||||||
{ success: true }
|
|
||||||
>('aiSettingsNode:saveOpenAiSettings');
|
|
||||||
|
|
||||||
const modelOptions = [
|
|
||||||
{ value: 'gpt-5.5', label: 'GPT-5.5' },
|
|
||||||
{ value: 'gpt-5.5-pro', label: 'GPT-5.5 Pro' },
|
|
||||||
{ value: 'gpt-5.4', label: 'GPT-5.4' },
|
|
||||||
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const reasoningOptions: { value: ReasoningEffort; label: string }[] = [
|
|
||||||
{ value: 'none', label: 'None' },
|
|
||||||
{ value: 'minimal', label: 'Minimal' },
|
|
||||||
{ value: 'low', label: 'Low' },
|
|
||||||
{ value: 'medium', label: 'Medium' },
|
|
||||||
{ value: 'high', label: 'High' },
|
|
||||||
{ value: 'xhigh', label: 'Extra high' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const OpenAiStatusPanel = () => {
|
|
||||||
const status = useQuery(api.integrations.getStatus, {});
|
|
||||||
const settings = useQuery(api.aiSettings.getMine, {});
|
|
||||||
const saveOpenAiSettings = useAction(saveOpenAiSettingsRef);
|
|
||||||
const updatePreferences = useMutation(api.aiSettings.updatePreferences);
|
|
||||||
const removeOpenAiKey = useMutation(api.aiSettings.removeOpenAiKey);
|
|
||||||
const [apiKey, setApiKey] = useState('');
|
|
||||||
const [model, setModel] = useState('gpt-5.5');
|
|
||||||
const [reasoningEffort, setReasoningEffort] =
|
|
||||||
useState<ReasoningEffort>('medium');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!settings) return;
|
|
||||||
setModel(settings.model);
|
|
||||||
setReasoningEffort(settings.reasoningEffort as ReasoningEffort);
|
|
||||||
}, [settings]);
|
|
||||||
|
|
||||||
const save = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
if (apiKey.trim()) {
|
|
||||||
await saveOpenAiSettings({
|
|
||||||
apiKey,
|
|
||||||
model,
|
|
||||||
reasoningEffort,
|
|
||||||
});
|
|
||||||
setApiKey('');
|
|
||||||
} else {
|
|
||||||
await updatePreferences({
|
|
||||||
model,
|
|
||||||
reasoningEffort,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
toast.success('OpenAI settings saved.');
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
toast.error('Could not save OpenAI settings.');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const remove = async () => {
|
|
||||||
try {
|
|
||||||
await removeOpenAiKey({});
|
|
||||||
toast.success('OpenAI API key removed.');
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
toast.error('Could not remove OpenAI API key.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className='shadow-none'>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className='flex items-center gap-2 text-base'>
|
|
||||||
<Brain className='size-4' />
|
|
||||||
OpenAI reviews
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className='space-y-2 text-sm'>
|
|
||||||
<p className='text-muted-foreground'>
|
|
||||||
Compatibility reviews use your own OpenAI API key. Spoon encrypts the
|
|
||||||
key before storing it and only shows a short preview.
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
<p className='text-muted-foreground'>Encryption</p>
|
|
||||||
<p className='font-medium'>
|
|
||||||
{status?.encryptionConfigured ? 'Configured' : 'Missing server key'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className='text-muted-foreground'>OpenAI API key</p>
|
|
||||||
<p className='font-medium'>
|
|
||||||
{settings?.configured ? settings.apiKeyPreview : 'Not configured'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={save} className='space-y-4 pt-2'>
|
|
||||||
<div className='grid gap-2'>
|
|
||||||
<Label htmlFor='openai-api-key'>API key</Label>
|
|
||||||
<Input
|
|
||||||
id='openai-api-key'
|
|
||||||
type='password'
|
|
||||||
value={apiKey}
|
|
||||||
placeholder={
|
|
||||||
settings?.configured
|
|
||||||
? 'Leave blank to keep current key'
|
|
||||||
: 'sk-...'
|
|
||||||
}
|
|
||||||
onChange={(event) => setApiKey(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className='grid gap-2 md:grid-cols-2'>
|
|
||||||
<div className='grid gap-2'>
|
|
||||||
<Label>Review model</Label>
|
|
||||||
<Select value={model} onValueChange={(value) => setModel(value)}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{modelOptions.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className='grid gap-2'>
|
|
||||||
<Label>Thinking</Label>
|
|
||||||
<Select
|
|
||||||
value={reasoningEffort}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setReasoningEffort(value as ReasoningEffort)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{reasoningOptions.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex flex-wrap gap-2'>
|
|
||||||
<Button type='submit' disabled={submitting}>
|
|
||||||
{submitting ? 'Saving...' : 'Save OpenAI settings'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
variant='outline'
|
|
||||||
onClick={remove}
|
|
||||||
disabled={!settings?.configured}
|
|
||||||
>
|
|
||||||
Remove key
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -15,16 +15,17 @@ export const CTA = () => {
|
|||||||
<div className='flex flex-col items-start justify-between gap-8 md:flex-row md:items-center'>
|
<div className='flex flex-col items-start justify-between gap-8 md:flex-row md:items-center'>
|
||||||
<div>
|
<div>
|
||||||
<h2 className='text-2xl font-semibold tracking-normal md:text-3xl'>
|
<h2 className='text-2xl font-semibold tracking-normal md:text-3xl'>
|
||||||
Keep the fork. Lose the maintenance dread.
|
Fork the project. Keep the relationship.
|
||||||
</h2>
|
</h2>
|
||||||
<p className='text-primary-foreground/80 mt-3 max-w-2xl leading-7'>
|
<p className='text-primary-foreground/80 mt-3 max-w-2xl leading-7'>
|
||||||
Create your first Spoon, connect GitHub, and make upstream drift
|
Create your first Spoon, connect GitHub, and let upstream
|
||||||
something you can see, review, and act on.
|
maintenance become a visible thread instead of a lonely recurring
|
||||||
|
chore.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant='secondary' size='lg' asChild>
|
<Button variant='secondary' size='lg' asChild>
|
||||||
<Link href={isAuthenticated ? '/spoons/new' : '/sign-in'}>
|
<Link href={isAuthenticated ? '/spoons/new' : '/sign-in'}>
|
||||||
{isAuthenticated ? 'New Spoon' : 'Start with Spoon'}
|
{isAuthenticated ? 'Create a Spoon' : 'Start with Spoon'}
|
||||||
<ArrowRight className='size-4' />
|
<ArrowRight className='size-4' />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -4,107 +4,135 @@ import {
|
|||||||
GitBranch,
|
GitBranch,
|
||||||
GitCompare,
|
GitCompare,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
History,
|
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LockKeyhole,
|
LockKeyhole,
|
||||||
|
MessagesSquare,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
ServerCog,
|
ServerCog,
|
||||||
Sparkles,
|
ShieldCheck,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { Badge } from '@spoon/ui';
|
import { Badge } from '@spoon/ui';
|
||||||
|
|
||||||
const workflow = [
|
const workflow = [
|
||||||
{
|
{
|
||||||
title: 'Connect GitHub',
|
title: 'Create the Spoon',
|
||||||
description:
|
description:
|
||||||
'Install the Spoon GitHub App so Spoon can read forks, compare branches, push agent branches, and open draft pull requests.',
|
'Register the upstream project, your GitHub fork, default branches, clone URLs, and any extra remotes you want visible.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Create a Spoon',
|
title: 'Watch upstream',
|
||||||
description:
|
description:
|
||||||
'Register a managed fork with its upstream project, fork repository, default branches, sync cadence, and additional remote URLs.',
|
'Spoon intermittently checks the upstream default branch and compares it against the current fork state.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Watch drift',
|
title: 'Auto-sync clean drift',
|
||||||
description:
|
description:
|
||||||
'Refresh GitHub state to see upstream commits waiting, fork-only commits, open pull requests, sync health, and merge history.',
|
'If the fork has no custom commits and upstream moved, Spoon can fast-forward the fork without turning it into a chore.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Review safely',
|
title: 'Open a thread when it matters',
|
||||||
description:
|
description:
|
||||||
'Use AI compatibility reviews to summarize upstream changes, flag important files, and decide when a manual review is needed.',
|
'If your fork has custom commits, Spoon creates a durable maintenance thread instead of guessing.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Ship through PRs',
|
title: 'Resolve with context',
|
||||||
description:
|
description:
|
||||||
'Queue agent jobs that work on fresh branches and open draft pull requests, while GitHub remains the source of truth.',
|
'Review commits, changed files, pull requests, fork-only work, ignored upstream changes, and workspace output together.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Ship through draft PRs',
|
||||||
|
description:
|
||||||
|
'When code is needed, OpenCode works in an isolated workspace and hands changes back as a draft PR.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
{
|
{
|
||||||
title: 'Project dashboards',
|
title: 'Spoon dashboards',
|
||||||
description:
|
description:
|
||||||
'Each Spoon gets a focused dashboard with upstream drift, fork-only changes, pull requests, AI reviews, activity, settings, clone URLs, and extra remotes.',
|
'See drift, fork-only commits, pull requests, clone URLs, extra remotes, threads, activity, and settings for each managed fork.',
|
||||||
icon: GitCompare,
|
icon: GitCompare,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Upstream maintenance queue',
|
title: 'Thread-first maintenance',
|
||||||
description:
|
description:
|
||||||
'The global dashboard makes it obvious which forks are up to date, behind, ahead, diverged, or waiting for review.',
|
'Every review, conflict, ignore decision, and requested code change has a durable conversation attached to it.',
|
||||||
|
icon: MessagesSquare,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Effective drift',
|
||||||
|
description:
|
||||||
|
'Spoon shows raw upstream state and the effective maintenance state after intentional ignore decisions.',
|
||||||
icon: RefreshCw,
|
icon: RefreshCw,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Pull request visibility',
|
title: 'OpenCode workspaces',
|
||||||
description:
|
description:
|
||||||
'Spoon caches fork pull requests and relevant upstream pull requests so maintenance decisions are tied to real GitHub activity.',
|
'Open a file tree, browser editor, diff view, job logs, command panel, and thread context when a fork needs code.',
|
||||||
icon: GitPullRequest,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'AI compatibility review',
|
|
||||||
description:
|
|
||||||
'OpenAI reviews upstream changes against fork-only commits and returns structured risk, summary, recommended action, and conflict signals.',
|
|
||||||
icon: Sparkles,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Per-user AI settings',
|
|
||||||
description:
|
|
||||||
'Users bring their own OpenAI API key, choose a review model, and set reasoning effort. Keys are encrypted before storage.',
|
|
||||||
icon: KeyRound,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Agent job foundation',
|
|
||||||
description:
|
|
||||||
'Spoon can queue coding-agent work per project with selected secrets, job settings, event logs, artifacts, and draft PR targets.',
|
|
||||||
icon: Bot,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const builtFor = [
|
|
||||||
{
|
|
||||||
title: 'Self-hosted by design',
|
|
||||||
description:
|
|
||||||
'Run the app, Convex backend, Postgres, and optional agent worker on your own server so code automation stays under your control.',
|
|
||||||
icon: ServerCog,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Secrets stay deliberate',
|
|
||||||
description:
|
|
||||||
'Project secrets are per Spoon and selected per job. The agent runtime never receives every secret by default.',
|
|
||||||
icon: LockKeyhole,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Outside work is expected',
|
|
||||||
description:
|
|
||||||
'Spoon assumes you may push to GitHub directly, edit locally, or use another CI system. Refresh always starts from current GitHub state.',
|
|
||||||
icon: Code2,
|
icon: Code2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'History stays inspectable',
|
title: 'Provider-owned AI',
|
||||||
description:
|
description:
|
||||||
'Sync runs, AI reviews, job logs, PR URLs, errors, and artifacts are stored so maintenance work is reviewable after the fact.',
|
'Use encrypted provider profiles, OpenCode auth, or user-owned API keys rather than a shared application key.',
|
||||||
icon: History,
|
icon: KeyRound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Draft PR handoff',
|
||||||
|
description:
|
||||||
|
'Agent work becomes a branch and draft pull request. Spoon does not auto-merge custom forks behind your back.',
|
||||||
|
icon: GitPullRequest,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const decisions = [
|
||||||
|
{
|
||||||
|
condition: 'No fork-only commits + upstream ahead',
|
||||||
|
action: 'Auto-sync',
|
||||||
|
explanation: 'The fork is still close enough to fast-forward.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: 'Fork-only commits + upstream ahead',
|
||||||
|
action: 'Create thread',
|
||||||
|
explanation: 'Spoon reviews whether upstream affects custom work.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: 'Merge conflicts',
|
||||||
|
action: 'Open workspace',
|
||||||
|
explanation: 'Resolve in an isolated worker and ship a draft PR.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
condition: 'Irrelevant upstream changes',
|
||||||
|
action: 'Ignore intentionally',
|
||||||
|
explanation: 'Record why those commits no longer matter to this fork.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ownership = [
|
||||||
|
{
|
||||||
|
title: 'Your GitHub App',
|
||||||
|
description:
|
||||||
|
'GitHub remains the active source of truth for forks, branches, compares, and draft PRs.',
|
||||||
|
icon: GitBranch,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Your providers',
|
||||||
|
description:
|
||||||
|
'AI provider profiles and Codex/OpenCode auth stay encrypted and selected by you.',
|
||||||
|
icon: ShieldCheck,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Your secrets',
|
||||||
|
description:
|
||||||
|
'Project secrets are per Spoon, redacted in logs, and refused from commits when materialized.',
|
||||||
|
icon: LockKeyhole,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Your workflow',
|
||||||
|
description:
|
||||||
|
'Local commits, Gitea mirrors, CI changes, and direct GitHub edits are expected parts of the loop.',
|
||||||
|
icon: ServerCog,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -116,44 +144,42 @@ export const Workflow = () => (
|
|||||||
Workflow
|
Workflow
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
|
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
|
||||||
Forking should not mean drifting alone.
|
Forking should start a relationship, not a support burden.
|
||||||
</h2>
|
</h2>
|
||||||
<p className='text-muted-foreground mt-4 text-lg leading-8'>
|
<p className='text-muted-foreground mt-4 text-lg leading-8'>
|
||||||
Spoon treats a fork as an ongoing relationship with upstream. The
|
Spoon keeps watching the upstream project after the fork. When
|
||||||
product keeps the original project, your custom work, and future
|
upstream moves, it decides whether your fork can fast-forward, needs a
|
||||||
automation visible in one place.
|
maintenance thread, or should ignore changes that no longer matter to
|
||||||
|
your version. Forking a project should not mean supporting it alone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid gap-8 lg:grid-cols-[0.8fr_1.2fr]'>
|
<div className='grid gap-8 lg:grid-cols-[0.75fr_1.25fr]'>
|
||||||
<div className='border-border bg-background rounded-lg border p-6'>
|
<div className='border-border bg-background rounded-lg border p-6'>
|
||||||
<GitBranch className='text-primary size-6' />
|
<MessagesSquare className='text-primary size-6' />
|
||||||
<h3 className='mt-5 text-xl font-semibold'>
|
<h3 className='mt-5 text-xl font-semibold'>
|
||||||
A Spoon is a managed fork
|
Spoon keeps the conversation going
|
||||||
</h3>
|
</h3>
|
||||||
<p className='text-muted-foreground mt-3 leading-7'>
|
<p className='text-muted-foreground mt-3 leading-7'>
|
||||||
It knows where upstream lives, where your fork lives, which branch
|
A fork is not a one-time split. Spoon keeps the fork and upstream in
|
||||||
matters, what extra remotes you care about, and what rules should
|
conversation by turning maintenance into visible, reviewable threads
|
||||||
govern updates. That gives maintenance a durable home instead of a
|
instead of surprise drift.
|
||||||
pile of one-off Git commands.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ol className='grid gap-3'>
|
<ol className='grid gap-3 md:grid-cols-2'>
|
||||||
{workflow.map((step, index) => (
|
{workflow.map((step, index) => (
|
||||||
<li
|
<li
|
||||||
key={step.title}
|
key={step.title}
|
||||||
className='border-border bg-background grid gap-4 rounded-lg border p-5 sm:grid-cols-[4rem_1fr]'
|
className='border-border bg-background rounded-lg border p-5'
|
||||||
>
|
>
|
||||||
<span className='text-primary text-sm font-semibold'>
|
<span className='text-primary text-sm font-semibold'>
|
||||||
{String(index + 1).padStart(2, '0')}
|
{String(index + 1).padStart(2, '0')}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<h3 className='mt-3 font-semibold'>{step.title}</h3>
|
||||||
<h3 className='font-semibold'>{step.title}</h3>
|
<p className='text-muted-foreground mt-2 text-sm leading-6'>
|
||||||
<p className='text-muted-foreground mt-1 text-sm leading-6'>
|
|
||||||
{step.description}
|
{step.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
@@ -170,12 +196,12 @@ export const Features = () => (
|
|||||||
Product surface
|
Product surface
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
|
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
|
||||||
Everything important about a fork, without opening six tabs.
|
The maintenance cockpit for forks you actually care about.
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className='text-muted-foreground max-w-xl leading-7'>
|
<p className='text-muted-foreground max-w-xl leading-7'>
|
||||||
Spoon is not trying to replace GitHub. It is the layer that explains how
|
Custom work should not mean permanent drift. Spoon keeps the operational
|
||||||
your fork relates to upstream and what should happen next.
|
picture clear from the first upstream check to the final draft PR.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -195,59 +221,113 @@ export const Features = () => (
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Agents = () => (
|
export const MaintenanceDecisions = () => (
|
||||||
<section id='agents' className='border-border/60 bg-muted/30 border-y'>
|
<section id='maintenance' className='border-border/60 bg-muted/30 border-y'>
|
||||||
<div className='container mx-auto grid gap-10 px-4 py-24 lg:grid-cols-[0.95fr_1.05fr]'>
|
<div className='container mx-auto px-4 py-24'>
|
||||||
<div>
|
<div className='mb-10 max-w-3xl'>
|
||||||
<Badge variant='outline' className='mb-4'>
|
<Badge variant='outline' className='mb-4'>
|
||||||
Agent work
|
Maintenance decisions
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
|
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
|
||||||
The agent belongs inside the fork dashboard.
|
Spoon knows when to sync, when to thread, and when to stay out of the
|
||||||
|
way.
|
||||||
</h2>
|
</h2>
|
||||||
<p className='text-muted-foreground mt-4 text-lg leading-8'>
|
|
||||||
The goal is simple: ask for a change, let a worker clone the current
|
|
||||||
fork, expose only the secrets you selected, run checks, push a branch,
|
|
||||||
and open a draft pull request. The first pieces are already modeled:
|
|
||||||
encrypted Spoon secrets, agent settings, queued jobs, logs, and
|
|
||||||
artifacts.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='border-border bg-background rounded-lg border'>
|
<div className='overflow-hidden rounded-lg border'>
|
||||||
<div className='border-border border-b p-5'>
|
{decisions.map((decision) => (
|
||||||
<div className='flex items-center gap-3'>
|
<div
|
||||||
<span className='bg-primary/10 text-primary flex size-9 items-center justify-center rounded-md'>
|
key={decision.condition}
|
||||||
<Bot className='size-4' />
|
className='bg-background border-border grid gap-4 border-b p-5 last:border-b-0 lg:grid-cols-[1fr_12rem_1.3fr]'
|
||||||
</span>
|
>
|
||||||
<div>
|
<p className='font-medium'>{decision.condition}</p>
|
||||||
<p className='font-medium'>Draft PR agent flow</p>
|
<Badge
|
||||||
<p className='text-muted-foreground text-sm'>
|
variant='outline'
|
||||||
Built for review, not automatic merge.
|
className='bg-primary/10 text-primary w-fit'
|
||||||
</p>
|
>
|
||||||
</div>
|
{decision.action}
|
||||||
</div>
|
</Badge>
|
||||||
</div>
|
|
||||||
<div className='divide-border divide-y'>
|
|
||||||
{[
|
|
||||||
['Clone', 'Start from the current GitHub fork state.'],
|
|
||||||
['Branch', 'Create a short-lived agent branch.'],
|
|
||||||
['Edit', 'Apply focused changes with selected project context.'],
|
|
||||||
[
|
|
||||||
'Check',
|
|
||||||
'Run configured install, lint, typecheck, or test steps.',
|
|
||||||
],
|
|
||||||
['Review', 'Open a draft pull request with logs and artifacts.'],
|
|
||||||
].map(([phase, detail]) => (
|
|
||||||
<div key={phase} className='grid gap-3 p-5 sm:grid-cols-[8rem_1fr]'>
|
|
||||||
<p className='text-sm font-semibold'>{phase}</p>
|
|
||||||
<p className='text-muted-foreground text-sm leading-6'>
|
<p className='text-muted-foreground text-sm leading-6'>
|
||||||
{detail}
|
{decision.explanation}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ThreadedWork = () => (
|
||||||
|
<section id='threads' className='container mx-auto px-4 py-24'>
|
||||||
|
<div className='grid gap-10 lg:grid-cols-[0.9fr_1.1fr]'>
|
||||||
|
<div>
|
||||||
|
<Badge variant='outline' className='mb-4'>
|
||||||
|
Threads
|
||||||
|
</Badge>
|
||||||
|
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
|
||||||
|
Threads keep the whole maintenance conversation in one place.
|
||||||
|
</h2>
|
||||||
|
<p className='text-muted-foreground mt-4 text-lg leading-8'>
|
||||||
|
Upstream changed, a fork drifted, a conflict appeared, or you asked
|
||||||
|
for a code change. Spoon puts the reasoning, messages, workspace,
|
||||||
|
artifacts, and draft PR handoff in the same thread.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='border-border bg-card overflow-hidden rounded-lg border shadow-sm'>
|
||||||
|
<div className='border-border flex flex-wrap items-center justify-between gap-3 border-b p-5'>
|
||||||
|
<div>
|
||||||
|
<p className='font-medium'>Thread: Upstream auth changes landed</p>
|
||||||
|
<p className='text-muted-foreground text-sm'>
|
||||||
|
Source: upstream update
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge className='bg-amber-500/10 text-amber-700 hover:bg-amber-500/10'>
|
||||||
|
Waiting for review
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className='bg-border grid gap-px lg:grid-cols-[1.35fr_0.65fr]'>
|
||||||
|
<div className='bg-background space-y-3 p-5'>
|
||||||
|
{[
|
||||||
|
['system', 'Spoon found 3 upstream commits after 8f3a2c1.'],
|
||||||
|
[
|
||||||
|
'assistant',
|
||||||
|
'These touch auth callback handling and package scripts. Your fork has Authentik-only changes in the same area.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'user',
|
||||||
|
'Open a review PR and preserve Authentik as the only provider.',
|
||||||
|
],
|
||||||
|
].map(([role, message]) => (
|
||||||
|
<div key={role} className='rounded-md border p-3 text-sm'>
|
||||||
|
<p className='text-muted-foreground text-xs'>{role}</p>
|
||||||
|
<p className='mt-1 leading-6'>{message}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className='bg-background space-y-4 p-5 text-sm'>
|
||||||
|
<div>
|
||||||
|
<p className='text-muted-foreground text-xs'>Latest job</p>
|
||||||
|
<p className='mt-1 font-medium'>OpenCode workspace active</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='text-muted-foreground text-xs'>Model/provider</p>
|
||||||
|
<p className='mt-1 font-medium'>Codex profile</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className='text-muted-foreground text-xs'>PR target</p>
|
||||||
|
<p className='mt-1 font-medium'>fork:main</p>
|
||||||
|
</div>
|
||||||
|
<div className='bg-muted/60 rounded-md p-3'>
|
||||||
|
<Bot className='text-primary mb-2 size-4' />
|
||||||
|
<p className='text-muted-foreground leading-6'>
|
||||||
|
Workspace logs, diffs, checks, and the final PR stay attached to
|
||||||
|
the thread.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -260,18 +340,18 @@ export const Security = () => (
|
|||||||
Ownership
|
Ownership
|
||||||
</Badge>
|
</Badge>
|
||||||
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
|
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
|
||||||
Useful because it respects how forks are really maintained.
|
Self-hosted because the fork is yours.
|
||||||
</h2>
|
</h2>
|
||||||
<p className='text-muted-foreground mt-4 text-lg leading-8'>
|
<p className='text-muted-foreground mt-4 text-lg leading-8'>
|
||||||
A fork can have local experiments, CI changes, private deployment
|
Your fork can have local commits, private deploy settings, Gitea
|
||||||
settings, and emergency upstream fixes all happening at once. Spoon
|
mirrors, CI experiments, and emergency GitHub edits. Spoon keeps that
|
||||||
keeps those threads visible without pretending every change must come
|
relationship with upstream visible without taking ownership away from
|
||||||
through the app.
|
you.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid gap-4 sm:grid-cols-2'>
|
<div className='grid gap-4 sm:grid-cols-2'>
|
||||||
{builtFor.map(({ title, description, icon: Icon }) => (
|
{ownership.map(({ title, description, icon: Icon }) => (
|
||||||
<div key={title} className='border-border rounded-lg border p-5'>
|
<div key={title} className='border-border rounded-lg border p-5'>
|
||||||
<div className='flex items-center gap-3'>
|
<div className='flex items-center gap-3'>
|
||||||
<Icon className='text-primary size-5 shrink-0' />
|
<Icon className='text-primary size-5 shrink-0' />
|
||||||
|
|||||||
@@ -2,42 +2,11 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useConvexAuth } from 'convex/react';
|
import { useConvexAuth } from 'convex/react';
|
||||||
import {
|
import { ArrowRight, CircleDot, ShieldCheck } from 'lucide-react';
|
||||||
ArrowRight,
|
|
||||||
Bot,
|
|
||||||
CheckCircle2,
|
|
||||||
CircleDot,
|
|
||||||
GitBranch,
|
|
||||||
GitPullRequest,
|
|
||||||
KeyRound,
|
|
||||||
ShieldCheck,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import { Badge, Button } from '@spoon/ui';
|
import { Badge, Button } from '@spoon/ui';
|
||||||
|
|
||||||
const previewRows = [
|
import { ProductStoryDemo } from './product-story-demo';
|
||||||
{
|
|
||||||
name: 'gibsend',
|
|
||||||
upstream: 'usesend/usesend',
|
|
||||||
status: '3 upstream commits',
|
|
||||||
icon: CheckCircle2,
|
|
||||||
tone: 'text-emerald-600',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'internal-docs',
|
|
||||||
upstream: 'platform/docs',
|
|
||||||
status: 'AI review ready',
|
|
||||||
icon: Bot,
|
|
||||||
tone: 'text-teal-600',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ops-console',
|
|
||||||
upstream: 'console/main',
|
|
||||||
status: 'fork-only changes',
|
|
||||||
icon: GitPullRequest,
|
|
||||||
tone: 'text-amber-600',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const Hero = () => {
|
export const Hero = () => {
|
||||||
const { isAuthenticated } = useConvexAuth();
|
const { isAuthenticated } = useConvexAuth();
|
||||||
@@ -47,17 +16,17 @@ export const Hero = () => {
|
|||||||
<div className='max-w-3xl'>
|
<div className='max-w-3xl'>
|
||||||
<Badge variant='outline' className='mb-5 gap-2'>
|
<Badge variant='outline' className='mb-5 gap-2'>
|
||||||
<ShieldCheck className='size-3.5 text-emerald-600' />
|
<ShieldCheck className='size-3.5 text-emerald-600' />
|
||||||
Self-hostable fork maintenance cockpit
|
Self-hostable fork maintenance with Threads
|
||||||
</Badge>
|
</Badge>
|
||||||
<h1 className='max-w-4xl text-4xl font-semibold tracking-normal text-balance sm:text-5xl md:text-6xl'>
|
<h1 className='max-w-4xl text-4xl font-semibold tracking-normal text-balance sm:text-5xl md:text-6xl'>
|
||||||
Make your forks <em className='text-primary'>intimately</em> close
|
Fork freely & keep them all{' '}
|
||||||
to upstream.
|
<em className='text-primary'>intimately</em> close to upstream.
|
||||||
</h1>
|
</h1>
|
||||||
<p className='text-muted-foreground mt-6 max-w-2xl text-lg leading-8'>
|
<p className='text-muted-foreground mt-6 max-w-2xl text-lg leading-8'>
|
||||||
Spoon gives every important fork a living maintenance dashboard.
|
Spoon is a self-hostable maintenance cockpit for forks. It watches
|
||||||
Track upstream drift, preserve your custom commits, review pull
|
upstream, understands your fork-only changes, opens threads when
|
||||||
requests, and queue AI-assisted work without losing sight of the
|
decisions are needed, and helps keep your managed forks close
|
||||||
project you forked from.
|
without asking you to support them alone.
|
||||||
</p>
|
</p>
|
||||||
<div className='mt-8 flex flex-col gap-3 sm:flex-row'>
|
<div className='mt-8 flex flex-col gap-3 sm:flex-row'>
|
||||||
<Button size='lg' asChild>
|
<Button size='lg' asChild>
|
||||||
@@ -67,13 +36,14 @@ export const Hero = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button size='lg' variant='outline' asChild>
|
<Button size='lg' variant='outline' asChild>
|
||||||
<Link href='#workflow'>See how it works</Link>
|
<Link href='#demo'>Watch the flow</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className='text-muted-foreground mt-8 grid max-w-xl gap-3 text-sm sm:grid-cols-3'>
|
<div className='text-muted-foreground mt-8 grid max-w-2xl gap-3 text-sm sm:grid-cols-2'>
|
||||||
{[
|
{[
|
||||||
'GitHub App backed',
|
'GitHub App backed',
|
||||||
'OpenAI key per user',
|
'Thread-first maintenance',
|
||||||
|
'OpenCode workspaces',
|
||||||
'Draft PR workflow',
|
'Draft PR workflow',
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<span key={item} className='flex items-center gap-2'>
|
<span key={item} className='flex items-center gap-2'>
|
||||||
@@ -84,71 +54,7 @@ export const Hero = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='border-border bg-card overflow-hidden rounded-lg border shadow-sm'>
|
<ProductStoryDemo />
|
||||||
<div className='border-border flex items-center justify-between border-b px-5 py-4'>
|
|
||||||
<div>
|
|
||||||
<p className='text-sm font-medium'>Fork health</p>
|
|
||||||
<p className='text-muted-foreground text-xs'>
|
|
||||||
Current state across managed Spoons
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge className='bg-primary/10 text-primary hover:bg-primary/10'>
|
|
||||||
Live GitHub sync
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className='grid gap-4 p-5 md:grid-cols-3'>
|
|
||||||
{[
|
|
||||||
['Behind', '3', 'upstream commits'],
|
|
||||||
['Fork-only', '12', 'custom changes'],
|
|
||||||
['AI risk', 'Low', 'reviewed'],
|
|
||||||
].map(([label, value, note]) => (
|
|
||||||
<div
|
|
||||||
key={label}
|
|
||||||
className='border-border bg-background rounded-md border p-4'
|
|
||||||
>
|
|
||||||
<p className='text-muted-foreground text-xs'>{label}</p>
|
|
||||||
<p className='mt-2 text-2xl font-semibold'>{value}</p>
|
|
||||||
<p className='text-muted-foreground mt-1 text-xs'>{note}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className='space-y-3 px-5 pb-5'>
|
|
||||||
{previewRows.map(({ name, upstream, status, icon: Icon, tone }) => (
|
|
||||||
<div
|
|
||||||
key={name}
|
|
||||||
className='border-border bg-background flex items-center justify-between gap-4 rounded-md border p-4'
|
|
||||||
>
|
|
||||||
<div className='flex items-center gap-3'>
|
|
||||||
<span className='bg-muted flex size-9 items-center justify-center rounded-md'>
|
|
||||||
<GitBranch className='size-4' />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<p className='text-sm font-medium'>{name}</p>
|
|
||||||
<p className='text-muted-foreground text-xs'>{upstream}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className='flex items-center gap-2 text-sm'>
|
|
||||||
<Icon className={`size-4 ${tone}`} />
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className='border-border bg-muted/30 grid gap-3 border-t p-5 text-sm sm:grid-cols-2'>
|
|
||||||
<div className='flex items-start gap-3'>
|
|
||||||
<KeyRound className='text-primary mt-0.5 size-4' />
|
|
||||||
<p className='text-muted-foreground'>
|
|
||||||
User-owned OpenAI keys stay encrypted and selectable.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className='flex items-start gap-3'>
|
|
||||||
<GitPullRequest className='text-primary mt-0.5 size-4' />
|
|
||||||
<p className='text-muted-foreground'>
|
|
||||||
Agent jobs are shaped around draft pull requests.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
export { Hero } from './hero';
|
export { Hero } from './hero';
|
||||||
export { Agents, Features, Security, Workflow } from './features';
|
export {
|
||||||
|
Features,
|
||||||
|
MaintenanceDecisions,
|
||||||
|
Security,
|
||||||
|
ThreadedWork,
|
||||||
|
Workflow,
|
||||||
|
} from './features';
|
||||||
|
export { ProductStoryDemo } from './product-story-demo';
|
||||||
|
export { WorkspaceShowcase } from './workspace-showcase';
|
||||||
export { CTA } from './cta';
|
export { CTA } from './cta';
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Code2,
|
||||||
|
GitBranch,
|
||||||
|
GitPullRequest,
|
||||||
|
MessagesSquare,
|
||||||
|
RefreshCw,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge, Button } from '@spoon/ui';
|
||||||
|
|
||||||
|
type DemoStep = 'check' | 'thread' | 'workspace' | 'decision' | 'pr';
|
||||||
|
|
||||||
|
type DemoStepConfig = {
|
||||||
|
id: DemoStep;
|
||||||
|
label: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultStep: DemoStepConfig = {
|
||||||
|
id: 'check',
|
||||||
|
label: 'Check',
|
||||||
|
title: 'Upstream moved',
|
||||||
|
description: 'Spoon checks default branches and compares the fork network.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const steps: DemoStepConfig[] = [
|
||||||
|
defaultStep,
|
||||||
|
{
|
||||||
|
id: 'thread',
|
||||||
|
label: 'Thread',
|
||||||
|
title: 'Custom work needs context',
|
||||||
|
description:
|
||||||
|
'Fork-only commits turn an upstream update into a durable thread.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'workspace',
|
||||||
|
label: 'Workspace',
|
||||||
|
title: 'OpenCode gets a sandbox',
|
||||||
|
description:
|
||||||
|
'The worker opens a repo workspace with files, thread context, and checks.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'decision',
|
||||||
|
label: 'Decision',
|
||||||
|
title: 'Review the maintenance call',
|
||||||
|
description:
|
||||||
|
'Spoon records risk, conflicts, and the recommended next step.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pr',
|
||||||
|
label: 'Draft PR',
|
||||||
|
title: 'Ship as reviewable work',
|
||||||
|
description:
|
||||||
|
'Code changes leave the workspace as a branch and draft pull request.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const getReducedMotionPreference = () => {
|
||||||
|
if (
|
||||||
|
typeof window === 'undefined' ||
|
||||||
|
typeof window.matchMedia !== 'function'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
};
|
||||||
|
|
||||||
|
const usePrefersReducedMotion = () => {
|
||||||
|
const [reducedMotion, setReducedMotion] = useState(
|
||||||
|
getReducedMotionPreference,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window.matchMedia !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
const update = () => setReducedMotion(query.matches);
|
||||||
|
update();
|
||||||
|
query.addEventListener('change', update);
|
||||||
|
return () => query.removeEventListener('change', update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return reducedMotion;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Metric = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone = 'default',
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: 'default' | 'warning' | 'good';
|
||||||
|
}) => (
|
||||||
|
<div className='border-border bg-background rounded-md border p-3'>
|
||||||
|
<p className='text-muted-foreground text-xs'>{label}</p>
|
||||||
|
<p
|
||||||
|
className={
|
||||||
|
tone === 'warning'
|
||||||
|
? 'mt-1 text-lg font-semibold text-amber-600'
|
||||||
|
: tone === 'good'
|
||||||
|
? 'mt-1 text-lg font-semibold text-emerald-600'
|
||||||
|
: 'mt-1 text-lg font-semibold'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CheckPreview = () => (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<div className='grid gap-3 sm:grid-cols-3'>
|
||||||
|
<Metric label='Raw upstream ahead' value='3 commits' tone='warning' />
|
||||||
|
<Metric label='Fork-only work' value='5 commits' />
|
||||||
|
<Metric label='Status' value='Needs thread' tone='warning' />
|
||||||
|
</div>
|
||||||
|
<div className='border-border bg-background rounded-md border p-4'>
|
||||||
|
<div className='flex items-center justify-between gap-4'>
|
||||||
|
<div className='min-w-0'>
|
||||||
|
<p className='font-medium'>usesend-authentik</p>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
usesend/usesend {'->'} gibbyb/usesend
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant='outline'>daily check</Badge>
|
||||||
|
</div>
|
||||||
|
<div className='mt-4 grid gap-2 text-sm'>
|
||||||
|
<div className='bg-muted/50 flex items-center justify-between rounded-md px-3 py-2'>
|
||||||
|
<span className='flex items-center gap-2'>
|
||||||
|
<RefreshCw className='text-primary size-4' />
|
||||||
|
Compare upstream main
|
||||||
|
</span>
|
||||||
|
<span className='text-muted-foreground'>complete</span>
|
||||||
|
</div>
|
||||||
|
<div className='bg-muted/50 flex items-center justify-between rounded-md px-3 py-2'>
|
||||||
|
<span className='flex items-center gap-2'>
|
||||||
|
<GitBranch className='text-primary size-4' />
|
||||||
|
Detect fork-only commits
|
||||||
|
</span>
|
||||||
|
<span className='text-muted-foreground'>custom auth work</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ThreadPreview = () => (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<div className='border-border bg-background rounded-md border p-4'>
|
||||||
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
|
<MessagesSquare className='text-primary size-4' />
|
||||||
|
<p className='font-medium'>
|
||||||
|
Upstream changed: auth and webhook updates
|
||||||
|
</p>
|
||||||
|
<Badge className='bg-amber-500/10 text-amber-700 hover:bg-amber-500/10'>
|
||||||
|
Review required
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className='mt-4 space-y-3 text-sm'>
|
||||||
|
<div className='bg-muted/60 rounded-md p-3'>
|
||||||
|
<p className='text-muted-foreground text-xs'>system</p>
|
||||||
|
<p className='mt-1'>
|
||||||
|
Spoon found upstream commits that touch auth-adjacent files. Fork
|
||||||
|
has custom Authentik work.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-md border p-3'>
|
||||||
|
<p className='text-muted-foreground text-xs'>assistant</p>
|
||||||
|
<p className='mt-1'>
|
||||||
|
These updates are probably valuable, but they overlap with fork-only
|
||||||
|
provider changes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const WorkspacePreview = () => (
|
||||||
|
<div className='grid min-h-[19rem] gap-3 lg:grid-cols-[0.8fr_1.4fr_0.9fr]'>
|
||||||
|
<div className='border-border bg-background rounded-md border p-3 text-xs'>
|
||||||
|
<p className='mb-3 font-medium'>Files</p>
|
||||||
|
{['packages/auth/providers.ts', '.env.example', 'apps/web/auth.ts'].map(
|
||||||
|
(file) => (
|
||||||
|
<div
|
||||||
|
key={file}
|
||||||
|
className='text-muted-foreground hover:text-foreground rounded px-2 py-1.5'
|
||||||
|
>
|
||||||
|
{file}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='border-border rounded-md border bg-zinc-950 p-3 font-mono text-xs text-zinc-100'>
|
||||||
|
<div className='mb-3 flex items-center justify-between text-zinc-400'>
|
||||||
|
<span>providers.ts</span>
|
||||||
|
<span>vim mode</span>
|
||||||
|
</div>
|
||||||
|
<pre className='overflow-hidden leading-6'>
|
||||||
|
<code>{`export const providers = [
|
||||||
|
Authentik({
|
||||||
|
issuer: env.AUTHENTIK_ISSUER,
|
||||||
|
clientId: env.AUTHENTIK_CLIENT_ID,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
// GitHub provider removed in fork`}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
<div className='border-border bg-background rounded-md border p-3 text-xs'>
|
||||||
|
<p className='mb-3 font-medium'>Thread</p>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<p className='bg-muted/60 rounded-md p-2'>
|
||||||
|
Preserve Authentik-only auth.
|
||||||
|
</p>
|
||||||
|
<p className='rounded-md border p-2'>
|
||||||
|
Running typecheck after provider update.
|
||||||
|
</p>
|
||||||
|
<p className='text-muted-foreground bg-muted/40 rounded-md p-2'>
|
||||||
|
Secrets available as process env.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DecisionPreview = () => (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{[
|
||||||
|
['Risk', 'Medium', 'Auth provider wiring overlaps custom fork changes.'],
|
||||||
|
[
|
||||||
|
'Recommended action',
|
||||||
|
'Open review PR',
|
||||||
|
'Keep the fork branch reviewable.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Conflict signals',
|
||||||
|
'2 files',
|
||||||
|
'OAuth callback copy and package scripts.',
|
||||||
|
],
|
||||||
|
].map(([label, value, detail]) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className='border-border bg-background rounded-md border p-4'
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-between gap-3'>
|
||||||
|
<p className='text-muted-foreground text-sm'>{label}</p>
|
||||||
|
<Badge variant='outline'>{value}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className='mt-2 text-sm'>{detail}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DraftPrPreview = () => (
|
||||||
|
<div className='border-border bg-background rounded-md border p-5'>
|
||||||
|
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||||||
|
<div>
|
||||||
|
<p className='font-medium'>Draft PR opened</p>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
spoon/thread/authentik-upstream
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge className='bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/10'>
|
||||||
|
ready for review
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className='mt-5 grid gap-2 text-sm'>
|
||||||
|
<div className='bg-muted/50 flex items-center justify-between rounded-md px-3 py-2'>
|
||||||
|
<span className='flex items-center gap-2'>
|
||||||
|
<CheckCircle2 className='size-4 text-emerald-600' />
|
||||||
|
lint
|
||||||
|
</span>
|
||||||
|
<span>passed</span>
|
||||||
|
</div>
|
||||||
|
<div className='bg-muted/50 flex items-center justify-between rounded-md px-3 py-2'>
|
||||||
|
<span className='flex items-center gap-2'>
|
||||||
|
<AlertTriangle className='size-4 text-amber-600' />
|
||||||
|
typecheck
|
||||||
|
</span>
|
||||||
|
<span>queued</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button className='mt-5 w-full' variant='outline'>
|
||||||
|
Review PR
|
||||||
|
<GitPullRequest className='size-4' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderPreview = (step: DemoStep) => {
|
||||||
|
if (step === 'check') return <CheckPreview />;
|
||||||
|
if (step === 'thread') return <ThreadPreview />;
|
||||||
|
if (step === 'workspace') return <WorkspacePreview />;
|
||||||
|
if (step === 'decision') return <DecisionPreview />;
|
||||||
|
return <DraftPrPreview />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProductStoryDemo = () => {
|
||||||
|
const [activeStep, setActiveStep] = useState<DemoStep>('check');
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
const reducedMotion = usePrefersReducedMotion();
|
||||||
|
const active = useMemo(
|
||||||
|
() => steps.find((step) => step.id === activeStep) ?? defaultStep,
|
||||||
|
[activeStep],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused || reducedMotion) return;
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
setActiveStep((current) => {
|
||||||
|
const index = steps.findIndex((step) => step.id === current);
|
||||||
|
return steps[(index + 1) % steps.length]?.id ?? 'check';
|
||||||
|
});
|
||||||
|
}, 3500);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [paused, reducedMotion]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id='demo'
|
||||||
|
aria-label='Animated Spoon maintenance flow demo'
|
||||||
|
className='border-border bg-card overflow-hidden rounded-lg border shadow-sm'
|
||||||
|
onMouseEnter={() => setPaused(true)}
|
||||||
|
onMouseLeave={() => setPaused(false)}
|
||||||
|
onFocus={() => setPaused(true)}
|
||||||
|
onBlur={() => setPaused(false)}
|
||||||
|
>
|
||||||
|
<div className='border-border flex items-start justify-between gap-4 border-b px-5 py-4'>
|
||||||
|
<div>
|
||||||
|
<p className='text-sm font-medium'>{active.title}</p>
|
||||||
|
<p className='text-muted-foreground mt-1 text-xs'>
|
||||||
|
{active.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge className='bg-primary/10 text-primary hover:bg-primary/10'>
|
||||||
|
Live flow
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className='border-border flex gap-2 overflow-x-auto border-b p-2'>
|
||||||
|
{steps.map((step) => (
|
||||||
|
<button
|
||||||
|
key={step.id}
|
||||||
|
type='button'
|
||||||
|
aria-current={step.id === activeStep ? 'step' : undefined}
|
||||||
|
onClick={() => setActiveStep(step.id)}
|
||||||
|
className={
|
||||||
|
step.id === activeStep
|
||||||
|
? 'bg-primary text-primary-foreground flex-none rounded-md px-3 py-2 text-xs font-medium'
|
||||||
|
: 'text-muted-foreground hover:bg-muted hover:text-foreground flex-none rounded-md px-3 py-2 text-xs font-medium'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className='min-h-[24rem] p-4 sm:p-5'>
|
||||||
|
{renderPreview(activeStep)}
|
||||||
|
</div>
|
||||||
|
<div className='border-border bg-muted/30 grid gap-3 border-t p-4 text-sm sm:grid-cols-3'>
|
||||||
|
<span className='flex items-center gap-2'>
|
||||||
|
<GitBranch className='text-primary size-4' />
|
||||||
|
fork stays source of truth
|
||||||
|
</span>
|
||||||
|
<span className='flex items-center gap-2'>
|
||||||
|
<MessagesSquare className='text-primary size-4' />
|
||||||
|
decisions stay threaded
|
||||||
|
</span>
|
||||||
|
<span className='flex items-center gap-2'>
|
||||||
|
<Code2 className='text-primary size-4' />
|
||||||
|
workspace opens when needed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Code2,
|
||||||
|
FileCode2,
|
||||||
|
GitBranch,
|
||||||
|
MessagesSquare,
|
||||||
|
Terminal,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@spoon/ui';
|
||||||
|
|
||||||
|
const files = [
|
||||||
|
'apps/web/auth.ts',
|
||||||
|
'packages/auth/providers.ts',
|
||||||
|
'packages/auth/env.ts',
|
||||||
|
'.env.example',
|
||||||
|
];
|
||||||
|
|
||||||
|
const codeLines = [
|
||||||
|
'export const authProviders = [',
|
||||||
|
' Authentik({',
|
||||||
|
' issuer: env.AUTHENTIK_ISSUER,',
|
||||||
|
' clientId: env.AUTHENTIK_CLIENT_ID,',
|
||||||
|
' }),',
|
||||||
|
'];',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const WorkspaceShowcase = () => (
|
||||||
|
<section id='workspace' className='border-border/60 bg-muted/30 border-y'>
|
||||||
|
<div className='container mx-auto px-4 py-24'>
|
||||||
|
<div className='mb-10 max-w-3xl'>
|
||||||
|
<Badge variant='outline' className='mb-4'>
|
||||||
|
Workspace
|
||||||
|
</Badge>
|
||||||
|
<h2 className='text-3xl font-semibold tracking-normal md:text-4xl'>
|
||||||
|
When a thread needs code, open a real workspace.
|
||||||
|
</h2>
|
||||||
|
<p className='text-muted-foreground mt-4 text-lg leading-8'>
|
||||||
|
Spoon can expose project secrets as process env, optionally
|
||||||
|
materialize an env file, run configured checks, and refuse to commit
|
||||||
|
`.env*` files. The result is reviewable code, not a mystery patch.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='border-border bg-card overflow-hidden rounded-lg border shadow-sm'>
|
||||||
|
<div className='border-border flex flex-wrap items-center justify-between gap-3 border-b px-5 py-4'>
|
||||||
|
<div className='flex flex-wrap items-center gap-3 text-sm'>
|
||||||
|
<span className='flex items-center gap-2'>
|
||||||
|
<GitBranch className='text-primary size-4' />
|
||||||
|
spoon/thread/authentik-upstream
|
||||||
|
</span>
|
||||||
|
<span className='text-muted-foreground hidden sm:inline'>/</span>
|
||||||
|
<span className='flex items-center gap-2'>
|
||||||
|
<Code2 className='text-primary size-4' />
|
||||||
|
OpenCode
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Badge className='bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/10'>
|
||||||
|
Workspace active
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='bg-border grid min-h-[30rem] gap-px lg:grid-cols-[0.8fr_1.5fr_0.9fr]'>
|
||||||
|
<div className='bg-background p-4'>
|
||||||
|
<p className='mb-3 flex items-center gap-2 text-sm font-medium'>
|
||||||
|
<FileCode2 className='text-primary size-4' />
|
||||||
|
Files
|
||||||
|
</p>
|
||||||
|
<div className='space-y-1 text-sm'>
|
||||||
|
{files.map((file, index) => (
|
||||||
|
<div
|
||||||
|
key={file}
|
||||||
|
className={
|
||||||
|
index === 1
|
||||||
|
? 'bg-primary/10 text-primary rounded-md px-3 py-2 font-medium'
|
||||||
|
: 'text-muted-foreground rounded-md px-3 py-2'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{file}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='bg-zinc-950 p-4 text-zinc-100'>
|
||||||
|
<div className='mb-4 flex items-center justify-between text-xs text-zinc-400'>
|
||||||
|
<span>packages/auth/providers.ts</span>
|
||||||
|
<span>vim mode on</span>
|
||||||
|
</div>
|
||||||
|
<pre className='overflow-x-auto rounded-md bg-zinc-900 p-4 text-xs leading-7'>
|
||||||
|
<code>
|
||||||
|
{codeLines.map((line, index) => (
|
||||||
|
<span key={line} className='block'>
|
||||||
|
<span className='mr-4 text-zinc-600'>
|
||||||
|
{String(index + 1).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
{line}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
<div className='mt-4 rounded-md border border-zinc-800 bg-zinc-900 p-4 text-xs'>
|
||||||
|
<p className='text-emerald-400'>+ Authentik provider</p>
|
||||||
|
<p className='text-red-300'>- GitHub provider fallback</p>
|
||||||
|
<p className='mt-2 text-zinc-400'>
|
||||||
|
Diff stays attached to the thread before the draft PR opens.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='bg-background bg-border grid gap-px'>
|
||||||
|
<div className='bg-background p-4'>
|
||||||
|
<p className='mb-3 flex items-center gap-2 text-sm font-medium'>
|
||||||
|
<MessagesSquare className='text-primary size-4' />
|
||||||
|
Thread
|
||||||
|
</p>
|
||||||
|
<div className='space-y-3 text-sm'>
|
||||||
|
<p className='bg-muted/60 rounded-md p-3'>
|
||||||
|
Preserve Authentik as the only provider.
|
||||||
|
</p>
|
||||||
|
<p className='rounded-md border p-3'>
|
||||||
|
I found the provider wiring and updated the env example.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='bg-background p-4'>
|
||||||
|
<p className='mb-3 flex items-center gap-2 text-sm font-medium'>
|
||||||
|
<Terminal className='text-primary size-4' />
|
||||||
|
Checks
|
||||||
|
</p>
|
||||||
|
<div className='space-y-2 text-sm'>
|
||||||
|
<div className='bg-muted/60 flex items-center justify-between rounded-md px-3 py-2'>
|
||||||
|
<span>lint</span>
|
||||||
|
<span className='flex items-center gap-1 text-emerald-600'>
|
||||||
|
<CheckCircle2 className='size-4' />
|
||||||
|
passed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='bg-muted/60 flex items-center justify-between rounded-md px-3 py-2'>
|
||||||
|
<span>typecheck</span>
|
||||||
|
<span className='text-muted-foreground'>queued</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
@@ -9,9 +9,8 @@ export default function Footer() {
|
|||||||
<div className='md:col-span-2'>
|
<div className='md:col-span-2'>
|
||||||
<SpoonLogo className='mb-4' />
|
<SpoonLogo className='mb-4' />
|
||||||
<p className='text-muted-foreground max-w-md text-sm'>
|
<p className='text-muted-foreground max-w-md text-sm'>
|
||||||
Spoon is a self-hostable fork maintenance dashboard for teams who
|
Spoon is a self-hostable fork maintenance cockpit for keeping
|
||||||
want to customize upstream projects without drifting away from
|
important forks close to upstream without supporting them alone.
|
||||||
security fixes, product updates, and merge history.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -36,10 +35,10 @@ export default function Footer() {
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
href='/updates'
|
href='/threads'
|
||||||
className='text-muted-foreground hover:text-foreground transition-colors'
|
className='text-muted-foreground hover:text-foreground transition-colors'
|
||||||
>
|
>
|
||||||
Updates
|
Threads
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -50,18 +49,18 @@ export default function Footer() {
|
|||||||
<ul className='space-y-2 text-sm'>
|
<ul className='space-y-2 text-sm'>
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
href='/agents'
|
href='/settings/ai-providers'
|
||||||
className='text-muted-foreground hover:text-foreground transition-colors'
|
className='text-muted-foreground hover:text-foreground transition-colors'
|
||||||
>
|
>
|
||||||
Agents
|
AI providers
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
href='/profile'
|
href='/settings/integrations'
|
||||||
className='text-muted-foreground hover:text-foreground transition-colors'
|
className='text-muted-foreground hover:text-foreground transition-colors'
|
||||||
>
|
>
|
||||||
Profile
|
Integrations
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -80,7 +79,7 @@ export default function Footer() {
|
|||||||
|
|
||||||
<div className='border-border/40 text-muted-foreground mt-12 border-t pt-8 text-center text-sm'>
|
<div className='border-border/40 text-muted-foreground mt-12 border-t pt-8 text-center text-sm'>
|
||||||
<p>
|
<p>
|
||||||
Self-hostable fork maintenance for teams that stay close to
|
Self-hostable fork maintenance for projects that stay close to
|
||||||
upstream.
|
upstream.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ import { useConvexAuth } from 'convex/react';
|
|||||||
import {
|
import {
|
||||||
GitBranch,
|
GitBranch,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
RefreshCw,
|
MessagesSquare,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Sparkles,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@spoon/ui';
|
import { Button } from '@spoon/ui';
|
||||||
@@ -31,12 +30,12 @@ const Header = (headerProps: ComponentProps<'header'>) => {
|
|||||||
{
|
{
|
||||||
href: '/spoons',
|
href: '/spoons',
|
||||||
icon: GitBranch,
|
icon: GitBranch,
|
||||||
label: 'My Spoons',
|
label: 'Spoons',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/updates',
|
href: '/threads',
|
||||||
icon: RefreshCw,
|
icon: MessagesSquare,
|
||||||
label: 'Updates',
|
label: 'Threads',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/settings/profile',
|
href: '/settings/profile',
|
||||||
@@ -51,9 +50,9 @@ const Header = (headerProps: ComponentProps<'header'>) => {
|
|||||||
label: 'Workflow',
|
label: 'Workflow',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/#features',
|
href: '/#threads',
|
||||||
icon: Sparkles,
|
icon: MessagesSquare,
|
||||||
label: 'Features',
|
label: 'Threads',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/#security',
|
href: '/#security',
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ const formatDate = (value: number) =>
|
|||||||
|
|
||||||
export const SpoonActivityTimeline = ({
|
export const SpoonActivityTimeline = ({
|
||||||
syncRuns,
|
syncRuns,
|
||||||
reviews,
|
threads,
|
||||||
requests,
|
jobs,
|
||||||
}: {
|
}: {
|
||||||
syncRuns: Doc<'syncRuns'>[];
|
syncRuns: Doc<'syncRuns'>[];
|
||||||
reviews: Doc<'aiReviews'>[];
|
threads: Doc<'threads'>[];
|
||||||
requests: Doc<'agentRequests'>[];
|
jobs: Doc<'agentJobs'>[];
|
||||||
}) => {
|
}) => {
|
||||||
const items = [
|
const items = [
|
||||||
...syncRuns.map((item) => ({
|
...syncRuns.map((item) => ({
|
||||||
@@ -24,18 +24,18 @@ export const SpoonActivityTimeline = ({
|
|||||||
summary: item.summary ?? item.error ?? 'Sync run recorded.',
|
summary: item.summary ?? item.error ?? 'Sync run recorded.',
|
||||||
time: item.createdAt,
|
time: item.createdAt,
|
||||||
})),
|
})),
|
||||||
...reviews.map((item) => ({
|
...threads.map((item) => ({
|
||||||
id: item._id,
|
id: item._id,
|
||||||
kind: 'AI review',
|
kind: item.source.replaceAll('_', ' '),
|
||||||
status: item.status,
|
status: item.status,
|
||||||
summary: item.outputSummary ?? item.inputSummary,
|
summary: item.summary ?? item.title,
|
||||||
time: item.createdAt,
|
time: item.createdAt,
|
||||||
})),
|
})),
|
||||||
...requests.map((item) => ({
|
...jobs.map((item) => ({
|
||||||
id: item._id,
|
id: item._id,
|
||||||
kind: 'Agent request',
|
kind: item.jobType?.replaceAll('_', ' ') ?? 'workspace job',
|
||||||
status: item.status,
|
status: item.status,
|
||||||
summary: item.prompt,
|
summary: item.summary ?? item.prompt,
|
||||||
time: item.createdAt,
|
time: item.createdAt,
|
||||||
})),
|
})),
|
||||||
].sort((a, b) => b.time - a.time);
|
].sort((a, b) => b.time - a.time);
|
||||||
@@ -62,7 +62,7 @@ export const SpoonActivityTimeline = ({
|
|||||||
) : (
|
) : (
|
||||||
<Card className='shadow-none'>
|
<Card className='shadow-none'>
|
||||||
<CardContent className='text-muted-foreground p-6 text-sm'>
|
<CardContent className='text-muted-foreground p-6 text-sm'>
|
||||||
Refreshes, AI reviews, and queued requests will build this timeline.
|
Refreshes, threads, and workspace jobs will build this timeline.
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import type { ProviderModelOption } from '@/lib/models-dev';
|
||||||
import { useMutation } from 'convex/react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { loadModelsDevOptions } from '@/lib/models-dev';
|
||||||
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
import { Bot } from 'lucide-react';
|
import { Bot } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -24,17 +26,9 @@ import {
|
|||||||
} from '@spoon/ui';
|
} from '@spoon/ui';
|
||||||
|
|
||||||
const efforts = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const;
|
const efforts = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const;
|
||||||
const modelOptions = [
|
|
||||||
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
|
||||||
{ value: 'gpt-5.5', label: 'GPT-5.5' },
|
|
||||||
{ value: 'gpt-5.5-pro', label: 'GPT-5.5 Pro' },
|
|
||||||
{ value: 'gpt-5.4', label: 'GPT-5.4' },
|
|
||||||
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' },
|
|
||||||
] as const;
|
|
||||||
type AgentModel = (typeof modelOptions)[number]['value'];
|
|
||||||
|
|
||||||
type AgentSettings = {
|
type AgentSettings = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
runtime?: 'opencode' | 'openai_direct';
|
||||||
defaultBaseBranch?: string;
|
defaultBaseBranch?: string;
|
||||||
branchPrefix: string;
|
branchPrefix: string;
|
||||||
installCommand?: string;
|
installCommand?: string;
|
||||||
@@ -42,13 +36,14 @@ type AgentSettings = {
|
|||||||
testCommand?: string;
|
testCommand?: string;
|
||||||
agentModel: string;
|
agentModel: string;
|
||||||
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||||
|
envFilePath?: string;
|
||||||
|
customEnvFilePath?: string;
|
||||||
|
materializeEnvFileByDefault?: boolean;
|
||||||
|
autoDetectCommands?: boolean;
|
||||||
|
allowUserFileEditing?: boolean;
|
||||||
|
aiProviderProfileId?: Id<'aiProviderProfiles'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toAgentModel = (value?: string): AgentModel =>
|
|
||||||
modelOptions.some((option) => option.value === value)
|
|
||||||
? (value as AgentModel)
|
|
||||||
: 'gpt-5.1-codex';
|
|
||||||
|
|
||||||
export const SpoonAgentSettingsForm = ({
|
export const SpoonAgentSettingsForm = ({
|
||||||
spoon,
|
spoon,
|
||||||
settings,
|
settings,
|
||||||
@@ -57,6 +52,13 @@ export const SpoonAgentSettingsForm = ({
|
|||||||
settings?: AgentSettings | null;
|
settings?: AgentSettings | null;
|
||||||
}) => {
|
}) => {
|
||||||
const update = useMutation(api.spoonAgentSettings.update);
|
const update = useMutation(api.spoonAgentSettings.update);
|
||||||
|
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||||
|
const configuredProfiles = profiles.filter(
|
||||||
|
(profile) => profile.enabled && profile.configured,
|
||||||
|
);
|
||||||
|
const defaultProfile = configuredProfiles.find(
|
||||||
|
(profile) => profile.isDefault,
|
||||||
|
);
|
||||||
const [enabled, setEnabled] = useState(settings?.enabled ?? true);
|
const [enabled, setEnabled] = useState(settings?.enabled ?? true);
|
||||||
const [defaultBaseBranch, setDefaultBaseBranch] = useState(
|
const [defaultBaseBranch, setDefaultBaseBranch] = useState(
|
||||||
settings?.defaultBaseBranch ??
|
settings?.defaultBaseBranch ??
|
||||||
@@ -73,29 +75,113 @@ export const SpoonAgentSettingsForm = ({
|
|||||||
settings?.checkCommand ?? '',
|
settings?.checkCommand ?? '',
|
||||||
);
|
);
|
||||||
const [testCommand, setTestCommand] = useState(settings?.testCommand ?? '');
|
const [testCommand, setTestCommand] = useState(settings?.testCommand ?? '');
|
||||||
const [agentModel, setAgentModel] = useState<AgentModel>(
|
const [envFilePath, setEnvFilePath] = useState(
|
||||||
toAgentModel(settings?.agentModel),
|
settings?.envFilePath ?? '.env.local',
|
||||||
|
);
|
||||||
|
const [customEnvFilePath, setCustomEnvFilePath] = useState(
|
||||||
|
settings?.customEnvFilePath ?? '',
|
||||||
|
);
|
||||||
|
const [materializeEnvFileByDefault, setMaterializeEnvFileByDefault] =
|
||||||
|
useState(settings?.materializeEnvFileByDefault ?? false);
|
||||||
|
const [autoDetectCommands, setAutoDetectCommands] = useState(
|
||||||
|
settings?.autoDetectCommands ?? true,
|
||||||
|
);
|
||||||
|
const [allowUserFileEditing, setAllowUserFileEditing] = useState(
|
||||||
|
settings?.allowUserFileEditing ?? true,
|
||||||
|
);
|
||||||
|
const [aiProviderProfileId, setAiProviderProfileId] = useState(
|
||||||
|
settings?.aiProviderProfileId ?? '__default',
|
||||||
|
);
|
||||||
|
const selectedProfile = profiles.find(
|
||||||
|
(profile) =>
|
||||||
|
profile._id ===
|
||||||
|
(aiProviderProfileId === '__default'
|
||||||
|
? defaultProfile?._id
|
||||||
|
: aiProviderProfileId),
|
||||||
|
);
|
||||||
|
const [availableModels, setAvailableModels] = useState<ProviderModelOption[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [agentModel, setAgentModel] = useState(
|
||||||
|
settings?.aiProviderProfileId ? settings.agentModel : '',
|
||||||
);
|
);
|
||||||
const [reasoningEffort, setReasoningEffort] = useState<
|
const [reasoningEffort, setReasoningEffort] = useState<
|
||||||
'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
|
'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
|
||||||
>(
|
>(
|
||||||
settings?.reasoningEffort === 'none'
|
!settings?.aiProviderProfileId
|
||||||
|
? 'medium'
|
||||||
|
: settings.reasoningEffort === 'none'
|
||||||
? 'minimal'
|
? 'minimal'
|
||||||
: (settings?.reasoningEffort ?? 'high'),
|
: 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 save = async () => {
|
const save = async () => {
|
||||||
try {
|
try {
|
||||||
await update({
|
await update({
|
||||||
spoonId: spoon._id,
|
spoonId: spoon._id,
|
||||||
enabled,
|
enabled,
|
||||||
|
runtime: 'opencode',
|
||||||
defaultBaseBranch,
|
defaultBaseBranch,
|
||||||
branchPrefix,
|
branchPrefix,
|
||||||
installCommand: installCommand || undefined,
|
installCommand: installCommand || undefined,
|
||||||
checkCommand: checkCommand || undefined,
|
checkCommand: checkCommand || undefined,
|
||||||
testCommand: testCommand || undefined,
|
testCommand: testCommand || undefined,
|
||||||
agentModel,
|
agentModel: agentModel.trim()
|
||||||
|
? agentModel
|
||||||
|
: (selectableModels[0]?.id ?? undefined),
|
||||||
reasoningEffort,
|
reasoningEffort,
|
||||||
|
envFilePath: envFilePath as
|
||||||
|
| '.env'
|
||||||
|
| '.env.local'
|
||||||
|
| '.env.production'
|
||||||
|
| '.env.production.local'
|
||||||
|
| 'custom',
|
||||||
|
customEnvFilePath: customEnvFilePath || undefined,
|
||||||
|
materializeEnvFileByDefault,
|
||||||
|
autoDetectCommands,
|
||||||
|
allowUserFileEditing,
|
||||||
|
aiProviderProfileId:
|
||||||
|
aiProviderProfileId === '__default'
|
||||||
|
? undefined
|
||||||
|
: (aiProviderProfileId as Id<'aiProviderProfiles'>),
|
||||||
|
clearAiProviderProfile: aiProviderProfileId === '__default',
|
||||||
});
|
});
|
||||||
toast.success('Agent settings saved.');
|
toast.success('Agent settings saved.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -122,6 +208,50 @@ export const SpoonAgentSettingsForm = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='grid gap-3 md:grid-cols-2'>
|
<div className='grid gap-3 md:grid-cols-2'>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label>Runtime</Label>
|
||||||
|
<Input value='OpenCode workspace' disabled />
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label>AI provider profile</Label>
|
||||||
|
<Select
|
||||||
|
value={aiProviderProfileId}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setAiProviderProfileId(value);
|
||||||
|
const nextProfile = profiles.find(
|
||||||
|
(profile) =>
|
||||||
|
profile._id ===
|
||||||
|
(value === '__default' ? defaultProfile?._id : value),
|
||||||
|
);
|
||||||
|
if (!nextProfile?.configured) setAgentModel('');
|
||||||
|
if (nextProfile?.configured) {
|
||||||
|
setReasoningEffort(
|
||||||
|
nextProfile.reasoningEffort === 'none'
|
||||||
|
? 'minimal'
|
||||||
|
: nextProfile.reasoningEffort,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='__default'>
|
||||||
|
Use account default
|
||||||
|
{defaultProfile ? ` (${defaultProfile.name})` : ''}
|
||||||
|
</SelectItem>
|
||||||
|
{configuredProfiles.map((profile) => (
|
||||||
|
<SelectItem key={profile._id} value={profile._id}>
|
||||||
|
{profile.name} · {profile.provider.replaceAll('_', ' ')}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
OpenCode jobs and maintenance review threads use this profile.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className='grid gap-2'>
|
<div className='grid gap-2'>
|
||||||
<Label htmlFor='defaultBaseBranch'>Default base branch</Label>
|
<Label htmlFor='defaultBaseBranch'>Default base branch</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -142,19 +272,26 @@ export const SpoonAgentSettingsForm = ({
|
|||||||
<Label htmlFor='agentModel'>Model</Label>
|
<Label htmlFor='agentModel'>Model</Label>
|
||||||
<Select
|
<Select
|
||||||
value={agentModel}
|
value={agentModel}
|
||||||
onValueChange={(value) => setAgentModel(value as AgentModel)}
|
onValueChange={setAgentModel}
|
||||||
|
disabled={!selectableModels.length}
|
||||||
>
|
>
|
||||||
<SelectTrigger id='agentModel'>
|
<SelectTrigger id='agentModel'>
|
||||||
<SelectValue />
|
<SelectValue placeholder='Choose a configured model' />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{modelOptions.map((option) => (
|
{selectableModels.map((option) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={option.id} value={option.id}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{!selectableModels.length ? (
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Configure an enabled AI provider profile in Settings before
|
||||||
|
choosing a model.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className='grid gap-2'>
|
<div className='grid gap-2'>
|
||||||
<Label>Reasoning effort</Label>
|
<Label>Reasoning effort</Label>
|
||||||
@@ -215,8 +352,80 @@ export const SpoonAgentSettingsForm = ({
|
|||||||
Leave blank to run the detected test script when one exists.
|
Leave blank to run the detected test script when one exists.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label>Env file path</Label>
|
||||||
|
<Select value={envFilePath} onValueChange={setEnvFilePath}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='.env'>.env</SelectItem>
|
||||||
|
<SelectItem value='.env.local'>.env.local</SelectItem>
|
||||||
|
<SelectItem value='.env.production'>.env.production</SelectItem>
|
||||||
|
<SelectItem value='.env.production.local'>
|
||||||
|
.env.production.local
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value='custom'>Custom path</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Button type='button' onClick={save}>
|
{envFilePath === 'custom' ? (
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor='customEnvFilePath'>Custom env path</Label>
|
||||||
|
<Input
|
||||||
|
id='customEnvFilePath'
|
||||||
|
value={customEnvFilePath}
|
||||||
|
placeholder='.env.spoon'
|
||||||
|
onChange={(event) => setCustomEnvFilePath(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
|
||||||
|
<div>
|
||||||
|
<Label>Materialize env file by default</Label>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Write all Spoon secrets into the chosen .env file for new
|
||||||
|
workspaces.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={materializeEnvFileByDefault}
|
||||||
|
onCheckedChange={setMaterializeEnvFileByDefault}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
|
||||||
|
<div>
|
||||||
|
<Label>Auto-detect commands</Label>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Inspect package files after cloning.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={autoDetectCommands}
|
||||||
|
onCheckedChange={setAutoDetectCommands}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-between gap-4 rounded-md border p-3'>
|
||||||
|
<div>
|
||||||
|
<Label>Allow browser file editing</Label>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Let users edit workspace files manually.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={allowUserFileEditing}
|
||||||
|
onCheckedChange={setAllowUserFileEditing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
onClick={save}
|
||||||
|
disabled={
|
||||||
|
!selectedProfile?.configured ||
|
||||||
|
!selectableModels.some((model) => model.id === agentModel)
|
||||||
|
}
|
||||||
|
>
|
||||||
Save agent settings
|
Save agent settings
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
|
||||||
import { Badge, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
|
||||||
|
|
||||||
export const SpoonAiReviewPanel = ({
|
|
||||||
latestReview,
|
|
||||||
reviews,
|
|
||||||
}: {
|
|
||||||
latestReview?: Doc<'aiReviews'> | null;
|
|
||||||
reviews: Doc<'aiReviews'>[];
|
|
||||||
}) => (
|
|
||||||
<div className='space-y-4'>
|
|
||||||
<Card className='shadow-none'>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className='text-base'>Latest compatibility review</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{latestReview ? (
|
|
||||||
<div className='space-y-4'>
|
|
||||||
<div className='flex flex-wrap gap-2'>
|
|
||||||
<Badge>{latestReview.risk}</Badge>
|
|
||||||
<Badge variant='outline'>{latestReview.recommendedAction}</Badge>
|
|
||||||
{latestReview.requiresHumanReview ? (
|
|
||||||
<Badge variant='secondary'>Human review required</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<p className='text-sm'>
|
|
||||||
{latestReview.outputSummary ?? latestReview.inputSummary}
|
|
||||||
</p>
|
|
||||||
{latestReview.reasoningSummary ? (
|
|
||||||
<p className='text-muted-foreground text-sm'>
|
|
||||||
{latestReview.reasoningSummary}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{latestReview.potentialConflicts?.length ? (
|
|
||||||
<div>
|
|
||||||
<p className='text-sm font-medium'>Potential conflicts</p>
|
|
||||||
<ul className='text-muted-foreground mt-2 list-disc space-y-1 pl-5 text-sm'>
|
|
||||||
{latestReview.potentialConflicts.map((item) => (
|
|
||||||
<li key={item}>{item}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className='text-muted-foreground text-sm'>
|
|
||||||
Run an AI review after a GitHub refresh to get compatibility notes.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className='shadow-none'>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className='text-base'>Review history</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className='space-y-3'>
|
|
||||||
{reviews.length ? (
|
|
||||||
reviews.map((review) => (
|
|
||||||
<div key={review._id} className='border-border border p-3 text-sm'>
|
|
||||||
<div className='flex flex-wrap gap-2'>
|
|
||||||
<Badge variant='outline'>{review.status}</Badge>
|
|
||||||
<Badge variant='secondary'>{review.risk}</Badge>
|
|
||||||
</div>
|
|
||||||
<p className='mt-2'>
|
|
||||||
{review.outputSummary ?? review.inputSummary}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className='text-muted-foreground text-sm'>No AI reviews yet.</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
|||||||
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
|
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
|
||||||
import { useAction } from 'convex/react';
|
import { useAction } from 'convex/react';
|
||||||
import { makeFunctionReference } from 'convex/server';
|
import { makeFunctionReference } from 'convex/server';
|
||||||
import { Brain, RefreshCw, RotateCw } from 'lucide-react';
|
import { RefreshCw, RotateCw } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
@@ -27,12 +27,6 @@ const syncRef = makeFunctionReference<
|
|||||||
unknown
|
unknown
|
||||||
>('githubSync:syncForkWithUpstream');
|
>('githubSync:syncForkWithUpstream');
|
||||||
|
|
||||||
const reviewRef = makeFunctionReference<
|
|
||||||
'action',
|
|
||||||
{ spoonId: Id<'spoons'> },
|
|
||||||
{ reviewId: Id<'aiReviews'>; risk: string; recommendedAction: string }
|
|
||||||
>('aiReviewActions:reviewLatestUpstreamChanges');
|
|
||||||
|
|
||||||
export const SpoonDetailHeader = ({
|
export const SpoonDetailHeader = ({
|
||||||
spoon,
|
spoon,
|
||||||
state,
|
state,
|
||||||
@@ -42,7 +36,6 @@ export const SpoonDetailHeader = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const refresh = useAction(refreshRef);
|
const refresh = useAction(refreshRef);
|
||||||
const sync = useAction(syncRef);
|
const sync = useAction(syncRef);
|
||||||
const review = useAction(reviewRef);
|
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const canSync =
|
const canSync =
|
||||||
spoon.provider === 'github' &&
|
spoon.provider === 'github' &&
|
||||||
@@ -110,14 +103,6 @@ export const SpoonDetailHeader = ({
|
|||||||
<RefreshCw className='size-4' />
|
<RefreshCw className='size-4' />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
onClick={() => run('AI review', () => review({ spoonId: spoon._id }))}
|
|
||||||
disabled={Boolean(busy)}
|
|
||||||
>
|
|
||||||
<Brain className='size-4' />
|
|
||||||
Review with AI
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => run('Sync', () => sync({ spoonId: spoon._id }))}
|
onClick={() => run('Sync', () => sync({ spoonId: spoon._id }))}
|
||||||
disabled={Boolean(busy) || !canSync}
|
disabled={Boolean(busy) || !canSync}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
GitCommit,
|
GitCommit,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
ShieldCheck,
|
MessagesSquare,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -17,11 +17,11 @@ const formatDate = (value?: number) =>
|
|||||||
export const SpoonMetrics = ({
|
export const SpoonMetrics = ({
|
||||||
spoon,
|
spoon,
|
||||||
state,
|
state,
|
||||||
latestReview,
|
latestThread,
|
||||||
}: {
|
}: {
|
||||||
spoon: Doc<'spoons'>;
|
spoon: Doc<'spoons'>;
|
||||||
state?: Doc<'spoonRepositoryStates'> | null;
|
state?: Doc<'spoonRepositoryStates'> | null;
|
||||||
latestReview?: Doc<'aiReviews'> | null;
|
latestThread?: Doc<'threads'> | null;
|
||||||
}) => {
|
}) => {
|
||||||
const metrics = [
|
const metrics = [
|
||||||
{
|
{
|
||||||
@@ -42,9 +42,9 @@ export const SpoonMetrics = ({
|
|||||||
icon: GitPullRequest,
|
icon: GitPullRequest,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Latest AI risk',
|
label: 'Latest thread',
|
||||||
value: latestReview?.risk ?? 'unknown',
|
value: latestThread?.status.replaceAll('_', ' ') ?? 'none',
|
||||||
icon: ShieldCheck,
|
icon: MessagesSquare,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Last check',
|
label: 'Last check',
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export const SpoonSettingsForm = ({
|
|||||||
onChange: setAutoRefreshEnabled,
|
onChange: setAutoRefreshEnabled,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Auto AI review',
|
label: 'Auto maintenance threads',
|
||||||
value: autoReviewEnabled,
|
value: autoReviewEnabled,
|
||||||
onChange: setAutoReviewEnabled,
|
onChange: setAutoReviewEnabled,
|
||||||
},
|
},
|
||||||
@@ -103,7 +103,7 @@ export const SpoonSettingsForm = ({
|
|||||||
onChange: setAutoSyncEnabled,
|
onChange: setAutoSyncEnabled,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Require low AI risk for sync',
|
label: 'Require low-risk thread decision for sync',
|
||||||
value: requireAiLowRiskForSync,
|
value: requireAiLowRiskForSync,
|
||||||
onChange: setRequireAiLowRiskForSync,
|
onChange: setRequireAiLowRiskForSync,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { Badge, Button, Card, CardContent } from '@spoon/ui';
|
||||||
|
|
||||||
|
export const MaintenanceQueue = ({
|
||||||
|
threads,
|
||||||
|
}: {
|
||||||
|
threads: Doc<'threads'>[];
|
||||||
|
}) => {
|
||||||
|
const queued = threads.filter(
|
||||||
|
(thread) =>
|
||||||
|
['upstream_update', 'merge_conflict'].includes(thread.source) &&
|
||||||
|
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{queued.length ? (
|
||||||
|
queued.map((thread) => (
|
||||||
|
<Card key={thread._id} className='shadow-none'>
|
||||||
|
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
|
||||||
|
<div>
|
||||||
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
|
<p className='font-medium'>{thread.title}</p>
|
||||||
|
<Badge>{thread.status.replaceAll('_', ' ')}</Badge>
|
||||||
|
{thread.maintenanceOutcome ? (
|
||||||
|
<Badge variant='secondary'>
|
||||||
|
{thread.maintenanceOutcome.replaceAll('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
{thread.summary ?? 'Maintenance thread waiting for review.'}
|
||||||
|
</p>
|
||||||
|
<p className='text-muted-foreground mt-1 text-xs'>
|
||||||
|
{new Date(thread.updatedAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant='outline' size='sm' asChild>
|
||||||
|
<Link href={`/threads/${thread._id}`}>Open thread</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Card className='shadow-none'>
|
||||||
|
<CardContent className='text-muted-foreground p-6 text-sm'>
|
||||||
|
No Spoons currently need review. Refresh GitHub state to populate
|
||||||
|
this queue.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
|
|
||||||
|
|
||||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
|
||||||
import { Button, Card, CardContent } from '@spoon/ui';
|
|
||||||
|
|
||||||
export const MaintenanceQueue = ({ spoons }: { spoons: Doc<'spoons'>[] }) => {
|
|
||||||
const queued = spoons
|
|
||||||
.filter((spoon) =>
|
|
||||||
['behind', 'diverged', 'conflict', 'error'].includes(
|
|
||||||
spoon.syncStatus ?? '',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.sort((a, b) => (b.upstreamAheadBy ?? 0) - (a.upstreamAheadBy ?? 0));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='space-y-3'>
|
|
||||||
{queued.length ? (
|
|
||||||
queued.map((spoon) => (
|
|
||||||
<Card key={spoon._id} className='shadow-none'>
|
|
||||||
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
|
|
||||||
<div>
|
|
||||||
<div className='flex flex-wrap items-center gap-2'>
|
|
||||||
<p className='font-medium'>{spoon.name}</p>
|
|
||||||
<SpoonStatusBadge status={spoon.syncStatus} />
|
|
||||||
</div>
|
|
||||||
<p className='text-muted-foreground mt-1 text-sm'>
|
|
||||||
{spoon.upstreamOwner}/{spoon.upstreamRepo} →{' '}
|
|
||||||
{spoon.forkOwner ?? 'unknown'}/{spoon.forkRepo ?? 'unknown'}
|
|
||||||
</p>
|
|
||||||
<p className='text-muted-foreground mt-1 text-xs'>
|
|
||||||
{spoon.upstreamAheadBy ?? 0} upstream commit(s),{' '}
|
|
||||||
{spoon.forkAheadBy ?? 0} fork-only commit(s)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button variant='outline' size='sm' asChild>
|
|
||||||
<Link href={`/spoons/${spoon._id}`}>Open Spoon</Link>
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Card className='shadow-none'>
|
|
||||||
<CardContent className='text-muted-foreground p-6 text-sm'>
|
|
||||||
No Spoons currently need review. Refresh GitHub state to populate
|
|
||||||
this queue.
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -19,6 +19,9 @@ export const env = createEnv({
|
|||||||
GITHUB_APP_SLUG: z.string().optional(),
|
GITHUB_APP_SLUG: z.string().optional(),
|
||||||
GITHUB_APP_INSTALLATION_ID: z.string().optional(),
|
GITHUB_APP_INSTALLATION_ID: z.string().optional(),
|
||||||
GITHUB_APP_OWNER: z.string().optional(),
|
GITHUB_APP_OWNER: z.string().optional(),
|
||||||
|
SPOON_AGENT_WORKER_URL: z.url().default('http://localhost:3921'),
|
||||||
|
SPOON_AGENT_WORKER_INTERNAL_TOKEN: z.string().optional(),
|
||||||
|
SPOON_WORKER_TOKEN: z.string().optional(),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,6 +55,10 @@ export const env = createEnv({
|
|||||||
GITHUB_APP_SLUG: process.env.GITHUB_APP_SLUG,
|
GITHUB_APP_SLUG: process.env.GITHUB_APP_SLUG,
|
||||||
GITHUB_APP_INSTALLATION_ID: process.env.GITHUB_APP_INSTALLATION_ID,
|
GITHUB_APP_INSTALLATION_ID: process.env.GITHUB_APP_INSTALLATION_ID,
|
||||||
GITHUB_APP_OWNER: process.env.GITHUB_APP_OWNER,
|
GITHUB_APP_OWNER: process.env.GITHUB_APP_OWNER,
|
||||||
|
SPOON_AGENT_WORKER_URL: process.env.SPOON_AGENT_WORKER_URL,
|
||||||
|
SPOON_AGENT_WORKER_INTERNAL_TOKEN:
|
||||||
|
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN,
|
||||||
|
SPOON_WORKER_TOKEN: process.env.SPOON_WORKER_TOKEN,
|
||||||
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
|
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
|
||||||
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
|
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
|
||||||
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
|
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import 'server-only';
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { env } from '@/env';
|
||||||
|
import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server';
|
||||||
|
import { fetchQuery } from 'convex/nextjs';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
type RouteContext = {
|
||||||
|
params: Promise<{ jobId: string }> | { jobId: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const routeJobId = async (context: RouteContext) => {
|
||||||
|
const params = await context.params;
|
||||||
|
return params.jobId as Id<'agentJobs'>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const workerToken = () =>
|
||||||
|
env.SPOON_AGENT_WORKER_INTERNAL_TOKEN ?? env.SPOON_WORKER_TOKEN;
|
||||||
|
|
||||||
|
export const requireOwnedJob = async (jobId: Id<'agentJobs'>) => {
|
||||||
|
const token = await convexAuthNextjsToken();
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await fetchQuery(api.agentJobs.assertOwned, { jobId }, { token });
|
||||||
|
return { ok: true as const };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const proxyWorker = async (
|
||||||
|
jobId: Id<'agentJobs'>,
|
||||||
|
action: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
search?: URLSearchParams,
|
||||||
|
) => {
|
||||||
|
const token = workerToken();
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'SPOON_AGENT_WORKER_INTERNAL_TOKEN is not configured.' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const url = new URL(
|
||||||
|
`/jobs/${encodeURIComponent(jobId)}/${action}`,
|
||||||
|
env.SPOON_AGENT_WORKER_URL,
|
||||||
|
);
|
||||||
|
if (search) {
|
||||||
|
for (const [key, value] of search) url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
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 withOwnedJob = async (
|
||||||
|
context: RouteContext,
|
||||||
|
handler: (jobId: Id<'agentJobs'>) => Promise<Response>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const jobId = await routeJobId(context);
|
||||||
|
const owned = await requireOwnedJob(jobId);
|
||||||
|
if (!owned.ok) return owned.response;
|
||||||
|
return await handler(jobId);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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));
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ const isProtectedRoute = createRouteMatcher([
|
|||||||
'/spoons(.*)',
|
'/spoons(.*)',
|
||||||
'/updates(.*)',
|
'/updates(.*)',
|
||||||
'/agents(.*)',
|
'/agents(.*)',
|
||||||
|
'/threads(.*)',
|
||||||
'/github(.*)',
|
'/github(.*)',
|
||||||
'/settings(.*)',
|
'/settings(.*)',
|
||||||
'/profile(.*)',
|
'/profile(.*)',
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe('component test harness', () => {
|
|||||||
render(<Hero />);
|
render(<Hero />);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('heading', {
|
screen.getByRole('heading', {
|
||||||
name: /make your forks intimately close to upstream\./i,
|
name: /fork freely & keep them all intimately close to upstream\./i,
|
||||||
}),
|
}),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,10 +21,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "^8.2.0",
|
"@octokit/auth-app": "^8.2.0",
|
||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
"@openai/agents": "latest",
|
"@opencode-ai/sdk": "latest",
|
||||||
"convex": "catalog:convex",
|
"convex": "catalog:convex",
|
||||||
"execa": "latest",
|
"execa": "latest",
|
||||||
"openai": "^6.44.0",
|
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -94,11 +93,14 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@convex-dev/auth": "catalog:convex",
|
"@convex-dev/auth": "catalog:convex",
|
||||||
|
"@monaco-editor/react": "latest",
|
||||||
"@sentry/nextjs": "^10.46.0",
|
"@sentry/nextjs": "^10.46.0",
|
||||||
"@spoon/backend": "workspace:*",
|
"@spoon/backend": "workspace:*",
|
||||||
"@spoon/ui": "workspace:*",
|
"@spoon/ui": "workspace:*",
|
||||||
"@t3-oss/env-nextjs": "^0.13.11",
|
"@t3-oss/env-nextjs": "^0.13.11",
|
||||||
"convex": "catalog:convex",
|
"convex": "catalog:convex",
|
||||||
|
"monaco-editor": "latest",
|
||||||
|
"monaco-vim": "latest",
|
||||||
"next": "^16.2.1",
|
"next": "^16.2.1",
|
||||||
"next-plausible": "^3.12.5",
|
"next-plausible": "^3.12.5",
|
||||||
"react": "catalog:react19",
|
"react": "catalog:react19",
|
||||||
@@ -137,7 +139,6 @@
|
|||||||
"@react-email/components": "1.0.10",
|
"@react-email/components": "1.0.10",
|
||||||
"@react-email/render": "^2.0.4",
|
"@react-email/render": "^2.0.4",
|
||||||
"convex": "catalog:convex",
|
"convex": "catalog:convex",
|
||||||
"openai": "^6.44.0",
|
|
||||||
"react": "catalog:react19",
|
"react": "catalog:react19",
|
||||||
"react-dom": "catalog:react19",
|
"react-dom": "catalog:react19",
|
||||||
"usesend-js": "^1.6.3",
|
"usesend-js": "^1.6.3",
|
||||||
@@ -700,8 +701,6 @@
|
|||||||
|
|
||||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||||
|
|
||||||
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
|
||||||
|
|
||||||
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
|
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
|
||||||
|
|
||||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||||
@@ -830,7 +829,9 @@
|
|||||||
|
|
||||||
"@legendapp/list": ["@legendapp/list@2.0.19", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-zDWg8yg0smKxxk+M7gwAbZAnf5uczohPA+IjqLSkImz7+e9ytxeT0Mq35RBO9RTKODOXfV/aIgm1uqUHLBEdmg=="],
|
"@legendapp/list": ["@legendapp/list@2.0.19", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-zDWg8yg0smKxxk+M7gwAbZAnf5uczohPA+IjqLSkImz7+e9ytxeT0Mq35RBO9RTKODOXfV/aIgm1uqUHLBEdmg=="],
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
"@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="],
|
||||||
|
|
||||||
|
"@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="],
|
||||||
|
|
||||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="],
|
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="],
|
||||||
|
|
||||||
@@ -956,13 +957,7 @@
|
|||||||
|
|
||||||
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||||
|
|
||||||
"@openai/agents": ["@openai/agents@0.11.8", "", { "dependencies": { "@openai/agents-core": "0.11.8", "@openai/agents-openai": "0.11.8", "@openai/agents-realtime": "0.11.8", "debug": "^4.4.0", "openai": "^6.35.0" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-D4XHF2g+Ub/L9fRJT/xpuiCqHyxiKzZbi0BqQxnso42t+J049O/OSvVzFBcRskF4uPFAvs0TOOB7KBbanCwaYQ=="],
|
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.9", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-MHmXEpGPHkg14v1p+cUlIOUxd6DQdSElfau9nqY7tcDI0x5r4Y8D0dKXcyAh0Gc73ptaGW67Vg84nkcV6O27Pw=="],
|
||||||
|
|
||||||
"@openai/agents-core": ["@openai/agents-core@0.11.8", "", { "dependencies": { "debug": "^4.4.0", "openai": "^6.35.0" }, "optionalDependencies": { "@modelcontextprotocol/sdk": "^1.26.0" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-TrE34RXXPoWYv2PjXf5hq3Eq+uvRJMNiY+Q5WBgEPjAg60yt2hya8cS2I8qkO6i25MjNJl37a25X0vL/gs5Wdg=="],
|
|
||||||
|
|
||||||
"@openai/agents-openai": ["@openai/agents-openai@0.11.8", "", { "dependencies": { "@openai/agents-core": "0.11.8", "debug": "^4.4.0", "openai": "^6.35.0" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-XjHCnJPGapgZBlh8y5oxU7zV0hrAQTF5im6HpUwaPcH5CeRFLtc06VXLso0vJ5G3g9e/J5gIh3S1iAxiJqEAVQ=="],
|
|
||||||
|
|
||||||
"@openai/agents-realtime": ["@openai/agents-realtime@0.11.8", "", { "dependencies": { "@openai/agents-core": "0.11.8", "@types/ws": "^8.18.1", "debug": "^4.4.0", "ws": "^8.18.1" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-i1qEGUE8GTW0neWgAc1aj/3wZFtstz8bVG2BvVbU/BzQbyhZV8j3CvndkMJGFfgeobvVmn2qGTV5Ry6ibfuxeQ=="],
|
|
||||||
|
|
||||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
@@ -1552,6 +1547,8 @@
|
|||||||
|
|
||||||
"@types/tedious": ["@types/tedious@4.0.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw=="],
|
"@types/tedious": ["@types/tedious@4.0.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw=="],
|
||||||
|
|
||||||
|
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||||
|
|
||||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||||
|
|
||||||
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
||||||
@@ -1760,8 +1757,6 @@
|
|||||||
|
|
||||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||||
|
|
||||||
"body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
|
|
||||||
|
|
||||||
"bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="],
|
"bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="],
|
||||||
|
|
||||||
"bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="],
|
"bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="],
|
||||||
@@ -1864,8 +1859,6 @@
|
|||||||
|
|
||||||
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
||||||
|
|
||||||
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
|
||||||
|
|
||||||
"content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
"content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||||
|
|
||||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
@@ -1876,8 +1869,6 @@
|
|||||||
|
|
||||||
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
||||||
|
|
||||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
|
||||||
|
|
||||||
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
|
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
|
||||||
|
|
||||||
"core-js-compat": ["core-js-compat@3.46.0", "", { "dependencies": { "browserslist": "^4.26.3" } }, "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law=="],
|
"core-js-compat": ["core-js-compat@3.46.0", "", { "dependencies": { "browserslist": "^4.26.3" } }, "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law=="],
|
||||||
@@ -1980,6 +1971,8 @@
|
|||||||
|
|
||||||
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||||
|
|
||||||
|
"dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="],
|
||||||
|
|
||||||
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||||
|
|
||||||
"dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="],
|
"dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="],
|
||||||
@@ -2094,10 +2087,6 @@
|
|||||||
|
|
||||||
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
|
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
|
||||||
|
|
||||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
|
||||||
|
|
||||||
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
|
|
||||||
|
|
||||||
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
|
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
|
||||||
|
|
||||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||||
@@ -2158,10 +2147,6 @@
|
|||||||
|
|
||||||
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
|
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
|
||||||
|
|
||||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
|
||||||
|
|
||||||
"express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="],
|
|
||||||
|
|
||||||
"exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="],
|
"exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="],
|
||||||
|
|
||||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
@@ -2206,8 +2191,6 @@
|
|||||||
|
|
||||||
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
|
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
|
||||||
|
|
||||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
|
||||||
|
|
||||||
"forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="],
|
"forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="],
|
||||||
|
|
||||||
"framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
|
"framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
|
||||||
@@ -2290,8 +2273,6 @@
|
|||||||
|
|
||||||
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
|
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
|
||||||
|
|
||||||
"hono": ["hono@4.12.26", "", {}, "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw=="],
|
|
||||||
|
|
||||||
"hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="],
|
"hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="],
|
||||||
|
|
||||||
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
|
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
|
||||||
@@ -2346,10 +2327,6 @@
|
|||||||
|
|
||||||
"invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="],
|
"invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="],
|
||||||
|
|
||||||
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
|
|
||||||
|
|
||||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
|
||||||
|
|
||||||
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
||||||
|
|
||||||
"is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
|
"is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
|
||||||
@@ -2398,8 +2375,6 @@
|
|||||||
|
|
||||||
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
||||||
|
|
||||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
|
||||||
|
|
||||||
"is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
|
"is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
|
||||||
|
|
||||||
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
|
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
|
||||||
@@ -2566,7 +2541,7 @@
|
|||||||
|
|
||||||
"makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="],
|
"makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="],
|
||||||
|
|
||||||
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
"marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
|
||||||
|
|
||||||
"marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],
|
"marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],
|
||||||
|
|
||||||
@@ -2574,16 +2549,12 @@
|
|||||||
|
|
||||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||||
|
|
||||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
|
||||||
|
|
||||||
"memfs": ["memfs@3.5.3", "", { "dependencies": { "fs-monkey": "^1.0.4" } }, "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw=="],
|
"memfs": ["memfs@3.5.3", "", { "dependencies": { "fs-monkey": "^1.0.4" } }, "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw=="],
|
||||||
|
|
||||||
"memfs-browser": ["memfs-browser@3.5.10302", "", { "dependencies": { "memfs": "3.5.3" } }, "sha512-JJTc/nh3ig05O0gBBGZjTCPOyydaTxNF0uHYBrcc1gHNnO+KIHIvo0Y1FKCJsaei6FCl8C6xfQomXqu+cuzkIw=="],
|
"memfs-browser": ["memfs-browser@3.5.10302", "", { "dependencies": { "memfs": "3.5.3" } }, "sha512-JJTc/nh3ig05O0gBBGZjTCPOyydaTxNF0uHYBrcc1gHNnO+KIHIvo0Y1FKCJsaei6FCl8C6xfQomXqu+cuzkIw=="],
|
||||||
|
|
||||||
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
|
"memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="],
|
||||||
|
|
||||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
|
||||||
|
|
||||||
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
||||||
|
|
||||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||||
@@ -2642,6 +2613,10 @@
|
|||||||
|
|
||||||
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
|
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
|
||||||
|
|
||||||
|
"monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="],
|
||||||
|
|
||||||
|
"monaco-vim": ["monaco-vim@0.4.4", "", { "peerDependencies": { "monaco-editor": "*" } }, "sha512-LNChAb//WEm/W+eyeHG/0+pdVEHotk2hLTN+M3sQZx5E8cAlSWSgqcxpcRuQnxDybSln7pfHF9i63HmbIQvrWw=="],
|
||||||
|
|
||||||
"motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="],
|
"motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="],
|
||||||
|
|
||||||
"motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
|
"motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
|
||||||
@@ -2722,8 +2697,6 @@
|
|||||||
|
|
||||||
"open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
|
"open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
|
||||||
|
|
||||||
"openai": ["openai@6.44.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"] }, "sha512-09/gH+8jH0RgUwsgWHAaxsKGRT5zVZ95IaJUnqAWj6XejIBmnFRwq2WUIF37VtDEsmGrtPmvCs5+yBSeZGWvkA=="],
|
|
||||||
|
|
||||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||||
|
|
||||||
"ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="],
|
"ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="],
|
||||||
@@ -2778,8 +2751,6 @@
|
|||||||
|
|
||||||
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
|
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
|
||||||
|
|
||||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
|
||||||
|
|
||||||
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
|
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
|
||||||
|
|
||||||
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
|
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
|
||||||
@@ -2828,16 +2799,12 @@
|
|||||||
|
|
||||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||||
|
|
||||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
|
||||||
|
|
||||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||||
|
|
||||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
"qrcode-terminal": ["qrcode-terminal@0.11.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ=="],
|
"qrcode-terminal": ["qrcode-terminal@0.11.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ=="],
|
||||||
|
|
||||||
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
|
|
||||||
|
|
||||||
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
|
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
|
||||||
|
|
||||||
"queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="],
|
"queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="],
|
||||||
@@ -2850,8 +2817,6 @@
|
|||||||
|
|
||||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||||
|
|
||||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
|
||||||
|
|
||||||
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||||
|
|
||||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||||
@@ -2964,8 +2929,6 @@
|
|||||||
|
|
||||||
"rollup": ["rollup@4.52.5", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.5", "@rollup/rollup-android-arm64": "4.52.5", "@rollup/rollup-darwin-arm64": "4.52.5", "@rollup/rollup-darwin-x64": "4.52.5", "@rollup/rollup-freebsd-arm64": "4.52.5", "@rollup/rollup-freebsd-x64": "4.52.5", "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", "@rollup/rollup-linux-arm-musleabihf": "4.52.5", "@rollup/rollup-linux-arm64-gnu": "4.52.5", "@rollup/rollup-linux-arm64-musl": "4.52.5", "@rollup/rollup-linux-loong64-gnu": "4.52.5", "@rollup/rollup-linux-ppc64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-musl": "4.52.5", "@rollup/rollup-linux-s390x-gnu": "4.52.5", "@rollup/rollup-linux-x64-gnu": "4.52.5", "@rollup/rollup-linux-x64-musl": "4.52.5", "@rollup/rollup-openharmony-arm64": "4.52.5", "@rollup/rollup-win32-arm64-msvc": "4.52.5", "@rollup/rollup-win32-ia32-msvc": "4.52.5", "@rollup/rollup-win32-x64-gnu": "4.52.5", "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw=="],
|
"rollup": ["rollup@4.52.5", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.5", "@rollup/rollup-android-arm64": "4.52.5", "@rollup/rollup-darwin-arm64": "4.52.5", "@rollup/rollup-darwin-x64": "4.52.5", "@rollup/rollup-freebsd-arm64": "4.52.5", "@rollup/rollup-freebsd-x64": "4.52.5", "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", "@rollup/rollup-linux-arm-musleabihf": "4.52.5", "@rollup/rollup-linux-arm64-gnu": "4.52.5", "@rollup/rollup-linux-arm64-musl": "4.52.5", "@rollup/rollup-linux-loong64-gnu": "4.52.5", "@rollup/rollup-linux-ppc64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-musl": "4.52.5", "@rollup/rollup-linux-s390x-gnu": "4.52.5", "@rollup/rollup-linux-x64-gnu": "4.52.5", "@rollup/rollup-linux-x64-musl": "4.52.5", "@rollup/rollup-openharmony-arm64": "4.52.5", "@rollup/rollup-win32-arm64-msvc": "4.52.5", "@rollup/rollup-win32-ia32-msvc": "4.52.5", "@rollup/rollup-win32-x64-gnu": "4.52.5", "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw=="],
|
||||||
|
|
||||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
|
||||||
|
|
||||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||||
|
|
||||||
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
|
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
|
||||||
@@ -3074,6 +3037,8 @@
|
|||||||
|
|
||||||
"stacktrace-parser": ["stacktrace-parser@0.1.11", "", { "dependencies": { "type-fest": "^0.7.1" } }, "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg=="],
|
"stacktrace-parser": ["stacktrace-parser@0.1.11", "", { "dependencies": { "type-fest": "^0.7.1" } }, "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg=="],
|
||||||
|
|
||||||
|
"state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="],
|
||||||
|
|
||||||
"statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
|
"statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
|
||||||
|
|
||||||
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
|
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
|
||||||
@@ -3208,8 +3173,6 @@
|
|||||||
|
|
||||||
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
|
"type-fest": ["type-fest@0.7.1", "", {}, "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg=="],
|
||||||
|
|
||||||
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
|
||||||
|
|
||||||
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
|
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
|
||||||
|
|
||||||
"typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
|
"typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
|
||||||
@@ -3360,8 +3323,6 @@
|
|||||||
|
|
||||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
|
|
||||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
|
||||||
|
|
||||||
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||||
|
|
||||||
"@babel/core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
|
"@babel/core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
|
||||||
@@ -3518,12 +3479,6 @@
|
|||||||
|
|
||||||
"@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk/content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
|
||||||
|
|
||||||
"@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
"@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||||
|
|
||||||
"@node-rs/argon2-wasm32-wasi/@emnapi/core": ["@emnapi/core@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-DPWjcUDQkCeEM4VnljEOEcXdAD7pp8zSZsgOujk/LGIwCXWbXJngin+MO4zbH429lzeC3WbYLGjE2MaUOwzpyw=="],
|
"@node-rs/argon2-wasm32-wasi/@emnapi/core": ["@emnapi/core@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-DPWjcUDQkCeEM4VnljEOEcXdAD7pp8zSZsgOujk/LGIwCXWbXJngin+MO4zbH429lzeC3WbYLGjE2MaUOwzpyw=="],
|
||||||
@@ -3534,8 +3489,6 @@
|
|||||||
|
|
||||||
"@node-rs/bcrypt-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w=="],
|
"@node-rs/bcrypt-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@0.45.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w=="],
|
||||||
|
|
||||||
"@openai/agents-realtime/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
|
||||||
|
|
||||||
"@opentelemetry/instrumentation/require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
|
"@opentelemetry/instrumentation/require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
|
||||||
|
|
||||||
"@opentelemetry/sql-common/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="],
|
"@opentelemetry/sql-common/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="],
|
||||||
@@ -3688,6 +3641,8 @@
|
|||||||
|
|
||||||
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@react-email/markdown/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||||
|
|
||||||
"@react-email/tailwind/tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
"@react-email/tailwind/tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||||
|
|
||||||
"@react-native/babel-plugin-codegen/@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
|
"@react-native/babel-plugin-codegen/@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
|
||||||
@@ -3842,8 +3797,6 @@
|
|||||||
|
|
||||||
"better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
|
"better-opn/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
|
||||||
|
|
||||||
"body-parser/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
|
||||||
|
|
||||||
"browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.8.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q=="],
|
"browserslist/baseline-browser-mapping": ["baseline-browser-mapping@2.8.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q=="],
|
||||||
|
|
||||||
"chrome-launcher/@types/node": ["@types/node@22.18.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A=="],
|
"chrome-launcher/@types/node": ["@types/node@22.18.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A=="],
|
||||||
@@ -3930,22 +3883,6 @@
|
|||||||
|
|
||||||
"expo-router/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
"expo-router/semver": ["semver@7.6.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="],
|
||||||
|
|
||||||
"express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
|
||||||
|
|
||||||
"express/content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
|
||||||
|
|
||||||
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
|
||||||
|
|
||||||
"express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
|
||||||
|
|
||||||
"express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
|
||||||
|
|
||||||
"express/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
|
||||||
|
|
||||||
"express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
|
||||||
|
|
||||||
"express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
|
||||||
|
|
||||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
"fbjs/promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="],
|
"fbjs/promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="],
|
||||||
@@ -4068,8 +4005,6 @@
|
|||||||
|
|
||||||
"radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
"radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||||
|
|
||||||
"raw-body/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
|
||||||
|
|
||||||
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
||||||
|
|
||||||
"react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
|
"react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
|
||||||
@@ -4104,8 +4039,6 @@
|
|||||||
|
|
||||||
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||||
|
|
||||||
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
|
||||||
|
|
||||||
"sass/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
"sass/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||||
|
|
||||||
"schema-utils/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
"schema-utils/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||||
@@ -4158,8 +4091,6 @@
|
|||||||
|
|
||||||
"tsx/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
|
"tsx/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
|
||||||
|
|
||||||
"type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
|
||||||
|
|
||||||
"typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg=="],
|
"typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg=="],
|
||||||
|
|
||||||
"usesend-js/@react-email/render": ["@react-email/render@1.4.0", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3", "react-promise-suspense": "^0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ZtJ3noggIvW1ZAryoui95KJENKdCzLmN5F7hyZY1F/17B1vwzuxHB7YkuCg0QqHjDivc5axqYEYdIOw4JIQdUw=="],
|
"usesend-js/@react-email/render": ["@react-email/render@1.4.0", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3", "react-promise-suspense": "^0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ZtJ3noggIvW1ZAryoui95KJENKdCzLmN5F7hyZY1F/17B1vwzuxHB7YkuCg0QqHjDivc5axqYEYdIOw4JIQdUw=="],
|
||||||
@@ -4314,8 +4245,6 @@
|
|||||||
|
|
||||||
"@jest/types/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
"@jest/types/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
|
||||||
|
|
||||||
"@opentelemetry/sql-common/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="],
|
"@opentelemetry/sql-common/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="],
|
||||||
|
|
||||||
"@prisma/instrumentation/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.207.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ=="],
|
"@prisma/instrumentation/@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.207.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ=="],
|
||||||
@@ -4554,8 +4483,6 @@
|
|||||||
|
|
||||||
"babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="],
|
"babel-plugin-syntax-hermes-parser/hermes-parser/hermes-estree": ["hermes-estree@0.29.1", "", {}, "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ=="],
|
||||||
|
|
||||||
"body-parser/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
|
||||||
|
|
||||||
"chrome-launcher/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
"chrome-launcher/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|
||||||
"chromium-edge-launcher/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
"chromium-edge-launcher/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
@@ -4742,18 +4669,6 @@
|
|||||||
|
|
||||||
"expo-router/@react-navigation/native/@react-navigation/core": ["@react-navigation/core@7.16.2", "", { "dependencies": { "@react-navigation/routers": "^7.5.3", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-0dbCC2aTjNW7MvG1fY7zeq6eYvmmaFCEnBDXPuMPJ8uKgfs9lFGXIQFIfBdmcBVX6vHhS+K213VCsuHSIv5jYw=="],
|
"expo-router/@react-navigation/native/@react-navigation/core": ["@react-navigation/core@7.16.2", "", { "dependencies": { "@react-navigation/routers": "^7.5.3", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "nanoid": "^3.3.11", "query-string": "^7.1.3", "react-is": "^19.1.0", "use-latest-callback": "^0.2.4", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": ">= 18.2.0" } }, "sha512-0dbCC2aTjNW7MvG1fY7zeq6eYvmmaFCEnBDXPuMPJ8uKgfs9lFGXIQFIfBdmcBVX6vHhS+K213VCsuHSIv5jYw=="],
|
||||||
|
|
||||||
"express/accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
|
||||||
|
|
||||||
"express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
|
||||||
|
|
||||||
"express/finalhandler/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
|
||||||
|
|
||||||
"express/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
|
||||||
|
|
||||||
"express/send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
|
||||||
|
|
||||||
"express/send/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
|
||||||
|
|
||||||
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||||
|
|
||||||
"happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
"happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
@@ -4802,8 +4717,6 @@
|
|||||||
|
|
||||||
"ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
|
"ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
|
||||||
|
|
||||||
"raw-body/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
|
||||||
|
|
||||||
"react-email/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
|
"react-email/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
|
||||||
|
|
||||||
"react-email/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
|
"react-email/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ RUN apt-get update \
|
|||||||
python3 \
|
python3 \
|
||||||
ripgrep \
|
ripgrep \
|
||||||
&& corepack enable \
|
&& corepack enable \
|
||||||
&& npm install -g bun@1.3.10 \
|
&& npm install -g bun@1.3.10 opencode-ai@latest \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN useradd --create-home --shell /bin/bash agent
|
RUN useradd --create-home --shell /bin/bash agent
|
||||||
|
|||||||
@@ -18,6 +18,51 @@ const jobStatus = v.union(
|
|||||||
v.literal('timed_out'),
|
v.literal('timed_out'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const runtime = v.literal('opencode');
|
||||||
|
|
||||||
|
const jobType = v.union(
|
||||||
|
v.literal('user_change'),
|
||||||
|
v.literal('maintenance_review'),
|
||||||
|
v.literal('conflict_resolution'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const workspaceStatus = v.union(
|
||||||
|
v.literal('not_started'),
|
||||||
|
v.literal('starting'),
|
||||||
|
v.literal('active'),
|
||||||
|
v.literal('idle'),
|
||||||
|
v.literal('stopped'),
|
||||||
|
v.literal('expired'),
|
||||||
|
v.literal('failed'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const messageRole = v.union(
|
||||||
|
v.literal('user'),
|
||||||
|
v.literal('assistant'),
|
||||||
|
v.literal('system'),
|
||||||
|
v.literal('tool'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const messageStatus = v.union(
|
||||||
|
v.literal('queued'),
|
||||||
|
v.literal('streaming'),
|
||||||
|
v.literal('completed'),
|
||||||
|
v.literal('failed'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeSource = v.union(
|
||||||
|
v.literal('user'),
|
||||||
|
v.literal('agent'),
|
||||||
|
v.literal('command'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeType = v.union(
|
||||||
|
v.literal('added'),
|
||||||
|
v.literal('modified'),
|
||||||
|
v.literal('deleted'),
|
||||||
|
v.literal('renamed'),
|
||||||
|
);
|
||||||
|
|
||||||
const eventLevel = v.union(
|
const eventLevel = v.union(
|
||||||
v.literal('debug'),
|
v.literal('debug'),
|
||||||
v.literal('info'),
|
v.literal('info'),
|
||||||
@@ -55,13 +100,34 @@ const artifactContentType = v.union(
|
|||||||
v.literal('text/x-diff'),
|
v.literal('text/x-diff'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const maintenanceDecision = v.union(
|
||||||
|
v.literal('sync'),
|
||||||
|
v.literal('ignore'),
|
||||||
|
v.literal('open_review_pr'),
|
||||||
|
v.literal('manual_review'),
|
||||||
|
v.literal('conflict_resolution'),
|
||||||
|
v.literal('unknown'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const maintenanceRisk = v.union(
|
||||||
|
v.literal('low'),
|
||||||
|
v.literal('medium'),
|
||||||
|
v.literal('high'),
|
||||||
|
v.literal('unknown'),
|
||||||
|
);
|
||||||
|
|
||||||
const defaultAgentSettings = {
|
const defaultAgentSettings = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
runtime: 'opencode' as const,
|
||||||
branchPrefix: 'spoon/agent',
|
branchPrefix: 'spoon/agent',
|
||||||
agentModel: 'gpt-5.1-codex',
|
agentModel: '',
|
||||||
reasoningEffort: 'high' as const,
|
reasoningEffort: 'medium' as const,
|
||||||
maxJobDurationMs: 1_800_000,
|
maxJobDurationMs: 1_800_000,
|
||||||
maxOutputBytes: 200_000,
|
maxOutputBytes: 200_000,
|
||||||
|
envFilePath: '.env.local',
|
||||||
|
materializeEnvFileByDefault: false,
|
||||||
|
autoDetectCommands: true,
|
||||||
|
allowUserFileEditing: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const getWorkerToken = () => process.env.SPOON_WORKER_TOKEN?.trim();
|
const getWorkerToken = () => process.env.SPOON_WORKER_TOKEN?.trim();
|
||||||
@@ -94,6 +160,18 @@ const buildBranch = (
|
|||||||
)}`;
|
)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeEnvFilePath = (value?: string) => {
|
||||||
|
const trimmed = optionalText(value);
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
if (trimmed.startsWith('/') || trimmed.includes('..')) {
|
||||||
|
throw new ConvexError('Env file path must stay inside the repository.');
|
||||||
|
}
|
||||||
|
if (!/^\.env(?:[./-][A-Za-z0-9_.-]+)?$/.test(trimmed)) {
|
||||||
|
throw new ConvexError('Env file path must be a .env-style path.');
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
const getAgentSettings = async (ctx: MutationCtx, spoon: Doc<'spoons'>) => {
|
const getAgentSettings = async (ctx: MutationCtx, spoon: Doc<'spoons'>) => {
|
||||||
const settings = await ctx.db
|
const settings = await ctx.db
|
||||||
.query('spoonAgentSettings')
|
.query('spoonAgentSettings')
|
||||||
@@ -120,12 +198,207 @@ const assertSecretOwnership = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getJobProfile = async (
|
||||||
|
ctx: MutationCtx,
|
||||||
|
ownerId: Id<'users'>,
|
||||||
|
profileId?: Id<'aiProviderProfiles'>,
|
||||||
|
) => {
|
||||||
|
const profile = profileId
|
||||||
|
? await ctx.db.get(profileId)
|
||||||
|
: await getDefaultJobProfile(ctx, ownerId);
|
||||||
|
if (profile?.ownerId !== ownerId || !profile.enabled) {
|
||||||
|
throw new ConvexError('AI provider profile not found.');
|
||||||
|
}
|
||||||
|
if (profile.authType !== 'none' && !profile.encryptedSecret) {
|
||||||
|
throw new ConvexError('Selected AI provider is missing credentials.');
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDefaultJobProfile = async (ctx: MutationCtx, ownerId: Id<'users'>) => {
|
||||||
|
const profiles = await ctx.db
|
||||||
|
.query('aiProviderProfiles')
|
||||||
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||||
|
.collect();
|
||||||
|
const configuredProfiles = profiles.filter(
|
||||||
|
(profile) =>
|
||||||
|
profile.enabled &&
|
||||||
|
(profile.authType === 'none' || Boolean(profile.encryptedSecret)),
|
||||||
|
);
|
||||||
|
const explicitDefault = configuredProfiles.find(
|
||||||
|
(profile) =>
|
||||||
|
(profile as Doc<'aiProviderProfiles'> & { isDefault?: boolean })
|
||||||
|
.isDefault,
|
||||||
|
);
|
||||||
|
const profile =
|
||||||
|
explicitDefault ??
|
||||||
|
(configuredProfiles.length === 1 ? configuredProfiles[0] : undefined);
|
||||||
|
if (!profile) {
|
||||||
|
throw new ConvexError(
|
||||||
|
'Choose a default AI provider before queueing agent work.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
};
|
||||||
|
|
||||||
|
const listSpoonSecretIds = async (
|
||||||
|
ctx: MutationCtx,
|
||||||
|
spoonId: Id<'spoons'>,
|
||||||
|
ownerId: Id<'users'>,
|
||||||
|
) => {
|
||||||
|
const secrets = await ctx.db
|
||||||
|
.query('spoonSecrets')
|
||||||
|
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||||
|
.collect();
|
||||||
|
return secrets
|
||||||
|
.filter((secret) => secret.ownerId === ownerId)
|
||||||
|
.map((secret) => secret._id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertJob = async (
|
||||||
|
ctx: MutationCtx,
|
||||||
|
{
|
||||||
|
ownerId,
|
||||||
|
spoon,
|
||||||
|
requestId,
|
||||||
|
prompt,
|
||||||
|
settings,
|
||||||
|
threadId,
|
||||||
|
requestedJobType,
|
||||||
|
baseBranch,
|
||||||
|
requestedBranchName,
|
||||||
|
requestedRuntime,
|
||||||
|
materializeEnvFile,
|
||||||
|
requestedEnvFilePath,
|
||||||
|
requestedProfileId,
|
||||||
|
}: {
|
||||||
|
ownerId: Id<'users'>;
|
||||||
|
spoon: Doc<'spoons'>;
|
||||||
|
requestId: Id<'agentRequests'>;
|
||||||
|
prompt: string;
|
||||||
|
settings: Awaited<ReturnType<typeof getAgentSettings>>;
|
||||||
|
threadId?: Id<'threads'>;
|
||||||
|
requestedJobType:
|
||||||
|
| 'user_change'
|
||||||
|
| 'maintenance_review'
|
||||||
|
| 'conflict_resolution';
|
||||||
|
baseBranch?: string;
|
||||||
|
requestedBranchName?: string;
|
||||||
|
requestedRuntime?: 'opencode';
|
||||||
|
materializeEnvFile?: boolean;
|
||||||
|
requestedEnvFilePath?: string;
|
||||||
|
requestedProfileId?: Id<'aiProviderProfiles'>;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if (spoon.provider !== 'github') {
|
||||||
|
throw new ConvexError('Agent jobs currently require a GitHub Spoon.');
|
||||||
|
}
|
||||||
|
if (!spoon.forkOwner || !spoon.forkRepo || !spoon.forkUrl) {
|
||||||
|
throw new ConvexError(
|
||||||
|
'Add fork repository metadata before queueing a job.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!settings.enabled) {
|
||||||
|
throw new ConvexError('Agent jobs are disabled for this Spoon.');
|
||||||
|
}
|
||||||
|
const aiProviderProfileId =
|
||||||
|
requestedProfileId ?? settings.aiProviderProfileId;
|
||||||
|
const profile = await getJobProfile(ctx, ownerId, aiProviderProfileId);
|
||||||
|
const selectedSecretIds = await listSpoonSecretIds(ctx, spoon._id, ownerId);
|
||||||
|
const now = Date.now();
|
||||||
|
const resolvedBaseBranch =
|
||||||
|
optionalText(baseBranch) ?? settings.defaultBaseBranch;
|
||||||
|
const jobRuntime = requestedRuntime ?? 'opencode';
|
||||||
|
const shouldMaterializeEnvFile =
|
||||||
|
materializeEnvFile ?? settings.materializeEnvFileByDefault;
|
||||||
|
const envFilePath =
|
||||||
|
normalizeEnvFilePath(requestedEnvFilePath) ??
|
||||||
|
normalizeEnvFilePath(
|
||||||
|
settings.envFilePath === 'custom'
|
||||||
|
? settings.customEnvFilePath
|
||||||
|
: settings.envFilePath,
|
||||||
|
);
|
||||||
|
const workBranch = buildBranch(
|
||||||
|
requestId,
|
||||||
|
prompt,
|
||||||
|
settings.branchPrefix,
|
||||||
|
requestedBranchName,
|
||||||
|
);
|
||||||
|
const jobId = await ctx.db.insert('agentJobs', {
|
||||||
|
spoonId: spoon._id,
|
||||||
|
ownerId,
|
||||||
|
agentRequestId: requestId,
|
||||||
|
threadId,
|
||||||
|
jobType: requestedJobType,
|
||||||
|
status: 'queued',
|
||||||
|
prompt,
|
||||||
|
runtime: jobRuntime,
|
||||||
|
workspaceStatus: 'not_started',
|
||||||
|
baseBranch: resolvedBaseBranch,
|
||||||
|
workBranch,
|
||||||
|
envFilePath,
|
||||||
|
materializeEnvFile: shouldMaterializeEnvFile,
|
||||||
|
githubInstallationId: spoon.githubInstallationId,
|
||||||
|
forkOwner: spoon.forkOwner,
|
||||||
|
forkRepo: spoon.forkRepo,
|
||||||
|
forkUrl: spoon.forkUrl,
|
||||||
|
upstreamOwner: spoon.upstreamOwner,
|
||||||
|
upstreamRepo: spoon.upstreamRepo,
|
||||||
|
selectedSecretIds,
|
||||||
|
aiProviderProfileId: profile._id,
|
||||||
|
model: profile.defaultModel,
|
||||||
|
reasoningEffort: profile.reasoningEffort,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
await ctx.db.patch(requestId, {
|
||||||
|
agentJobId: jobId,
|
||||||
|
selectedSecretIds,
|
||||||
|
baseBranch: resolvedBaseBranch,
|
||||||
|
requestedBranchName: optionalText(requestedBranchName),
|
||||||
|
status: 'queued',
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
if (threadId) {
|
||||||
|
await ctx.db.patch(threadId, {
|
||||||
|
latestAgentJobId: jobId,
|
||||||
|
relatedAgentRequestId: requestId,
|
||||||
|
status: 'queued',
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await ctx.db.insert('agentJobEvents', {
|
||||||
|
jobId,
|
||||||
|
spoonId: spoon._id,
|
||||||
|
ownerId,
|
||||||
|
level: 'info',
|
||||||
|
phase: 'queued',
|
||||||
|
message: 'OpenCode job queued.',
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
await ctx.db.insert('agentJobMessages', {
|
||||||
|
jobId,
|
||||||
|
spoonId: spoon._id,
|
||||||
|
ownerId,
|
||||||
|
role: 'user',
|
||||||
|
content: prompt,
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
return jobId;
|
||||||
|
};
|
||||||
|
|
||||||
export const createFromRequest = mutation({
|
export const createFromRequest = mutation({
|
||||||
args: {
|
args: {
|
||||||
requestId: v.id('agentRequests'),
|
requestId: v.id('agentRequests'),
|
||||||
selectedSecretIds: v.array(v.id('spoonSecrets')),
|
selectedSecretIds: v.array(v.id('spoonSecrets')),
|
||||||
baseBranch: v.optional(v.string()),
|
baseBranch: v.optional(v.string()),
|
||||||
requestedBranchName: v.optional(v.string()),
|
requestedBranchName: v.optional(v.string()),
|
||||||
|
runtime: v.optional(runtime),
|
||||||
|
materializeEnvFile: v.optional(v.boolean()),
|
||||||
|
envFilePath: v.optional(v.string()),
|
||||||
|
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const ownerId = await getRequiredUserId(ctx);
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
@@ -137,74 +410,158 @@ export const createFromRequest = mutation({
|
|||||||
throw new ConvexError('This request already has an agent job.');
|
throw new ConvexError('This request already has an agent job.');
|
||||||
}
|
}
|
||||||
const spoon = await getOwnedSpoon(ctx, request.spoonId, ownerId);
|
const spoon = await getOwnedSpoon(ctx, request.spoonId, ownerId);
|
||||||
if (spoon.provider !== 'github') {
|
|
||||||
throw new ConvexError('Agent jobs currently require a GitHub Spoon.');
|
|
||||||
}
|
|
||||||
if (!spoon.forkOwner || !spoon.forkRepo || !spoon.forkUrl) {
|
|
||||||
throw new ConvexError(
|
|
||||||
'Add fork repository metadata before queueing a job.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const settings = await getAgentSettings(ctx, spoon);
|
const settings = await getAgentSettings(ctx, spoon);
|
||||||
if (!settings.enabled) {
|
|
||||||
throw new ConvexError('Agent jobs are disabled for this Spoon.');
|
|
||||||
}
|
|
||||||
await assertSecretOwnership(
|
await assertSecretOwnership(
|
||||||
ctx,
|
ctx,
|
||||||
spoon._id,
|
spoon._id,
|
||||||
ownerId,
|
ownerId,
|
||||||
args.selectedSecretIds,
|
args.selectedSecretIds,
|
||||||
);
|
);
|
||||||
const now = Date.now();
|
return await insertJob(ctx, {
|
||||||
const baseBranch =
|
|
||||||
optionalText(args.baseBranch) ?? settings.defaultBaseBranch;
|
|
||||||
const workBranch = buildBranch(
|
|
||||||
request._id,
|
|
||||||
request.prompt,
|
|
||||||
settings.branchPrefix,
|
|
||||||
args.requestedBranchName,
|
|
||||||
);
|
|
||||||
const jobId = await ctx.db.insert('agentJobs', {
|
|
||||||
spoonId: spoon._id,
|
|
||||||
ownerId,
|
ownerId,
|
||||||
agentRequestId: request._id,
|
spoon,
|
||||||
status: 'queued',
|
requestId: request._id,
|
||||||
prompt: request.prompt,
|
prompt: request.prompt,
|
||||||
baseBranch,
|
settings,
|
||||||
workBranch,
|
requestedJobType: 'user_change',
|
||||||
githubInstallationId: spoon.githubInstallationId,
|
baseBranch: args.baseBranch,
|
||||||
forkOwner: spoon.forkOwner,
|
requestedBranchName: args.requestedBranchName,
|
||||||
forkRepo: spoon.forkRepo,
|
requestedRuntime: args.runtime,
|
||||||
forkUrl: spoon.forkUrl,
|
materializeEnvFile: args.materializeEnvFile,
|
||||||
upstreamOwner: spoon.upstreamOwner,
|
requestedEnvFilePath: args.envFilePath,
|
||||||
upstreamRepo: spoon.upstreamRepo,
|
requestedProfileId: args.aiProviderProfileId,
|
||||||
selectedSecretIds: args.selectedSecretIds,
|
|
||||||
model: settings.agentModel,
|
|
||||||
reasoningEffort: settings.reasoningEffort,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
});
|
});
|
||||||
await ctx.db.patch(request._id, {
|
},
|
||||||
agentJobId: jobId,
|
});
|
||||||
selectedSecretIds: args.selectedSecretIds,
|
|
||||||
baseBranch,
|
export const createForThread = mutation({
|
||||||
requestedBranchName: optionalText(args.requestedBranchName),
|
args: {
|
||||||
status: 'queued',
|
threadId: v.id('threads'),
|
||||||
updatedAt: now,
|
jobType,
|
||||||
});
|
baseBranch: v.optional(v.string()),
|
||||||
await ctx.db.insert('agentJobEvents', {
|
requestedBranchName: v.optional(v.string()),
|
||||||
jobId,
|
materializeEnvFile: v.optional(v.boolean()),
|
||||||
|
envFilePath: v.optional(v.string()),
|
||||||
|
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const thread = await ctx.db.get(args.threadId);
|
||||||
|
if (thread?.ownerId !== ownerId || !thread.spoonId) {
|
||||||
|
throw new ConvexError('Thread not found.');
|
||||||
|
}
|
||||||
|
if (thread.latestAgentJobId) {
|
||||||
|
throw new ConvexError('This thread already has an agent job.');
|
||||||
|
}
|
||||||
|
const spoon = await getOwnedSpoon(ctx, thread.spoonId, ownerId);
|
||||||
|
const promptMessage = await ctx.db
|
||||||
|
.query('threadMessages')
|
||||||
|
.withIndex('by_thread', (q) => q.eq('threadId', args.threadId))
|
||||||
|
.order('desc')
|
||||||
|
.first();
|
||||||
|
const prompt =
|
||||||
|
promptMessage?.content ??
|
||||||
|
thread.summary ??
|
||||||
|
`Work on thread: ${thread.title}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const requestId = await ctx.db.insert('agentRequests', {
|
||||||
spoonId: spoon._id,
|
spoonId: spoon._id,
|
||||||
ownerId,
|
ownerId,
|
||||||
level: 'info',
|
prompt,
|
||||||
phase: 'queued',
|
status: 'queued',
|
||||||
message: 'Agent job queued.',
|
requestType:
|
||||||
|
args.jobType === 'user_change'
|
||||||
|
? 'future_code_change'
|
||||||
|
: 'upstream_review',
|
||||||
|
priority: thread.priority,
|
||||||
|
source: thread.source === 'user_request' ? 'user' : 'system',
|
||||||
|
targetBranch: optionalText(args.baseBranch),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
const settings = await getAgentSettings(ctx, spoon);
|
||||||
|
const jobId = await insertJob(ctx, {
|
||||||
|
ownerId,
|
||||||
|
spoon,
|
||||||
|
requestId,
|
||||||
|
prompt,
|
||||||
|
settings,
|
||||||
|
threadId: args.threadId,
|
||||||
|
requestedJobType: args.jobType,
|
||||||
|
baseBranch: args.baseBranch,
|
||||||
|
requestedBranchName: args.requestedBranchName,
|
||||||
|
materializeEnvFile: args.materializeEnvFile,
|
||||||
|
requestedEnvFilePath: args.envFilePath,
|
||||||
|
requestedProfileId: args.aiProviderProfileId,
|
||||||
});
|
});
|
||||||
return jobId;
|
return jobId;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const createForThreadInternal = internalMutation({
|
||||||
|
args: {
|
||||||
|
threadId: v.id('threads'),
|
||||||
|
ownerId: v.id('users'),
|
||||||
|
jobType,
|
||||||
|
baseBranch: v.optional(v.string()),
|
||||||
|
requestedBranchName: v.optional(v.string()),
|
||||||
|
materializeEnvFile: v.optional(v.boolean()),
|
||||||
|
envFilePath: v.optional(v.string()),
|
||||||
|
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const thread = await ctx.db.get(args.threadId);
|
||||||
|
if (thread?.ownerId !== args.ownerId || !thread.spoonId) {
|
||||||
|
throw new ConvexError('Thread not found.');
|
||||||
|
}
|
||||||
|
if (thread.latestAgentJobId) return thread.latestAgentJobId;
|
||||||
|
const spoon = await ctx.db.get(thread.spoonId);
|
||||||
|
if (spoon?.ownerId !== args.ownerId) {
|
||||||
|
throw new ConvexError('Spoon not found.');
|
||||||
|
}
|
||||||
|
const promptMessage = await ctx.db
|
||||||
|
.query('threadMessages')
|
||||||
|
.withIndex('by_thread', (q) => q.eq('threadId', args.threadId))
|
||||||
|
.order('desc')
|
||||||
|
.first();
|
||||||
|
const prompt =
|
||||||
|
promptMessage?.content ??
|
||||||
|
thread.summary ??
|
||||||
|
`Review maintenance thread: ${thread.title}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const requestId = await ctx.db.insert('agentRequests', {
|
||||||
|
spoonId: spoon._id,
|
||||||
|
ownerId: args.ownerId,
|
||||||
|
prompt,
|
||||||
|
status: 'queued',
|
||||||
|
requestType:
|
||||||
|
args.jobType === 'user_change'
|
||||||
|
? 'future_code_change'
|
||||||
|
: 'upstream_review',
|
||||||
|
priority: thread.priority,
|
||||||
|
source: thread.source === 'user_request' ? 'user' : 'system',
|
||||||
|
targetBranch: optionalText(args.baseBranch),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
const settings = await getAgentSettings(ctx, spoon);
|
||||||
|
return await insertJob(ctx, {
|
||||||
|
ownerId: args.ownerId,
|
||||||
|
spoon,
|
||||||
|
requestId,
|
||||||
|
prompt,
|
||||||
|
settings,
|
||||||
|
threadId: args.threadId,
|
||||||
|
requestedJobType: args.jobType,
|
||||||
|
baseBranch: args.baseBranch,
|
||||||
|
requestedBranchName: args.requestedBranchName,
|
||||||
|
materializeEnvFile: args.materializeEnvFile,
|
||||||
|
requestedEnvFilePath: args.envFilePath,
|
||||||
|
requestedProfileId: args.aiProviderProfileId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const listForSpoon = query({
|
export const listForSpoon = query({
|
||||||
args: { spoonId: v.id('spoons'), limit: v.optional(v.number()) },
|
args: { spoonId: v.id('spoons'), limit: v.optional(v.number()) },
|
||||||
handler: async (ctx, { spoonId, limit }) => {
|
handler: async (ctx, { spoonId, limit }) => {
|
||||||
@@ -228,6 +585,66 @@ export const get = query({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const assertOwned = 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.');
|
||||||
|
return { job, ownerId };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listMessages = query({
|
||||||
|
args: { jobId: v.id('agentJobs'), limit: v.optional(v.number()) },
|
||||||
|
handler: async (ctx, { jobId, limit }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const job = await ctx.db.get(jobId);
|
||||||
|
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
|
||||||
|
return await ctx.db
|
||||||
|
.query('agentJobMessages')
|
||||||
|
.withIndex('by_job', (q) => q.eq('jobId', jobId))
|
||||||
|
.order('asc')
|
||||||
|
.take(limit ?? 200);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const appendUserMessage = mutation({
|
||||||
|
args: { jobId: v.id('agentJobs'), content: v.string() },
|
||||||
|
handler: async (ctx, { jobId, content }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const job = await ctx.db.get(jobId);
|
||||||
|
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
|
||||||
|
const trimmed = optionalText(content);
|
||||||
|
if (!trimmed) throw new ConvexError('Message is required.');
|
||||||
|
const now = Date.now();
|
||||||
|
return await ctx.db.insert('agentJobMessages', {
|
||||||
|
jobId,
|
||||||
|
spoonId: job.spoonId,
|
||||||
|
ownerId,
|
||||||
|
role: 'user',
|
||||||
|
content: trimmed,
|
||||||
|
status: 'queued',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listWorkspaceChanges = query({
|
||||||
|
args: { jobId: v.id('agentJobs'), limit: v.optional(v.number()) },
|
||||||
|
handler: async (ctx, { jobId, limit }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const job = await ctx.db.get(jobId);
|
||||||
|
if (job?.ownerId !== ownerId) throw new ConvexError('Agent job not found.');
|
||||||
|
return await ctx.db
|
||||||
|
.query('agentWorkspaceChanges')
|
||||||
|
.withIndex('by_job', (q) => q.eq('jobId', jobId))
|
||||||
|
.order('desc')
|
||||||
|
.take(limit ?? 100);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const listEvents = query({
|
export const listEvents = query({
|
||||||
args: { jobId: v.id('agentJobs'), limit: v.optional(v.number()) },
|
args: { jobId: v.id('agentJobs'), limit: v.optional(v.number()) },
|
||||||
handler: async (ctx, { jobId, limit }) => {
|
handler: async (ctx, { jobId, limit }) => {
|
||||||
@@ -312,6 +729,9 @@ export const claimNextInternal = internalMutation({
|
|||||||
.query('spoonAgentSettings')
|
.query('spoonAgentSettings')
|
||||||
.withIndex('by_spoon', (q) => q.eq('spoonId', job.spoonId))
|
.withIndex('by_spoon', (q) => q.eq('spoonId', job.spoonId))
|
||||||
.first();
|
.first();
|
||||||
|
const aiProviderProfile = job.aiProviderProfileId
|
||||||
|
? await ctx.db.get(job.aiProviderProfileId)
|
||||||
|
: null;
|
||||||
const secrets = [];
|
const secrets = [];
|
||||||
for (const secretId of job.selectedSecretIds) {
|
for (const secretId of job.selectedSecretIds) {
|
||||||
const secret = await ctx.db.get(secretId);
|
const secret = await ctx.db.get(secretId);
|
||||||
@@ -343,6 +763,8 @@ export const claimNextInternal = internalMutation({
|
|||||||
job: { ...job, status: 'claimed' as const, claimedBy: workerId },
|
job: { ...job, status: 'claimed' as const, claimedBy: workerId },
|
||||||
spoon,
|
spoon,
|
||||||
aiSettings,
|
aiSettings,
|
||||||
|
aiProviderProfile:
|
||||||
|
aiProviderProfile?.ownerId === job.ownerId ? aiProviderProfile : null,
|
||||||
agentSettings,
|
agentSettings,
|
||||||
secrets,
|
secrets,
|
||||||
};
|
};
|
||||||
@@ -380,6 +802,110 @@ export const updateStatus = mutation({
|
|||||||
if (args.error !== undefined) patch.error = args.error;
|
if (args.error !== undefined) patch.error = args.error;
|
||||||
if (args.summary !== undefined) patch.summary = args.summary;
|
if (args.summary !== undefined) patch.summary = args.summary;
|
||||||
await ctx.db.patch(args.jobId, patch);
|
await ctx.db.patch(args.jobId, patch);
|
||||||
|
if (job.threadId) {
|
||||||
|
const threadStatus =
|
||||||
|
args.status === 'queued' || args.status === 'claimed'
|
||||||
|
? 'queued'
|
||||||
|
: args.status === 'running' || args.status === 'checks_running'
|
||||||
|
? 'running'
|
||||||
|
: args.status === 'changes_ready'
|
||||||
|
? 'changes_ready'
|
||||||
|
: args.status === 'draft_pr_opened'
|
||||||
|
? 'draft_pr_opened'
|
||||||
|
: args.status === 'failed' || args.status === 'timed_out'
|
||||||
|
? 'failed'
|
||||||
|
: args.status === 'cancelled'
|
||||||
|
? 'cancelled'
|
||||||
|
: undefined;
|
||||||
|
if (threadStatus) {
|
||||||
|
const threadPatch: Partial<Doc<'threads'>> = {
|
||||||
|
status: threadStatus,
|
||||||
|
summary: args.summary ?? job.summary,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
['draft_pr_opened', 'failed', 'cancelled', 'timed_out'].includes(
|
||||||
|
args.status,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
threadPatch.resolvedAt = now;
|
||||||
|
}
|
||||||
|
await ctx.db.patch(job.threadId, threadPatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const markWorkspaceActive = mutation({
|
||||||
|
args: {
|
||||||
|
workerToken: v.string(),
|
||||||
|
workerId: v.string(),
|
||||||
|
jobId: v.id('agentJobs'),
|
||||||
|
opencodeSessionId: v.optional(v.string()),
|
||||||
|
containerId: v.optional(v.string()),
|
||||||
|
workspaceUrl: v.optional(v.string()),
|
||||||
|
workspaceExpiresAt: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
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();
|
||||||
|
await ctx.db.patch(args.jobId, {
|
||||||
|
workspaceStatus: 'active',
|
||||||
|
opencodeSessionId: optionalText(args.opencodeSessionId),
|
||||||
|
containerId: optionalText(args.containerId),
|
||||||
|
workspaceUrl: optionalText(args.workspaceUrl),
|
||||||
|
workspaceExpiresAt: args.workspaceExpiresAt,
|
||||||
|
lastHeartbeatAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const markWorkspaceStopped = mutation({
|
||||||
|
args: {
|
||||||
|
workerToken: v.string(),
|
||||||
|
workerId: v.string(),
|
||||||
|
jobId: v.id('agentJobs'),
|
||||||
|
workspaceStatus: v.optional(workspaceStatus),
|
||||||
|
},
|
||||||
|
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();
|
||||||
|
await ctx.db.patch(args.jobId, {
|
||||||
|
workspaceStatus: args.workspaceStatus ?? 'stopped',
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const heartbeatWorkspace = mutation({
|
||||||
|
args: {
|
||||||
|
workerToken: v.string(),
|
||||||
|
workerId: v.string(),
|
||||||
|
jobId: v.id('agentJobs'),
|
||||||
|
},
|
||||||
|
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, {
|
||||||
|
workspaceStatus: 'active',
|
||||||
|
lastHeartbeatAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -416,6 +942,98 @@ export const completeWithDraftPr = mutation({
|
|||||||
summary: args.summary,
|
summary: args.summary,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
|
if (job.threadId) {
|
||||||
|
await ctx.db.patch(job.threadId, {
|
||||||
|
status: 'draft_pr_opened',
|
||||||
|
summary: args.summary,
|
||||||
|
updatedAt: now,
|
||||||
|
resolvedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const applyMaintenanceDecision = mutation({
|
||||||
|
args: {
|
||||||
|
workerToken: v.string(),
|
||||||
|
workerId: v.string(),
|
||||||
|
jobId: v.id('agentJobs'),
|
||||||
|
decision: maintenanceDecision,
|
||||||
|
risk: maintenanceRisk,
|
||||||
|
summary: v.string(),
|
||||||
|
ignoredCommitShas: v.array(v.string()),
|
||||||
|
ignoredReason: v.optional(v.string()),
|
||||||
|
recommendedAction: v.string(),
|
||||||
|
requiresUserApproval: v.boolean(),
|
||||||
|
},
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
if (!job.threadId) return { success: true };
|
||||||
|
const now = Date.now();
|
||||||
|
const outcome =
|
||||||
|
args.decision === 'sync'
|
||||||
|
? 'sync_recommended'
|
||||||
|
: args.decision === 'ignore'
|
||||||
|
? 'ignored'
|
||||||
|
: args.decision === 'open_review_pr'
|
||||||
|
? 'review_pr_recommended'
|
||||||
|
: args.decision === 'conflict_resolution'
|
||||||
|
? 'conflict_resolution_required'
|
||||||
|
: args.decision === 'manual_review'
|
||||||
|
? 'manual_review_required'
|
||||||
|
: 'unknown';
|
||||||
|
const status =
|
||||||
|
args.decision === 'ignore'
|
||||||
|
? 'ignored'
|
||||||
|
: args.decision === 'sync' && !args.requiresUserApproval
|
||||||
|
? 'resolved'
|
||||||
|
: 'waiting_for_user';
|
||||||
|
const threadPatch: Partial<Doc<'threads'>> = {
|
||||||
|
status,
|
||||||
|
maintenanceOutcome: outcome,
|
||||||
|
summary: args.summary,
|
||||||
|
ignoredCommitShas: args.ignoredCommitShas,
|
||||||
|
ignoredReason: args.ignoredReason,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
if (status === 'ignored' || status === 'resolved') {
|
||||||
|
threadPatch.resolvedAt = now;
|
||||||
|
}
|
||||||
|
await ctx.db.patch(job.threadId, threadPatch);
|
||||||
|
if (args.decision === 'ignore' && args.ignoredCommitShas.length > 0) {
|
||||||
|
const thread = await ctx.db.get(job.threadId);
|
||||||
|
await ctx.db.insert('ignoredUpstreamChanges', {
|
||||||
|
spoonId: job.spoonId,
|
||||||
|
ownerId: job.ownerId,
|
||||||
|
upstreamFrom: thread?.upstreamFrom,
|
||||||
|
upstreamTo: thread?.upstreamTo ?? job.upstreamRepo,
|
||||||
|
commitShas: args.ignoredCommitShas,
|
||||||
|
reason: args.ignoredReason ?? args.summary,
|
||||||
|
decidedBy: 'agent',
|
||||||
|
threadId: job.threadId,
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await ctx.db.insert('threadMessages', {
|
||||||
|
threadId: job.threadId,
|
||||||
|
ownerId: job.ownerId,
|
||||||
|
spoonId: job.spoonId,
|
||||||
|
role: 'assistant',
|
||||||
|
content: args.summary,
|
||||||
|
status: 'completed',
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
decision: args.decision,
|
||||||
|
risk: args.risk,
|
||||||
|
recommendedAction: args.recommendedAction,
|
||||||
|
}),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -449,6 +1067,108 @@ export const appendEvent = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const appendMessage = mutation({
|
||||||
|
args: {
|
||||||
|
workerToken: v.string(),
|
||||||
|
workerId: v.string(),
|
||||||
|
jobId: v.id('agentJobs'),
|
||||||
|
role: messageRole,
|
||||||
|
content: v.string(),
|
||||||
|
status: messageStatus,
|
||||||
|
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 messageId = await ctx.db.insert('agentJobMessages', {
|
||||||
|
jobId: args.jobId,
|
||||||
|
spoonId: job.spoonId,
|
||||||
|
ownerId: job.ownerId,
|
||||||
|
role: args.role,
|
||||||
|
content: args.content,
|
||||||
|
status: args.status,
|
||||||
|
metadata: args.metadata,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
if (job.threadId) {
|
||||||
|
await ctx.db.insert('threadMessages', {
|
||||||
|
threadId: job.threadId,
|
||||||
|
spoonId: job.spoonId,
|
||||||
|
ownerId: job.ownerId,
|
||||||
|
role: args.role,
|
||||||
|
content: args.content,
|
||||||
|
status: args.status,
|
||||||
|
metadata: args.metadata,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return messageId;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateMessage = mutation({
|
||||||
|
args: {
|
||||||
|
workerToken: v.string(),
|
||||||
|
workerId: v.string(),
|
||||||
|
messageId: v.id('agentJobMessages'),
|
||||||
|
content: v.optional(v.string()),
|
||||||
|
status: v.optional(messageStatus),
|
||||||
|
metadata: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
requireWorkerToken(args.workerToken);
|
||||||
|
const message = await ctx.db.get(args.messageId);
|
||||||
|
if (!message) throw new ConvexError('Agent message not found.');
|
||||||
|
const job = await ctx.db.get(message.jobId);
|
||||||
|
if (job?.claimedBy !== args.workerId) {
|
||||||
|
throw new ConvexError('Agent job not claimed by this worker.');
|
||||||
|
}
|
||||||
|
const patch: Partial<Doc<'agentJobMessages'>> = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
if (args.content !== undefined) patch.content = args.content;
|
||||||
|
if (args.status !== undefined) patch.status = args.status;
|
||||||
|
if (args.metadata !== undefined) patch.metadata = args.metadata;
|
||||||
|
await ctx.db.patch(args.messageId, patch);
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const recordWorkspaceChange = mutation({
|
||||||
|
args: {
|
||||||
|
workerToken: v.string(),
|
||||||
|
workerId: v.string(),
|
||||||
|
jobId: v.id('agentJobs'),
|
||||||
|
path: v.string(),
|
||||||
|
source: changeSource,
|
||||||
|
changeType,
|
||||||
|
diff: 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.');
|
||||||
|
}
|
||||||
|
return await ctx.db.insert('agentWorkspaceChanges', {
|
||||||
|
jobId: args.jobId,
|
||||||
|
spoonId: job.spoonId,
|
||||||
|
ownerId: job.ownerId,
|
||||||
|
path: args.path,
|
||||||
|
source: args.source,
|
||||||
|
changeType: args.changeType,
|
||||||
|
diff: args.diff,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const addArtifact = mutation({
|
export const addArtifact = mutation({
|
||||||
args: {
|
args: {
|
||||||
workerToken: v.string(),
|
workerToken: v.string(),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type ClaimedJob = {
|
|||||||
job: Doc<'agentJobs'>;
|
job: Doc<'agentJobs'>;
|
||||||
spoon: Doc<'spoons'> | null;
|
spoon: Doc<'spoons'> | null;
|
||||||
aiSettings: Doc<'userAiSettings'> | null;
|
aiSettings: Doc<'userAiSettings'> | null;
|
||||||
|
aiProviderProfile: Doc<'aiProviderProfiles'> | null;
|
||||||
agentSettings: Doc<'spoonAgentSettings'> | null;
|
agentSettings: Doc<'spoonAgentSettings'> | null;
|
||||||
secrets: Doc<'spoonSecrets'>[];
|
secrets: Doc<'spoonSecrets'>[];
|
||||||
};
|
};
|
||||||
@@ -19,10 +20,20 @@ type WorkerClaim = {
|
|||||||
job: Doc<'agentJobs'>;
|
job: Doc<'agentJobs'>;
|
||||||
spoon: Doc<'spoons'>;
|
spoon: Doc<'spoons'>;
|
||||||
openai: {
|
openai: {
|
||||||
apiKey: string;
|
apiKey?: string;
|
||||||
model: string;
|
model: string;
|
||||||
reasoningEffort: Doc<'agentJobs'>['reasoningEffort'];
|
reasoningEffort: Doc<'agentJobs'>['reasoningEffort'];
|
||||||
};
|
};
|
||||||
|
aiProviderProfile?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
provider: Doc<'aiProviderProfiles'>['provider'];
|
||||||
|
authType: Doc<'aiProviderProfiles'>['authType'];
|
||||||
|
secret?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
model: string;
|
||||||
|
reasoningEffort: Doc<'aiProviderProfiles'>['reasoningEffort'];
|
||||||
|
};
|
||||||
agentSettings: Doc<'spoonAgentSettings'> | null;
|
agentSettings: Doc<'spoonAgentSettings'> | null;
|
||||||
github: {
|
github: {
|
||||||
installationId?: string;
|
installationId?: string;
|
||||||
@@ -53,18 +64,36 @@ export const claimNextForWorker = action({
|
|||||||
if (!claimed.spoon) {
|
if (!claimed.spoon) {
|
||||||
throw new ConvexError('Claimed job points at a missing Spoon.');
|
throw new ConvexError('Claimed job points at a missing Spoon.');
|
||||||
}
|
}
|
||||||
if (!claimed.aiSettings?.encryptedApiKey) {
|
if (!claimed.aiProviderProfile) {
|
||||||
throw new ConvexError(
|
throw new ConvexError(
|
||||||
'OpenAI is not configured for this user. Add an OpenAI API key in settings.',
|
'AI is not configured for this user. Add an AI provider in settings.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
claimed.aiProviderProfile.authType !== 'none' &&
|
||||||
|
!claimed.aiProviderProfile.encryptedSecret
|
||||||
|
) {
|
||||||
|
throw new ConvexError('Selected AI provider is missing credentials.');
|
||||||
|
}
|
||||||
|
const profile = claimed.aiProviderProfile;
|
||||||
return {
|
return {
|
||||||
job: claimed.job,
|
job: claimed.job,
|
||||||
spoon: claimed.spoon,
|
spoon: claimed.spoon,
|
||||||
openai: {
|
openai: {
|
||||||
apiKey: decryptSecret(claimed.aiSettings.encryptedApiKey),
|
model: profile.defaultModel,
|
||||||
model: claimed.job.model,
|
reasoningEffort: profile.reasoningEffort,
|
||||||
reasoningEffort: claimed.job.reasoningEffort,
|
},
|
||||||
|
aiProviderProfile: {
|
||||||
|
id: profile._id,
|
||||||
|
name: profile.name,
|
||||||
|
provider: profile.provider,
|
||||||
|
authType: profile.authType,
|
||||||
|
secret: profile.encryptedSecret
|
||||||
|
? decryptSecret(profile.encryptedSecret)
|
||||||
|
: undefined,
|
||||||
|
baseUrl: profile.baseUrl,
|
||||||
|
model: profile.defaultModel,
|
||||||
|
reasoningEffort: profile.reasoningEffort,
|
||||||
},
|
},
|
||||||
agentSettings: claimed.agentSettings,
|
agentSettings: claimed.agentSettings,
|
||||||
github: {
|
github: {
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { ConvexError, v } from 'convex/values';
|
||||||
|
|
||||||
|
import type { Doc, Id } from './_generated/dataModel';
|
||||||
|
import type { MutationCtx } from './_generated/server';
|
||||||
|
import { internalMutation, mutation, query } from './_generated/server';
|
||||||
|
import { getRequiredUserId, optionalText } from './model';
|
||||||
|
|
||||||
|
type AiProviderProfileWithDefault = Doc<'aiProviderProfiles'> & {
|
||||||
|
isDefault?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const provider = v.union(
|
||||||
|
v.literal('openai'),
|
||||||
|
v.literal('anthropic'),
|
||||||
|
v.literal('google'),
|
||||||
|
v.literal('openrouter'),
|
||||||
|
v.literal('requesty'),
|
||||||
|
v.literal('litellm'),
|
||||||
|
v.literal('cloudflare_ai_gateway'),
|
||||||
|
v.literal('custom_openai_compatible'),
|
||||||
|
v.literal('opencode_openai_login'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authType = v.union(
|
||||||
|
v.literal('api_key'),
|
||||||
|
v.literal('opencode_auth_json'),
|
||||||
|
v.literal('none'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const reasoningEffort = v.union(
|
||||||
|
v.literal('none'),
|
||||||
|
v.literal('minimal'),
|
||||||
|
v.literal('low'),
|
||||||
|
v.literal('medium'),
|
||||||
|
v.literal('high'),
|
||||||
|
v.literal('xhigh'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isConfigured = (profile: Doc<'aiProviderProfiles'>) =>
|
||||||
|
profile.authType === 'none' || Boolean(profile.encryptedSecret);
|
||||||
|
|
||||||
|
const defaultPatch = (isDefault: boolean) =>
|
||||||
|
({ isDefault }) as Partial<Doc<'aiProviderProfiles'>>;
|
||||||
|
|
||||||
|
const publicProfile = (
|
||||||
|
profile: AiProviderProfileWithDefault,
|
||||||
|
defaultProfileId?: Id<'aiProviderProfiles'>,
|
||||||
|
) => ({
|
||||||
|
_id: profile._id,
|
||||||
|
_creationTime: profile._creationTime,
|
||||||
|
name: profile.name,
|
||||||
|
provider: profile.provider,
|
||||||
|
authType: profile.authType,
|
||||||
|
secretPreview: profile.secretPreview,
|
||||||
|
baseUrl: profile.baseUrl,
|
||||||
|
defaultModel: profile.defaultModel,
|
||||||
|
modelOptions: profile.modelOptions,
|
||||||
|
reasoningEffort: profile.reasoningEffort,
|
||||||
|
enabled: profile.enabled,
|
||||||
|
configured: isConfigured(profile),
|
||||||
|
isDefault: profile._id === defaultProfileId,
|
||||||
|
createdAt: profile.createdAt,
|
||||||
|
updatedAt: profile.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requireOwnedProfile = async (
|
||||||
|
ctx: MutationCtx,
|
||||||
|
profileId: Id<'aiProviderProfiles'>,
|
||||||
|
ownerId: Id<'users'>,
|
||||||
|
) => {
|
||||||
|
const profile = await ctx.db.get(profileId);
|
||||||
|
if (profile?.ownerId !== ownerId) {
|
||||||
|
throw new ConvexError('AI provider profile not found.');
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listMine = 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 && isConfigured(profile),
|
||||||
|
);
|
||||||
|
const explicitDefault = configuredProfiles.find(
|
||||||
|
(profile) => (profile as AiProviderProfileWithDefault).isDefault,
|
||||||
|
);
|
||||||
|
const defaultProfileId =
|
||||||
|
explicitDefault?._id ??
|
||||||
|
(configuredProfiles.length === 1
|
||||||
|
? configuredProfiles[0]?._id
|
||||||
|
: undefined);
|
||||||
|
return profiles.map((profile) => publicProfile(profile, defaultProfileId));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const get = query({
|
||||||
|
args: { profileId: v.id('aiProviderProfiles') },
|
||||||
|
handler: async (ctx, { profileId }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const profile = await ctx.db.get(profileId);
|
||||||
|
if (profile?.ownerId !== ownerId) {
|
||||||
|
throw new ConvexError('AI provider profile not found.');
|
||||||
|
}
|
||||||
|
const profiles = await ctx.db
|
||||||
|
.query('aiProviderProfiles')
|
||||||
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||||
|
.collect();
|
||||||
|
const configuredProfiles = profiles.filter(
|
||||||
|
(item) => item.enabled && isConfigured(item),
|
||||||
|
);
|
||||||
|
const explicitDefault = configuredProfiles.find(
|
||||||
|
(item) => (item as AiProviderProfileWithDefault).isDefault,
|
||||||
|
);
|
||||||
|
const defaultProfileId =
|
||||||
|
explicitDefault?._id ??
|
||||||
|
(configuredProfiles.length === 1
|
||||||
|
? configuredProfiles[0]?._id
|
||||||
|
: undefined);
|
||||||
|
return publicProfile(profile, defaultProfileId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const upsertEncryptedInternal = internalMutation({
|
||||||
|
args: {
|
||||||
|
ownerId: v.id('users'),
|
||||||
|
profileId: v.optional(v.id('aiProviderProfiles')),
|
||||||
|
name: v.string(),
|
||||||
|
provider,
|
||||||
|
authType,
|
||||||
|
encryptedSecret: v.optional(v.string()),
|
||||||
|
secretPreview: v.optional(v.string()),
|
||||||
|
baseUrl: v.optional(v.string()),
|
||||||
|
defaultModel: v.string(),
|
||||||
|
modelOptions: v.optional(v.array(v.string())),
|
||||||
|
reasoningEffort,
|
||||||
|
enabled: v.boolean(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const patch: Partial<Doc<'aiProviderProfiles'>> = {
|
||||||
|
name: args.name.trim() || 'AI provider',
|
||||||
|
provider: args.provider,
|
||||||
|
authType: args.authType,
|
||||||
|
baseUrl: optionalText(args.baseUrl),
|
||||||
|
defaultModel: args.defaultModel.trim() || 'gpt-5.5',
|
||||||
|
modelOptions: args.modelOptions
|
||||||
|
?.map((model) => model.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
reasoningEffort: args.reasoningEffort,
|
||||||
|
enabled: args.enabled,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
if (args.encryptedSecret !== undefined) {
|
||||||
|
patch.encryptedSecret = args.encryptedSecret;
|
||||||
|
patch.secretPreview = args.secretPreview;
|
||||||
|
}
|
||||||
|
if (args.profileId) {
|
||||||
|
await requireOwnedProfile(ctx, args.profileId, args.ownerId);
|
||||||
|
await ctx.db.patch(args.profileId, patch);
|
||||||
|
return args.profileId;
|
||||||
|
}
|
||||||
|
const existingProfiles = await ctx.db
|
||||||
|
.query('aiProviderProfiles')
|
||||||
|
.withIndex('by_owner', (q) => q.eq('ownerId', args.ownerId))
|
||||||
|
.collect();
|
||||||
|
const shouldBecomeDefault =
|
||||||
|
args.enabled &&
|
||||||
|
(args.authType === 'none' || Boolean(args.encryptedSecret)) &&
|
||||||
|
existingProfiles.filter(
|
||||||
|
(profile) => profile.enabled && isConfigured(profile),
|
||||||
|
).length === 0;
|
||||||
|
const profileId = await ctx.db.insert('aiProviderProfiles', {
|
||||||
|
ownerId: args.ownerId,
|
||||||
|
name: patch.name ?? 'AI provider',
|
||||||
|
provider: args.provider,
|
||||||
|
authType: args.authType,
|
||||||
|
encryptedSecret: args.encryptedSecret,
|
||||||
|
secretPreview: args.secretPreview,
|
||||||
|
baseUrl: optionalText(args.baseUrl),
|
||||||
|
defaultModel: patch.defaultModel ?? 'gpt-5.5',
|
||||||
|
modelOptions: patch.modelOptions,
|
||||||
|
reasoningEffort: args.reasoningEffort,
|
||||||
|
enabled: args.enabled,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
if (shouldBecomeDefault) {
|
||||||
|
await ctx.db.patch(profileId, defaultPatch(true));
|
||||||
|
}
|
||||||
|
return profileId;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateMetadata = mutation({
|
||||||
|
args: {
|
||||||
|
profileId: v.id('aiProviderProfiles'),
|
||||||
|
name: v.string(),
|
||||||
|
baseUrl: v.optional(v.string()),
|
||||||
|
defaultModel: v.string(),
|
||||||
|
modelOptions: v.optional(v.array(v.string())),
|
||||||
|
reasoningEffort,
|
||||||
|
enabled: v.boolean(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
await requireOwnedProfile(ctx, args.profileId, ownerId);
|
||||||
|
await ctx.db.patch(args.profileId, {
|
||||||
|
name: args.name.trim() || 'AI provider',
|
||||||
|
baseUrl: optionalText(args.baseUrl),
|
||||||
|
defaultModel: args.defaultModel.trim() || 'gpt-5.5',
|
||||||
|
modelOptions: args.modelOptions
|
||||||
|
?.map((model) => model.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
reasoningEffort: args.reasoningEffort,
|
||||||
|
enabled: args.enabled,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const remove = mutation({
|
||||||
|
args: { profileId: v.id('aiProviderProfiles') },
|
||||||
|
handler: async (ctx, { profileId }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const profile = (await requireOwnedProfile(
|
||||||
|
ctx,
|
||||||
|
profileId,
|
||||||
|
ownerId,
|
||||||
|
)) as AiProviderProfileWithDefault;
|
||||||
|
await ctx.db.delete(profileId);
|
||||||
|
if (profile.isDefault) {
|
||||||
|
const remaining = await ctx.db
|
||||||
|
.query('aiProviderProfiles')
|
||||||
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||||
|
.collect();
|
||||||
|
const nextDefault = remaining.find(
|
||||||
|
(item) => item.enabled && isConfigured(item),
|
||||||
|
);
|
||||||
|
if (nextDefault) {
|
||||||
|
await ctx.db.patch(nextDefault._id, defaultPatch(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setDefault = mutation({
|
||||||
|
args: { profileId: v.id('aiProviderProfiles') },
|
||||||
|
handler: async (ctx, { profileId }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const target = await requireOwnedProfile(ctx, profileId, ownerId);
|
||||||
|
if (!target.enabled || !isConfigured(target)) {
|
||||||
|
throw new ConvexError('Default provider must be enabled and configured.');
|
||||||
|
}
|
||||||
|
const profiles = await ctx.db
|
||||||
|
.query('aiProviderProfiles')
|
||||||
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||||
|
.collect();
|
||||||
|
await Promise.all(
|
||||||
|
profiles.map((profile) =>
|
||||||
|
ctx.db.patch(profile._id, defaultPatch(profile._id === profileId)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
'use node';
|
||||||
|
|
||||||
|
import { getAuthUserId } from '@convex-dev/auth/server';
|
||||||
|
import { ConvexError, v } from 'convex/values';
|
||||||
|
|
||||||
|
import type { Id } from './_generated/dataModel';
|
||||||
|
import type { ActionCtx } from './_generated/server';
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
import { action } from './_generated/server';
|
||||||
|
import { encryptSecret } from './secretCrypto';
|
||||||
|
|
||||||
|
const provider = v.union(
|
||||||
|
v.literal('openai'),
|
||||||
|
v.literal('anthropic'),
|
||||||
|
v.literal('google'),
|
||||||
|
v.literal('openrouter'),
|
||||||
|
v.literal('requesty'),
|
||||||
|
v.literal('litellm'),
|
||||||
|
v.literal('cloudflare_ai_gateway'),
|
||||||
|
v.literal('custom_openai_compatible'),
|
||||||
|
v.literal('opencode_openai_login'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authType = v.union(
|
||||||
|
v.literal('api_key'),
|
||||||
|
v.literal('opencode_auth_json'),
|
||||||
|
v.literal('none'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const reasoningEffort = v.union(
|
||||||
|
v.literal('none'),
|
||||||
|
v.literal('minimal'),
|
||||||
|
v.literal('low'),
|
||||||
|
v.literal('medium'),
|
||||||
|
v.literal('high'),
|
||||||
|
v.literal('xhigh'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const getRequiredUserId = async (ctx: ActionCtx): Promise<Id<'users'>> => {
|
||||||
|
const userId = await getAuthUserId(ctx);
|
||||||
|
if (!userId) throw new ConvexError('Not authenticated.');
|
||||||
|
return userId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewSecret = (secret: string) => {
|
||||||
|
const trimmed = secret.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
if (trimmed.startsWith('{')) return 'auth json configured';
|
||||||
|
if (trimmed.length <= 10) return 'configured';
|
||||||
|
return `${trimmed.slice(0, 7)}...${trimmed.slice(-4)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const save = action({
|
||||||
|
args: {
|
||||||
|
profileId: v.optional(v.id('aiProviderProfiles')),
|
||||||
|
name: v.string(),
|
||||||
|
provider,
|
||||||
|
authType,
|
||||||
|
secret: v.optional(v.string()),
|
||||||
|
baseUrl: v.optional(v.string()),
|
||||||
|
defaultModel: v.string(),
|
||||||
|
modelOptions: v.optional(v.array(v.string())),
|
||||||
|
reasoningEffort,
|
||||||
|
enabled: v.boolean(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args): Promise<Id<'aiProviderProfiles'>> => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const secret = args.secret?.trim();
|
||||||
|
if (!args.profileId && args.authType !== 'none' && !secret) {
|
||||||
|
throw new ConvexError('A credential is required for this provider.');
|
||||||
|
}
|
||||||
|
return await ctx.runMutation(
|
||||||
|
internal.aiProviderProfiles.upsertEncryptedInternal,
|
||||||
|
{
|
||||||
|
ownerId,
|
||||||
|
profileId: args.profileId,
|
||||||
|
name: args.name,
|
||||||
|
provider: args.provider,
|
||||||
|
authType: args.authType,
|
||||||
|
encryptedSecret: secret ? encryptSecret(secret) : undefined,
|
||||||
|
secretPreview: secret ? previewSecret(secret) : undefined,
|
||||||
|
baseUrl: args.baseUrl,
|
||||||
|
defaultModel: args.defaultModel,
|
||||||
|
modelOptions: args.modelOptions,
|
||||||
|
reasoningEffort: args.reasoningEffort,
|
||||||
|
enabled: args.enabled,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
'use node';
|
|
||||||
|
|
||||||
import { getAuthUserId } from '@convex-dev/auth/server';
|
|
||||||
import { ConvexError, v } from 'convex/values';
|
|
||||||
|
|
||||||
import type { Doc, Id } from './_generated/dataModel';
|
|
||||||
import type { ActionCtx } from './_generated/server';
|
|
||||||
import { internal } from './_generated/api';
|
|
||||||
import { action } from './_generated/server';
|
|
||||||
import { reviewUpstreamCompatibility } from './openaiClient';
|
|
||||||
import { decryptSecret } from './secretCrypto';
|
|
||||||
|
|
||||||
const getRequiredUserId = async (ctx: ActionCtx): Promise<Id<'users'>> => {
|
|
||||||
const userId = await getAuthUserId(ctx);
|
|
||||||
if (!userId) throw new ConvexError('Not authenticated.');
|
|
||||||
return userId;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const reviewLatestUpstreamChanges = action({
|
|
||||||
args: { spoonId: v.id('spoons') },
|
|
||||||
handler: async (
|
|
||||||
ctx,
|
|
||||||
{ spoonId },
|
|
||||||
): Promise<{
|
|
||||||
reviewId: Id<'aiReviews'>;
|
|
||||||
risk: 'low' | 'medium' | 'high';
|
|
||||||
recommendedAction:
|
|
||||||
| 'sync'
|
|
||||||
| 'open_review_pr'
|
|
||||||
| 'manual_review'
|
|
||||||
| 'do_not_sync';
|
|
||||||
}> => {
|
|
||||||
const ownerId = await getRequiredUserId(ctx);
|
|
||||||
const spoon: Doc<'spoons'> = await ctx.runQuery(
|
|
||||||
internal.spoons.getOwnedForAction,
|
|
||||||
{
|
|
||||||
spoonId,
|
|
||||||
ownerId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const [state, settings, upstreamCommits, forkCommits]: [
|
|
||||||
Doc<'spoonRepositoryStates'> | null,
|
|
||||||
Doc<'spoonSettings'> | null,
|
|
||||||
Doc<'spoonCommits'>[],
|
|
||||||
Doc<'spoonCommits'>[],
|
|
||||||
] = await Promise.all([
|
|
||||||
ctx.runQuery(internal.spoonState.getInternal, { spoonId, ownerId }),
|
|
||||||
ctx.runQuery(internal.spoonSettings.getInternal, { spoonId, ownerId }),
|
|
||||||
ctx.runQuery(internal.spoonCommits.listInternal, {
|
|
||||||
spoonId,
|
|
||||||
ownerId,
|
|
||||||
side: 'upstream',
|
|
||||||
limit: 80,
|
|
||||||
}),
|
|
||||||
ctx.runQuery(internal.spoonCommits.listInternal, {
|
|
||||||
spoonId,
|
|
||||||
ownerId,
|
|
||||||
side: 'fork',
|
|
||||||
limit: 80,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
const aiSettings: Doc<'userAiSettings'> | null = await ctx.runQuery(
|
|
||||||
internal.aiSettings.getForUserInternal,
|
|
||||||
{ userId: ownerId },
|
|
||||||
);
|
|
||||||
if (!aiSettings?.encryptedApiKey) {
|
|
||||||
throw new ConvexError(
|
|
||||||
'Add your OpenAI API key in Settings before running AI review.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const model = aiSettings.model;
|
|
||||||
const syncRunId: Id<'syncRuns'> = await ctx.runMutation(
|
|
||||||
internal.syncRuns.createInternal,
|
|
||||||
{
|
|
||||||
spoonId,
|
|
||||||
ownerId,
|
|
||||||
kind: 'ai_review',
|
|
||||||
status: 'running',
|
|
||||||
summary: 'Reviewing upstream changes with OpenAI.',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const reviewId: Id<'aiReviews'> = await ctx.runMutation(
|
|
||||||
internal.aiReviews.createInternal,
|
|
||||||
{
|
|
||||||
spoonId,
|
|
||||||
ownerId,
|
|
||||||
syncRunId,
|
|
||||||
model,
|
|
||||||
status: 'running',
|
|
||||||
reviewType: 'upstream_update',
|
|
||||||
inputSummary: `${upstreamCommits.length} upstream commit(s), ${forkCommits.length} fork-only commit(s).`,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
if (upstreamCommits.length === 0) {
|
|
||||||
await ctx.runMutation(internal.aiReviews.completeInternal, {
|
|
||||||
reviewId,
|
|
||||||
outputSummary: 'The fork is already up to date with upstream.',
|
|
||||||
risk: 'low',
|
|
||||||
compatible: true,
|
|
||||||
requiresHumanReview: false,
|
|
||||||
recommendedAction: 'sync',
|
|
||||||
potentialConflicts: [],
|
|
||||||
importantFiles: [],
|
|
||||||
reasoningSummary:
|
|
||||||
'No upstream-only commits are cached for this Spoon, so there is nothing to review.',
|
|
||||||
});
|
|
||||||
await Promise.all([
|
|
||||||
ctx.runMutation(internal.spoons.patchSyncFields, {
|
|
||||||
spoonId,
|
|
||||||
lastAiReviewId: reviewId,
|
|
||||||
}),
|
|
||||||
ctx.runMutation(internal.syncRuns.patchInternal, {
|
|
||||||
syncRunId,
|
|
||||||
status: 'clean',
|
|
||||||
aiAssessment: 'No upstream-only commits are waiting.',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
return { reviewId, risk: 'low', recommendedAction: 'sync' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const review = await reviewUpstreamCompatibility(
|
|
||||||
{
|
|
||||||
spoonName: spoon.name,
|
|
||||||
upstreamFullName:
|
|
||||||
state?.upstreamFullName ??
|
|
||||||
`${spoon.upstreamOwner}/${spoon.upstreamRepo}`,
|
|
||||||
forkFullName:
|
|
||||||
state?.forkFullName ??
|
|
||||||
`${spoon.forkOwner ?? 'unknown'}/${spoon.forkRepo ?? 'unknown'}`,
|
|
||||||
status: state?.status ?? spoon.syncStatus ?? 'unknown',
|
|
||||||
upstreamAheadBy: state?.upstreamAheadBy ?? spoon.upstreamAheadBy ?? 0,
|
|
||||||
forkAheadBy: state?.forkAheadBy ?? spoon.forkAheadBy ?? 0,
|
|
||||||
upstreamCommits: upstreamCommits.map((commit) => ({
|
|
||||||
sha: commit.sha,
|
|
||||||
message: commit.message,
|
|
||||||
authorName: commit.authorName,
|
|
||||||
committedAt: commit.committedAt,
|
|
||||||
})),
|
|
||||||
forkCommits: forkCommits.map((commit) => ({
|
|
||||||
sha: commit.sha,
|
|
||||||
message: commit.message,
|
|
||||||
authorName: commit.authorName,
|
|
||||||
committedAt: commit.committedAt,
|
|
||||||
})),
|
|
||||||
importantFilePatterns: settings?.importantFilePatterns,
|
|
||||||
ignoredFilePatterns: settings?.ignoredFilePatterns,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
apiKey: decryptSecret(aiSettings.encryptedApiKey),
|
|
||||||
model,
|
|
||||||
reasoningEffort: aiSettings.reasoningEffort,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await ctx.runMutation(internal.aiReviews.completeInternal, {
|
|
||||||
reviewId,
|
|
||||||
outputSummary: review.summary,
|
|
||||||
risk: review.risk,
|
|
||||||
compatible: review.compatible,
|
|
||||||
requiresHumanReview: review.requiresHumanReview,
|
|
||||||
recommendedAction: review.recommendedAction,
|
|
||||||
potentialConflicts: review.potentialConflicts,
|
|
||||||
importantFiles: review.importantFiles,
|
|
||||||
reasoningSummary: review.reasoningSummary,
|
|
||||||
});
|
|
||||||
await Promise.all([
|
|
||||||
ctx.runMutation(internal.spoons.patchSyncFields, {
|
|
||||||
spoonId,
|
|
||||||
lastAiReviewId: reviewId,
|
|
||||||
}),
|
|
||||||
ctx.runMutation(internal.syncRuns.patchInternal, {
|
|
||||||
syncRunId,
|
|
||||||
status:
|
|
||||||
review.compatible && review.risk === 'low'
|
|
||||||
? 'clean'
|
|
||||||
: 'needs_review',
|
|
||||||
aiAssessment: review.summary,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
reviewId,
|
|
||||||
risk: review.risk,
|
|
||||||
recommendedAction: review.recommendedAction,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
await Promise.all([
|
|
||||||
ctx.runMutation(internal.aiReviews.failInternal, {
|
|
||||||
reviewId,
|
|
||||||
error: message,
|
|
||||||
}),
|
|
||||||
ctx.runMutation(internal.syncRuns.patchInternal, {
|
|
||||||
syncRunId,
|
|
||||||
status: 'failed',
|
|
||||||
error: message,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
throw new ConvexError(message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -50,6 +50,7 @@ const refreshOwnedSpoon = async (
|
|||||||
ownerId: Id<'users'>,
|
ownerId: Id<'users'>,
|
||||||
spoonId: Id<'spoons'>,
|
spoonId: Id<'spoons'>,
|
||||||
kind: 'manual_check' | 'scheduled_check' = 'manual_check',
|
kind: 'manual_check' | 'scheduled_check' = 'manual_check',
|
||||||
|
allowAutoSync = true,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
status: ReturnType<typeof toStatus>;
|
status: ReturnType<typeof toStatus>;
|
||||||
@@ -200,6 +201,87 @@ const refreshOwnedSpoon = async (
|
|||||||
status: status === 'diverged' ? 'needs_review' : 'clean',
|
status: status === 'diverged' ? 'needs_review' : 'clean',
|
||||||
summary: `GitHub refresh complete: ${upstreamCompare.aheadBy} upstream commit(s), ${forkCompare.aheadBy} fork-only commit(s).`,
|
summary: `GitHub refresh complete: ${upstreamCompare.aheadBy} upstream commit(s), ${forkCompare.aheadBy} fork-only commit(s).`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (status === 'behind' && forkCompare.aheadBy === 0 && allowAutoSync) {
|
||||||
|
try {
|
||||||
|
await syncForkBranch(octokit, {
|
||||||
|
forkOwner,
|
||||||
|
forkRepo,
|
||||||
|
branch: resolvedForkBranch,
|
||||||
|
});
|
||||||
|
await ctx.runMutation(internal.syncRuns.patchInternal, {
|
||||||
|
syncRunId,
|
||||||
|
status: 'merged',
|
||||||
|
decision: 'auto_synced',
|
||||||
|
summary:
|
||||||
|
'Fork had no custom commits, so Spoon synced it with upstream automatically.',
|
||||||
|
});
|
||||||
|
return await refreshOwnedSpoon(ctx, ownerId, spoonId, kind, false);
|
||||||
|
} catch (syncError) {
|
||||||
|
const message =
|
||||||
|
syncError instanceof Error ? syncError.message : String(syncError);
|
||||||
|
const threadId = await ctx.runMutation(
|
||||||
|
internal.threads.createMaintenanceThread,
|
||||||
|
{
|
||||||
|
spoonId,
|
||||||
|
ownerId,
|
||||||
|
source: 'merge_conflict',
|
||||||
|
title: `Resolve upstream sync conflict for ${spoon.name}`,
|
||||||
|
summary: `GitHub refused the automatic upstream sync: ${message}`,
|
||||||
|
upstreamFrom: upstreamCompare.mergeBaseSha,
|
||||||
|
upstreamTo: upstreamCompare.headSha ?? `${Date.now()}`,
|
||||||
|
forkHeadAtCreation: forkCompare.headSha,
|
||||||
|
mergeBaseAtCreation:
|
||||||
|
upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha,
|
||||||
|
relatedSyncRunId: syncRunId,
|
||||||
|
jobType: 'conflict_resolution',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await ctx.runMutation(internal.syncRuns.patchInternal, {
|
||||||
|
syncRunId,
|
||||||
|
threadId,
|
||||||
|
status: 'conflict',
|
||||||
|
decision: 'thread_created',
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
await ctx.runMutation(internal.spoons.patchSyncFields, {
|
||||||
|
spoonId,
|
||||||
|
syncStatus: 'conflict',
|
||||||
|
lastError: message,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: 'unknown' as const,
|
||||||
|
upstreamAheadBy: upstreamCompare.aheadBy,
|
||||||
|
forkAheadBy: forkCompare.aheadBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'diverged') {
|
||||||
|
const threadId = await ctx.runMutation(
|
||||||
|
internal.threads.createMaintenanceThread,
|
||||||
|
{
|
||||||
|
spoonId,
|
||||||
|
ownerId,
|
||||||
|
source: 'upstream_update',
|
||||||
|
title: `Review upstream changes for ${spoon.name}`,
|
||||||
|
summary: `Upstream has ${upstreamCompare.aheadBy} commit(s) and the fork has ${forkCompare.aheadBy} custom commit(s). Review whether upstream should be merged, ignored, or resolved in a draft PR.`,
|
||||||
|
upstreamFrom: upstreamCompare.mergeBaseSha,
|
||||||
|
upstreamTo: upstreamCompare.headSha ?? `${Date.now()}`,
|
||||||
|
forkHeadAtCreation: forkCompare.headSha,
|
||||||
|
mergeBaseAtCreation:
|
||||||
|
upstreamCompare.mergeBaseSha ?? forkCompare.mergeBaseSha,
|
||||||
|
relatedSyncRunId: syncRunId,
|
||||||
|
jobType: 'maintenance_review',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await ctx.runMutation(internal.syncRuns.patchInternal, {
|
||||||
|
syncRunId,
|
||||||
|
threadId,
|
||||||
|
decision: 'thread_created',
|
||||||
|
});
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
status,
|
status,
|
||||||
@@ -301,6 +383,32 @@ export const syncForkWithUpstream = action({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
const conflict = message.toLowerCase().includes('conflict');
|
const conflict = message.toLowerCase().includes('conflict');
|
||||||
|
if (conflict) {
|
||||||
|
const threadId = await ctx.runMutation(
|
||||||
|
internal.threads.createMaintenanceThread,
|
||||||
|
{
|
||||||
|
spoonId,
|
||||||
|
ownerId,
|
||||||
|
source: 'merge_conflict',
|
||||||
|
title: `Resolve upstream sync conflict for ${spoon.name}`,
|
||||||
|
summary: `GitHub reported a conflict while syncing upstream into this fork: ${message}`,
|
||||||
|
upstreamTo:
|
||||||
|
state.upstreamHeadSha ??
|
||||||
|
spoon.lastUpstreamCommit ??
|
||||||
|
`${Date.now()}`,
|
||||||
|
forkHeadAtCreation: state.forkHeadSha ?? spoon.lastForkCommit,
|
||||||
|
mergeBaseAtCreation:
|
||||||
|
state.mergeBaseSha ?? spoon.lastMergeBaseCommit,
|
||||||
|
relatedSyncRunId: syncRunId,
|
||||||
|
jobType: 'conflict_resolution',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await ctx.runMutation(internal.syncRuns.patchInternal, {
|
||||||
|
syncRunId,
|
||||||
|
threadId,
|
||||||
|
decision: 'thread_created',
|
||||||
|
});
|
||||||
|
}
|
||||||
await ctx.runMutation(internal.syncRuns.patchInternal, {
|
await ctx.runMutation(internal.syncRuns.patchInternal, {
|
||||||
syncRunId,
|
syncRunId,
|
||||||
status: conflict ? 'conflict' : 'failed',
|
status: conflict ? 'conflict' : 'failed',
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
import { ConvexError } from 'convex/values';
|
|
||||||
import OpenAI from 'openai';
|
|
||||||
|
|
||||||
type ReviewRisk = 'low' | 'medium' | 'high';
|
|
||||||
type ReviewAction = 'sync' | 'open_review_pr' | 'manual_review' | 'do_not_sync';
|
|
||||||
|
|
||||||
export type AiCompatibilityReview = {
|
|
||||||
summary: string;
|
|
||||||
risk: ReviewRisk;
|
|
||||||
compatible: boolean;
|
|
||||||
requiresHumanReview: boolean;
|
|
||||||
recommendedAction: ReviewAction;
|
|
||||||
potentialConflicts: string[];
|
|
||||||
importantFiles: string[];
|
|
||||||
reasoningSummary: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ReviewInput = {
|
|
||||||
spoonName: string;
|
|
||||||
upstreamFullName: string;
|
|
||||||
forkFullName: string;
|
|
||||||
status: string;
|
|
||||||
upstreamAheadBy: number;
|
|
||||||
forkAheadBy: number;
|
|
||||||
upstreamCommits: {
|
|
||||||
sha: string;
|
|
||||||
message: string;
|
|
||||||
authorName?: string;
|
|
||||||
committedAt?: number;
|
|
||||||
}[];
|
|
||||||
forkCommits: {
|
|
||||||
sha: string;
|
|
||||||
message: string;
|
|
||||||
authorName?: string;
|
|
||||||
committedAt?: number;
|
|
||||||
}[];
|
|
||||||
importantFilePatterns?: string[];
|
|
||||||
ignoredFilePatterns?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ReasoningEffort =
|
|
||||||
| 'none'
|
|
||||||
| 'minimal'
|
|
||||||
| 'low'
|
|
||||||
| 'medium'
|
|
||||||
| 'high'
|
|
||||||
| 'xhigh';
|
|
||||||
|
|
||||||
export type OpenAiReviewSettings = {
|
|
||||||
apiKey: string;
|
|
||||||
model: string;
|
|
||||||
reasoningEffort: ReasoningEffort;
|
|
||||||
};
|
|
||||||
|
|
||||||
const reviewSchema = {
|
|
||||||
type: 'object',
|
|
||||||
additionalProperties: false,
|
|
||||||
properties: {
|
|
||||||
summary: { type: 'string' },
|
|
||||||
risk: { type: 'string', enum: ['low', 'medium', 'high'] },
|
|
||||||
compatible: { type: 'boolean' },
|
|
||||||
requiresHumanReview: { type: 'boolean' },
|
|
||||||
recommendedAction: {
|
|
||||||
type: 'string',
|
|
||||||
enum: ['sync', 'open_review_pr', 'manual_review', 'do_not_sync'],
|
|
||||||
},
|
|
||||||
potentialConflicts: { type: 'array', items: { type: 'string' } },
|
|
||||||
importantFiles: { type: 'array', items: { type: 'string' } },
|
|
||||||
reasoningSummary: { type: 'string' },
|
|
||||||
},
|
|
||||||
required: [
|
|
||||||
'summary',
|
|
||||||
'risk',
|
|
||||||
'compatible',
|
|
||||||
'requiresHumanReview',
|
|
||||||
'recommendedAction',
|
|
||||||
'potentialConflicts',
|
|
||||||
'importantFiles',
|
|
||||||
'reasoningSummary',
|
|
||||||
],
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const isReviewRisk = (value: unknown): value is ReviewRisk =>
|
|
||||||
value === 'low' || value === 'medium' || value === 'high';
|
|
||||||
|
|
||||||
const isReviewAction = (value: unknown): value is ReviewAction =>
|
|
||||||
value === 'sync' ||
|
|
||||||
value === 'open_review_pr' ||
|
|
||||||
value === 'manual_review' ||
|
|
||||||
value === 'do_not_sync';
|
|
||||||
|
|
||||||
const validateReview = (value: unknown): AiCompatibilityReview => {
|
|
||||||
if (!value || typeof value !== 'object') {
|
|
||||||
throw new ConvexError('OpenAI returned an invalid review payload.');
|
|
||||||
}
|
|
||||||
const record = value as Record<string, unknown>;
|
|
||||||
if (
|
|
||||||
typeof record.summary !== 'string' ||
|
|
||||||
!isReviewRisk(record.risk) ||
|
|
||||||
typeof record.compatible !== 'boolean' ||
|
|
||||||
typeof record.requiresHumanReview !== 'boolean' ||
|
|
||||||
!isReviewAction(record.recommendedAction) ||
|
|
||||||
!Array.isArray(record.potentialConflicts) ||
|
|
||||||
!Array.isArray(record.importantFiles) ||
|
|
||||||
typeof record.reasoningSummary !== 'string'
|
|
||||||
) {
|
|
||||||
throw new ConvexError('OpenAI review did not match the expected schema.');
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
summary: record.summary,
|
|
||||||
risk: record.risk,
|
|
||||||
compatible: record.compatible,
|
|
||||||
requiresHumanReview: record.requiresHumanReview,
|
|
||||||
recommendedAction: record.recommendedAction,
|
|
||||||
potentialConflicts: record.potentialConflicts.filter(
|
|
||||||
(item): item is string => typeof item === 'string',
|
|
||||||
),
|
|
||||||
importantFiles: record.importantFiles.filter(
|
|
||||||
(item): item is string => typeof item === 'string',
|
|
||||||
),
|
|
||||||
reasoningSummary: record.reasoningSummary,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const reviewUpstreamCompatibility = async (
|
|
||||||
input: ReviewInput,
|
|
||||||
settings: OpenAiReviewSettings,
|
|
||||||
): Promise<AiCompatibilityReview> => {
|
|
||||||
const response = await new OpenAI({
|
|
||||||
apiKey: settings.apiKey,
|
|
||||||
}).responses.create({
|
|
||||||
model: settings.model,
|
|
||||||
store: false,
|
|
||||||
reasoning: {
|
|
||||||
effort: settings.reasoningEffort,
|
|
||||||
},
|
|
||||||
input: [
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content:
|
|
||||||
'You are reviewing whether upstream changes can be safely brought into a maintained fork. You do not execute code. You do not claim tests passed. Treat fork-only commits as user customizations that must be preserved. If changed files overlap with fork-only changes, increase risk. If patch context is incomplete, say so and require human review. Prefer conservative recommendations. Return only the required structured output.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: JSON.stringify(input, null, 2),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
text: {
|
|
||||||
format: {
|
|
||||||
type: 'json_schema',
|
|
||||||
name: 'spoon_upstream_compatibility_review',
|
|
||||||
strict: true,
|
|
||||||
schema: reviewSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const raw = response.output_text;
|
|
||||||
if (!raw) throw new ConvexError('OpenAI returned an empty review.');
|
|
||||||
return validateReview(JSON.parse(raw));
|
|
||||||
};
|
|
||||||
@@ -219,6 +219,7 @@ const applicationTables = {
|
|||||||
syncRuns: defineTable({
|
syncRuns: defineTable({
|
||||||
spoonId: v.id('spoons'),
|
spoonId: v.id('spoons'),
|
||||||
ownerId: v.id('users'),
|
ownerId: v.id('users'),
|
||||||
|
threadId: v.optional(v.id('threads')),
|
||||||
kind: v.union(
|
kind: v.union(
|
||||||
v.literal('scheduled_check'),
|
v.literal('scheduled_check'),
|
||||||
v.literal('manual_check'),
|
v.literal('manual_check'),
|
||||||
@@ -241,6 +242,14 @@ const applicationTables = {
|
|||||||
aiAssessment: v.optional(v.string()),
|
aiAssessment: v.optional(v.string()),
|
||||||
mergeRequestUrl: v.optional(v.string()),
|
mergeRequestUrl: v.optional(v.string()),
|
||||||
error: v.optional(v.string()),
|
error: v.optional(v.string()),
|
||||||
|
decision: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal('auto_synced'),
|
||||||
|
v.literal('thread_created'),
|
||||||
|
v.literal('ignored'),
|
||||||
|
v.literal('failed'),
|
||||||
|
),
|
||||||
|
),
|
||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
updatedAt: v.number(),
|
updatedAt: v.number(),
|
||||||
})
|
})
|
||||||
@@ -339,6 +348,46 @@ const applicationTables = {
|
|||||||
})
|
})
|
||||||
.index('by_user', ['userId'])
|
.index('by_user', ['userId'])
|
||||||
.index('by_user_provider', ['userId', 'provider']),
|
.index('by_user_provider', ['userId', 'provider']),
|
||||||
|
aiProviderProfiles: defineTable({
|
||||||
|
ownerId: v.id('users'),
|
||||||
|
name: v.string(),
|
||||||
|
provider: v.union(
|
||||||
|
v.literal('openai'),
|
||||||
|
v.literal('anthropic'),
|
||||||
|
v.literal('google'),
|
||||||
|
v.literal('openrouter'),
|
||||||
|
v.literal('requesty'),
|
||||||
|
v.literal('litellm'),
|
||||||
|
v.literal('cloudflare_ai_gateway'),
|
||||||
|
v.literal('custom_openai_compatible'),
|
||||||
|
v.literal('opencode_openai_login'),
|
||||||
|
),
|
||||||
|
authType: v.union(
|
||||||
|
v.literal('api_key'),
|
||||||
|
v.literal('opencode_auth_json'),
|
||||||
|
v.literal('none'),
|
||||||
|
),
|
||||||
|
encryptedSecret: v.optional(v.string()),
|
||||||
|
secretPreview: v.optional(v.string()),
|
||||||
|
baseUrl: v.optional(v.string()),
|
||||||
|
defaultModel: v.string(),
|
||||||
|
modelOptions: v.optional(v.array(v.string())),
|
||||||
|
reasoningEffort: v.union(
|
||||||
|
v.literal('none'),
|
||||||
|
v.literal('minimal'),
|
||||||
|
v.literal('low'),
|
||||||
|
v.literal('medium'),
|
||||||
|
v.literal('high'),
|
||||||
|
v.literal('xhigh'),
|
||||||
|
),
|
||||||
|
enabled: v.boolean(),
|
||||||
|
isDefault: v.optional(v.boolean()),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index('by_owner', ['ownerId'])
|
||||||
|
.index('by_owner_provider', ['ownerId', 'provider'])
|
||||||
|
.index('by_owner_enabled', ['ownerId', 'enabled']),
|
||||||
agentRequests: defineTable({
|
agentRequests: defineTable({
|
||||||
spoonId: v.id('spoons'),
|
spoonId: v.id('spoons'),
|
||||||
ownerId: v.id('users'),
|
ownerId: v.id('users'),
|
||||||
@@ -395,6 +444,9 @@ const applicationTables = {
|
|||||||
spoonId: v.id('spoons'),
|
spoonId: v.id('spoons'),
|
||||||
ownerId: v.id('users'),
|
ownerId: v.id('users'),
|
||||||
enabled: v.boolean(),
|
enabled: v.boolean(),
|
||||||
|
runtime: v.optional(
|
||||||
|
v.union(v.literal('opencode'), v.literal('openai_direct')),
|
||||||
|
),
|
||||||
defaultBaseBranch: v.optional(v.string()),
|
defaultBaseBranch: v.optional(v.string()),
|
||||||
branchPrefix: v.string(),
|
branchPrefix: v.string(),
|
||||||
installCommand: v.optional(v.string()),
|
installCommand: v.optional(v.string()),
|
||||||
@@ -411,6 +463,20 @@ const applicationTables = {
|
|||||||
),
|
),
|
||||||
maxJobDurationMs: v.number(),
|
maxJobDurationMs: v.number(),
|
||||||
maxOutputBytes: v.number(),
|
maxOutputBytes: v.number(),
|
||||||
|
envFilePath: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal('.env'),
|
||||||
|
v.literal('.env.local'),
|
||||||
|
v.literal('.env.production'),
|
||||||
|
v.literal('.env.production.local'),
|
||||||
|
v.literal('custom'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
customEnvFilePath: v.optional(v.string()),
|
||||||
|
materializeEnvFileByDefault: v.optional(v.boolean()),
|
||||||
|
autoDetectCommands: v.optional(v.boolean()),
|
||||||
|
allowUserFileEditing: v.optional(v.boolean()),
|
||||||
|
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
|
||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
updatedAt: v.number(),
|
updatedAt: v.number(),
|
||||||
})
|
})
|
||||||
@@ -420,6 +486,14 @@ const applicationTables = {
|
|||||||
spoonId: v.id('spoons'),
|
spoonId: v.id('spoons'),
|
||||||
ownerId: v.id('users'),
|
ownerId: v.id('users'),
|
||||||
agentRequestId: v.id('agentRequests'),
|
agentRequestId: v.id('agentRequests'),
|
||||||
|
threadId: v.optional(v.id('threads')),
|
||||||
|
jobType: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal('user_change'),
|
||||||
|
v.literal('maintenance_review'),
|
||||||
|
v.literal('conflict_resolution'),
|
||||||
|
),
|
||||||
|
),
|
||||||
status: v.union(
|
status: v.union(
|
||||||
v.literal('queued'),
|
v.literal('queued'),
|
||||||
v.literal('claimed'),
|
v.literal('claimed'),
|
||||||
@@ -433,8 +507,29 @@ const applicationTables = {
|
|||||||
v.literal('timed_out'),
|
v.literal('timed_out'),
|
||||||
),
|
),
|
||||||
prompt: v.string(),
|
prompt: v.string(),
|
||||||
|
runtime: v.optional(
|
||||||
|
v.union(v.literal('openai_direct'), v.literal('opencode')),
|
||||||
|
),
|
||||||
|
workspaceStatus: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal('not_started'),
|
||||||
|
v.literal('starting'),
|
||||||
|
v.literal('active'),
|
||||||
|
v.literal('idle'),
|
||||||
|
v.literal('stopped'),
|
||||||
|
v.literal('expired'),
|
||||||
|
v.literal('failed'),
|
||||||
|
),
|
||||||
|
),
|
||||||
baseBranch: v.string(),
|
baseBranch: v.string(),
|
||||||
workBranch: v.string(),
|
workBranch: v.string(),
|
||||||
|
opencodeSessionId: v.optional(v.string()),
|
||||||
|
containerId: v.optional(v.string()),
|
||||||
|
workspaceUrl: v.optional(v.string()),
|
||||||
|
workspaceExpiresAt: v.optional(v.number()),
|
||||||
|
lastHeartbeatAt: v.optional(v.number()),
|
||||||
|
envFilePath: v.optional(v.string()),
|
||||||
|
materializeEnvFile: v.optional(v.boolean()),
|
||||||
githubInstallationId: v.optional(v.string()),
|
githubInstallationId: v.optional(v.string()),
|
||||||
forkOwner: v.string(),
|
forkOwner: v.string(),
|
||||||
forkRepo: v.string(),
|
forkRepo: v.string(),
|
||||||
@@ -442,6 +537,7 @@ const applicationTables = {
|
|||||||
upstreamOwner: v.string(),
|
upstreamOwner: v.string(),
|
||||||
upstreamRepo: v.string(),
|
upstreamRepo: v.string(),
|
||||||
selectedSecretIds: v.array(v.id('spoonSecrets')),
|
selectedSecretIds: v.array(v.id('spoonSecrets')),
|
||||||
|
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
|
||||||
model: v.string(),
|
model: v.string(),
|
||||||
reasoningEffort: v.union(
|
reasoningEffort: v.union(
|
||||||
v.literal('none'),
|
v.literal('none'),
|
||||||
@@ -468,6 +564,50 @@ const applicationTables = {
|
|||||||
.index('by_request', ['agentRequestId'])
|
.index('by_request', ['agentRequestId'])
|
||||||
.index('by_status', ['status'])
|
.index('by_status', ['status'])
|
||||||
.index('by_claim', ['status', 'createdAt']),
|
.index('by_claim', ['status', 'createdAt']),
|
||||||
|
agentJobMessages: defineTable({
|
||||||
|
jobId: v.id('agentJobs'),
|
||||||
|
spoonId: v.id('spoons'),
|
||||||
|
ownerId: v.id('users'),
|
||||||
|
role: v.union(
|
||||||
|
v.literal('user'),
|
||||||
|
v.literal('assistant'),
|
||||||
|
v.literal('system'),
|
||||||
|
v.literal('tool'),
|
||||||
|
),
|
||||||
|
content: v.string(),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('queued'),
|
||||||
|
v.literal('streaming'),
|
||||||
|
v.literal('completed'),
|
||||||
|
v.literal('failed'),
|
||||||
|
),
|
||||||
|
metadata: v.optional(v.string()),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index('by_job', ['jobId'])
|
||||||
|
.index('by_owner', ['ownerId']),
|
||||||
|
agentWorkspaceChanges: defineTable({
|
||||||
|
jobId: v.id('agentJobs'),
|
||||||
|
spoonId: v.id('spoons'),
|
||||||
|
ownerId: v.id('users'),
|
||||||
|
path: v.string(),
|
||||||
|
source: v.union(
|
||||||
|
v.literal('user'),
|
||||||
|
v.literal('agent'),
|
||||||
|
v.literal('command'),
|
||||||
|
),
|
||||||
|
changeType: v.union(
|
||||||
|
v.literal('added'),
|
||||||
|
v.literal('modified'),
|
||||||
|
v.literal('deleted'),
|
||||||
|
v.literal('renamed'),
|
||||||
|
),
|
||||||
|
diff: v.optional(v.string()),
|
||||||
|
createdAt: v.number(),
|
||||||
|
})
|
||||||
|
.index('by_job', ['jobId'])
|
||||||
|
.index('by_path', ['jobId', 'path']),
|
||||||
agentJobEvents: defineTable({
|
agentJobEvents: defineTable({
|
||||||
jobId: v.id('agentJobs'),
|
jobId: v.id('agentJobs'),
|
||||||
spoonId: v.id('spoons'),
|
spoonId: v.id('spoons'),
|
||||||
@@ -523,6 +663,98 @@ const applicationTables = {
|
|||||||
.index('by_job', ['jobId'])
|
.index('by_job', ['jobId'])
|
||||||
.index('by_spoon', ['spoonId'])
|
.index('by_spoon', ['spoonId'])
|
||||||
.index('by_owner', ['ownerId']),
|
.index('by_owner', ['ownerId']),
|
||||||
|
threads: defineTable({
|
||||||
|
ownerId: v.id('users'),
|
||||||
|
spoonId: v.optional(v.id('spoons')),
|
||||||
|
title: v.string(),
|
||||||
|
summary: v.optional(v.string()),
|
||||||
|
source: v.union(
|
||||||
|
v.literal('user_request'),
|
||||||
|
v.literal('upstream_update'),
|
||||||
|
v.literal('merge_conflict'),
|
||||||
|
v.literal('manual_review'),
|
||||||
|
v.literal('system'),
|
||||||
|
),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('open'),
|
||||||
|
v.literal('queued'),
|
||||||
|
v.literal('running'),
|
||||||
|
v.literal('waiting_for_user'),
|
||||||
|
v.literal('changes_ready'),
|
||||||
|
v.literal('draft_pr_opened'),
|
||||||
|
v.literal('resolved'),
|
||||||
|
v.literal('ignored'),
|
||||||
|
v.literal('failed'),
|
||||||
|
v.literal('cancelled'),
|
||||||
|
),
|
||||||
|
priority: v.union(v.literal('low'), v.literal('normal'), v.literal('high')),
|
||||||
|
upstreamFrom: v.optional(v.string()),
|
||||||
|
upstreamTo: v.optional(v.string()),
|
||||||
|
forkHeadAtCreation: v.optional(v.string()),
|
||||||
|
mergeBaseAtCreation: v.optional(v.string()),
|
||||||
|
relatedSyncRunId: v.optional(v.id('syncRuns')),
|
||||||
|
relatedAgentRequestId: v.optional(v.id('agentRequests')),
|
||||||
|
latestAgentJobId: v.optional(v.id('agentJobs')),
|
||||||
|
maintenanceOutcome: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal('auto_synced'),
|
||||||
|
v.literal('sync_recommended'),
|
||||||
|
v.literal('ignored'),
|
||||||
|
v.literal('review_pr_recommended'),
|
||||||
|
v.literal('manual_review_required'),
|
||||||
|
v.literal('conflict_resolution_required'),
|
||||||
|
v.literal('failed'),
|
||||||
|
v.literal('unknown'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ignoredCommitShas: v.optional(v.array(v.string())),
|
||||||
|
ignoredReason: v.optional(v.string()),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
resolvedAt: v.optional(v.number()),
|
||||||
|
})
|
||||||
|
.index('by_owner', ['ownerId'])
|
||||||
|
.index('by_owner_status', ['ownerId', 'status'])
|
||||||
|
.index('by_spoon', ['spoonId'])
|
||||||
|
.index('by_source', ['ownerId', 'source'])
|
||||||
|
.index('by_created', ['createdAt']),
|
||||||
|
threadMessages: defineTable({
|
||||||
|
threadId: v.id('threads'),
|
||||||
|
ownerId: v.id('users'),
|
||||||
|
spoonId: v.optional(v.id('spoons')),
|
||||||
|
role: v.union(
|
||||||
|
v.literal('user'),
|
||||||
|
v.literal('assistant'),
|
||||||
|
v.literal('system'),
|
||||||
|
v.literal('tool'),
|
||||||
|
),
|
||||||
|
content: v.string(),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('queued'),
|
||||||
|
v.literal('streaming'),
|
||||||
|
v.literal('completed'),
|
||||||
|
v.literal('failed'),
|
||||||
|
),
|
||||||
|
metadata: v.optional(v.string()),
|
||||||
|
createdAt: v.number(),
|
||||||
|
updatedAt: v.number(),
|
||||||
|
})
|
||||||
|
.index('by_thread', ['threadId'])
|
||||||
|
.index('by_owner', ['ownerId']),
|
||||||
|
ignoredUpstreamChanges: defineTable({
|
||||||
|
spoonId: v.id('spoons'),
|
||||||
|
ownerId: v.id('users'),
|
||||||
|
upstreamFrom: v.optional(v.string()),
|
||||||
|
upstreamTo: v.string(),
|
||||||
|
commitShas: v.array(v.string()),
|
||||||
|
reason: v.string(),
|
||||||
|
decidedBy: v.union(v.literal('agent'), v.literal('user')),
|
||||||
|
threadId: v.optional(v.id('threads')),
|
||||||
|
createdAt: v.number(),
|
||||||
|
})
|
||||||
|
.index('by_spoon', ['spoonId'])
|
||||||
|
.index('by_owner', ['ownerId'])
|
||||||
|
.index('by_upstream_to', ['spoonId', 'upstreamTo']),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineSchema({
|
export default defineSchema({
|
||||||
|
|||||||
@@ -13,21 +13,28 @@ const reasoningEffort = v.union(
|
|||||||
v.literal('xhigh'),
|
v.literal('xhigh'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const agentModel = v.union(
|
const runtime = v.literal('opencode');
|
||||||
v.literal('gpt-5.1-codex'),
|
|
||||||
v.literal('gpt-5.5'),
|
const envFilePath = v.union(
|
||||||
v.literal('gpt-5.5-pro'),
|
v.literal('.env'),
|
||||||
v.literal('gpt-5.4'),
|
v.literal('.env.local'),
|
||||||
v.literal('gpt-5.4-mini'),
|
v.literal('.env.production'),
|
||||||
|
v.literal('.env.production.local'),
|
||||||
|
v.literal('custom'),
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaults = {
|
const defaults = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
runtime: 'opencode' as const,
|
||||||
branchPrefix: 'spoon/agent',
|
branchPrefix: 'spoon/agent',
|
||||||
agentModel: 'gpt-5.1-codex',
|
agentModel: '',
|
||||||
reasoningEffort: 'high' as const,
|
reasoningEffort: 'medium' as const,
|
||||||
maxJobDurationMs: 1_800_000,
|
maxJobDurationMs: 1_800_000,
|
||||||
maxOutputBytes: 200_000,
|
maxOutputBytes: 200_000,
|
||||||
|
envFilePath: '.env.local' as const,
|
||||||
|
materializeEnvFileByDefault: false,
|
||||||
|
autoDetectCommands: true,
|
||||||
|
allowUserFileEditing: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getForSpoon = query({
|
export const getForSpoon = query({
|
||||||
@@ -60,10 +67,18 @@ export const update = mutation({
|
|||||||
installCommand: v.optional(v.string()),
|
installCommand: v.optional(v.string()),
|
||||||
checkCommand: v.optional(v.string()),
|
checkCommand: v.optional(v.string()),
|
||||||
testCommand: v.optional(v.string()),
|
testCommand: v.optional(v.string()),
|
||||||
agentModel: v.optional(agentModel),
|
runtime: v.optional(runtime),
|
||||||
|
agentModel: v.optional(v.string()),
|
||||||
reasoningEffort: v.optional(reasoningEffort),
|
reasoningEffort: v.optional(reasoningEffort),
|
||||||
maxJobDurationMs: v.optional(v.number()),
|
maxJobDurationMs: v.optional(v.number()),
|
||||||
maxOutputBytes: v.optional(v.number()),
|
maxOutputBytes: v.optional(v.number()),
|
||||||
|
envFilePath: v.optional(envFilePath),
|
||||||
|
customEnvFilePath: v.optional(v.string()),
|
||||||
|
materializeEnvFileByDefault: v.optional(v.boolean()),
|
||||||
|
autoDetectCommands: v.optional(v.boolean()),
|
||||||
|
allowUserFileEditing: v.optional(v.boolean()),
|
||||||
|
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
|
||||||
|
clearAiProviderProfile: v.optional(v.boolean()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const ownerId = await getRequiredUserId(ctx);
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
@@ -107,6 +122,9 @@ export const update = mutation({
|
|||||||
if (args.testCommand !== undefined) {
|
if (args.testCommand !== undefined) {
|
||||||
patch.testCommand = optionalText(args.testCommand);
|
patch.testCommand = optionalText(args.testCommand);
|
||||||
}
|
}
|
||||||
|
if (args.runtime !== undefined) {
|
||||||
|
patch.runtime = 'opencode';
|
||||||
|
}
|
||||||
if (args.agentModel !== undefined) {
|
if (args.agentModel !== undefined) {
|
||||||
patch.agentModel = optionalText(args.agentModel) ?? defaults.agentModel;
|
patch.agentModel = optionalText(args.agentModel) ?? defaults.agentModel;
|
||||||
}
|
}
|
||||||
@@ -119,6 +137,31 @@ export const update = mutation({
|
|||||||
if (args.maxOutputBytes !== undefined) {
|
if (args.maxOutputBytes !== undefined) {
|
||||||
patch.maxOutputBytes = Math.max(10_000, args.maxOutputBytes);
|
patch.maxOutputBytes = Math.max(10_000, args.maxOutputBytes);
|
||||||
}
|
}
|
||||||
|
if (args.envFilePath !== undefined) {
|
||||||
|
patch.envFilePath = args.envFilePath;
|
||||||
|
}
|
||||||
|
if (args.customEnvFilePath !== undefined) {
|
||||||
|
patch.customEnvFilePath = optionalText(args.customEnvFilePath);
|
||||||
|
}
|
||||||
|
if (args.materializeEnvFileByDefault !== undefined) {
|
||||||
|
patch.materializeEnvFileByDefault = args.materializeEnvFileByDefault;
|
||||||
|
}
|
||||||
|
if (args.autoDetectCommands !== undefined) {
|
||||||
|
patch.autoDetectCommands = args.autoDetectCommands;
|
||||||
|
}
|
||||||
|
if (args.allowUserFileEditing !== undefined) {
|
||||||
|
patch.allowUserFileEditing = args.allowUserFileEditing;
|
||||||
|
}
|
||||||
|
if (args.aiProviderProfileId !== undefined) {
|
||||||
|
const profile = await ctx.db.get(args.aiProviderProfileId);
|
||||||
|
if (profile?.ownerId !== ownerId) {
|
||||||
|
throw new Error('AI provider profile not found.');
|
||||||
|
}
|
||||||
|
patch.aiProviderProfileId = args.aiProviderProfileId;
|
||||||
|
}
|
||||||
|
if (args.clearAiProviderProfile) {
|
||||||
|
patch.aiProviderProfileId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
await ctx.db.patch(settings._id, patch);
|
await ctx.db.patch(settings._id, patch);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|||||||
@@ -100,8 +100,14 @@ export const getDetails = query({
|
|||||||
handler: async (ctx, { spoonId }) => {
|
handler: async (ctx, { spoonId }) => {
|
||||||
const ownerId = await getRequiredUserId(ctx);
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
const spoon = await getOwnedSpoon(ctx, spoonId, ownerId);
|
const spoon = await getOwnedSpoon(ctx, spoonId, ownerId);
|
||||||
const [state, settings, latestReview, recentRuns, agentRequests] =
|
const [
|
||||||
await Promise.all([
|
state,
|
||||||
|
settings,
|
||||||
|
latestReview,
|
||||||
|
recentRuns,
|
||||||
|
agentRequests,
|
||||||
|
ignoredChanges,
|
||||||
|
] = await Promise.all([
|
||||||
ctx.db
|
ctx.db
|
||||||
.query('spoonRepositoryStates')
|
.query('spoonRepositoryStates')
|
||||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||||
@@ -125,8 +131,28 @@ export const getDetails = query({
|
|||||||
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||||
.order('desc')
|
.order('desc')
|
||||||
.take(10),
|
.take(10),
|
||||||
|
ctx.db
|
||||||
|
.query('ignoredUpstreamChanges')
|
||||||
|
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||||
|
.collect(),
|
||||||
]);
|
]);
|
||||||
return { spoon, state, settings, latestReview, recentRuns, agentRequests };
|
const ignoredShas = new Set(
|
||||||
|
ignoredChanges.flatMap((change) => change.commitShas),
|
||||||
|
);
|
||||||
|
const effectiveUpstreamAheadBy = Math.max(
|
||||||
|
0,
|
||||||
|
(state?.upstreamAheadBy ?? spoon.upstreamAheadBy ?? 0) - ignoredShas.size,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
spoon,
|
||||||
|
state,
|
||||||
|
settings,
|
||||||
|
latestReview,
|
||||||
|
recentRuns,
|
||||||
|
agentRequests,
|
||||||
|
ignoredChanges,
|
||||||
|
effectiveUpstreamAheadBy,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,12 +236,18 @@ export const createManual = mutation({
|
|||||||
spoonId,
|
spoonId,
|
||||||
ownerId,
|
ownerId,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
runtime: 'opencode',
|
||||||
defaultBaseBranch: forkDefaultBranch ?? args.upstreamDefaultBranch,
|
defaultBaseBranch: forkDefaultBranch ?? args.upstreamDefaultBranch,
|
||||||
branchPrefix: 'spoon/agent',
|
branchPrefix: 'spoon/agent',
|
||||||
agentModel: 'gpt-5.1-codex',
|
agentModel: '',
|
||||||
reasoningEffort: 'high',
|
reasoningEffort: 'medium',
|
||||||
maxJobDurationMs: 1_800_000,
|
maxJobDurationMs: 1_800_000,
|
||||||
maxOutputBytes: 200_000,
|
maxOutputBytes: 200_000,
|
||||||
|
envFilePath: '.env.local',
|
||||||
|
materializeEnvFileByDefault: false,
|
||||||
|
autoDetectCommands: true,
|
||||||
|
allowUserFileEditing: true,
|
||||||
|
aiProviderProfileId: undefined,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ const syncStatus = v.union(
|
|||||||
v.literal('merged'),
|
v.literal('merged'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const syncDecision = v.union(
|
||||||
|
v.literal('auto_synced'),
|
||||||
|
v.literal('thread_created'),
|
||||||
|
v.literal('ignored'),
|
||||||
|
v.literal('failed'),
|
||||||
|
);
|
||||||
|
|
||||||
export const listRecent = query({
|
export const listRecent = query({
|
||||||
args: { limit: v.optional(v.number()) },
|
args: { limit: v.optional(v.number()) },
|
||||||
handler: async (ctx, { limit }) => {
|
handler: async (ctx, { limit }) => {
|
||||||
@@ -52,6 +59,7 @@ export const createInternal = internalMutation({
|
|||||||
args: {
|
args: {
|
||||||
spoonId: v.id('spoons'),
|
spoonId: v.id('spoons'),
|
||||||
ownerId: v.id('users'),
|
ownerId: v.id('users'),
|
||||||
|
threadId: v.optional(v.id('threads')),
|
||||||
kind: syncKind,
|
kind: syncKind,
|
||||||
status: syncStatus,
|
status: syncStatus,
|
||||||
upstreamFrom: v.optional(v.string()),
|
upstreamFrom: v.optional(v.string()),
|
||||||
@@ -60,6 +68,7 @@ export const createInternal = internalMutation({
|
|||||||
aiAssessment: v.optional(v.string()),
|
aiAssessment: v.optional(v.string()),
|
||||||
mergeRequestUrl: v.optional(v.string()),
|
mergeRequestUrl: v.optional(v.string()),
|
||||||
error: v.optional(v.string()),
|
error: v.optional(v.string()),
|
||||||
|
decision: v.optional(syncDecision),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args): Promise<Id<'syncRuns'>> => {
|
handler: async (ctx, args): Promise<Id<'syncRuns'>> => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -74,6 +83,7 @@ export const createInternal = internalMutation({
|
|||||||
export const patchInternal = internalMutation({
|
export const patchInternal = internalMutation({
|
||||||
args: {
|
args: {
|
||||||
syncRunId: v.id('syncRuns'),
|
syncRunId: v.id('syncRuns'),
|
||||||
|
threadId: v.optional(v.id('threads')),
|
||||||
status: v.optional(syncStatus),
|
status: v.optional(syncStatus),
|
||||||
upstreamFrom: v.optional(v.string()),
|
upstreamFrom: v.optional(v.string()),
|
||||||
upstreamTo: v.optional(v.string()),
|
upstreamTo: v.optional(v.string()),
|
||||||
@@ -81,9 +91,11 @@ export const patchInternal = internalMutation({
|
|||||||
aiAssessment: v.optional(v.string()),
|
aiAssessment: v.optional(v.string()),
|
||||||
mergeRequestUrl: v.optional(v.string()),
|
mergeRequestUrl: v.optional(v.string()),
|
||||||
error: v.optional(v.string()),
|
error: v.optional(v.string()),
|
||||||
|
decision: v.optional(syncDecision),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const patch: Partial<Doc<'syncRuns'>> = { updatedAt: Date.now() };
|
const patch: Partial<Doc<'syncRuns'>> = { updatedAt: Date.now() };
|
||||||
|
if (args.threadId !== undefined) patch.threadId = args.threadId;
|
||||||
if (args.status !== undefined) patch.status = args.status;
|
if (args.status !== undefined) patch.status = args.status;
|
||||||
if (args.upstreamFrom !== undefined) patch.upstreamFrom = args.upstreamFrom;
|
if (args.upstreamFrom !== undefined) patch.upstreamFrom = args.upstreamFrom;
|
||||||
if (args.upstreamTo !== undefined) patch.upstreamTo = args.upstreamTo;
|
if (args.upstreamTo !== undefined) patch.upstreamTo = args.upstreamTo;
|
||||||
@@ -95,6 +107,7 @@ export const patchInternal = internalMutation({
|
|||||||
patch.mergeRequestUrl = args.mergeRequestUrl;
|
patch.mergeRequestUrl = args.mergeRequestUrl;
|
||||||
}
|
}
|
||||||
if (args.error !== undefined) patch.error = args.error;
|
if (args.error !== undefined) patch.error = args.error;
|
||||||
|
if (args.decision !== undefined) patch.decision = args.decision;
|
||||||
await ctx.db.patch(args.syncRunId, patch);
|
await ctx.db.patch(args.syncRunId, patch);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
import { ConvexError, v } from 'convex/values';
|
||||||
|
|
||||||
|
import type { Doc } from './_generated/dataModel';
|
||||||
|
import { internal } from './_generated/api';
|
||||||
|
import {
|
||||||
|
internalMutation,
|
||||||
|
internalQuery,
|
||||||
|
mutation,
|
||||||
|
query,
|
||||||
|
} from './_generated/server';
|
||||||
|
import {
|
||||||
|
getOwnedSpoon,
|
||||||
|
getRequiredUserId,
|
||||||
|
optionalText,
|
||||||
|
requireText,
|
||||||
|
} from './model';
|
||||||
|
|
||||||
|
const threadSource = v.union(
|
||||||
|
v.literal('user_request'),
|
||||||
|
v.literal('upstream_update'),
|
||||||
|
v.literal('merge_conflict'),
|
||||||
|
v.literal('manual_review'),
|
||||||
|
v.literal('system'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const threadStatus = v.union(
|
||||||
|
v.literal('open'),
|
||||||
|
v.literal('queued'),
|
||||||
|
v.literal('running'),
|
||||||
|
v.literal('waiting_for_user'),
|
||||||
|
v.literal('changes_ready'),
|
||||||
|
v.literal('draft_pr_opened'),
|
||||||
|
v.literal('resolved'),
|
||||||
|
v.literal('ignored'),
|
||||||
|
v.literal('failed'),
|
||||||
|
v.literal('cancelled'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const maintenanceOutcome = v.union(
|
||||||
|
v.literal('auto_synced'),
|
||||||
|
v.literal('sync_recommended'),
|
||||||
|
v.literal('ignored'),
|
||||||
|
v.literal('review_pr_recommended'),
|
||||||
|
v.literal('manual_review_required'),
|
||||||
|
v.literal('conflict_resolution_required'),
|
||||||
|
v.literal('failed'),
|
||||||
|
v.literal('unknown'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const messageRole = v.union(
|
||||||
|
v.literal('user'),
|
||||||
|
v.literal('assistant'),
|
||||||
|
v.literal('system'),
|
||||||
|
v.literal('tool'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const messageStatus = v.union(
|
||||||
|
v.literal('queued'),
|
||||||
|
v.literal('streaming'),
|
||||||
|
v.literal('completed'),
|
||||||
|
v.literal('failed'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const titleFromPrompt = (prompt: string) => {
|
||||||
|
const firstLine = prompt.trim().split('\n')[0] ?? 'Thread';
|
||||||
|
return firstLine.length > 80 ? `${firstLine.slice(0, 77)}...` : firstLine;
|
||||||
|
};
|
||||||
|
|
||||||
|
const publicThread = (thread: Doc<'threads'>) => thread;
|
||||||
|
|
||||||
|
export const listMine = query({
|
||||||
|
args: {
|
||||||
|
status: v.optional(v.union(threadStatus, v.literal('all'))),
|
||||||
|
source: v.optional(v.union(threadSource, v.literal('all'))),
|
||||||
|
spoonId: v.optional(v.id('spoons')),
|
||||||
|
limit: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const threads = await ctx.db
|
||||||
|
.query('threads')
|
||||||
|
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||||
|
.order('desc')
|
||||||
|
.take(args.limit ?? 50);
|
||||||
|
return threads.filter((thread) => {
|
||||||
|
if (
|
||||||
|
args.status &&
|
||||||
|
args.status !== 'all' &&
|
||||||
|
thread.status !== args.status
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
args.source &&
|
||||||
|
args.source !== 'all' &&
|
||||||
|
thread.source !== args.source
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (args.spoonId && thread.spoonId !== args.spoonId) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listForSpoon = query({
|
||||||
|
args: { spoonId: v.id('spoons'), limit: v.optional(v.number()) },
|
||||||
|
handler: async (ctx, { spoonId, limit }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
await getOwnedSpoon(ctx, spoonId, ownerId);
|
||||||
|
return await ctx.db
|
||||||
|
.query('threads')
|
||||||
|
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||||
|
.order('desc')
|
||||||
|
.take(limit ?? 25);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const get = query({
|
||||||
|
args: { threadId: v.id('threads') },
|
||||||
|
handler: async (ctx, { threadId }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const thread = await ctx.db.get(threadId);
|
||||||
|
if (thread?.ownerId !== ownerId) throw new ConvexError('Thread not found.');
|
||||||
|
const spoon = thread.spoonId ? await ctx.db.get(thread.spoonId) : null;
|
||||||
|
const job = thread.latestAgentJobId
|
||||||
|
? await ctx.db.get(thread.latestAgentJobId)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
thread: publicThread(thread),
|
||||||
|
spoon: spoon?.ownerId === ownerId ? spoon : null,
|
||||||
|
latestJob: job?.ownerId === ownerId ? job : null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listMessages = query({
|
||||||
|
args: { threadId: v.id('threads'), limit: v.optional(v.number()) },
|
||||||
|
handler: async (ctx, { threadId, limit }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const thread = await ctx.db.get(threadId);
|
||||||
|
if (thread?.ownerId !== ownerId) throw new ConvexError('Thread not found.');
|
||||||
|
return await ctx.db
|
||||||
|
.query('threadMessages')
|
||||||
|
.withIndex('by_thread', (q) => q.eq('threadId', threadId))
|
||||||
|
.order('asc')
|
||||||
|
.take(limit ?? 200);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createUserThread = mutation({
|
||||||
|
args: {
|
||||||
|
spoonId: v.id('spoons'),
|
||||||
|
title: v.optional(v.string()),
|
||||||
|
prompt: v.string(),
|
||||||
|
baseBranch: v.optional(v.string()),
|
||||||
|
requestedBranchName: v.optional(v.string()),
|
||||||
|
materializeEnvFile: v.optional(v.boolean()),
|
||||||
|
envFilePath: v.optional(v.string()),
|
||||||
|
aiProviderProfileId: v.optional(v.id('aiProviderProfiles')),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
await getOwnedSpoon(ctx, args.spoonId, ownerId);
|
||||||
|
const prompt = requireText(args.prompt, 'Prompt');
|
||||||
|
const now = Date.now();
|
||||||
|
const threadId = await ctx.db.insert('threads', {
|
||||||
|
ownerId,
|
||||||
|
spoonId: args.spoonId,
|
||||||
|
title: optionalText(args.title) ?? titleFromPrompt(prompt),
|
||||||
|
summary: prompt,
|
||||||
|
source: 'user_request',
|
||||||
|
status: 'open',
|
||||||
|
priority: 'normal',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
await ctx.db.insert('threadMessages', {
|
||||||
|
threadId,
|
||||||
|
ownerId,
|
||||||
|
spoonId: args.spoonId,
|
||||||
|
role: 'user',
|
||||||
|
content: prompt,
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
await ctx.scheduler.runAfter(
|
||||||
|
0,
|
||||||
|
internal.agentJobs.createForThreadInternal,
|
||||||
|
{
|
||||||
|
threadId,
|
||||||
|
ownerId,
|
||||||
|
jobType: 'user_change',
|
||||||
|
baseBranch: args.baseBranch,
|
||||||
|
requestedBranchName: args.requestedBranchName,
|
||||||
|
materializeEnvFile: args.materializeEnvFile,
|
||||||
|
envFilePath: args.envFilePath,
|
||||||
|
aiProviderProfileId: args.aiProviderProfileId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return threadId;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const appendUserMessage = mutation({
|
||||||
|
args: { threadId: v.id('threads'), content: v.string() },
|
||||||
|
handler: async (ctx, { threadId, content }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const thread = await ctx.db.get(threadId);
|
||||||
|
if (thread?.ownerId !== ownerId) throw new ConvexError('Thread not found.');
|
||||||
|
const now = Date.now();
|
||||||
|
return await ctx.db.insert('threadMessages', {
|
||||||
|
threadId,
|
||||||
|
ownerId,
|
||||||
|
spoonId: thread.spoonId,
|
||||||
|
role: 'user',
|
||||||
|
content: requireText(content, 'Message'),
|
||||||
|
status: 'queued',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cancel = mutation({
|
||||||
|
args: { threadId: v.id('threads') },
|
||||||
|
handler: async (ctx, { threadId }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const thread = await ctx.db.get(threadId);
|
||||||
|
if (thread?.ownerId !== ownerId) throw new ConvexError('Thread not found.');
|
||||||
|
await ctx.db.patch(threadId, {
|
||||||
|
status: 'cancelled',
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
resolvedAt: Date.now(),
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const markResolved = mutation({
|
||||||
|
args: { threadId: v.id('threads') },
|
||||||
|
handler: async (ctx, { threadId }) => {
|
||||||
|
const ownerId = await getRequiredUserId(ctx);
|
||||||
|
const thread = await ctx.db.get(threadId);
|
||||||
|
if (thread?.ownerId !== ownerId) throw new ConvexError('Thread not found.');
|
||||||
|
await ctx.db.patch(threadId, {
|
||||||
|
status: 'resolved',
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
resolvedAt: Date.now(),
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const findOpenMaintenanceThread = internalQuery({
|
||||||
|
args: {
|
||||||
|
spoonId: v.id('spoons'),
|
||||||
|
ownerId: v.id('users'),
|
||||||
|
upstreamTo: v.string(),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { spoonId, ownerId, upstreamTo }) => {
|
||||||
|
const threads = await ctx.db
|
||||||
|
.query('threads')
|
||||||
|
.withIndex('by_spoon', (q) => q.eq('spoonId', spoonId))
|
||||||
|
.order('desc')
|
||||||
|
.collect();
|
||||||
|
return (
|
||||||
|
threads.find(
|
||||||
|
(thread) =>
|
||||||
|
thread.ownerId === ownerId &&
|
||||||
|
thread.upstreamTo === upstreamTo &&
|
||||||
|
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
|
||||||
|
thread.status,
|
||||||
|
),
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createMaintenanceThread = internalMutation({
|
||||||
|
args: {
|
||||||
|
spoonId: v.id('spoons'),
|
||||||
|
ownerId: v.id('users'),
|
||||||
|
source: v.union(v.literal('upstream_update'), v.literal('merge_conflict')),
|
||||||
|
title: v.string(),
|
||||||
|
summary: v.string(),
|
||||||
|
upstreamFrom: v.optional(v.string()),
|
||||||
|
upstreamTo: v.string(),
|
||||||
|
forkHeadAtCreation: v.optional(v.string()),
|
||||||
|
mergeBaseAtCreation: v.optional(v.string()),
|
||||||
|
relatedSyncRunId: v.optional(v.id('syncRuns')),
|
||||||
|
jobType: v.union(
|
||||||
|
v.literal('maintenance_review'),
|
||||||
|
v.literal('conflict_resolution'),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query('threads')
|
||||||
|
.withIndex('by_spoon', (q) => q.eq('spoonId', args.spoonId))
|
||||||
|
.order('desc')
|
||||||
|
.collect()
|
||||||
|
.then((threads) =>
|
||||||
|
threads.find(
|
||||||
|
(thread) =>
|
||||||
|
thread.ownerId === args.ownerId &&
|
||||||
|
thread.upstreamTo === args.upstreamTo &&
|
||||||
|
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
|
||||||
|
thread.status,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
await ctx.db.insert('threadMessages', {
|
||||||
|
threadId: existing._id,
|
||||||
|
ownerId: args.ownerId,
|
||||||
|
spoonId: args.spoonId,
|
||||||
|
role: 'system',
|
||||||
|
content: args.summary,
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
await ctx.db.patch(existing._id, {
|
||||||
|
relatedSyncRunId: args.relatedSyncRunId,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
return existing._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadId = await ctx.db.insert('threads', {
|
||||||
|
ownerId: args.ownerId,
|
||||||
|
spoonId: args.spoonId,
|
||||||
|
title: args.title,
|
||||||
|
summary: args.summary,
|
||||||
|
source: args.source,
|
||||||
|
status: 'open',
|
||||||
|
priority: args.source === 'merge_conflict' ? 'high' : 'normal',
|
||||||
|
upstreamFrom: args.upstreamFrom,
|
||||||
|
upstreamTo: args.upstreamTo,
|
||||||
|
forkHeadAtCreation: args.forkHeadAtCreation,
|
||||||
|
mergeBaseAtCreation: args.mergeBaseAtCreation,
|
||||||
|
relatedSyncRunId: args.relatedSyncRunId,
|
||||||
|
maintenanceOutcome: 'unknown',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
await ctx.db.insert('threadMessages', {
|
||||||
|
threadId,
|
||||||
|
ownerId: args.ownerId,
|
||||||
|
spoonId: args.spoonId,
|
||||||
|
role: 'system',
|
||||||
|
content: args.summary,
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
await ctx.scheduler.runAfter(
|
||||||
|
0,
|
||||||
|
internal.agentJobs.createForThreadInternal,
|
||||||
|
{
|
||||||
|
threadId,
|
||||||
|
ownerId: args.ownerId,
|
||||||
|
jobType: args.jobType,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return threadId;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const patchThreadInternal = internalMutation({
|
||||||
|
args: {
|
||||||
|
threadId: v.id('threads'),
|
||||||
|
status: v.optional(threadStatus),
|
||||||
|
summary: v.optional(v.string()),
|
||||||
|
maintenanceOutcome: v.optional(maintenanceOutcome),
|
||||||
|
ignoredCommitShas: v.optional(v.array(v.string())),
|
||||||
|
ignoredReason: v.optional(v.string()),
|
||||||
|
latestAgentJobId: v.optional(v.id('agentJobs')),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const thread = await ctx.db.get(args.threadId);
|
||||||
|
if (!thread) throw new ConvexError('Thread not found.');
|
||||||
|
const patch: Partial<Doc<'threads'>> = { updatedAt: Date.now() };
|
||||||
|
if (args.status !== undefined) patch.status = args.status;
|
||||||
|
if (args.summary !== undefined) patch.summary = optionalText(args.summary);
|
||||||
|
if (args.maintenanceOutcome !== undefined) {
|
||||||
|
patch.maintenanceOutcome = args.maintenanceOutcome;
|
||||||
|
}
|
||||||
|
if (args.ignoredCommitShas !== undefined) {
|
||||||
|
patch.ignoredCommitShas = args.ignoredCommitShas;
|
||||||
|
}
|
||||||
|
if (args.ignoredReason !== undefined) {
|
||||||
|
patch.ignoredReason = optionalText(args.ignoredReason);
|
||||||
|
}
|
||||||
|
if (args.latestAgentJobId !== undefined) {
|
||||||
|
patch.latestAgentJobId = args.latestAgentJobId;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
args.status &&
|
||||||
|
['resolved', 'ignored', 'failed', 'cancelled'].includes(args.status)
|
||||||
|
) {
|
||||||
|
patch.resolvedAt = Date.now();
|
||||||
|
}
|
||||||
|
await ctx.db.patch(args.threadId, patch);
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const appendMessageInternal = internalMutation({
|
||||||
|
args: {
|
||||||
|
threadId: v.id('threads'),
|
||||||
|
ownerId: v.id('users'),
|
||||||
|
role: messageRole,
|
||||||
|
content: v.string(),
|
||||||
|
status: v.optional(messageStatus),
|
||||||
|
metadata: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const thread = await ctx.db.get(args.threadId);
|
||||||
|
if (thread?.ownerId !== args.ownerId) {
|
||||||
|
throw new ConvexError('Thread not found.');
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
return await ctx.db.insert('threadMessages', {
|
||||||
|
threadId: args.threadId,
|
||||||
|
ownerId: args.ownerId,
|
||||||
|
spoonId: thread.spoonId,
|
||||||
|
role: args.role,
|
||||||
|
content: args.content,
|
||||||
|
status: args.status ?? 'completed',
|
||||||
|
metadata: optionalText(args.metadata),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const recordIgnoredUpstreamChange = internalMutation({
|
||||||
|
args: {
|
||||||
|
spoonId: v.id('spoons'),
|
||||||
|
ownerId: v.id('users'),
|
||||||
|
upstreamFrom: v.optional(v.string()),
|
||||||
|
upstreamTo: v.string(),
|
||||||
|
commitShas: v.array(v.string()),
|
||||||
|
reason: v.string(),
|
||||||
|
decidedBy: v.union(v.literal('agent'), v.literal('user')),
|
||||||
|
threadId: v.optional(v.id('threads')),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db.insert('ignoredUpstreamChanges', {
|
||||||
|
...args,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -35,7 +35,6 @@
|
|||||||
"@react-email/components": "1.0.10",
|
"@react-email/components": "1.0.10",
|
||||||
"@react-email/render": "^2.0.4",
|
"@react-email/render": "^2.0.4",
|
||||||
"convex": "catalog:convex",
|
"convex": "catalog:convex",
|
||||||
"openai": "^6.44.0",
|
|
||||||
"react": "catalog:react19",
|
"react": "catalog:react19",
|
||||||
"react-dom": "catalog:react19",
|
"react-dom": "catalog:react19",
|
||||||
"usesend-js": "^1.6.3",
|
"usesend-js": "^1.6.3",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const Checkbox = ({
|
|||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
data-slot='checkbox'
|
data-slot='checkbox'
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3',
|
'border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const RadioGroupItem = ({
|
|||||||
<RadioGroupPrimitive.Item
|
<RadioGroupPrimitive.Item
|
||||||
data-slot='radio-group-item'
|
data-slot='radio-group-item'
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
'border-input text-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ const Switch = ({
|
|||||||
data-slot='switch'
|
data-slot='switch'
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
'data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-3 aria-invalid:ring-3 data-disabled:cursor-not-allowed data-disabled:opacity-50 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px]',
|
'data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-[state=unchecked]:bg-input/80 peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:ring-3 aria-invalid:ring-3 data-disabled:cursor-not-allowed data-disabled:opacity-50 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px]',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SwitchPrimitive.Thumb
|
<SwitchPrimitive.Thumb
|
||||||
data-slot='switch-thumb'
|
data-slot='switch-thumb'
|
||||||
className='bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0'
|
className='bg-background dark:group-data-[state=unchecked]/switch:bg-foreground dark:group-data-[state=checked]/switch:bg-primary-foreground pointer-events-none block rounded-full ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:group-data-[state=checked]/switch:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:group-data-[state=checked]/switch:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:group-data-[state=unchecked]/switch:translate-x-0 group-data-[size=sm]/switch:group-data-[state=unchecked]/switch:translate-x-0'
|
||||||
/>
|
/>
|
||||||
</SwitchPrimitive.Root>
|
</SwitchPrimitive.Root>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,6 +43,9 @@
|
|||||||
"SPOON_AGENT_WORKDIR",
|
"SPOON_AGENT_WORKDIR",
|
||||||
"SPOON_AGENT_NETWORK",
|
"SPOON_AGENT_NETWORK",
|
||||||
"SPOON_AGENT_POLL_MS",
|
"SPOON_AGENT_POLL_MS",
|
||||||
|
"SPOON_AGENT_WORKER_URL",
|
||||||
|
"SPOON_AGENT_WORKER_HTTP_PORT",
|
||||||
|
"SPOON_AGENT_WORKER_INTERNAL_TOKEN",
|
||||||
"SKIP_E2E",
|
"SKIP_E2E",
|
||||||
"BASE_URL",
|
"BASE_URL",
|
||||||
"NETWORK",
|
"NETWORK",
|
||||||
|
|||||||
Reference in New Issue
Block a user