diff --git a/AGENTS.md b/AGENTS.md index a9fe942..0d3ac97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,12 +3,17 @@ ## Architecture - `apps/next`: Next.js 16 frontend. +- `apps/agent-worker`: optional server-side coding-agent worker. It polls + Convex for queued jobs and may control Docker/Podman to run ephemeral job + containers. - `apps/expo`: Expo scaffold; only work here when explicitly requested. - `packages/backend/convex`: self-hosted Convex functions, schema, and auth. - `packages/ui`: shared shadcn-based UI components. - `tools`: shared ESLint, Prettier, Tailwind, TypeScript, and Vitest config. - Local development uses host-run apps, local Convex on ports 3210/3211, local Postgres on port 5432 for Convex storage, and the Convex dashboard on port 6791. + Agent jobs are opt-in; build `docker/agent-job.Dockerfile` as + `spoon-agent-job:latest` before running Docker-backed jobs. ## Protected and generated files @@ -26,6 +31,10 @@ - Run `infisical login` and `infisical init` before local development. - Machine-generated values belong in `.local/.generated.env`; never put the generated Convex admin key in shared Infisical. +- `scripts/sync-convex-env ` copies Authentik, GitHub App, + UseSend, `SITE_URL`, `SPOON_WORKER_TOKEN`, encryption, and Convex Auth signing + variables from Infisical into the selected Convex deployment. Backend + dev/setup scripts run it before `convex dev`. - CI uses Gitea-injected secrets or `CI_ENV_FILE` and must not call Infisical. - App code imports validated variables from `@/env`, never `process.env`. - Add cache-relevant variables to `turbo.json` `globalEnv`. @@ -47,6 +56,8 @@ ```sh bun db:up # start Postgres, Convex, and dashboard bun dev:next # host Next + deploy/watch local Convex functions +bun dev:agent # run the optional coding-agent worker on the host +bun sync:convex # sync Infisical values into Convex bun db:down # stop and preserve local data bun db:down:wipe # remove local data volumes and generated admin key ``` diff --git a/README.md b/README.md index 48df821..7258671 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,77 @@ # Spoon -A reusable Bun/Turborepo template with Next.js 16, Expo, self-hosted Convex, -shared UI/config packages, Vitest, and Docker deployment. +Spoon is a self-hostable fork maintenance dashboard. + +The product goal is simple: make it practical to fork a project, customize it, +and still stay close to upstream. Spoon tracks managed forks, called +**Spoons**, and lays the foundation for upstream update checks, AI-assisted +change review, and agent-authored merge requests. + +This repository is the Spoon application itself, not a generic starter. + +## Current scope + +Implemented today: + +- Public Spoon landing page in Next.js. +- Authenticated web dashboard routes: + - `/dashboard` + - `/spoons` + - `/spoons/new` + - `/updates` + - `/spoons/[spoonId]` + - `/settings` +- Manual and GitHub-created Spoon records stored in Convex. +- GitHub App connection, repository listing, fork creation, drift refresh, + commit/PR cache, and safe manual sync foundation. +- Per-user OpenAI settings for upstream compatibility review. +- Per-Spoon encrypted project secrets and agent runtime settings. +- Optional `apps/agent-worker` service that can claim queued jobs, clone the + GitHub fork, ask OpenAI for bounded file edits, run checks, push a branch, and + open a draft PR. +- Password auth and Authentik OAuth through Convex Auth. +- Expo companion app shell with password and Authentik sign-in. +- Self-hosted local Convex using Postgres storage. + +Not implemented yet: + +- Browser IDE/editor. +- Automatic merge. +- Additional Git provider automation beyond preserving provider-neutral fields. +- Additional remotes as push targets. +- Long-running service-stack orchestration inside agent jobs. +- Production mobile build/release setup. + +## Architecture + +- `apps/next`: Next.js 16 web app and primary product UI. +- `apps/agent-worker`: optional server-side worker for queued coding-agent jobs. +- `apps/expo`: Expo companion app. +- `packages/backend/convex`: self-hosted Convex schema, functions, auth, and + HTTP routes. +- `packages/ui`: shared shadcn-based UI components. +- `tools`: shared ESLint, Prettier, Tailwind, TypeScript, and Vitest config. +- `docker`: local and production Compose files. +- `scripts`: environment, database, and CI helpers. + +The core domain objects are: + +- `spoons`: managed fork records. +- `gitConnections`: future Git provider connection metadata. +- `syncRuns`: future upstream checks, merge attempts, and AI reviews. +- `agentRequests`: prompt-driven agent work requests. +- `agentJobs`: worker-executed coding-agent jobs and their PR lifecycle. +- `spoonSecrets`: encrypted per-Spoon environment variables. +- `spoonAgentSettings`: per-Spoon agent model, branch, and command settings. ## Local setup -Requirements: Bun 1.3.10, Docker or Podman, Node 22, and the Infisical CLI. +Requirements: + +- Bun 1.3.10 +- Node 22 +- Docker or Podman +- Infisical CLI ```sh bun install --frozen-lockfile @@ -15,72 +81,172 @@ bun db:up bun dev:next ``` -The committed `.infisical.json` links this repository to its own Infisical -project. Local commands read `dev` by default and never fall back to `.env` -files. Select staging with `INFISICAL_ENV=staging bun dev:next`. - Local services: - Next.js: `http://localhost:3000` - Convex API: `http://localhost:3210` +- Convex site HTTP routes: `http://localhost:3211` - Convex dashboard: `http://localhost:6791` - Convex Postgres: `localhost:5432` -Next and Expo run on the host. Spoon runs local Convex with Postgres storage by -default. Local Compose creates the database named by `LOCAL_INSTANCE_NAME` -because the Convex backend opens that database inside the Postgres cluster. -Convex receives a database-cluster URL without a path. +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, +Convex, and the dashboard, generates a machine-local Convex admin key in +`.local/dev.generated.env` when needed, deploys functions/schema, and +configures local Convex Auth keys. ```sh bun db:down # stop; preserve local data bun db:down:wipe # remove local data volumes and generated admin key ``` -Normal `bun db:up` never contacts staging. It starts local Postgres, Convex, and -the dashboard, generates a machine-local Convex admin key in -`.local/dev.generated.env` when needed, deploys functions/schema, and configures -local Convex Auth keys. +Use staging services explicitly: -Physical devices cannot resolve their own `localhost`; override the public -Convex URL with the development host's LAN address when testing Expo on-device. +```sh +INFISICAL_ENV=staging bun dev:next +``` + +Run the optional local agent worker in a separate terminal: + +```sh +bun dev:agent +``` + +The Docker Compose local worker service is disabled by default behind the +`agent` profile. Build the job image before using Docker-backed jobs: + +```sh +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 +``` ## Environment model -- Local `dev` and `staging`: Infisical. -- Generated local state: `.local/.generated.env`. -- CI/CD: Gitea `DOTENV_PROD`, materialized only as a temporary runner file. -- Docker compilation: explicit Compose build args; `.env*` stays outside the - image context. +Local `dev` and `staging` values come from Infisical through `scripts/with-env`. +App commands do not fall back to root `.env` files. -Run `sh scripts/with-env dev -- ` for an environment-aware command or -`sh scripts/export-env dev` to materialize a temporary merged dotenv stream. -Do not commit or maintain root `.env` files. +Generated local state belongs in: -## Development and quality +```txt +.local/.generated.env +``` + +CI uses Gitea-provided secrets or `CI_ENV_FILE` and must not call Infisical. + +Useful helpers: + +```sh +sh scripts/with-env dev -- +sh scripts/export-env dev +bun sync:convex +``` + +### Convex deployment env + +Convex functions and HTTP actions read environment variables from the Convex +deployment environment, not directly from the host process. For OAuth providers, +that means Infisical values must also be present in local Convex env. + +`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 +values into the selected Convex deployment first. Run it manually when needed: + +```sh +sh scripts/sync-convex-env dev +sh scripts/sync-convex-env staging +INFISICAL_ENV=staging bun sync:convex +``` + +The sync includes: + +```txt +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 +http://localhost:3211/api/auth/callback/authentik +http://localhost:3211/api/auth/callback/github +``` + +If GitHub App actions fail with `GITHUB_APP_PRIVATE_KEY is not configured`, add +the full PEM contents to Infisical as `GITHUB_APP_PRIVATE_KEY` and rerun the +sync command. + +## Development ```sh bun dev:next bun dev:expo +``` + +Physical devices cannot resolve their own `localhost`; override the public +Convex URL with the development host's LAN address when testing Expo on-device. + +Shared dependency versions belong in root catalogs. Edit the root catalog, run +`bun install`, then `bun lint:ws`. Do not run `bun update` inside a workspace. + +## Validation + +Routine checks: + +```sh bun lint:ws bun format bun lint bun typecheck -bun test:unit -bun test:integration -bun test:component +bun run test +``` + +Full local gate without e2e: + +```sh SKIP_E2E=1 bun run ci:check ``` -`bun test:e2e` starts the isolated local stack and currently performs generic -stack smoke checks. It stops the stack afterward only when the stack was not -already running. It skips in CI and when `SKIP_E2E=1` is set. +Local-stack smoke e2e: -Shared dependency versions belong in root catalogs. Edit the root catalog, run -`bun install`, then `bun lint:ws`; do not run `bun update` inside a workspace. +```sh +bun test:e2e +``` + +`bun test:e2e` starts the isolated local stack when needed and stops it +afterward only when it was not already running. + +Use `bun run test`, not bare `bun test`; bare `bun test` invokes Bun's built-in +test runner instead of the repo's Turbo/Vitest test script. ## Deployment -Production Compose retains the self-hosted Convex backend/dashboard and expects -`POSTGRES_URL` to be a database-cluster URL without a database path. Gitea runs -the quality gate first, builds the Next image from a temporary Gitea-secret env -file, then pushes SHA and `latest` tags. CI never installs or invokes Infisical. +Production Compose keeps the self-hosted Convex backend/dashboard and expects +`POSTGRES_URL` to be a database-cluster URL without a database path. + +Gitea runs the quality gate first, builds the Next image from a temporary +Gitea-secret env file, then pushes SHA and `latest` tags. CI never installs or +invokes Infisical. diff --git a/apps/agent-worker/eslint.config.ts b/apps/agent-worker/eslint.config.ts new file mode 100644 index 0000000..5982a3c --- /dev/null +++ b/apps/agent-worker/eslint.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'eslint/config'; + +import { baseConfig } from '@spoon/eslint-config/base'; + +export default defineConfig(baseConfig, { + languageOptions: { + parserOptions: { + tsconfigRootDir: import.meta.dirname, + }, + }, +}); diff --git a/apps/agent-worker/package.json b/apps/agent-worker/package.json new file mode 100644 index 0000000..29f73c6 --- /dev/null +++ b/apps/agent-worker/package.json @@ -0,0 +1,37 @@ +{ + "name": "@spoon/agent-worker", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun with-env src/index.ts", + "start": "bun src/index.ts", + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint --flag unstable_native_nodejs_ts_config", + "typecheck": "tsc --noEmit", + "test:unit": "vitest run --project unit --passWithNoTests", + "test:integration": "vitest run --project integration --passWithNoTests", + "test:component": "vitest run --project component --passWithNoTests", + "with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --" + }, + "dependencies": { + "@octokit/auth-app": "^8.2.0", + "@octokit/rest": "^22.0.1", + "@openai/agents": "latest", + "convex": "catalog:convex", + "execa": "latest", + "openai": "^6.44.0", + "zod": "catalog:" + }, + "devDependencies": { + "@spoon/eslint-config": "workspace:*", + "@spoon/prettier-config": "workspace:*", + "@spoon/tsconfig": "workspace:*", + "@types/node": "catalog:", + "eslint": "catalog:", + "prettier": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:test" + }, + "prettier": "@spoon/prettier-config" +} diff --git a/apps/agent-worker/src/agent.ts b/apps/agent-worker/src/agent.ts new file mode 100644 index 0000000..f546fa3 --- /dev/null +++ b/apps/agent-worker/src/agent.ts @@ -0,0 +1,190 @@ +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; +}; diff --git a/apps/agent-worker/src/env.ts b/apps/agent-worker/src/env.ts new file mode 100644 index 0000000..fa4d49c --- /dev/null +++ b/apps/agent-worker/src/env.ts @@ -0,0 +1,34 @@ +const intEnv = (name: string, fallback: number) => { + const value = process.env[name]; + if (!value) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +const requiredEnv = (name: string) => { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required.`); + return value; +}; + +export const env = { + convexUrl: + process.env.NEXT_PUBLIC_CONVEX_URL?.trim() ?? + process.env.CONVEX_SELF_HOSTED_URL?.trim() ?? + 'http://localhost:3210', + workerToken: requiredEnv('SPOON_WORKER_TOKEN'), + workerId: process.env.SPOON_AGENT_WORKER_ID?.trim() ?? 'local-worker', + runtime: process.env.SPOON_AGENT_RUNTIME?.trim() ?? 'docker', + jobImage: + process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest', + workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work', + network: process.env.SPOON_AGENT_NETWORK?.trim(), + pollMs: intEnv('SPOON_AGENT_POLL_MS', 5_000), + maxConcurrentJobs: intEnv('SPOON_AGENT_MAX_CONCURRENT_JOBS', 1), + jobTimeoutMs: intEnv('SPOON_AGENT_JOB_TIMEOUT_MS', 1_800_000), + githubAppId: requiredEnv('GITHUB_APP_ID'), + githubPrivateKey: requiredEnv('GITHUB_APP_PRIVATE_KEY').replaceAll( + '\\n', + '\n', + ), +}; diff --git a/apps/agent-worker/src/git.ts b/apps/agent-worker/src/git.ts new file mode 100644 index 0000000..f07380d --- /dev/null +++ b/apps/agent-worker/src/git.ts @@ -0,0 +1,144 @@ +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { execa } from 'execa'; + +export type RunOptions = { + cwd: string; + env?: Record; + redact: (value: string) => string; + timeoutMs: number; +}; + +export const run = async ( + command: string, + args: string[], + options: RunOptions, +) => { + const result = await execa(command, args, { + cwd: options.cwd, + env: options.env, + all: true, + reject: false, + timeout: options.timeoutMs, + }); + return { + exitCode: result.exitCode ?? 0, + output: options.redact(result.all), + }; +}; + +export const cloneRepository = async (args: { + workdir: string; + token: string; + owner: string; + repo: string; + baseBranch: string; + workBranch: string; + redact: (value: string) => string; + timeoutMs: number; +}) => { + await mkdir(args.workdir, { recursive: true }); + const repoUrl = `https://x-access-token:${args.token}@github.com/${args.owner}/${args.repo}.git`; + const clone = await run( + 'git', + ['clone', '--branch', args.baseBranch, '--single-branch', repoUrl, 'repo'], + { + cwd: args.workdir, + redact: args.redact, + timeoutMs: args.timeoutMs, + }, + ); + if (clone.exitCode !== 0) { + throw new Error(`git clone failed:\n${clone.output}`); + } + const repoDir = path.join(args.workdir, 'repo'); + const checkout = await run('git', ['checkout', '-b', args.workBranch], { + cwd: repoDir, + redact: args.redact, + timeoutMs: args.timeoutMs, + }); + if (checkout.exitCode !== 0) { + throw new Error(`git checkout failed:\n${checkout.output}`); + } + return repoDir; +}; + +export const commitAndPush = async (args: { + repoDir: string; + workBranch: string; + message: string; + redact: (value: string) => string; + timeoutMs: number; +}) => { + await run('git', ['config', 'user.name', 'Spoon Agent'], { + cwd: args.repoDir, + redact: args.redact, + timeoutMs: args.timeoutMs, + }); + await run( + 'git', + ['config', 'user.email', 'spoon-agent@users.noreply.github.com'], + { + cwd: args.repoDir, + redact: args.redact, + timeoutMs: args.timeoutMs, + }, + ); + await run('git', ['add', '-A'], { + cwd: args.repoDir, + redact: args.redact, + timeoutMs: args.timeoutMs, + }); + const commit = await run('git', ['commit', '-m', args.message], { + cwd: args.repoDir, + redact: args.redact, + timeoutMs: args.timeoutMs, + }); + if (commit.exitCode !== 0) { + throw new Error(`git commit failed:\n${commit.output}`); + } + const sha = await run('git', ['rev-parse', 'HEAD'], { + cwd: args.repoDir, + redact: args.redact, + timeoutMs: args.timeoutMs, + }); + const push = await run('git', ['push', 'origin', args.workBranch], { + cwd: args.repoDir, + redact: args.redact, + timeoutMs: args.timeoutMs, + }); + if (push.exitCode !== 0) { + throw new Error(`git push failed:\n${push.output}`); + } + return sha.output.trim(); +}; + +export const getDiff = async ( + repoDir: string, + redact: (value: string) => string, +) => + await run('git', ['diff', '--cached', '--', '.'], { + cwd: repoDir, + redact, + timeoutMs: 60_000, + }); + +export const getWorktreeDiff = async ( + repoDir: string, + redact: (value: string) => string, +) => + await run('git', ['diff', '--', '.'], { + cwd: repoDir, + redact, + timeoutMs: 60_000, + }); + +export const getStatus = async ( + repoDir: string, + redact: (value: string) => string, +) => + await run('git', ['status', '--short'], { + cwd: repoDir, + redact, + timeoutMs: 60_000, + }); diff --git a/apps/agent-worker/src/github.ts b/apps/agent-worker/src/github.ts new file mode 100644 index 0000000..43185bc --- /dev/null +++ b/apps/agent-worker/src/github.ts @@ -0,0 +1,52 @@ +import { createAppAuth } from '@octokit/auth-app'; +import { Octokit } from '@octokit/rest'; + +import { env } from './env'; + +export const getInstallationToken = async (installationId: string) => { + const auth = createAppAuth({ + appId: env.githubAppId, + privateKey: env.githubPrivateKey, + installationId, + }); + const result = await auth({ type: 'installation' }); + return result.token; +}; + +export const getInstallationOctokit = (installationId: string) => + new Octokit({ + authStrategy: createAppAuth, + auth: { + appId: env.githubAppId, + privateKey: env.githubPrivateKey, + installationId, + }, + userAgent: 'Spoon Agent Worker', + request: { + headers: { + 'X-GitHub-Api-Version': '2022-11-28', + }, + }, + }); + +export const openDraftPullRequest = async (args: { + installationId: string; + forkOwner: string; + forkRepo: string; + baseBranch: string; + workBranch: string; + title: string; + body: string; +}) => { + const octokit = getInstallationOctokit(args.installationId); + const result = await octokit.rest.pulls.create({ + owner: args.forkOwner, + repo: args.forkRepo, + base: args.baseBranch, + head: args.workBranch, + title: args.title, + body: args.body, + draft: true, + }); + return result.data; +}; diff --git a/apps/agent-worker/src/index.ts b/apps/agent-worker/src/index.ts new file mode 100644 index 0000000..9623767 --- /dev/null +++ b/apps/agent-worker/src/index.ts @@ -0,0 +1,3 @@ +import { startWorker } from './worker'; + +await startWorker(); diff --git a/apps/agent-worker/src/redact.ts b/apps/agent-worker/src/redact.ts new file mode 100644 index 0000000..b2e6756 --- /dev/null +++ b/apps/agent-worker/src/redact.ts @@ -0,0 +1,26 @@ +const secretPatterns = [ + /ghs_[A-Za-z0-9_]+/g, + /github_pat_[A-Za-z0-9_]+/g, + /sk-[A-Za-z0-9_-]+/g, + /(client_secret|auth_secret|api_key|token|password)=([^ \n\r]+)/gi, +]; + +export const createRedactor = (values: string[]) => { + const secrets = values.filter((value) => value.length >= 3); + return (input: string) => { + let output = input; + for (const secret of secrets) { + output = output.split(secret).join('[redacted]'); + } + for (const pattern of secretPatterns) { + output = output.replace(pattern, '$1=[redacted]'); + } + return output; + }; +}; + +export const truncate = (value: string, maxBytes: number) => { + const buffer = Buffer.from(value); + if (buffer.byteLength <= maxBytes) return value; + return `${buffer.subarray(0, maxBytes).toString('utf8')}\n[truncated]`; +}; diff --git a/apps/agent-worker/src/runtime/docker.ts b/apps/agent-worker/src/runtime/docker.ts new file mode 100644 index 0000000..bfa0181 --- /dev/null +++ b/apps/agent-worker/src/runtime/docker.ts @@ -0,0 +1,45 @@ +import { execa } from 'execa'; + +import { env } from '../env'; + +export const runInJobContainer = async (args: { + workdir: string; + command: string[]; + environment: Record; + redact: (value: string) => string; + timeoutMs: number; +}) => { + const envArgs = Object.entries(args.environment).flatMap(([name, value]) => [ + '-e', + `${name}=${value}`, + ]); + const networkArgs = env.network ? ['--network', env.network] : []; + const result = await execa( + 'docker', + [ + 'run', + '--rm', + '--memory', + '4g', + '--cpus', + '2', + ...networkArgs, + ...envArgs, + '-v', + `${args.workdir}:/workspace`, + '-w', + '/workspace/repo', + env.jobImage, + ...args.command, + ], + { + all: true, + reject: false, + timeout: args.timeoutMs, + }, + ); + return { + exitCode: result.exitCode ?? 0, + output: args.redact(result.all), + }; +}; diff --git a/apps/agent-worker/src/worker.ts b/apps/agent-worker/src/worker.ts new file mode 100644 index 0000000..1bee109 --- /dev/null +++ b/apps/agent-worker/src/worker.ts @@ -0,0 +1,423 @@ +import { access, readFile, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { ConvexHttpClient } from 'convex/browser'; + +import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; +import { api } from '@spoon/backend/convex/_generated/api.js'; + +import { runOpenAiEdit } from './agent'; +import { env } from './env'; +import { + cloneRepository, + commitAndPush, + getStatus, + getWorktreeDiff, + run, +} from './git'; +import { getInstallationToken, openDraftPullRequest } from './github'; +import { createRedactor, truncate } from './redact'; +import { runInJobContainer } from './runtime/docker'; + +type Claim = { + job: { + _id: Id<'agentJobs'>; + prompt: string; + baseBranch: string; + workBranch: string; + forkOwner: string; + forkRepo: string; + upstreamOwner: string; + upstreamRepo: string; + }; + spoon: { name: string }; + openai: { + apiKey: string; + model: string; + reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; + }; + github: { installationId?: string }; + agentSettings?: { + installCommand?: string; + checkCommand?: string; + testCommand?: string; + } | null; + secrets: { name: string; value: string }[]; +}; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const client = new ConvexHttpClient(env.convexUrl); + +const appendEvent = async ( + jobId: Id<'agentJobs'>, + level: 'debug' | 'info' | 'warn' | 'error', + phase: + | 'queued' + | 'clone' + | 'plan' + | 'edit' + | 'install' + | 'check' + | 'test' + | 'commit' + | 'push' + | 'pr' + | 'cleanup', + message: string, + metadata?: string, +) => + await client.mutation(api.agentJobs.appendEvent, { + workerToken: env.workerToken, + workerId: env.workerId, + jobId, + level, + phase, + message, + metadata, + }); + +const updateStatus = async ( + jobId: Id<'agentJobs'>, + status: + | 'queued' + | 'claimed' + | 'preparing' + | 'running' + | 'checks_running' + | 'changes_ready' + | 'draft_pr_opened' + | 'failed' + | 'cancelled' + | 'timed_out', + extra?: { error?: string; summary?: string }, +) => + await client.mutation(api.agentJobs.updateStatus, { + workerToken: env.workerToken, + workerId: env.workerId, + jobId, + status, + ...extra, + }); + +const addArtifact = async (args: { + jobId: Id<'agentJobs'>; + kind: 'plan' | 'diff' | 'test_output' | 'summary' | 'error' | 'pr_body'; + title: string; + content: string; + contentType: + | 'text/markdown' + | 'text/plain' + | 'application/json' + | 'text/x-diff'; +}) => + await client.mutation(api.agentJobs.addArtifact, { + workerToken: env.workerToken, + workerId: env.workerId, + ...args, + }); + +const completeWithDraftPr = async (args: { + jobId: Id<'agentJobs'>; + commitSha: string; + pullRequestUrl: string; + pullRequestNumber: number; + summary: string; +}) => + await client.mutation(api.agentJobs.completeWithDraftPr, { + workerToken: env.workerToken, + workerId: env.workerId, + ...args, + }); + +const commandToShell = (command: string) => ['bash', '-lc', command]; + +const fileExists = async (filePath: string) => { + try { + await access(filePath); + return true; + } catch { + return false; + } +}; + +const runProjectCommand = async (args: { + command: string; + phase: 'install' | 'check' | 'test'; + claim: Claim; + workdir: string; + repoDir: string; + redact: (value: string) => string; +}) => { + await appendEvent(args.claim.job._id, 'info', args.phase, args.command); + const result = + env.runtime === 'docker' + ? await runInJobContainer({ + workdir: args.workdir, + command: commandToShell(args.command), + environment: Object.fromEntries( + args.claim.secrets.map((secret) => [secret.name, secret.value]), + ), + redact: args.redact, + timeoutMs: env.jobTimeoutMs, + }) + : await run('bash', ['-lc', args.command], { + cwd: args.repoDir, + env: Object.fromEntries( + args.claim.secrets.map((secret) => [secret.name, secret.value]), + ), + redact: args.redact, + timeoutMs: env.jobTimeoutMs, + }); + await addArtifact({ + jobId: args.claim.job._id, + kind: args.phase === 'test' ? 'test_output' : 'summary', + title: args.command, + content: truncate(result.output, 100_000), + contentType: 'text/plain', + }); + if (result.exitCode !== 0) { + throw new Error(`${args.command} failed:\n${result.output}`); + } +}; + +const detectPackageCommands = async ( + repoDir: string, +): Promise<{ install?: string; check?: string; test?: string }> => { + const packageJsonPath = path.join(repoDir, 'package.json'); + try { + const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as { + scripts?: Record; + }; + const scripts = packageJson.scripts ?? {}; + return { + install: (await fileExists(path.join(repoDir, 'bun.lock'))) + ? 'bun install' + : (await fileExists(path.join(repoDir, 'pnpm-lock.yaml'))) + ? 'pnpm install' + : (await fileExists(path.join(repoDir, 'yarn.lock'))) + ? 'yarn install' + : 'npm install', + check: scripts.typecheck + ? 'npm run typecheck' + : scripts.lint + ? 'npm run lint' + : undefined, + test: scripts.test ? 'npm test' : undefined, + }; + } catch { + return {}; + } +}; + +const buildPrBody = (args: { + prompt: string; + summary: string; + commands: string[]; + limitations: string[]; +}) => `## Spoon agent request + +${args.prompt} + +## Summary + +${args.summary} + +## Validation + +${ + args.commands.length + ? args.commands.map((command) => `- \`${command}\``).join('\n') + : '- No validation commands were requested by the agent.' +} + +## Limitations + +${ + args.limitations.length + ? args.limitations.map((item) => `- ${item}`).join('\n') + : '- No limitations reported.' +} + +Generated by Spoon.`; + +const runClaim = async (claim: Claim) => { + const jobId = claim.job._id; + const workdir = path.resolve(env.workdir, jobId); + const secretValues = [ + claim.openai.apiKey, + ...claim.secrets.map((secret) => secret.value), + ]; + const redact = createRedactor(secretValues); + try { + await updateStatus(jobId, 'preparing'); + await appendEvent(jobId, 'info', 'clone', 'Creating installation token.'); + if (!claim.github.installationId) { + throw new Error('GitHub installation ID is missing.'); + } + const githubToken = await getInstallationToken(claim.github.installationId); + const repoDir = await cloneRepository({ + workdir, + token: githubToken, + owner: claim.job.forkOwner, + repo: claim.job.forkRepo, + baseBranch: claim.job.baseBranch, + workBranch: claim.job.workBranch, + redact, + timeoutMs: env.jobTimeoutMs, + }); + await updateStatus(jobId, 'running'); + await appendEvent(jobId, 'info', 'plan', 'Gathering repo context.'); + const edit = await runOpenAiEdit({ + repoDir, + apiKey: claim.openai.apiKey, + model: claim.openai.model, + reasoningEffort: claim.openai.reasoningEffort, + prompt: claim.job.prompt, + 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 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({ + jobId, + kind: 'pr_body', + title: 'Draft PR body', + content: prBody, + contentType: 'text/markdown', + }); + await appendEvent(jobId, 'info', 'pr', 'Opening draft pull request.'); + 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, + commitSha, + pullRequestUrl: pullRequest.html_url, + pullRequestNumber: pullRequest.number, + summary: edit.summary, + }); + await appendEvent(jobId, 'info', 'cleanup', 'Agent job completed.'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await appendEvent( + jobId, + 'error', + 'cleanup', + truncate(redact(message), 20_000), + ); + await addArtifact({ + jobId, + kind: 'error', + title: 'Failure', + content: truncate(redact(message), 50_000), + contentType: 'text/plain', + }); + await updateStatus( + jobId, + message.toLowerCase().includes('timed out') ? 'timed_out' : 'failed', + { error: truncate(redact(message), 10_000) }, + ); + } finally { + await rm(workdir, { recursive: true, force: true }); + } +}; + +export const startWorker = async () => { + console.log(`Spoon agent worker ${env.workerId} polling ${env.convexUrl}`); + for (;;) { + try { + const claim = await client.action(api.agentJobsNode.claimNextForWorker, { + workerId: env.workerId, + workerToken: env.workerToken, + }); + if (!claim) { + await sleep(env.pollMs); + continue; + } + await runClaim(claim); + } catch (error) { + console.error(error); + await sleep(env.pollMs); + } + } +}; diff --git a/apps/agent-worker/tsconfig.json b/apps/agent-worker/tsconfig.json new file mode 100644 index 0000000..eba6a0f --- /dev/null +++ b/apps/agent-worker/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@spoon/tsconfig/base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM"], + "types": ["node"] + }, + "include": ["src", "eslint.config.ts", "vitest.config.ts"], + "exclude": ["node_modules"] +} diff --git a/apps/agent-worker/vitest.config.ts b/apps/agent-worker/vitest.config.ts new file mode 100644 index 0000000..be5b5b5 --- /dev/null +++ b/apps/agent-worker/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +import { nodeProject } from '@spoon/vitest-config'; + +export default defineConfig({ + test: { + projects: [ + nodeProject('unit', ['tests/unit/**/*.test.ts']), + nodeProject('integration', ['tests/integration/**/*.test.ts']), + nodeProject('component', ['tests/component/**/*.test.ts']), + ], + }, +}); diff --git a/apps/next/src/app/(app)/agents/page.tsx b/apps/next/src/app/(app)/agents/page.tsx index 2f9ed0d..f2e63ee 100644 --- a/apps/next/src/app/(app)/agents/page.tsx +++ b/apps/next/src/app/(app)/agents/page.tsx @@ -120,7 +120,12 @@ const AgentsPage = () => {

{request.prompt}

- {request.status.replaceAll('_', ' ')} + {request.status.replaceAll('_', ' ')} ·{' '} + {(request.requestType ?? 'future_code_change').replaceAll( + '_', + ' ', + )}{' '} + · {request.source ?? 'user'}

))} diff --git a/apps/next/src/app/(app)/dashboard/page.tsx b/apps/next/src/app/(app)/dashboard/page.tsx index 3448abf..63335a7 100644 --- a/apps/next/src/app/(app)/dashboard/page.tsx +++ b/apps/next/src/app/(app)/dashboard/page.tsx @@ -3,8 +3,15 @@ import Link from 'next/link'; import { MetricCard } from '@/components/dashboard/metric-card'; import { SpoonCard } from '@/components/spoons/spoon-card'; +import { MaintenanceQueue } from '@/components/updates/maintenance-queue'; import { useQuery } from 'convex/react'; -import { Bot, GitBranch, GitPullRequest, RefreshCw } from 'lucide-react'; +import { + Bot, + GitBranch, + GitPullRequest, + RefreshCw, + ShieldCheck, +} from 'lucide-react'; import { api } from '@spoon/backend/convex/_generated/api.js'; import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui'; @@ -14,12 +21,18 @@ const DashboardPage = () => { const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? []; const agentRequests = useQuery(api.agentRequests.listRecent, { limit: 5 }) ?? []; + const aiReviews = useQuery(api.aiReviews.listRecent, { limit: 5 }) ?? []; const activeSpoons = spoons.filter( (spoon) => spoon.status === 'active', ).length; - const needsReview = syncRuns.filter( - (run) => run.status === 'needs_review', + const behind = spoons.filter((spoon) => spoon.syncStatus === 'behind').length; + const diverged = spoons.filter( + (spoon) => spoon.syncStatus === 'diverged', ).length; + const openPullRequests = spoons.reduce( + (total, spoon) => total + (spoon.upstreamAheadBy ?? 0), + 0, + ); return (
@@ -49,9 +62,9 @@ const DashboardPage = () => { icon={GitPullRequest} /> { note='Queued and recent' icon={Bot} /> + +
+

Maintenance queue

+ +
+

Recent Spoons

@@ -112,6 +136,35 @@ const DashboardPage = () => { )} + + + AI reviews + + + {aiReviews.length ? ( +
+ {aiReviews.map((review) => ( +
+

+ {review.risk} risk +

+

+ {review.outputSummary ?? review.inputSummary} +

+
+ ))} +
+ ) : ( +

+ OpenAI compatibility reviews will appear here after you run + them on a Spoon. +

+ )} +
+
diff --git a/apps/next/src/app/(app)/github/connect/page.tsx b/apps/next/src/app/(app)/github/connect/page.tsx new file mode 100644 index 0000000..3e50380 --- /dev/null +++ b/apps/next/src/app/(app)/github/connect/page.tsx @@ -0,0 +1,23 @@ +import { GitHubConnectClient } from '@/components/github/github-connect-client'; + +export default async function Page({ + searchParams, +}: { + searchParams: Promise<{ installation_id?: string }>; +}) { + const params = await searchParams; + return ( +
+
+

+ Connect GitHub +

+

+ Spoon stores the GitHub App installation ID and uses short-lived + installation tokens for repository automation. +

+
+ +
+ ); +} diff --git a/apps/next/src/app/(app)/settings/ai/page.tsx b/apps/next/src/app/(app)/settings/ai/page.tsx new file mode 100644 index 0000000..6c8b794 --- /dev/null +++ b/apps/next/src/app/(app)/settings/ai/page.tsx @@ -0,0 +1,16 @@ +import { OpenAiStatusPanel } from '@/components/integrations/openai-status-panel'; + +const AiSettingsPage = () => ( +
+
+

AI

+

+ Configure the OpenAI key, review model, and thinking level used for + compatibility reviews. +

+
+ +
+); + +export default AiSettingsPage; diff --git a/apps/next/src/app/(app)/settings/integrations/page.tsx b/apps/next/src/app/(app)/settings/integrations/page.tsx new file mode 100644 index 0000000..35a210c --- /dev/null +++ b/apps/next/src/app/(app)/settings/integrations/page.tsx @@ -0,0 +1,15 @@ +import { GithubIntegrationPanel } from '@/components/integrations/github-integration-panel'; + +const IntegrationsPage = () => ( +
+
+

Integrations

+

+ Provider access used by Spoon maintenance workflows. +

+
+ +
+); + +export default IntegrationsPage; diff --git a/apps/next/src/app/(app)/settings/layout.tsx b/apps/next/src/app/(app)/settings/layout.tsx new file mode 100644 index 0000000..0e1ace1 --- /dev/null +++ b/apps/next/src/app/(app)/settings/layout.tsx @@ -0,0 +1,54 @@ +'use client'; + +import type { ReactNode } from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { Brain, Github, Shield, User } from 'lucide-react'; + +import { cn } from '@spoon/ui'; + +const settingsItems = [ + { href: '/settings/profile', label: 'Profile', icon: User }, + { href: '/settings/integrations', label: 'Integrations', icon: Github }, + { href: '/settings/ai', label: 'AI', icon: Brain }, + { href: '/settings/security', label: 'Security', icon: Shield }, +]; + +const SettingsLayout = ({ children }: { children: ReactNode }) => { + const pathname = usePathname(); + return ( +
+
+
+

Settings

+

+ Account, provider, AI, and security controls for this Spoon + workspace. +

+
+
+
+ +
{children}
+
+
+ ); +}; + +export default SettingsLayout; diff --git a/apps/next/src/app/(app)/settings/page.tsx b/apps/next/src/app/(app)/settings/page.tsx new file mode 100644 index 0000000..1bb8991 --- /dev/null +++ b/apps/next/src/app/(app)/settings/page.tsx @@ -0,0 +1,7 @@ +import { redirect } from 'next/navigation'; + +const SettingsPage = () => { + redirect('/settings/profile'); +}; + +export default SettingsPage; diff --git a/apps/next/src/app/(app)/settings/profile/page.tsx b/apps/next/src/app/(app)/settings/profile/page.tsx new file mode 100644 index 0000000..d6e80ca --- /dev/null +++ b/apps/next/src/app/(app)/settings/profile/page.tsx @@ -0,0 +1,42 @@ +'use server'; + +import { + AvatarUpload, + ProfileHeader, + ResetPasswordForm, + UserInfoForm, +} from '@/components/layout/auth/profile'; +import { preloadQuery } from 'convex/nextjs'; + +import { api } from '@spoon/backend/convex/_generated/api.js'; +import { Card, Separator } from '@spoon/ui'; + +const SettingsProfilePage = async () => { + const preloadedUser = await preloadQuery(api.auth.getUser, {}); + const preloadedUserProvider = await preloadQuery( + api.auth.getUserProvider, + {}, + ); + return ( +
+
+

Profile

+

+ Manage your identity, avatar, and account email. +

+
+ + + + + + + +
+ ); +}; + +export default SettingsProfilePage; diff --git a/apps/next/src/app/(app)/settings/security/page.tsx b/apps/next/src/app/(app)/settings/security/page.tsx new file mode 100644 index 0000000..f1f10fb --- /dev/null +++ b/apps/next/src/app/(app)/settings/security/page.tsx @@ -0,0 +1,19 @@ +import { SignOutForm } from '@/components/layout/auth/profile'; + +import { Card } from '@spoon/ui'; + +const SecuritySettingsPage = () => ( +
+
+

Security

+

+ Session controls and security-sensitive account actions. +

+
+ + + +
+); + +export default SecuritySettingsPage; diff --git a/apps/next/src/app/(app)/spoons/[spoonId]/page.tsx b/apps/next/src/app/(app)/spoons/[spoonId]/page.tsx new file mode 100644 index 0000000..b21ab1c --- /dev/null +++ b/apps/next/src/app/(app)/spoons/[spoonId]/page.tsx @@ -0,0 +1,281 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { AgentJobList } from '@/components/agents/agent-job-list'; +import { AgentRequestForm } from '@/components/agents/agent-request-form'; +import { SpoonActivityTimeline } from '@/components/spoons/spoon-activity-timeline'; +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 { SpoonCommitList } from '@/components/spoons/spoon-commit-list'; +import { SpoonDetailHeader } from '@/components/spoons/spoon-detail-header'; +import { SpoonMetrics } from '@/components/spoons/spoon-metrics'; +import { SpoonPrList } from '@/components/spoons/spoon-pr-list'; +import { SpoonSecretsForm } from '@/components/spoons/spoon-secrets-form'; +import { SpoonSettingsForm } from '@/components/spoons/spoon-settings-form'; +import { useQuery } from 'convex/react'; + +import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; +import { api } from '@spoon/backend/convex/_generated/api.js'; +import { + Card, + CardContent, + CardHeader, + CardTitle, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@spoon/ui'; + +const SpoonDetailPage = () => { + const params = useParams<{ spoonId: string }>(); + const spoonId = params.spoonId as Id<'spoons'>; + const details = useQuery(api.spoons.getDetails, { spoonId }); + const upstreamCommits = + useQuery(api.spoonCommits.listForSpoon, { + spoonId, + side: 'upstream', + limit: 100, + }) ?? []; + const forkCommits = + useQuery(api.spoonCommits.listForSpoon, { + spoonId, + side: 'fork', + limit: 100, + }) ?? []; + const pullRequests = + useQuery(api.spoonPullRequests.listForSpoon, { spoonId, limit: 100 }) ?? []; + const reviews = + useQuery(api.aiReviews.listForSpoon, { spoonId, limit: 25 }) ?? []; + const syncRuns = + useQuery(api.syncRuns.listForSpoon, { spoonId, limit: 25 }) ?? []; + const agentRequests = + useQuery(api.agentRequests.listForSpoon, { spoonId, limit: 25 }) ?? []; + const agentSettings = useQuery(api.spoonAgentSettings.getForSpoon, { + spoonId, + }); + const agentJobs = + useQuery(api.agentJobs.listForSpoon, { spoonId, limit: 25 }) ?? []; + + if (details === undefined) { + return
Loading Spoon...
; + } + + return ( +
+ + + {details.spoon.lastError ? ( + + + {details.spoon.lastError} + + + ) : null} + + + + + Overview + + + Upstream + + + Fork changes + + + Pull requests + + + AI review + + + Agent work + + + Activity + + + Settings + + + + +
+ + + Repository health + + +
+

Drift state

+

+ {( + details.state?.status ?? + details.spoon.syncStatus ?? + 'unknown' + ).replaceAll('_', ' ')} +

+
+
+

Default branches

+

+ {details.state?.upstreamDefaultBranch ?? + details.spoon.upstreamDefaultBranch}{' '} + →{' '} + {details.state?.forkDefaultBranch ?? + details.spoon.forkDefaultBranch ?? + details.spoon.upstreamDefaultBranch} +

+
+
+

Merge base

+

+ {details.state?.mergeBaseSha ?? + details.spoon.lastMergeBaseCommit ?? + 'Unknown'} +

+
+
+

Cadence

+

+ {details.spoon.syncCadence} +

+
+
+
+ + + Latest AI review + + + {details.latestReview ? ( + <> +
+
+

Risk

+

+ {details.latestReview.risk} +

+
+
+

Action

+

+ {details.latestReview.recommendedAction.replaceAll( + '_', + ' ', + )} +

+
+
+

+ {details.latestReview.outputSummary ?? + details.latestReview.inputSummary} +

+ + ) : ( +

+ Run a refresh and AI review to get a compatibility summary + for upstream changes. +

+ )} +
+
+ +
+ +
+
+
+

Upstream waiting

+

+ Commits upstream has that your fork does not. +

+
+ +
+
+
+

Fork changes

+

+ Custom commits Spoon should preserve during maintenance. +

+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +}; + +export default SpoonDetailPage; diff --git a/apps/next/src/app/(app)/spoons/new/page.tsx b/apps/next/src/app/(app)/spoons/new/page.tsx index 1780c35..6e8bde7 100644 --- a/apps/next/src/app/(app)/spoons/new/page.tsx +++ b/apps/next/src/app/(app)/spoons/new/page.tsx @@ -1,3 +1,5 @@ +import { GitHubConnectionPanel } from '@/components/github/github-connection-panel'; +import { GitHubForkForm } from '@/components/spoons/github-fork-form'; import { NewSpoonForm } from '@/components/spoons/new-spoon-form'; const NewSpoonPage = () => ( @@ -5,8 +7,18 @@ const NewSpoonPage = () => (

New Spoon

- Create a provider-neutral managed fork record. This does not call a Git - provider yet; it prepares the dashboard surface for future automation. + Connect GitHub to create real forks, or add an existing managed fork + manually. +

+
+ + +
+

+ Add an existing fork manually +

+

+ Use this path for non-GitHub providers or forks that already exist.

diff --git a/apps/next/src/app/(app)/updates/page.tsx b/apps/next/src/app/(app)/updates/page.tsx index bed1d4a..cc3b907 100644 --- a/apps/next/src/app/(app)/updates/page.tsx +++ b/apps/next/src/app/(app)/updates/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { MaintenanceQueue } from '@/components/updates/maintenance-queue'; import { useQuery } from 'convex/react'; import { api } from '@spoon/backend/convex/_generated/api.js'; @@ -76,6 +77,10 @@ const UpdatesPage = () => { )} +
+

Maintenance queue

+ +
); }; diff --git a/apps/next/src/app/(auth)/profile/page.tsx b/apps/next/src/app/(auth)/profile/page.tsx index d81ca64..e38982b 100644 --- a/apps/next/src/app/(auth)/profile/page.tsx +++ b/apps/next/src/app/(auth)/profile/page.tsx @@ -1,51 +1,6 @@ -'use server'; +import { redirect } from 'next/navigation'; -import { - AvatarUpload, - ProfileHeader, - ResetPasswordForm, - SignOutForm, - UserInfoForm, -} from '@/components/layout/auth/profile'; -import { preloadQuery } from 'convex/nextjs'; - -import { api } from '@spoon/backend/convex/_generated/api.js'; -import { Card, Separator } from '@spoon/ui'; - -const Profile = async () => { - const preloadedUser = await preloadQuery(api.auth.getUser, {}); - const preloadedUserProvider = await preloadQuery( - api.auth.getUserProvider, - {}, - ); - return ( -
-
- {/* Page Header */} -
-

- Your Profile -

-

- Manage your personal information and preferences -

-
- - {/* Profile Card */} - - - - - - - - - -
-
- ); +const Profile = () => { + redirect('/settings/profile'); }; export default Profile; diff --git a/apps/next/src/app/(auth)/sign-in/page.tsx b/apps/next/src/app/(auth)/sign-in/page.tsx index 3b3b671..42d6eac 100644 --- a/apps/next/src/app/(auth)/sign-in/page.tsx +++ b/apps/next/src/app/(auth)/sign-in/page.tsx @@ -3,7 +3,10 @@ import { useState } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { AuthentikSignInButton } from '@/components/layout/auth/buttons'; +import { + AuthentikSignInButton, + GitHubSignInButton, +} from '@/components/layout/auth/buttons'; import { useAuthActions } from '@convex-dev/auth/react'; import { zodResolver } from '@hookform/resolvers/zod'; import { ConvexError } from 'convex/values'; @@ -342,7 +345,8 @@ const SignIn = () => { -
+
+
@@ -450,7 +454,8 @@ const SignIn = () => {
-
+
+
diff --git a/apps/next/src/app/global-error.tsx b/apps/next/src/app/global-error.tsx index 27a12d4..0570801 100644 --- a/apps/next/src/app/global-error.tsx +++ b/apps/next/src/app/global-error.tsx @@ -7,9 +7,6 @@ import { Geist, Geist_Mono } from 'next/font/google'; import '@/app/styles.css'; import { useEffect } from 'react'; -import Footer from '@/components/layout/footer'; -import Header from '@/components/layout/header'; -import { ConvexClientProvider } from '@/components/providers'; import { env } from '@/env'; import { generateMetadata } from '@/lib/metadata'; import * as Sentry from '@sentry/nextjs'; @@ -59,20 +56,13 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => { enableSystem disableTransitionOnChange > - -
-
- - {reset !== undefined && ( - - )} - -
-
-
- -
-
+
+ + {reset !== undefined && ( + + )} + +
diff --git a/apps/next/src/components/agents/agent-artifact-viewer.tsx b/apps/next/src/components/agents/agent-artifact-viewer.tsx new file mode 100644 index 0000000..679fc26 --- /dev/null +++ b/apps/next/src/components/agents/agent-artifact-viewer.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { Copy } from 'lucide-react'; +import { toast } from 'sonner'; + +import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js'; +import { Button } from '@spoon/ui'; + +export const AgentArtifactViewer = ({ + artifacts, +}: { + artifacts: Doc<'agentJobArtifacts'>[]; +}) => { + if (!artifacts.length) { + return ( +

+ No artifacts captured yet. +

+ ); + } + + return ( +
+ {artifacts.map((artifact) => ( +
+
+
+

{artifact.title}

+

{artifact.kind}

+
+ +
+
+            {artifact.content}
+          
+
+ ))} +
+ ); +}; diff --git a/apps/next/src/components/agents/agent-event-log.tsx b/apps/next/src/components/agents/agent-event-log.tsx new file mode 100644 index 0000000..bd75818 --- /dev/null +++ b/apps/next/src/components/agents/agent-event-log.tsx @@ -0,0 +1,47 @@ +'use client'; + +import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js'; + +const formatTime = (value: number) => + new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(value); + +export const AgentEventLog = ({ + events, +}: { + events: Doc<'agentJobEvents'>[]; +}) => { + if (!events.length) { + return ( +

No worker events yet.

+ ); + } + + return ( +
+ {events.map((event) => ( +
+
+ {event.phase} + + {formatTime(event.createdAt)} + + + {event.level} + +
+

{event.message}

+ {event.metadata ? ( +
+              {event.metadata}
+            
+ ) : null} +
+ ))} +
+ ); +}; diff --git a/apps/next/src/components/agents/agent-job-detail.tsx b/apps/next/src/components/agents/agent-job-detail.tsx new file mode 100644 index 0000000..06ce5a5 --- /dev/null +++ b/apps/next/src/components/agents/agent-job-detail.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { useQuery } from 'convex/react'; + +import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js'; +import { api } from '@spoon/backend/convex/_generated/api.js'; +import { Card, CardContent, CardHeader, CardTitle } from '@spoon/ui'; + +import { AgentArtifactViewer } from './agent-artifact-viewer'; +import { AgentEventLog } from './agent-event-log'; + +export const AgentJobDetail = ({ job }: { job: Doc<'agentJobs'> }) => { + const events = + useQuery(api.agentJobs.listEvents, { jobId: job._id, limit: 200 }) ?? []; + const artifacts = + useQuery(api.agentJobs.listArtifacts, { jobId: job._id }) ?? []; + + return ( + + + Job details + + +
+
+

Status

+

+ {job.status.replaceAll('_', ' ')} +

+
+
+

Branch

+

{job.workBranch}

+
+
+

Model

+

{job.model}

+
+
+ {job.pullRequestUrl ? ( + + Open draft PR #{job.pullRequestNumber} + + ) : null} + {job.error ? ( +
+            {job.error}
+          
+ ) : null} +
+

Events

+ +
+
+

Artifacts

+ +
+
+
+ ); +}; diff --git a/apps/next/src/components/agents/agent-job-list.tsx b/apps/next/src/components/agents/agent-job-list.tsx new file mode 100644 index 0000000..0e8654c --- /dev/null +++ b/apps/next/src/components/agents/agent-job-list.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation } from 'convex/react'; +import { ExternalLink, XCircle } from 'lucide-react'; +import { toast } from 'sonner'; + +import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js'; +import { api } from '@spoon/backend/convex/_generated/api.js'; +import { Badge, Button } from '@spoon/ui'; + +import { AgentJobDetail } from './agent-job-detail'; + +const formatTime = (value: number) => + new Intl.DateTimeFormat(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(value); + +export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => { + const cancel = useMutation(api.agentJobs.cancel); + const [selectedJobId, setSelectedJobId] = useState( + jobs[0]?._id ?? null, + ); + const selectedJob = jobs.find((job) => job._id === selectedJobId) ?? jobs[0]; + + if (!jobs.length) { + return ( +
+

No agent jobs yet

+

+ Queue a job to have Spoon open a draft PR against this fork. +

+
+ ); + } + + return ( +
+
+ {jobs.map((job) => ( + + ))} +
+ {selectedJob ? ( +
+ {[ + 'queued', + 'claimed', + 'preparing', + 'running', + 'checks_running', + ].includes(selectedJob.status) ? ( + + ) : null} + +
+ ) : null} +
+ ); +}; diff --git a/apps/next/src/components/agents/agent-request-form.tsx b/apps/next/src/components/agents/agent-request-form.tsx new file mode 100644 index 0000000..f61a123 --- /dev/null +++ b/apps/next/src/components/agents/agent-request-form.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQuery } from 'convex/react'; +import { Bot } from 'lucide-react'; +import { toast } from 'sonner'; + +import type { Doc, 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, + Textarea, +} from '@spoon/ui'; + +import { SecretSelector } from './secret-selector'; + +type AgentSettings = { + defaultBaseBranch?: string; + agentModel: string; + reasoningEffort: string; +}; + +export const AgentRequestForm = ({ + spoon, + agentSettings, +}: { + spoon: Doc<'spoons'>; + agentSettings?: AgentSettings | null; +}) => { + const secrets = useQuery(api.spoonSecrets.listForSpoon, { + spoonId: spoon._id, + }); + const createRequest = useMutation(api.agentRequests.create); + const createJob = useMutation(api.agentJobs.createFromRequest); + const [prompt, setPrompt] = useState(''); + const [baseBranch, setBaseBranch] = useState( + agentSettings?.defaultBaseBranch ?? + spoon.forkDefaultBranch ?? + spoon.upstreamDefaultBranch, + ); + const [requestedBranchName, setRequestedBranchName] = useState(''); + const [selectedSecretIds, setSelectedSecretIds] = useState< + Id<'spoonSecrets'>[] + >([]); + const [submitting, setSubmitting] = useState(false); + + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + setSubmitting(true); + try { + const requestId = await createRequest({ + spoonId: spoon._id, + prompt, + targetBranch: baseBranch, + }); + await createJob({ + requestId, + selectedSecretIds, + baseBranch, + requestedBranchName: requestedBranchName || undefined, + }); + setPrompt(''); + setRequestedBranchName(''); + setSelectedSecretIds([]); + toast.success('Agent job queued.'); + } catch (error) { + console.error(error); + toast.error('Could not queue agent job.'); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + + Request agent work + + + +
+
+ +