Compare commits
39 Commits
8ae6c4b533
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b09295570d | |||
| 3f1fee4e44 | |||
| 573246ce98 | |||
| 5fc1e2caf6 | |||
| ca5c623392 | |||
| 8d2a089268 | |||
| c6b27063a4 | |||
| c103430c7d | |||
| c0ff6d8bed | |||
| 2cd03b6a83 | |||
| 4c0de2cbf3 | |||
| 683fc62129 | |||
| 32a71f00ca | |||
| 65aae85369 | |||
| 5f7d56369f | |||
| fd48dcfc28 | |||
| 24a516c2b5 | |||
| 15407e7e9c | |||
| c1263b2e69 | |||
| 1072cf10cd | |||
| ae90681d9b | |||
| bb471a0917 | |||
| 40a6dd78e4 | |||
| a2976481d7 | |||
| 9643cb197b | |||
| 980a2c07e8 | |||
| 4fee7bf50d | |||
| 30a17196f5 | |||
| c3d265d428 | |||
| 5567a4be95 | |||
| a6f7ea7f78 | |||
| d207b8b0b8 | |||
| fe72fc2957 | |||
| 930fbf5965 | |||
| f33f76d874 | |||
| 7e7bec56d5 | |||
| 42f95530de | |||
| ddce5efb13 | |||
| 206b64176b |
+2
-1
@@ -45,7 +45,8 @@ packages/backend/.convex
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker
|
docker/*
|
||||||
|
!docker/agent-job-rootfs
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Build and Push Next App
|
name: Build and Push Spoon Images
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
printf '%s\n' "$DOTENV_PROD" > "$env_file"
|
printf '%s\n' "$DOTENV_PROD" > "$env_file"
|
||||||
bunx dotenv -e "$env_file" -- env NODE_ENV=test SKIP_E2E=1 bun run ci:check
|
bunx dotenv -e "$env_file" -- env NODE_ENV=test SKIP_E2E=1 bun run ci:check
|
||||||
|
|
||||||
build-next:
|
build-images:
|
||||||
needs: [quality]
|
needs: [quality]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
bun-version: 1.3.10
|
bun-version: 1.3.10
|
||||||
- run: bun install --frozen-lockfile
|
- run: bun install --frozen-lockfile
|
||||||
- name: Build image
|
- name: Build Next image
|
||||||
env:
|
env:
|
||||||
DOTENV_PROD: ${{ secrets.DOTENV_PROD }}
|
DOTENV_PROD: ${{ secrets.DOTENV_PROD }}
|
||||||
run: |
|
run: |
|
||||||
@@ -52,9 +52,23 @@ jobs:
|
|||||||
trap 'rm -f "$env_file"' EXIT
|
trap 'rm -f "$env_file"' EXIT
|
||||||
printf '%s\n' "$DOTENV_PROD" > "$env_file"
|
printf '%s\n' "$DOTENV_PROD" > "$env_file"
|
||||||
CI_ENV_FILE="$env_file" ./scripts/build-next-app production
|
CI_ENV_FILE="$env_file" ./scripts/build-next-app production
|
||||||
- name: Tag and push image
|
- name: Build agent images
|
||||||
|
env:
|
||||||
|
SPOON_BUILD_SHA: ${{ gitea.sha }}
|
||||||
|
run: SPOON_AGENT_CONTAINER_RUNTIME=docker ./scripts/build-agent-images
|
||||||
|
- name: Tag and push images
|
||||||
run: |
|
run: |
|
||||||
docker tag spoon-next:latest git.gbrown.org/gib/spoon-next:${{ gitea.sha }}
|
docker tag spoon-next:latest git.gbrown.org/gib/spoon-next:${{ gitea.sha }}
|
||||||
docker tag spoon-next:latest git.gbrown.org/gib/spoon-next:latest
|
docker tag spoon-next:latest git.gbrown.org/gib/spoon-next:latest
|
||||||
docker push git.gbrown.org/gib/spoon-next:${{ gitea.sha }}
|
docker push git.gbrown.org/gib/spoon-next:${{ gitea.sha }}
|
||||||
docker push git.gbrown.org/gib/spoon-next:latest
|
docker push git.gbrown.org/gib/spoon-next:latest
|
||||||
|
|
||||||
|
docker tag spoon-agent-worker:latest git.gbrown.org/gib/spoon-agent-worker:${{ gitea.sha }}
|
||||||
|
docker tag spoon-agent-worker:latest git.gbrown.org/gib/spoon-agent-worker:latest
|
||||||
|
docker push git.gbrown.org/gib/spoon-agent-worker:${{ gitea.sha }}
|
||||||
|
docker push git.gbrown.org/gib/spoon-agent-worker:latest
|
||||||
|
|
||||||
|
docker tag spoon-agent-job:latest git.gbrown.org/gib/spoon-agent-job:${{ gitea.sha }}
|
||||||
|
docker tag spoon-agent-job:latest git.gbrown.org/gib/spoon-agent-job:latest
|
||||||
|
docker push git.gbrown.org/gib/spoon-agent-job:${{ gitea.sha }}
|
||||||
|
docker push git.gbrown.org/gib/spoon-agent-job:latest
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
bunx lint-staged --concurrent 1
|
bunx lint-staged --concurrent 1
|
||||||
|
infisical scan git-changes --staged
|
||||||
|
|||||||
@@ -5,15 +5,30 @@
|
|||||||
- `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.
|
||||||
- `tools`: shared ESLint, Prettier, Tailwind, TypeScript, and Vitest config.
|
- `tools`: shared ESLint, Prettier, Tailwind, TypeScript, and Vitest config.
|
||||||
|
- Threads are the canonical user-facing workspace route. Normal navigation
|
||||||
|
should open `/threads/[threadId]`; legacy job URLs under
|
||||||
|
`/spoons/[spoonId]/agent/[jobId]` are compatibility routes for jobs that do
|
||||||
|
not have a thread yet.
|
||||||
- Local development uses host-run apps, local Convex on ports 3210/3211, local
|
- 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.
|
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
|
Agent jobs are opt-in; build `docker/agent-job.Dockerfile` as
|
||||||
`spoon-agent-job:latest` before running Docker-backed jobs.
|
`spoon-agent-job:latest` before running Docker-backed jobs.
|
||||||
|
- Gitea CI builds and pushes `spoon-next`, `spoon-agent-worker`, and
|
||||||
|
`spoon-agent-job` images to `git.gbrown.org/gib`. In production,
|
||||||
|
`SPOON_AGENT_JOB_IMAGE` should point to
|
||||||
|
`git.gbrown.org/gib/spoon-agent-job:latest`, and the worker service requires
|
||||||
|
access to the host Docker socket. API-key provider jobs run through OpenCode;
|
||||||
|
Codex ChatGPT login profiles run through the Codex CLI with an injected
|
||||||
|
`CODEX_HOME/.codex/auth.json` inside the isolated job workspace.
|
||||||
|
The job image must keep Node, npm, Bun, pnpm, yarn, git, ripgrep, jq,
|
||||||
|
Python, OpenCode, and Codex available.
|
||||||
|
|
||||||
## Protected and generated files
|
## Protected and generated files
|
||||||
|
|
||||||
@@ -31,13 +46,33 @@
|
|||||||
- Local `dev` and `staging` come only from Infisical via
|
- Local `dev` and `staging` come only from Infisical via
|
||||||
`scripts/with-env`; it never falls back to `.env*`.
|
`scripts/with-env`; it never falls back to `.env*`.
|
||||||
- Run `infisical login` and `infisical init` before local development.
|
- Run `infisical login` and `infisical init` before local development.
|
||||||
|
- `scripts/export-env` enforces `.local/infisical.env` when multiple local
|
||||||
|
Infisical accounts are logged in. Put `INFISICAL_EMAIL=you@example.com` there
|
||||||
|
for this project and do not commit it.
|
||||||
- Machine-generated values belong in `.local/<env>.generated.env`; never put
|
- Machine-generated values belong in `.local/<env>.generated.env`; never put
|
||||||
the generated Convex admin key in shared Infisical.
|
the generated Convex admin key in shared Infisical.
|
||||||
- `scripts/sync-convex-env <dev|staging>` copies Authentik, GitHub App,
|
- `scripts/sync-convex-env <dev|staging>` copies Authentik, GitHub App,
|
||||||
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.
|
||||||
|
- Host-run worker dev uses `scripts/dev-agent-worker` after Infisical env
|
||||||
|
loading. It prefers Podman, sets `SPOON_AGENT_CONTAINER_ACCESS=host_port`,
|
||||||
|
and expects `spoon-agent-job:latest` to exist locally.
|
||||||
|
- Containerized production workers that control the host Docker socket must set
|
||||||
|
`SPOON_AGENT_HOST_WORKDIR` to the host-side path backing
|
||||||
|
`SPOON_AGENT_WORKDIR`. Docker bind mount source paths are resolved on the host,
|
||||||
|
not inside the worker container.
|
||||||
|
- `bun smoke:agent-container` checks that the local job image has Node, npm,
|
||||||
|
Bun, pnpm, yarn, git, ripgrep, jq, Python, OpenCode, and Codex available.
|
||||||
|
- Old terminal workspaces can be deleted from `Settings -> Worker`; orphaned
|
||||||
|
containers/workdirs are cleaned through the worker HTTP API, not from the
|
||||||
|
browser directly.
|
||||||
- CI uses Gitea-injected secrets or `CI_ENV_FILE` and must not call Infisical.
|
- CI uses Gitea-injected secrets or `CI_ENV_FILE` and must not call Infisical.
|
||||||
|
- Gitea image builds force `SPOON_AGENT_CONTAINER_RUNTIME=docker`; keep local
|
||||||
|
Podman auto-detection out of CI image tagging/pushing.
|
||||||
- CI must provide Convex deployment env for codegen, either
|
- CI must provide Convex deployment env for codegen, either
|
||||||
`CONVEX_SELF_HOSTED_URL` plus `CONVEX_SELF_HOSTED_ADMIN_KEY`, or
|
`CONVEX_SELF_HOSTED_URL` plus `CONVEX_SELF_HOSTED_ADMIN_KEY`, or
|
||||||
`CONVEX_DEPLOYMENT`.
|
`CONVEX_DEPLOYMENT`.
|
||||||
@@ -62,6 +97,7 @@
|
|||||||
bun db:up # start Postgres, Convex, and dashboard
|
bun db:up # start Postgres, Convex, and dashboard
|
||||||
bun dev:next # host Next + deploy/watch local Convex functions
|
bun dev:next # host Next + deploy/watch local Convex functions
|
||||||
bun dev:agent # run the optional coding-agent worker on the host
|
bun dev:agent # run the optional coding-agent worker on the host
|
||||||
|
bun dev:next:worker # run Next, backend, and agent worker together
|
||||||
bun sync:convex # sync Infisical values into Convex
|
bun sync:convex # sync Infisical values into Convex
|
||||||
bun db:down # stop and preserve local data
|
bun db:down # stop and preserve local data
|
||||||
bun db:down:wipe # remove local data volumes and generated admin key
|
bun db:down:wipe # remove local data volumes and generated admin key
|
||||||
|
|||||||
@@ -1,252 +1,557 @@
|
|||||||
# Spoon
|
<p align="center">
|
||||||
|
<img src="apps/next/public/favicon.png" alt="Spoon logo" width="96" height="96" />
|
||||||
|
</p>
|
||||||
|
|
||||||
Spoon is a self-hostable fork maintenance dashboard.
|
<h1 align="center">Spoon</h1>
|
||||||
|
|
||||||
The product goal is simple: make it practical to fork a project, customize it,
|
<p align="center">
|
||||||
and still stay close to upstream. Spoon tracks managed forks, called
|
<strong>Fork freely & keep them all intimately close to upstream.</strong>
|
||||||
**Spoons**, and lays the foundation for upstream update checks, AI-assisted
|
</p>
|
||||||
change review, and agent-authored merge requests.
|
|
||||||
|
|
||||||
This repository is the Spoon application itself, not a generic starter.
|
<p align="center">
|
||||||
|
Spoon is a self-hostable fork maintenance cockpit built around managed forks,
|
||||||
|
durable maintenance threads, and OpenCode-powered workspaces.
|
||||||
|
</p>
|
||||||
|
|
||||||
## Current scope
|
<p align="center">
|
||||||
|
<a href="#what-this-is">What this is</a>
|
||||||
|
·
|
||||||
|
<a href="#product-model">Product model</a>
|
||||||
|
·
|
||||||
|
<a href="#architecture">Architecture</a>
|
||||||
|
·
|
||||||
|
<a href="#environment-reference">Environment</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
Implemented today:
|
---
|
||||||
|
|
||||||
- Public Spoon landing page in Next.js.
|
## What This Is
|
||||||
- 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:
|
Spoon is a private, actively evolving project for making forks less lonely to
|
||||||
|
maintain.
|
||||||
|
|
||||||
- Browser IDE/editor.
|
Forking a project is easy. Keeping that fork close to upstream after you add
|
||||||
- Automatic merge.
|
custom changes is the hard part. Spoon treats a fork as an ongoing relationship:
|
||||||
- Additional Git provider automation beyond preserving provider-neutral fields.
|
it watches upstream, understands fork-only commits, automatically syncs clean
|
||||||
- Additional remotes as push targets.
|
drift when it can, and opens a durable **Thread** when a decision needs context
|
||||||
- Long-running service-stack orchestration inside agent jobs.
|
or code.
|
||||||
- Production mobile build/release setup.
|
|
||||||
|
The application is currently GitHub-first. Future provider-neutral fields exist
|
||||||
|
in the data model, but GitHub is the active automation surface today.
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- **Managed forks, called Spoons**
|
||||||
|
Track upstream metadata, fork metadata, clone URLs, extra remotes, sync
|
||||||
|
cadence, production-ref strategy, fork-only commits, and pull requests.
|
||||||
|
|
||||||
|
- **Thread-first maintenance**
|
||||||
|
Upstream updates, conflict review, ignore decisions, user-requested work,
|
||||||
|
worker output, and draft PR handoff all live inside Threads.
|
||||||
|
|
||||||
|
- **Clean drift auto-sync**
|
||||||
|
If upstream moves and the fork has no custom commits, Spoon can fast-forward
|
||||||
|
the fork without creating busywork.
|
||||||
|
|
||||||
|
- **Custom forks get context**
|
||||||
|
If the fork has custom commits, Spoon creates a maintenance thread rather than
|
||||||
|
pretending the update is trivial.
|
||||||
|
|
||||||
|
- **Effective drift**
|
||||||
|
Spoon keeps raw GitHub drift visible while also tracking ignored upstream
|
||||||
|
changes so irrelevant commits do not keep a fork permanently actionable.
|
||||||
|
|
||||||
|
- **OpenCode workspaces**
|
||||||
|
Agent work happens in an isolated workspace with a file tree, browser editor,
|
||||||
|
diff viewer, command panel, logs, artifacts, and draft PR actions.
|
||||||
|
|
||||||
|
- **User-owned providers and secrets**
|
||||||
|
AI provider profiles, Codex/OpenCode auth, and per-Spoon project secrets are
|
||||||
|
encrypted. Secrets are redacted from logs and refused from commits when
|
||||||
|
materialized into env files.
|
||||||
|
|
||||||
|
- **Draft PR handoff**
|
||||||
|
Code changes become branches and draft pull requests. Spoon does not
|
||||||
|
auto-merge custom forks behind the user's back.
|
||||||
|
|
||||||
|
## Product Model
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary><strong>Spoons</strong></summary>
|
||||||
|
|
||||||
|
A **Spoon** is a managed fork. It records the upstream project, the fork
|
||||||
|
repository, default branches, sync policy, extra remotes, current drift, cached
|
||||||
|
commits, cached pull requests, secrets, and agent settings.
|
||||||
|
|
||||||
|
Spoons are the durable project-level objects. They answer:
|
||||||
|
|
||||||
|
- What did I fork?
|
||||||
|
- Where does my fork live?
|
||||||
|
- How far has it drifted?
|
||||||
|
- Which commits are mine?
|
||||||
|
- Which upstream changes matter?
|
||||||
|
- What threads or PRs are open?
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary><strong>Threads</strong></summary>
|
||||||
|
|
||||||
|
A **Thread** is the durable place where Spoon talks about maintenance work.
|
||||||
|
Threads can be created by a user or by the system.
|
||||||
|
|
||||||
|
Common thread sources:
|
||||||
|
|
||||||
|
- `user_request`: user asks Spoon to change a fork.
|
||||||
|
- `upstream_update`: upstream moved and the fork needs review.
|
||||||
|
- `merge_conflict`: a sync conflict needs context or code.
|
||||||
|
- `manual_review`: user explicitly asks for a review.
|
||||||
|
- `system`: internal maintenance coordination.
|
||||||
|
|
||||||
|
Threads hold messages, status, outcomes, related sync runs, related jobs,
|
||||||
|
workspace links, draft PR links, and ignored upstream decisions.
|
||||||
|
|
||||||
|
Opening a thread opens its workspace when a run exists. The workspace is the
|
||||||
|
primary surface for that thread: agent messages, tool activity, file edits,
|
||||||
|
manual edits, diffs, commands, and draft PR actions all happen there. Legacy
|
||||||
|
job URLs under `/spoons/[spoonId]/agent/[jobId]` are kept for compatibility,
|
||||||
|
but normal navigation targets `/threads/[threadId]`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary><strong>Maintenance decisions</strong></summary>
|
||||||
|
|
||||||
|
Spoon's maintenance policy is intentionally conservative:
|
||||||
|
|
||||||
|
| Situation | Default action |
|
||||||
|
| ------------------------------------------ | ------------------------------------- |
|
||||||
|
| No fork-only commits and upstream is ahead | Auto-sync |
|
||||||
|
| Fork-only commits and upstream is ahead | Create a maintenance thread |
|
||||||
|
| Merge conflicts | Open or continue a workspace thread |
|
||||||
|
| Irrelevant upstream changes | Record an intentional ignore decision |
|
||||||
|
| Agent/code changes | Open a draft PR |
|
||||||
|
|
||||||
|
The goal is to keep forks close without hiding risk or skipping review when
|
||||||
|
custom work exists.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>OpenCode workspaces</strong></summary>
|
||||||
|
|
||||||
|
Spoon's optional agent worker is designed to run outside Convex actions. The
|
||||||
|
worker claims queued jobs, clones the current GitHub fork, creates a branch,
|
||||||
|
starts an isolated workspace, and exposes workspace operations to the Next app
|
||||||
|
through server-only API proxies.
|
||||||
|
|
||||||
|
Workspace capabilities:
|
||||||
|
|
||||||
|
- browse repository files
|
||||||
|
- edit files in a browser editor
|
||||||
|
- use optional Vim keybindings
|
||||||
|
- resize the agent thread panel on desktop
|
||||||
|
- inspect diffs
|
||||||
|
- send thread messages to the agent
|
||||||
|
- run configured commands
|
||||||
|
- store logs and artifacts
|
||||||
|
- push a branch
|
||||||
|
- open a draft PR
|
||||||
|
|
||||||
|
The browser never receives worker tokens and never talks directly to the worker
|
||||||
|
or job container.
|
||||||
|
|
||||||
|
Worker cleanup is available in `Settings -> Worker`. It can delete old terminal
|
||||||
|
workspace records and ask the active worker to remove orphaned job containers
|
||||||
|
and inactive work directories.
|
||||||
|
|
||||||
|
Local worker development:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/build-agent-images
|
||||||
|
bun smoke:agent-container
|
||||||
|
bun dev:next:worker
|
||||||
|
bun dev:next:worker:staging
|
||||||
|
```
|
||||||
|
|
||||||
|
Local host-run worker commands still load env through Infisical, then
|
||||||
|
`scripts/dev-agent-worker` selects Podman when available, falls back to Docker,
|
||||||
|
and publishes the OpenCode server on a localhost port so the host worker can
|
||||||
|
reach the job container. Override with:
|
||||||
|
|
||||||
|
```env
|
||||||
|
SPOON_AGENT_CONTAINER_RUNTIME=podman
|
||||||
|
SPOON_AGENT_CONTAINER_ACCESS=host_port
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Production agent runtime images</strong></summary>
|
||||||
|
|
||||||
|
Gitea CI builds and pushes three production images:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
git.gbrown.org/gib/spoon-next:latest
|
||||||
|
git.gbrown.org/gib/spoon-agent-worker:latest
|
||||||
|
git.gbrown.org/gib/spoon-agent-job:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
The worker image is the long-running service that polls Convex. The job image is
|
||||||
|
the isolated workbench that the worker launches for each agent job. For the MVP,
|
||||||
|
production should use the repo-provided JS/TS workbench image:
|
||||||
|
|
||||||
|
```env
|
||||||
|
SPOON_AGENT_JOB_IMAGE="git.gbrown.org/gib/spoon-agent-job:latest"
|
||||||
|
```
|
||||||
|
|
||||||
|
The job image includes Node 22, Bun, pnpm and yarn through Corepack, npm, git,
|
||||||
|
ripgrep, Python, build tools, OpenCode, and the Codex CLI. It is not the forked
|
||||||
|
project's production runtime; it is the agent execution environment.
|
||||||
|
|
||||||
|
Production worker runtime requirements:
|
||||||
|
|
||||||
|
- `spoon-agent-worker` must run as a separate service.
|
||||||
|
- The worker needs `/var/run/docker.sock` mounted so it can launch job
|
||||||
|
containers.
|
||||||
|
- Production should keep `SPOON_AGENT_CONTAINER_RUNTIME=docker` and
|
||||||
|
`SPOON_AGENT_CONTAINER_ACCESS=network`.
|
||||||
|
- The production Docker host must be logged into `git.gbrown.org` so worker jobs
|
||||||
|
can pull the private `spoon-agent-job` image.
|
||||||
|
- `SPOON_WORKER_TOKEN` must match the value stored in Convex production env.
|
||||||
|
- `spoon-next` needs `SPOON_AGENT_WORKER_URL=http://spoon-agent-worker:3921` and
|
||||||
|
`SPOON_AGENT_WORKER_INTERNAL_TOKEN` so Next API routes can proxy workspace
|
||||||
|
file, diff, message, command, and draft PR actions.
|
||||||
|
- `spoon-agent-worker` also needs `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`.
|
||||||
|
If the private key is stored in a single-line dotenv value, encode newlines as
|
||||||
|
literal `\n` characters so the worker can restore the PEM before using it.
|
||||||
|
|
||||||
|
Useful production checks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker login git.gbrown.org
|
||||||
|
docker pull git.gbrown.org/gib/spoon-agent-worker:latest
|
||||||
|
docker pull git.gbrown.org/gib/spoon-agent-job:latest
|
||||||
|
docker logs --tail=200 spoon-agent-worker
|
||||||
|
curl -H "Authorization: Bearer $SPOON_AGENT_WORKER_INTERNAL_TOKEN" \
|
||||||
|
http://spoon-agent-worker:3921/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Deployment readiness checklist:
|
||||||
|
|
||||||
|
1. Production Convex env has `SPOON_WORKER_TOKEN`, `SPOON_ENCRYPTION_KEY`,
|
||||||
|
GitHub App env, and Convex Auth signing keys.
|
||||||
|
2. Compose env has `SPOON_AGENT_WORKER_URL`,
|
||||||
|
`SPOON_AGENT_WORKER_INTERNAL_TOKEN`, `SPOON_AGENT_JOB_IMAGE`, and the GitHub
|
||||||
|
App private key.
|
||||||
|
3. The production Docker host can pull private images from `git.gbrown.org`.
|
||||||
|
4. `Settings -> Worker` reports the expected job image, runtime, network, and
|
||||||
|
active workspace count.
|
||||||
|
5. The first test thread uses a configured API-key provider or a trusted Codex
|
||||||
|
login profile.
|
||||||
|
6. If a worker restart leaves stale workspace state, use the workspace recovery
|
||||||
|
panel or `Settings -> Worker` cleanup.
|
||||||
|
|
||||||
|
API-key based AI provider profiles run through OpenCode. Codex ChatGPT login
|
||||||
|
profiles run through the Codex CLI: Spoon writes the encrypted `auth.json` into
|
||||||
|
the isolated job workspace as `CODEX_HOME/.codex/auth.json` before execution.
|
||||||
|
Treat that saved auth file like a password and only use it on trusted workers.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- `apps/next`: Next.js 16 web app and primary product UI.
|
<details open>
|
||||||
- `apps/agent-worker`: optional server-side worker for queued coding-agent jobs.
|
<summary><strong>Workspace layout</strong></summary>
|
||||||
- `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
|
|
||||||
- Node 22
|
|
||||||
- Docker or Podman
|
|
||||||
- Infisical CLI
|
|
||||||
|
|
||||||
```sh
|
|
||||||
bun install --frozen-lockfile
|
|
||||||
infisical login
|
|
||||||
infisical init
|
|
||||||
bun db:up
|
|
||||||
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. 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
|
|
||||||
```
|
|
||||||
|
|
||||||
Use staging services explicitly:
|
|
||||||
|
|
||||||
```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` values come from Infisical through `scripts/with-env`.
|
|
||||||
App commands do not fall back to root `.env` files.
|
|
||||||
|
|
||||||
Generated local state belongs in:
|
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
.local/<environment>.generated.env
|
.
|
||||||
|
├── apps
|
||||||
|
│ ├── next # Next.js 16 web app and primary Spoon UI
|
||||||
|
│ ├── agent-worker # Optional OpenCode workspace / draft PR worker
|
||||||
|
│ └── expo # Expo companion app scaffold
|
||||||
|
├── packages
|
||||||
|
│ ├── backend # Convex backend package
|
||||||
|
│ │ └── convex # Schema, functions, auth, HTTP routes
|
||||||
|
│ └── ui # Shared shadcn-based UI components
|
||||||
|
├── tools # Shared lint, format, Tailwind, TS, Vitest config
|
||||||
|
├── docker # Compose files and worker/job Dockerfiles
|
||||||
|
└── scripts # Env, Convex, codegen, database, and CI helpers
|
||||||
```
|
```
|
||||||
|
|
||||||
CI uses Gitea-provided secrets or `CI_ENV_FILE` and must not call Infisical.
|
</details>
|
||||||
|
|
||||||
Useful helpers:
|
<details>
|
||||||
|
<summary><strong>Core tables</strong></summary>
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
| ------------------------ | --------------------------------------------------------- |
|
||||||
|
| `spoons` | Managed fork records |
|
||||||
|
| `threads` | Durable maintenance and work conversations |
|
||||||
|
| `threadMessages` | Messages inside threads |
|
||||||
|
| `syncRuns` | Upstream checks, sync attempts, and maintenance decisions |
|
||||||
|
| `ignoredUpstreamChanges` | Intentional ignore records that affect effective drift |
|
||||||
|
| `gitConnections` | Git provider connection metadata |
|
||||||
|
| `spoonRepositoryStates` | Latest cached upstream/fork state |
|
||||||
|
| `spoonCommits` | Cached upstream and fork-only commits |
|
||||||
|
| `spoonPullRequests` | Cached fork/upstream pull requests |
|
||||||
|
| `spoonSecrets` | Encrypted per-Spoon environment variables |
|
||||||
|
| `spoonAgentSettings` | Per-Spoon runtime, branch, command, and env-file settings |
|
||||||
|
| `aiProviderProfiles` | Encrypted provider/auth profiles used by OpenCode |
|
||||||
|
| `agentJobs` | Worker-executed workspace jobs and PR lifecycle |
|
||||||
|
| `agentJobEvents` | Append-only worker event log |
|
||||||
|
| `agentJobArtifacts` | Diffs, summaries, command output, PR body drafts |
|
||||||
|
| `agentWorkspaceChanges` | Recorded user, agent, and command file changes |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Important routes</strong></summary>
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
| --------------------------------- | --------------------------------------- |
|
||||||
|
| `/` | Public product landing page |
|
||||||
|
| `/dashboard` | Maintenance overview |
|
||||||
|
| `/spoons` | Managed fork list |
|
||||||
|
| `/spoons/new` | Manual/GitHub Spoon creation |
|
||||||
|
| `/spoons/[spoonId]` | Spoon detail dashboard |
|
||||||
|
| `/spoons/[spoonId]/agent/[jobId]` | Interactive workspace |
|
||||||
|
| `/threads` | Global thread queue |
|
||||||
|
| `/threads/[threadId]` | Thread detail |
|
||||||
|
| `/settings/profile` | User profile settings |
|
||||||
|
| `/settings/integrations` | GitHub and service integration settings |
|
||||||
|
| `/settings/ai-providers` | AI/OpenCode provider profiles |
|
||||||
|
|
||||||
|
Legacy `/updates` and `/agents` routes redirect into `/threads`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Mobile App
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary><strong>Current Expo scope</strong></summary>
|
||||||
|
|
||||||
|
`apps/expo` is the mobile Spoon client. It is designed to mirror the core web
|
||||||
|
product without exposing worker internals or trying to turn a phone into the
|
||||||
|
primary code-editing surface.
|
||||||
|
|
||||||
|
The mobile app currently supports:
|
||||||
|
|
||||||
|
- password, GitHub, and Authentik sign-in
|
||||||
|
- Dashboard, Spoons, Threads, Workspace Review, and Settings tabs/screens
|
||||||
|
- manual Spoon creation and GitHub-assisted repository tracking
|
||||||
|
- Spoon detail views for overview, upstream commits, fork-only commits, PRs,
|
||||||
|
threads, settings, clone URLs, and additional remotes
|
||||||
|
- Spoon maintenance settings, agent settings, encrypted secrets, and bulk
|
||||||
|
`.env` paste import
|
||||||
|
- thread list/detail, message composer, resolve/cancel actions, and workspace
|
||||||
|
review links
|
||||||
|
- GitHub integration status and repository listing
|
||||||
|
- AI provider profile management, including Codex auth JSON and API-key
|
||||||
|
providers
|
||||||
|
- read-only workspace review for job status, messages, diffs, events,
|
||||||
|
artifacts, and draft PR links
|
||||||
|
|
||||||
|
The mobile app intentionally does not currently support:
|
||||||
|
|
||||||
|
- live workspace file browsing/editing
|
||||||
|
- mobile command execution
|
||||||
|
- direct mobile calls to the agent worker HTTP API
|
||||||
|
- mobile access to worker/container tokens
|
||||||
|
- long-running app preview stacks
|
||||||
|
- production app-store/EAS release flow
|
||||||
|
|
||||||
|
Mobile workspace editing is deferred until worker authorization and mobile
|
||||||
|
editor UX are designed explicitly. For now, the phone is a strong review and
|
||||||
|
control surface; the browser remains the code workspace.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Expo validation</strong></summary>
|
||||||
|
|
||||||
|
Useful mobile checks:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sh scripts/with-env dev -- <command>
|
bun --filter @spoon/expo lint
|
||||||
sh scripts/export-env dev
|
bun --filter @spoon/expo typecheck
|
||||||
bun sync:convex
|
bun --filter @spoon/expo test:unit
|
||||||
|
bun --filter @spoon/expo test:component
|
||||||
```
|
```
|
||||||
|
|
||||||
### Convex deployment env
|
The Expo unit tests cover pure utilities such as `.env` parsing and formatting.
|
||||||
|
The component tests use a lightweight React Native mock layer to exercise shared
|
||||||
|
mobile controls, higher-value forms, and route smoke renders without booting a
|
||||||
|
native simulator.
|
||||||
|
|
||||||
Convex functions and HTTP actions read environment variables from the Convex
|
</details>
|
||||||
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
|
## Environment Reference
|
||||||
`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:
|
This project is currently private, so this section is a reference for what the
|
||||||
|
application expects rather than public setup documentation.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Local Infisical account selection</strong></summary>
|
||||||
|
|
||||||
|
Local `dev` and `staging` commands export secrets through Infisical. Spoon runs
|
||||||
|
`scripts/infisical-account ensure` from `scripts/export-env` before exporting so
|
||||||
|
machines logged into multiple Infisical accounts do not accidentally use the
|
||||||
|
wrong organization.
|
||||||
|
|
||||||
|
If your machine has only one local Infisical account, no extra setup is needed.
|
||||||
|
If it has multiple accounts, create this ignored local file:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sh scripts/sync-convex-env dev
|
mkdir -p .local
|
||||||
sh scripts/sync-convex-env staging
|
printf "INFISICAL_EMAIL=me@gbrown.org\n" > .local/infisical.env
|
||||||
INFISICAL_ENV=staging bun sync:convex
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The sync includes:
|
Log into each needed account once with `infisical login`. You can inspect local
|
||||||
|
profiles without printing tokens:
|
||||||
```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
|
```sh
|
||||||
bun dev:next
|
jq '.loggedInUsers[] | {email, domain}' ~/.infisical/infisical-config.json
|
||||||
bun dev:expo
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Physical devices cannot resolve their own `localhost`; override the public
|
`.local/infisical.env` supports only `INFISICAL_EMAIL=...` and must not be
|
||||||
Convex URL with the development host's LAN address when testing Expo on-device.
|
committed. CI is unchanged; it uses injected environment files/secrets and must
|
||||||
|
not call Infisical.
|
||||||
|
|
||||||
Shared dependency versions belong in root catalogs. Edit the root catalog, run
|
</details>
|
||||||
`bun install`, then `bun lint:ws`. Do not run `bun update` inside a workspace.
|
|
||||||
|
|
||||||
## Validation
|
<details open>
|
||||||
|
<summary><strong>Public Next variables</strong></summary>
|
||||||
|
|
||||||
Routine checks:
|
| Variable | Used for |
|
||||||
|
| --------------------------------- | ------------------------------------------- |
|
||||||
|
| `NEXT_PUBLIC_SITE_URL` | Canonical Spoon web URL |
|
||||||
|
| `NEXT_PUBLIC_CONVEX_URL` | Convex client URL |
|
||||||
|
| `NEXT_PUBLIC_DEPLOYMENT_URL` | Convex dashboard/deployment URL when needed |
|
||||||
|
| `NEXT_PUBLIC_PLAUSIBLE_URL` | Plausible analytics endpoint |
|
||||||
|
| `NEXT_PUBLIC_SENTRY_DSN` | Browser Sentry DSN |
|
||||||
|
| `NEXT_PUBLIC_SENTRY_URL` | Sentry instance URL |
|
||||||
|
| `NEXT_PUBLIC_SENTRY_ORG` | Sentry organization |
|
||||||
|
| `NEXT_PUBLIC_SENTRY_PROJECT_NAME` | Sentry project name |
|
||||||
|
|
||||||
```sh
|
</details>
|
||||||
bun lint:ws
|
|
||||||
bun format
|
|
||||||
bun lint
|
|
||||||
bun typecheck
|
|
||||||
bun run test
|
|
||||||
```
|
|
||||||
|
|
||||||
Full local gate without e2e:
|
<details>
|
||||||
|
<summary><strong>Auth and email</strong></summary>
|
||||||
|
|
||||||
```sh
|
| Variable | Used for |
|
||||||
SKIP_E2E=1 bun run ci:check
|
| ----------------------- | ----------------------------- |
|
||||||
```
|
| `SITE_URL` | Convex Auth site URL |
|
||||||
|
| `JWT_PRIVATE_KEY` | Convex Auth signing key |
|
||||||
|
| `JWKS` | Convex Auth JWKS |
|
||||||
|
| `AUTH_AUTHENTIK_ID` | Authentik OAuth client ID |
|
||||||
|
| `AUTH_AUTHENTIK_SECRET` | Authentik OAuth client secret |
|
||||||
|
| `AUTH_AUTHENTIK_ISSUER` | Authentik issuer URL |
|
||||||
|
| `AUTH_GITHUB_ID` | GitHub OAuth client ID |
|
||||||
|
| `AUTH_GITHUB_SECRET` | GitHub OAuth client secret |
|
||||||
|
| `USESEND_API_KEY` | UseSend API key |
|
||||||
|
| `USESEND_URL` | UseSend API URL |
|
||||||
|
| `USESEND_FROM_EMAIL` | Transactional email sender |
|
||||||
|
|
||||||
Local-stack smoke e2e:
|
</details>
|
||||||
|
|
||||||
```sh
|
<details>
|
||||||
bun test:e2e
|
<summary><strong>GitHub App</strong></summary>
|
||||||
```
|
|
||||||
|
|
||||||
`bun test:e2e` starts the isolated local stack when needed and stops it
|
| Variable | Used for |
|
||||||
afterward only when it was not already running.
|
| ---------------------------- | ---------------------------------- |
|
||||||
|
| `GITHUB_APP_ID` | GitHub App ID |
|
||||||
|
| `GITHUB_APP_CLIENT_ID` | GitHub App OAuth client ID |
|
||||||
|
| `GITHUB_APP_CLIENT_SECRET` | GitHub App OAuth client secret |
|
||||||
|
| `GITHUB_APP_PRIVATE_KEY` | GitHub App PEM private key |
|
||||||
|
| `GITHUB_APP_WEBHOOK_SECRET` | GitHub webhook verification secret |
|
||||||
|
| `GITHUB_APP_SLUG` | GitHub App slug |
|
||||||
|
| `GITHUB_APP_INSTALLATION_ID` | Default/local installation ID |
|
||||||
|
| `GITHUB_APP_OWNER` | Default/local installation owner |
|
||||||
|
|
||||||
Use `bun run test`, not bare `bun test`; bare `bun test` invokes Bun's built-in
|
</details>
|
||||||
test runner instead of the repo's Turbo/Vitest test script.
|
|
||||||
|
|
||||||
## Deployment
|
<details>
|
||||||
|
<summary><strong>Convex, storage, and runtime</strong></summary>
|
||||||
|
|
||||||
Production Compose keeps the self-hosted Convex backend/dashboard and expects
|
| Variable | Used for |
|
||||||
`POSTGRES_URL` to be a database-cluster URL without a database path.
|
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `CONVEX_SELF_HOSTED_URL` | Self-hosted Convex API URL |
|
||||||
|
| `CONVEX_SELF_HOSTED_ADMIN_KEY` | Admin key for deploying/syncing Convex |
|
||||||
|
| `CONVEX_CLOUD_ORIGIN` | Convex backend origin |
|
||||||
|
| `CONVEX_SITE_ORIGIN` | Convex site-function origin |
|
||||||
|
| `CONVEX_SITE_URL` | Site URL seen by Convex Auth |
|
||||||
|
| `POSTGRES_URL` | Convex storage database URL |
|
||||||
|
| `SPOON_ENCRYPTION_KEY` | Encryption key for stored secrets/provider auth |
|
||||||
|
| `SPOON_WORKER_TOKEN` | Worker token for Convex worker mutations |
|
||||||
|
| `SPOON_AGENT_WORKER_URL` | Internal worker HTTP URL used by Next |
|
||||||
|
| `SPOON_AGENT_WORKER_HTTP_PORT` | Worker HTTP port |
|
||||||
|
| `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | Server-only token for Next-to-worker proxy |
|
||||||
|
| `SPOON_AGENT_JOB_IMAGE` | Agent job container image |
|
||||||
|
| `SPOON_AGENT_RUNTIME` | Runtime mode, currently Docker/Podman-oriented |
|
||||||
|
| `SPOON_AGENT_CONTAINER_RUNTIME` | Container CLI used by worker, `docker`/`podman` |
|
||||||
|
| `SPOON_AGENT_CONTAINER_ACCESS` | `network` in prod, `host_port` for host dev |
|
||||||
|
| `SPOON_AGENT_MAX_CONCURRENT_JOBS` | Worker concurrency limit |
|
||||||
|
| `SPOON_AGENT_JOB_TIMEOUT_MS` | Job timeout |
|
||||||
|
| `SPOON_AGENT_WORKDIR` | Worker work directory |
|
||||||
|
| `SPOON_AGENT_HOST_WORKDIR` | Host path matching `SPOON_AGENT_WORKDIR` when the worker runs in Docker and controls the host Docker socket |
|
||||||
|
| `SPOON_AGENT_NETWORK` | Optional job container network |
|
||||||
|
|
||||||
Gitea runs the quality gate first, builds the Next image from a temporary
|
</details>
|
||||||
Gitea-secret env file, then pushes SHA and `latest` tags. CI never installs or
|
|
||||||
invokes Infisical.
|
<details>
|
||||||
|
<summary><strong>Deployment and observability</strong></summary>
|
||||||
|
|
||||||
|
| Variable | Used for |
|
||||||
|
| ----------------------- | --------------------------------- |
|
||||||
|
| `NODE_ENV` | Runtime environment |
|
||||||
|
| `SENTRY_AUTH_TOKEN` | Sentry source map/upload auth |
|
||||||
|
| `REDACT_LOGS_TO_CLIENT` | Convex log redaction setting |
|
||||||
|
| `DISABLE_BEACON` | Self-hosted Convex beacon setting |
|
||||||
|
| `DO_NOT_REQUIRE_SSL` | Self-hosted Convex SSL behavior |
|
||||||
|
| `CI_ENV_FILE` | CI-provided env file path |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary><strong>Implemented</strong></summary>
|
||||||
|
|
||||||
|
- Thread-first Next.js product shell
|
||||||
|
- GitHub App connection and fork creation foundation
|
||||||
|
- GitHub drift refresh, commit cache, PR cache, and sync-run history
|
||||||
|
- Effective drift and ignored upstream change records
|
||||||
|
- Global Threads page and Spoon-scoped Threads tab
|
||||||
|
- OpenCode/Codex-oriented agent worker and browser workspace foundation
|
||||||
|
- Monaco editor with optional Vim mode
|
||||||
|
- Diff viewer, command panel, worker logs, and artifacts
|
||||||
|
- Encrypted Spoon secrets and bulk `.env` import
|
||||||
|
- Encrypted AI provider profiles, including Codex auth JSON and API-key
|
||||||
|
provider support
|
||||||
|
- Authentik, GitHub, and password auth through Convex Auth
|
||||||
|
- Self-hosted Convex/Postgres deployment model
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Intentionally not done yet</strong></summary>
|
||||||
|
|
||||||
|
- Autonomous merging for custom/diverged forks
|
||||||
|
- Non-GitHub provider automation
|
||||||
|
- Pushing agent branches to additional remotes
|
||||||
|
- Long-running preview stacks for arbitrary forked projects
|
||||||
|
- Direct browser access to worker containers
|
||||||
|
- Public self-hosting setup documentation
|
||||||
|
- Production mobile release flow
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Spoon is built for a very specific maintenance problem: "I want to fork this
|
||||||
|
project, but I do not want to permanently become its maintenance team."
|
||||||
|
|
||||||
|
The current product direction is to make that maintenance visible, threaded,
|
||||||
|
reviewable, and increasingly automated where it is safe. Clean forks can stay
|
||||||
|
close automatically. Custom forks get context, workspace help, and draft PRs.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun with-env src/index.ts",
|
"dev": "bun with-env bash ../../scripts/dev-agent-worker -- bun src/index.ts",
|
||||||
"start": "bun src/index.ts",
|
"start": "bun src/index.ts",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
||||||
@@ -17,17 +17,20 @@
|
|||||||
"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",
|
||||||
|
"dockerode": "^4.0.7",
|
||||||
"execa": "latest",
|
"execa": "latest",
|
||||||
"openai": "^6.44.0",
|
"ws": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@spoon/eslint-config": "workspace:*",
|
"@spoon/eslint-config": "workspace:*",
|
||||||
"@spoon/prettier-config": "workspace:*",
|
"@spoon/prettier-config": "workspace:*",
|
||||||
"@spoon/tsconfig": "workspace:*",
|
"@spoon/tsconfig": "workspace:*",
|
||||||
|
"@types/dockerode": "^3.3.42",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
export type NormalizedAgentEvent =
|
||||||
|
| { kind: 'assistant_delta'; content: string; externalMessageId?: string }
|
||||||
|
| {
|
||||||
|
kind: 'assistant_completed';
|
||||||
|
content?: string;
|
||||||
|
externalMessageId?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'tool_started';
|
||||||
|
name: string;
|
||||||
|
input?: string;
|
||||||
|
externalMessageId?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'tool_completed';
|
||||||
|
name: string;
|
||||||
|
output?: string;
|
||||||
|
externalMessageId?: string;
|
||||||
|
}
|
||||||
|
| { kind: 'file_edited'; path: string }
|
||||||
|
| {
|
||||||
|
kind: 'command_executed';
|
||||||
|
command: string;
|
||||||
|
exitCode?: number;
|
||||||
|
output?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'permission_requested';
|
||||||
|
externalRequestId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
metadata?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'question_requested';
|
||||||
|
externalRequestId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
options?: string[];
|
||||||
|
metadata?: string;
|
||||||
|
}
|
||||||
|
| { kind: 'session'; sessionId: string }
|
||||||
|
| { kind: 'status'; status: string; metadata?: string }
|
||||||
|
| { kind: 'error'; message: string; metadata?: string };
|
||||||
|
|
||||||
|
const stringify = (value: unknown) => {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (value === undefined || value === null) return '';
|
||||||
|
if (
|
||||||
|
typeof value === 'number' ||
|
||||||
|
typeof value === 'boolean' ||
|
||||||
|
typeof value === 'bigint'
|
||||||
|
) {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
||||||
|
value && typeof value === 'object'
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const textFromPart = (part: Record<string, unknown>) => {
|
||||||
|
const text = part.text ?? part.content ?? part.delta;
|
||||||
|
return typeof text === 'string' ? text : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const commandString = (value: unknown) => {
|
||||||
|
if (Array.isArray(value))
|
||||||
|
return value.map((part) => stringify(part)).join(' ');
|
||||||
|
return stringify(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolNameFromRecord = (record: Record<string, unknown> | null) =>
|
||||||
|
stringify(
|
||||||
|
record?.tool ??
|
||||||
|
record?.tool_name ??
|
||||||
|
record?.toolName ??
|
||||||
|
record?.name ??
|
||||||
|
record?.function ??
|
||||||
|
(stringify(record?.type).toLowerCase().includes('exec') || record?.command
|
||||||
|
? 'Command'
|
||||||
|
: record?.type) ??
|
||||||
|
'tool',
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolInputFromRecord = (record: Record<string, unknown> | null) =>
|
||||||
|
commandString(
|
||||||
|
record?.input ??
|
||||||
|
record?.arguments ??
|
||||||
|
record?.args ??
|
||||||
|
record?.params ??
|
||||||
|
record?.command ??
|
||||||
|
record?.cmd,
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolOutputFromRecord = (
|
||||||
|
record: Record<string, unknown> | null,
|
||||||
|
fallback?: unknown,
|
||||||
|
) =>
|
||||||
|
stringify(
|
||||||
|
record?.output ??
|
||||||
|
record?.aggregated_output ??
|
||||||
|
record?.stdout ??
|
||||||
|
record?.stderr ??
|
||||||
|
record?.result ??
|
||||||
|
record?.content ??
|
||||||
|
record?.text ??
|
||||||
|
(record?.exit_code !== undefined
|
||||||
|
? `exit code: ${stringify(record.exit_code)}`
|
||||||
|
: undefined) ??
|
||||||
|
fallback,
|
||||||
|
);
|
||||||
|
|
||||||
|
const recordLooksLikeTool = (
|
||||||
|
type: string,
|
||||||
|
record: Record<string, unknown> | null,
|
||||||
|
) => {
|
||||||
|
const recordType = stringify(record?.type).toLowerCase();
|
||||||
|
const lowerType = type.toLowerCase();
|
||||||
|
return (
|
||||||
|
lowerType.includes('tool') ||
|
||||||
|
lowerType.includes('function_call') ||
|
||||||
|
recordType.includes('tool') ||
|
||||||
|
recordType.includes('function_call') ||
|
||||||
|
recordType.includes('local_shell_call') ||
|
||||||
|
recordType.includes('exec_command') ||
|
||||||
|
recordType.includes('command') ||
|
||||||
|
recordType.includes('mcp') ||
|
||||||
|
Boolean(
|
||||||
|
record?.tool ?? record?.tool_name ?? record?.name ?? record?.command,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCodexConfigWarning = (message: string) =>
|
||||||
|
message.includes('`[features].codex_hooks` is deprecated') ||
|
||||||
|
message.includes('Use `[features].hooks` instead');
|
||||||
|
|
||||||
|
// Handles the legacy `codex-rs` `{ id, msg: { type, ... } }` envelope.
|
||||||
|
const normalizeCodexMsgEvent = (
|
||||||
|
msg: Record<string, unknown>,
|
||||||
|
envelope: Record<string, unknown>,
|
||||||
|
): NormalizedAgentEvent[] => {
|
||||||
|
const msgType = stringify(msg.type).toLowerCase();
|
||||||
|
const events: NormalizedAgentEvent[] = [];
|
||||||
|
if (msgType === 'session_configured' || msgType.includes('session')) {
|
||||||
|
const sessionId = stringify(
|
||||||
|
msg.session_id ?? envelope.session_id ?? envelope.id,
|
||||||
|
);
|
||||||
|
if (sessionId) events.push({ kind: 'session', sessionId });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
msgType === 'agent_message_delta' ||
|
||||||
|
msgType === 'agent_reasoning_delta'
|
||||||
|
) {
|
||||||
|
const delta = stringify(msg.delta ?? msg.text);
|
||||||
|
if (delta) events.push({ kind: 'assistant_delta', content: delta });
|
||||||
|
}
|
||||||
|
if (msgType === 'agent_message') {
|
||||||
|
const text = stringify(msg.message ?? msg.text);
|
||||||
|
if (text) {
|
||||||
|
events.push({ kind: 'assistant_delta', content: `${text.trim()}\n\n` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msgType === 'exec_command_begin') {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_started',
|
||||||
|
name: 'Command',
|
||||||
|
input: commandString(msg.command),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (msgType === 'exec_command_end') {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: 'Command',
|
||||||
|
output: toolOutputFromRecord(msg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
msgType === 'error' ||
|
||||||
|
msgType === 'turn_failed' ||
|
||||||
|
msgType === 'task_error'
|
||||||
|
) {
|
||||||
|
const message = stringify(msg.message ?? msg.error ?? msg);
|
||||||
|
if (isCodexConfigWarning(message)) {
|
||||||
|
events.push({ kind: 'status', status: message });
|
||||||
|
} else {
|
||||||
|
events.push({ kind: 'error', message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msgType === 'task_complete' || msgType === 'turn_complete') {
|
||||||
|
events.push({ kind: 'assistant_completed' });
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeCodexJsonLine = (
|
||||||
|
line: string,
|
||||||
|
): NormalizedAgentEvent[] => {
|
||||||
|
if (!line.trim()) return [];
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(line) as unknown;
|
||||||
|
} catch {
|
||||||
|
return [{ kind: 'status', status: line }];
|
||||||
|
}
|
||||||
|
const event = asRecord(parsed);
|
||||||
|
if (!event) return [];
|
||||||
|
// Older Codex (`codex-rs`) protocol wraps events as `{ id, msg: { type, ... } }`
|
||||||
|
// instead of the newer `{ type, item: { ... } }` shape. Unwrap it so version
|
||||||
|
// skew between the pinned image and an upstream build degrades gracefully
|
||||||
|
// instead of silently producing an empty assistant response.
|
||||||
|
const msg = asRecord(event.msg);
|
||||||
|
if (msg) {
|
||||||
|
const msgEvents = normalizeCodexMsgEvent(msg, event);
|
||||||
|
if (msgEvents.length > 0) return msgEvents;
|
||||||
|
}
|
||||||
|
const type = stringify(event.type ?? event.event);
|
||||||
|
const id =
|
||||||
|
event.id ??
|
||||||
|
event.session_id ??
|
||||||
|
event.sessionId ??
|
||||||
|
event.thread_id ??
|
||||||
|
event.threadId;
|
||||||
|
const sessionId =
|
||||||
|
typeof id === 'string' &&
|
||||||
|
(type.toLowerCase().includes('session') ||
|
||||||
|
type.toLowerCase().includes('thread.started'))
|
||||||
|
? id
|
||||||
|
: undefined;
|
||||||
|
const events: NormalizedAgentEvent[] = sessionId
|
||||||
|
? [{ kind: 'session', sessionId }]
|
||||||
|
: [];
|
||||||
|
const message = asRecord(event.message);
|
||||||
|
const item = asRecord(event.item);
|
||||||
|
const data = asRecord(event.data);
|
||||||
|
const part = asRecord(event.part);
|
||||||
|
const itemType = item ? stringify(item.type) : '';
|
||||||
|
const lowerType = type.toLowerCase();
|
||||||
|
const lowerItemType = itemType.toLowerCase();
|
||||||
|
if (
|
||||||
|
item &&
|
||||||
|
recordLooksLikeTool(type, item) &&
|
||||||
|
(lowerType.includes('started') ||
|
||||||
|
lowerType.includes('in_progress') ||
|
||||||
|
lowerType.includes('created'))
|
||||||
|
) {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_started',
|
||||||
|
name: toolNameFromRecord(item),
|
||||||
|
input: toolInputFromRecord(item),
|
||||||
|
externalMessageId: stringify(item.id ?? event.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
item &&
|
||||||
|
recordLooksLikeTool(type, item) &&
|
||||||
|
(lowerType.includes('completed') ||
|
||||||
|
lowerType.includes('done') ||
|
||||||
|
lowerType.includes('finished'))
|
||||||
|
) {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: toolNameFromRecord(item),
|
||||||
|
output: toolOutputFromRecord(item, event.output ?? data?.output),
|
||||||
|
externalMessageId: stringify(item.id ?? event.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const delta = event.delta ?? data?.delta;
|
||||||
|
if (typeof delta === 'string') {
|
||||||
|
events.push({ kind: 'assistant_delta', content: delta });
|
||||||
|
}
|
||||||
|
const text =
|
||||||
|
(part ? textFromPart(part) : '') ||
|
||||||
|
(message ? stringify(message.content ?? message.text) : '') ||
|
||||||
|
(item ? stringify(item.content ?? item.text) : '');
|
||||||
|
if (
|
||||||
|
text &&
|
||||||
|
(type.includes('message') ||
|
||||||
|
type.includes('response.output_text') ||
|
||||||
|
type.includes('agent_message') ||
|
||||||
|
itemType.includes('message') ||
|
||||||
|
itemType.includes('agent_message'))
|
||||||
|
) {
|
||||||
|
events.push({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: itemType.includes('agent_message') ? `${text.trim()}\n\n` : text,
|
||||||
|
externalMessageId: stringify(item?.id ?? event.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const error = event.error ?? item?.error;
|
||||||
|
if (error || itemType === 'error') {
|
||||||
|
const message = stringify(error ?? item?.message ?? event.message);
|
||||||
|
if (isCodexConfigWarning(message)) {
|
||||||
|
events.push({ kind: 'status', status: message });
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
events.push({
|
||||||
|
kind: 'error',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const command =
|
||||||
|
event.command ??
|
||||||
|
data?.command ??
|
||||||
|
(lowerItemType.includes('shell') ? item?.command : undefined);
|
||||||
|
if (typeof command === 'string') {
|
||||||
|
events.push({
|
||||||
|
kind: 'command_executed',
|
||||||
|
command,
|
||||||
|
output: stringify(event.output ?? data?.output),
|
||||||
|
});
|
||||||
|
} else if (Array.isArray(command)) {
|
||||||
|
events.push({
|
||||||
|
kind: 'command_executed',
|
||||||
|
command: command.map((part) => stringify(part)).join(' '),
|
||||||
|
output: stringify(event.output ?? data?.output ?? item?.output),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const file =
|
||||||
|
event.file ??
|
||||||
|
event.path ??
|
||||||
|
data?.file ??
|
||||||
|
data?.path ??
|
||||||
|
item?.file ??
|
||||||
|
item?.path;
|
||||||
|
if (typeof file === 'string' && type.includes('file')) {
|
||||||
|
events.push({ kind: 'file_edited', path: file });
|
||||||
|
}
|
||||||
|
if (type.includes('error')) {
|
||||||
|
events.push({
|
||||||
|
kind: 'error',
|
||||||
|
message: stringify(event.message ?? event.error ?? data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
type.includes('completed') &&
|
||||||
|
itemType !== 'error' &&
|
||||||
|
!itemType.includes('message') &&
|
||||||
|
!itemType.includes('agent_message') &&
|
||||||
|
!recordLooksLikeTool(type, item)
|
||||||
|
) {
|
||||||
|
events.push({ kind: 'assistant_completed' });
|
||||||
|
}
|
||||||
|
if (type.includes('turn.done')) {
|
||||||
|
events.push({ kind: 'assistant_completed' });
|
||||||
|
}
|
||||||
|
if (events.length === 0) {
|
||||||
|
events.push({ kind: 'status', status: type || 'codex_event' });
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeOpenCodeEvent = (
|
||||||
|
input: unknown,
|
||||||
|
): NormalizedAgentEvent[] => {
|
||||||
|
const event = asRecord(input);
|
||||||
|
if (!event) return [];
|
||||||
|
const type = stringify(event.type);
|
||||||
|
const properties =
|
||||||
|
asRecord(event.properties) ?? asRecord(event.data) ?? event;
|
||||||
|
const events: NormalizedAgentEvent[] = [];
|
||||||
|
const sessionId = properties.sessionID ?? properties.sessionId;
|
||||||
|
if (typeof sessionId === 'string' && type.includes('session')) {
|
||||||
|
events.push({ kind: 'session', sessionId });
|
||||||
|
}
|
||||||
|
if (type === 'message.part.delta') {
|
||||||
|
const part = asRecord(properties.part) ?? properties;
|
||||||
|
const text = textFromPart(part);
|
||||||
|
if (text) {
|
||||||
|
events.push({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: text,
|
||||||
|
externalMessageId: stringify(properties.messageID),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === 'message.updated' || type === 'message.part.updated') {
|
||||||
|
const part = asRecord(properties.part);
|
||||||
|
const text = part ? textFromPart(part) : stringify(properties.message);
|
||||||
|
if (text) {
|
||||||
|
events.push({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: text,
|
||||||
|
externalMessageId: stringify(properties.messageID),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type.includes('tool.started')) {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_started',
|
||||||
|
name: stringify(properties.tool ?? properties.name ?? 'tool'),
|
||||||
|
input: stringify(properties.input),
|
||||||
|
externalMessageId: stringify(properties.messageID),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type.includes('tool.finished') || type.includes('tool.completed')) {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: stringify(properties.tool ?? properties.name ?? 'tool'),
|
||||||
|
output: stringify(properties.output ?? properties.result),
|
||||||
|
externalMessageId: stringify(properties.messageID),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type.includes('tool.updated') || type.includes('tool.output')) {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: stringify(properties.tool ?? properties.name ?? 'tool'),
|
||||||
|
output: stringify(properties.output ?? properties.result ?? properties),
|
||||||
|
externalMessageId: stringify(properties.messageID),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type === 'file.edited') {
|
||||||
|
const file = properties.file;
|
||||||
|
if (typeof file === 'string')
|
||||||
|
events.push({ kind: 'file_edited', path: file });
|
||||||
|
}
|
||||||
|
if (type === 'command.executed') {
|
||||||
|
events.push({
|
||||||
|
kind: 'command_executed',
|
||||||
|
command: stringify(properties.command),
|
||||||
|
output: stringify(properties.output),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type.includes('permission') && type.includes('asked')) {
|
||||||
|
events.push({
|
||||||
|
kind: 'permission_requested',
|
||||||
|
externalRequestId: stringify(properties.permissionID ?? properties.id),
|
||||||
|
title: 'Permission requested',
|
||||||
|
body: stringify(
|
||||||
|
properties.permission ?? properties.message ?? properties,
|
||||||
|
),
|
||||||
|
metadata: stringify(properties),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type.includes('question') && type.includes('asked')) {
|
||||||
|
events.push({
|
||||||
|
kind: 'question_requested',
|
||||||
|
externalRequestId: stringify(properties.requestID ?? properties.id),
|
||||||
|
title: 'Agent question',
|
||||||
|
body: stringify(properties.question ?? properties.message ?? properties),
|
||||||
|
metadata: stringify(properties),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type === 'session.idle') events.push({ kind: 'assistant_completed' });
|
||||||
|
if (type === 'session.error') {
|
||||||
|
events.push({
|
||||||
|
kind: 'error',
|
||||||
|
message: stringify(properties.error ?? properties.message ?? properties),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (events.length === 0 && type) {
|
||||||
|
events.push({
|
||||||
|
kind: 'status',
|
||||||
|
status: type,
|
||||||
|
metadata: stringify(properties),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
};
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { chmod, mkdir, stat } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export const codexContainerWorkspace = '/workspace';
|
||||||
|
export const codexContainerRepo = '/workspace/repo';
|
||||||
|
|
||||||
|
export const prepareCodexWorkspaceFiles = async (args: {
|
||||||
|
workdir: string;
|
||||||
|
repoDir: string;
|
||||||
|
}) => {
|
||||||
|
await mkdir(path.join(args.workdir, '.codex'), { recursive: true });
|
||||||
|
await mkdir(path.join(args.workdir, '.config'), { recursive: true });
|
||||||
|
await mkdir(path.join(args.workdir, '.local', 'share'), { recursive: true });
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
chmod(args.workdir, 0o755),
|
||||||
|
chmod(args.repoDir, 0o755),
|
||||||
|
chmod(path.join(args.workdir, '.codex'), 0o755),
|
||||||
|
chmod(path.join(args.workdir, '.config'), 0o755),
|
||||||
|
chmod(path.join(args.workdir, '.local'), 0o755),
|
||||||
|
chmod(path.join(args.workdir, '.local', 'share'), 0o755),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const projectCodexDir = path.join(args.repoDir, '.codex');
|
||||||
|
const projectConfig = path.join(projectCodexDir, 'config.toml');
|
||||||
|
try {
|
||||||
|
if ((await stat(projectCodexDir)).isDirectory()) {
|
||||||
|
await chmod(projectCodexDir, 0o755);
|
||||||
|
}
|
||||||
|
if ((await stat(projectConfig)).isFile()) {
|
||||||
|
await chmod(projectConfig, 0o644);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const code = error && typeof error === 'object' ? 'code' in error : false;
|
||||||
|
if (!code || (error as { code?: string }).code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -12,6 +12,8 @@ const requiredEnv = (name: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const env = {
|
export const env = {
|
||||||
|
buildSha: process.env.SPOON_BUILD_SHA?.trim() ?? 'development',
|
||||||
|
buildCreatedAt: process.env.SPOON_BUILD_CREATED_AT?.trim() ?? 'unknown',
|
||||||
convexUrl:
|
convexUrl:
|
||||||
process.env.NEXT_PUBLIC_CONVEX_URL?.trim() ??
|
process.env.NEXT_PUBLIC_CONVEX_URL?.trim() ??
|
||||||
process.env.CONVEX_SELF_HOSTED_URL?.trim() ??
|
process.env.CONVEX_SELF_HOSTED_URL?.trim() ??
|
||||||
@@ -19,11 +21,45 @@ export const env = {
|
|||||||
workerToken: requiredEnv('SPOON_WORKER_TOKEN'),
|
workerToken: requiredEnv('SPOON_WORKER_TOKEN'),
|
||||||
workerId: process.env.SPOON_AGENT_WORKER_ID?.trim() ?? 'local-worker',
|
workerId: process.env.SPOON_AGENT_WORKER_ID?.trim() ?? 'local-worker',
|
||||||
runtime: process.env.SPOON_AGENT_RUNTIME?.trim() ?? 'docker',
|
runtime: process.env.SPOON_AGENT_RUNTIME?.trim() ?? 'docker',
|
||||||
|
containerRuntime:
|
||||||
|
process.env.SPOON_AGENT_CONTAINER_RUNTIME?.trim() ??
|
||||||
|
process.env.SPOON_CONTAINER_RUNTIME?.trim() ??
|
||||||
|
'docker',
|
||||||
|
containerVolumeOptions:
|
||||||
|
process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS?.trim(),
|
||||||
|
containerAccess:
|
||||||
|
process.env.SPOON_AGENT_CONTAINER_ACCESS?.trim() === 'host_port'
|
||||||
|
? 'host_port'
|
||||||
|
: 'network',
|
||||||
jobImage:
|
jobImage:
|
||||||
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
|
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
|
||||||
|
// Interactive terminal: image for the persistent shell container (defaults to
|
||||||
|
// the job image), the secret shared with the Next app for verifying terminal
|
||||||
|
// tokens, and how long an idle terminal container survives before cleanup.
|
||||||
|
terminalImage:
|
||||||
|
process.env.SPOON_AGENT_TERMINAL_IMAGE?.trim() ??
|
||||||
|
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ??
|
||||||
|
'spoon-agent-job:latest',
|
||||||
|
terminalSecret:
|
||||||
|
process.env.SPOON_AGENT_TERMINAL_SECRET?.trim() ??
|
||||||
|
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN?.trim() ??
|
||||||
|
process.env.SPOON_WORKER_TOKEN?.trim() ??
|
||||||
|
'',
|
||||||
|
terminalIdleMs: intEnv('SPOON_AGENT_TERMINAL_IDLE_MS', 1_800_000),
|
||||||
|
// How long a per-user box container survives with no active jobs/terminals.
|
||||||
|
boxIdleMs: intEnv('SPOON_AGENT_BOX_IDLE_MS', 1_800_000),
|
||||||
|
// Dev-only: exit if the parent dev runner dies, so the worker never orphans
|
||||||
|
// and holds port 3921 across restarts. Set by scripts/dev-agent-worker.
|
||||||
|
devWatchdog: process.env.SPOON_AGENT_DEV_WATCHDOG === '1',
|
||||||
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
|
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
|
||||||
|
hostWorkdir: process.env.SPOON_AGENT_HOST_WORKDIR?.trim(),
|
||||||
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'),
|
||||||
|
|||||||
@@ -36,12 +36,16 @@ export const cloneRepository = async (args: {
|
|||||||
workBranch: string;
|
workBranch: string;
|
||||||
redact: (value: string) => string;
|
redact: (value: string) => string;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
|
// Directory name to clone into under `workdir` (default "repo"). Used to lay
|
||||||
|
// out checkouts as ~/Code/{spoon}/{branch}.
|
||||||
|
dirName?: string;
|
||||||
}) => {
|
}) => {
|
||||||
await mkdir(args.workdir, { recursive: true });
|
await mkdir(args.workdir, { recursive: true });
|
||||||
|
const dirName = args.dirName ?? 'repo';
|
||||||
const repoUrl = `https://x-access-token:${args.token}@github.com/${args.owner}/${args.repo}.git`;
|
const repoUrl = `https://x-access-token:${args.token}@github.com/${args.owner}/${args.repo}.git`;
|
||||||
const clone = await run(
|
const clone = await run(
|
||||||
'git',
|
'git',
|
||||||
['clone', '--branch', args.baseBranch, '--single-branch', repoUrl, 'repo'],
|
['clone', '--branch', args.baseBranch, '--single-branch', repoUrl, dirName],
|
||||||
{
|
{
|
||||||
cwd: args.workdir,
|
cwd: args.workdir,
|
||||||
redact: args.redact,
|
redact: args.redact,
|
||||||
@@ -51,7 +55,7 @@ export const cloneRepository = async (args: {
|
|||||||
if (clone.exitCode !== 0) {
|
if (clone.exitCode !== 0) {
|
||||||
throw new Error(`git clone failed:\n${clone.output}`);
|
throw new Error(`git clone failed:\n${clone.output}`);
|
||||||
}
|
}
|
||||||
const repoDir = path.join(args.workdir, 'repo');
|
const repoDir = path.join(args.workdir, dirName);
|
||||||
const checkout = await run('git', ['checkout', '-b', args.workBranch], {
|
const checkout = await run('git', ['checkout', '-b', args.workBranch], {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
redact: args.redact,
|
redact: args.redact,
|
||||||
@@ -126,12 +130,41 @@ export const getDiff = async (
|
|||||||
export const getWorktreeDiff = async (
|
export const getWorktreeDiff = async (
|
||||||
repoDir: string,
|
repoDir: string,
|
||||||
redact: (value: string) => string,
|
redact: (value: string) => string,
|
||||||
) =>
|
) => {
|
||||||
await run('git', ['diff', '--', '.'], {
|
const trackedDiff = await run('git', ['diff', '--', '.'], {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
redact,
|
redact,
|
||||||
timeoutMs: 60_000,
|
timeoutMs: 60_000,
|
||||||
});
|
});
|
||||||
|
const untracked = await run(
|
||||||
|
'git',
|
||||||
|
['ls-files', '--others', '--exclude-standard'],
|
||||||
|
{
|
||||||
|
cwd: repoDir,
|
||||||
|
redact,
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const untrackedDiffs: string[] = [];
|
||||||
|
for (const filePath of untracked.output.split('\n').filter(Boolean)) {
|
||||||
|
const diff = await run(
|
||||||
|
'git',
|
||||||
|
['diff', '--no-index', '--', '/dev/null', filePath],
|
||||||
|
{
|
||||||
|
cwd: repoDir,
|
||||||
|
redact,
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (diff.output.trim()) untrackedDiffs.push(diff.output);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
exitCode: trackedDiff.exitCode === 0 && untracked.exitCode === 0 ? 0 : 1,
|
||||||
|
output: [trackedDiff.output, ...untrackedDiffs]
|
||||||
|
.filter((part) => part.trim())
|
||||||
|
.join('\n'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const getStatus = async (
|
export const getStatus = async (
|
||||||
repoDir: string,
|
repoDir: string,
|
||||||
|
|||||||
@@ -1,3 +1,29 @@
|
|||||||
|
import { env } from './env';
|
||||||
|
import { startWorkerServer } from './server';
|
||||||
import { startWorker } from './worker';
|
import { startWorker } from './worker';
|
||||||
|
|
||||||
|
// Dev-only watchdog: the dev runner chain (turbo → with-env → dotenv → bash)
|
||||||
|
// doesn't always forward the stop signal to this leaf process, so on restart the
|
||||||
|
// worker can orphan and keep holding port 3921. Exit when our original parent
|
||||||
|
// goes away (we get reparented) or on a stop signal, so restarts stay clean.
|
||||||
|
// Never enabled in prod (gated on SPOON_AGENT_DEV_WATCHDOG).
|
||||||
|
if (env.devWatchdog) {
|
||||||
|
// Bun caches `process.ppid`, so poll whether the original parent still exists
|
||||||
|
// (signal 0 throws once it's gone) rather than comparing ppid.
|
||||||
|
const parentPid = process.ppid;
|
||||||
|
const watcher = setInterval(() => {
|
||||||
|
try {
|
||||||
|
process.kill(parentPid, 0);
|
||||||
|
} catch {
|
||||||
|
console.log('Dev parent exited; shutting down worker.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
watcher.unref();
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
|
||||||
|
process.on(signal, () => process.exit(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startWorkerServer();
|
||||||
await startWorker();
|
await startWorker();
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { OpencodeClient } from '@opencode-ai/sdk';
|
||||||
|
import { createOpencodeClient } from '@opencode-ai/sdk';
|
||||||
|
|
||||||
|
import type { NormalizedAgentEvent } from './agent-events';
|
||||||
|
import { normalizeOpenCodeEvent } from './agent-events';
|
||||||
|
|
||||||
|
export type OpenCodeSession = {
|
||||||
|
client: OpencodeClient;
|
||||||
|
sessionId: string;
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const basicAuth = (username: string, password: string) =>
|
||||||
|
`Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
|
|
||||||
|
const modelParts = (model: string) => {
|
||||||
|
const [rawProviderId, ...rest] = model.split('/');
|
||||||
|
const providerID =
|
||||||
|
rawProviderId && rawProviderId.length > 0 ? rawProviderId : 'openai';
|
||||||
|
const modelID = rest.length > 0 ? rest.join('/') : model;
|
||||||
|
return {
|
||||||
|
providerID,
|
||||||
|
modelID,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createOpenCodeSession = async (args: {
|
||||||
|
baseUrl: string;
|
||||||
|
password: string;
|
||||||
|
directory: string;
|
||||||
|
title: string;
|
||||||
|
onEvent: (event: NormalizedAgentEvent) => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const client = createOpencodeClient({
|
||||||
|
baseUrl: args.baseUrl,
|
||||||
|
directory: args.directory,
|
||||||
|
headers: {
|
||||||
|
authorization: basicAuth('opencode', args.password),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const created = await client.session.create({
|
||||||
|
query: { directory: args.directory },
|
||||||
|
body: { title: args.title },
|
||||||
|
});
|
||||||
|
if (!created.data) {
|
||||||
|
throw new Error('OpenCode session could not be created.');
|
||||||
|
}
|
||||||
|
const sessionId = created.data.id;
|
||||||
|
void (async () => {
|
||||||
|
const events = await client.event.subscribe({
|
||||||
|
signal: abortController.signal,
|
||||||
|
query: { directory: args.directory },
|
||||||
|
onSseEvent: (event) => {
|
||||||
|
for (const normalized of normalizeOpenCodeEvent(event.data)) {
|
||||||
|
void args.onEvent(normalized);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSseError: (error) => {
|
||||||
|
void args.onEvent({
|
||||||
|
kind: 'error',
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for await (const event of events.stream) {
|
||||||
|
for (const normalized of normalizeOpenCodeEvent(event)) {
|
||||||
|
await args.onEvent(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})().catch((error: unknown) => {
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
void args.onEvent({
|
||||||
|
kind: 'error',
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
sessionId,
|
||||||
|
close: () => abortController.abort(),
|
||||||
|
} satisfies OpenCodeSession;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const promptOpenCodeSession = async (args: {
|
||||||
|
session: OpenCodeSession;
|
||||||
|
prompt: string;
|
||||||
|
model: string;
|
||||||
|
directory: string;
|
||||||
|
}) => {
|
||||||
|
const model = modelParts(args.model);
|
||||||
|
const result = await args.session.client.session.promptAsync({
|
||||||
|
path: { id: args.session.sessionId },
|
||||||
|
query: { directory: args.directory },
|
||||||
|
body: {
|
||||||
|
model,
|
||||||
|
parts: [{ type: 'text', text: args.prompt }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error('OpenCode prompt was rejected.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const abortOpenCodeSession = async (session: OpenCodeSession) => {
|
||||||
|
await session.client.session.abort({
|
||||||
|
path: { id: session.sessionId },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const replyOpenCodePermission = async (args: {
|
||||||
|
session: OpenCodeSession;
|
||||||
|
permissionId: string;
|
||||||
|
response: 'once' | 'always' | 'reject';
|
||||||
|
directory: string;
|
||||||
|
}) => {
|
||||||
|
const result = await args.session.client.postSessionIdPermissionsPermissionId(
|
||||||
|
{
|
||||||
|
path: { id: args.session.sessionId, permissionID: args.permissionId },
|
||||||
|
query: { directory: args.directory },
|
||||||
|
body: { response: args.response },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error('OpenCode permission response was rejected.');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,21 +1,112 @@
|
|||||||
|
import { mkdir } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { Readable } from 'node:stream';
|
||||||
import { execa } from 'execa';
|
import { execa } from 'execa';
|
||||||
|
|
||||||
import { env } from '../env';
|
import { env } from '../env';
|
||||||
|
|
||||||
|
type CommandResult = {
|
||||||
|
exitCode: number;
|
||||||
|
output: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const environmentArgs = (environment: Record<string, string>) =>
|
||||||
|
Object.entries(environment).flatMap(([name, value]) => [
|
||||||
|
'-e',
|
||||||
|
`${name}=${value}`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const networkArgs = () => (env.network ? ['--network', env.network] : []);
|
||||||
|
|
||||||
|
const containerRuntime = () => env.containerRuntime;
|
||||||
|
|
||||||
|
// `docker run` reuses a stale local `:latest` forever, so without an explicit
|
||||||
|
// pull the job image never updates in production. Pull once per worker process
|
||||||
|
// (i.e. once per deploy/restart) so a fresh worker always runs a fresh job
|
||||||
|
// image. Best-effort: if the registry is unreachable we fall back to whatever
|
||||||
|
// image is present locally rather than failing the job.
|
||||||
|
let jobImagePullPromise: Promise<void> | undefined;
|
||||||
|
export const ensureJobImagePulled = () => {
|
||||||
|
jobImagePullPromise ??= (async () => {
|
||||||
|
try {
|
||||||
|
await execa(containerRuntime(), ['pull', env.jobImage], {
|
||||||
|
reject: false,
|
||||||
|
stdin: 'ignore',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore: keep running with the locally cached image.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return jobImagePullPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
// execa with `reject: false` resolves (does not throw) even when the runtime
|
||||||
|
// binary is missing (ENOENT) — `exitCode` is then `undefined`. Coercing that to
|
||||||
|
// 0 makes a failed spawn look like a successful empty run, which is exactly how
|
||||||
|
// a worker image without a `docker` CLI silently produced empty agent
|
||||||
|
// responses. Normalize so any spawn failure is a non-zero exit carrying the
|
||||||
|
// real reason.
|
||||||
|
export const normalizeRunResult = (
|
||||||
|
// Declared nullable on purpose: execa's types claim these are always present,
|
||||||
|
// but on a spawn failure (e.g. missing `docker` binary) `exitCode`/`all` are
|
||||||
|
// actually undefined at runtime.
|
||||||
|
result: { exitCode?: number; shortMessage?: string },
|
||||||
|
output: string | undefined,
|
||||||
|
redact: (value: string) => string,
|
||||||
|
): CommandResult => {
|
||||||
|
const text = output ?? '';
|
||||||
|
if (result.exitCode == null) {
|
||||||
|
const reason = result.shortMessage ?? 'container runtime failed to start';
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
output: redact(`${text}${text ? '\n' : ''}${reason}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { exitCode: result.exitCode, output: redact(text) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const hostWorkspacePath = (workdir: string) => {
|
||||||
|
if (!env.hostWorkdir) return workdir;
|
||||||
|
const workerRoot = path.resolve(env.workdir);
|
||||||
|
const resolvedWorkdir = path.resolve(workdir);
|
||||||
|
const relative = path.relative(workerRoot, resolvedWorkdir);
|
||||||
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||||
|
return workdir;
|
||||||
|
}
|
||||||
|
return path.join(env.hostWorkdir, relative);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const containerVolumeSuffix = () =>
|
||||||
|
env.containerVolumeOptions ??
|
||||||
|
(containerRuntime().endsWith('podman') ? 'Z' : undefined);
|
||||||
|
|
||||||
|
export { hostWorkspacePath };
|
||||||
|
|
||||||
|
export const jobWorkspaceVolumeSpec = (
|
||||||
|
workdir: string,
|
||||||
|
containerHome = '/workspace',
|
||||||
|
) => {
|
||||||
|
const volumeOptions =
|
||||||
|
env.containerVolumeOptions ??
|
||||||
|
(containerRuntime().endsWith('podman') ? 'Z' : undefined);
|
||||||
|
const source = hostWorkspacePath(workdir);
|
||||||
|
return volumeOptions
|
||||||
|
? `${source}:${containerHome}:${volumeOptions}`
|
||||||
|
: `${source}:${containerHome}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const runInJobContainer = async (args: {
|
export const runInJobContainer = async (args: {
|
||||||
workdir: string;
|
workdir: string;
|
||||||
|
containerHome?: string;
|
||||||
|
containerCwd?: string;
|
||||||
command: string[];
|
command: string[];
|
||||||
environment: Record<string, string>;
|
environment: Record<string, string>;
|
||||||
redact: (value: string) => string;
|
redact: (value: string) => string;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
}) => {
|
}): Promise<CommandResult> => {
|
||||||
const envArgs = Object.entries(args.environment).flatMap(([name, value]) => [
|
await ensureJobImagePulled();
|
||||||
'-e',
|
|
||||||
`${name}=${value}`,
|
|
||||||
]);
|
|
||||||
const networkArgs = env.network ? ['--network', env.network] : [];
|
|
||||||
const result = await execa(
|
const result = await execa(
|
||||||
'docker',
|
containerRuntime(),
|
||||||
[
|
[
|
||||||
'run',
|
'run',
|
||||||
'--rm',
|
'--rm',
|
||||||
@@ -23,18 +114,110 @@ export const runInJobContainer = async (args: {
|
|||||||
'4g',
|
'4g',
|
||||||
'--cpus',
|
'--cpus',
|
||||||
'2',
|
'2',
|
||||||
...networkArgs,
|
...networkArgs(),
|
||||||
...envArgs,
|
...environmentArgs(args.environment),
|
||||||
'-v',
|
'-v',
|
||||||
`${args.workdir}:/workspace`,
|
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||||
'-w',
|
'-w',
|
||||||
'/workspace/repo',
|
args.containerCwd ?? '/workspace/repo',
|
||||||
env.jobImage,
|
env.jobImage,
|
||||||
...args.command,
|
...args.command,
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
all: true,
|
all: true,
|
||||||
reject: false,
|
reject: false,
|
||||||
|
stdin: 'ignore',
|
||||||
|
timeout: args.timeoutMs,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return normalizeRunResult(result, result.all, args.redact);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const startWorkspaceContainer = async (args: {
|
||||||
|
workdir: string;
|
||||||
|
containerHome?: string;
|
||||||
|
containerCwd?: string;
|
||||||
|
containerName: string;
|
||||||
|
environment: Record<string, string>;
|
||||||
|
command?: string[];
|
||||||
|
publishTcpPort?: number;
|
||||||
|
}) => {
|
||||||
|
await ensureJobImagePulled();
|
||||||
|
await execa(containerRuntime(), ['rm', '-f', args.containerName], {
|
||||||
|
reject: false,
|
||||||
|
});
|
||||||
|
const result = await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
[
|
||||||
|
'run',
|
||||||
|
'-d',
|
||||||
|
'--name',
|
||||||
|
args.containerName,
|
||||||
|
'--memory',
|
||||||
|
'4g',
|
||||||
|
'--cpus',
|
||||||
|
'2',
|
||||||
|
...networkArgs(),
|
||||||
|
...(args.publishTcpPort
|
||||||
|
? ['-p', `127.0.0.1::${args.publishTcpPort}`]
|
||||||
|
: []),
|
||||||
|
...environmentArgs(args.environment),
|
||||||
|
'-v',
|
||||||
|
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||||
|
'-w',
|
||||||
|
args.containerCwd ?? '/workspace/repo',
|
||||||
|
env.jobImage,
|
||||||
|
...(args.command ?? ['sleep', 'infinity']),
|
||||||
|
],
|
||||||
|
{ all: true, stdin: 'ignore' },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
containerId: result.stdout.trim(),
|
||||||
|
containerName: args.containerName,
|
||||||
|
hostPort: args.publishTcpPort
|
||||||
|
? await getPublishedPort(args.containerName, args.publishTcpPort)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPublishedPort = async (
|
||||||
|
containerName: string,
|
||||||
|
containerPort: number,
|
||||||
|
) => {
|
||||||
|
const result = await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
['port', containerName, `${containerPort}/tcp`],
|
||||||
|
{ all: true, reject: false, stdin: 'ignore' },
|
||||||
|
);
|
||||||
|
const output = result.all.trim();
|
||||||
|
const match = /:(\d+)\s*$/.exec(output);
|
||||||
|
if (!match?.[1]) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not determine published port for ${containerName}:${containerPort}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return match[1];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const execInWorkspaceContainer = async (args: {
|
||||||
|
containerName: string;
|
||||||
|
command: string[];
|
||||||
|
environment?: Record<string, string>;
|
||||||
|
redact: (value: string) => string;
|
||||||
|
timeoutMs: number;
|
||||||
|
}): Promise<CommandResult> => {
|
||||||
|
const result = await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
[
|
||||||
|
'exec',
|
||||||
|
...(args.environment ? environmentArgs(args.environment) : []),
|
||||||
|
args.containerName,
|
||||||
|
...args.command,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
all: true,
|
||||||
|
reject: false,
|
||||||
|
stdin: 'ignore',
|
||||||
timeout: args.timeoutMs,
|
timeout: args.timeoutMs,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -43,3 +226,241 @@ export const runInJobContainer = async (args: {
|
|||||||
output: args.redact(result.all),
|
output: args.redact(result.all),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Shared line-streaming + result normalization for a started subprocess
|
||||||
|
// (used by both `docker run` and `docker exec` paths).
|
||||||
|
type StreamingSubprocess = {
|
||||||
|
stdout: Readable | null;
|
||||||
|
stderr: Readable | null;
|
||||||
|
} & Promise<{ exitCode?: number; shortMessage?: string; all?: string }>;
|
||||||
|
|
||||||
|
const streamSubprocess = async (
|
||||||
|
subprocess: StreamingSubprocess,
|
||||||
|
redact: (value: string) => string,
|
||||||
|
onStdoutLine?: (line: string) => Promise<void>,
|
||||||
|
onStderrLine?: (line: string) => Promise<void>,
|
||||||
|
): Promise<CommandResult> => {
|
||||||
|
let stdoutBuffer = '';
|
||||||
|
let stderrBuffer = '';
|
||||||
|
const output: string[] = [];
|
||||||
|
let lineHandlers = Promise.resolve();
|
||||||
|
const consume = async (
|
||||||
|
chunk: Buffer,
|
||||||
|
source: 'stdout' | 'stderr',
|
||||||
|
handler?: (line: string) => Promise<void>,
|
||||||
|
) => {
|
||||||
|
output.push(chunk.toString('utf8'));
|
||||||
|
const next = `${source === 'stdout' ? stdoutBuffer : stderrBuffer}${chunk.toString('utf8')}`;
|
||||||
|
const lines = next.split(/\r?\n/);
|
||||||
|
const remainder = lines.pop() ?? '';
|
||||||
|
if (source === 'stdout') stdoutBuffer = remainder;
|
||||||
|
else stderrBuffer = remainder;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (handler) await handler(redact(line));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
subprocess.stdout?.on('data', (chunk: Buffer) => {
|
||||||
|
lineHandlers = lineHandlers.then(() =>
|
||||||
|
consume(chunk, 'stdout', onStdoutLine),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
subprocess.stderr?.on('data', (chunk: Buffer) => {
|
||||||
|
lineHandlers = lineHandlers.then(() =>
|
||||||
|
consume(chunk, 'stderr', onStderrLine),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
let result: Awaited<StreamingSubprocess>;
|
||||||
|
try {
|
||||||
|
result = await subprocess;
|
||||||
|
} catch (error) {
|
||||||
|
await lineHandlers;
|
||||||
|
const outputText = output.join('');
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Container command failed.';
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
output: redact(`${outputText}${outputText ? '\n' : ''}${message}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await lineHandlers;
|
||||||
|
if (stdoutBuffer && onStdoutLine) await onStdoutLine(redact(stdoutBuffer));
|
||||||
|
if (stderrBuffer && onStderrLine) await onStderrLine(redact(stderrBuffer));
|
||||||
|
return normalizeRunResult(result, output.join(''), redact);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const streamInJobContainer = async (args: {
|
||||||
|
workdir: string;
|
||||||
|
containerHome?: string;
|
||||||
|
containerCwd?: string;
|
||||||
|
command: string[];
|
||||||
|
environment: Record<string, string>;
|
||||||
|
redact: (value: string) => string;
|
||||||
|
timeoutMs: number;
|
||||||
|
onStdoutLine?: (line: string) => Promise<void>;
|
||||||
|
onStderrLine?: (line: string) => Promise<void>;
|
||||||
|
}): Promise<CommandResult> => {
|
||||||
|
await ensureJobImagePulled();
|
||||||
|
const subprocess = execa(
|
||||||
|
containerRuntime(),
|
||||||
|
[
|
||||||
|
'run',
|
||||||
|
'--rm',
|
||||||
|
'--memory',
|
||||||
|
'4g',
|
||||||
|
'--cpus',
|
||||||
|
'2',
|
||||||
|
...networkArgs(),
|
||||||
|
...environmentArgs(args.environment),
|
||||||
|
'-v',
|
||||||
|
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||||
|
'-w',
|
||||||
|
args.containerCwd ?? '/workspace/repo',
|
||||||
|
env.jobImage,
|
||||||
|
...args.command,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
all: true,
|
||||||
|
reject: false,
|
||||||
|
stdin: 'ignore',
|
||||||
|
timeout: args.timeoutMs,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return streamSubprocess(
|
||||||
|
subprocess,
|
||||||
|
args.redact,
|
||||||
|
args.onStdoutLine,
|
||||||
|
args.onStderrLine,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Per-user persistent "box" container that all of a user's threads exec into
|
||||||
|
// (Phase 2). Started once, reused; the home volume persists state across stops.
|
||||||
|
export const userContainerName = (username: string) =>
|
||||||
|
`spoon-box-${username.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
|
||||||
|
|
||||||
|
export const ensureUserContainer = async (args: {
|
||||||
|
username: string;
|
||||||
|
workdir: string;
|
||||||
|
containerHome: string;
|
||||||
|
}): Promise<string> => {
|
||||||
|
await ensureJobImagePulled();
|
||||||
|
const name = userContainerName(args.username);
|
||||||
|
const inspect = await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
['inspect', '-f', '{{.State.Running}}', name],
|
||||||
|
{ reject: false, stdin: 'ignore' },
|
||||||
|
);
|
||||||
|
if (inspect.exitCode === 0 && inspect.stdout.trim() === 'true') return name;
|
||||||
|
// The box mounts the per-user home, but it's created before the thread's clone
|
||||||
|
// populates it — ensure it exists first, since podman (unlike docker) refuses to
|
||||||
|
// bind-mount a missing source directory (statfs: no such file or directory).
|
||||||
|
await mkdir(args.workdir, { recursive: true });
|
||||||
|
// Not running: remove any stale container, then start fresh.
|
||||||
|
await execa(containerRuntime(), ['rm', '-f', name], { reject: false });
|
||||||
|
await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
[
|
||||||
|
'run',
|
||||||
|
'-d',
|
||||||
|
'--name',
|
||||||
|
name,
|
||||||
|
'--memory',
|
||||||
|
'4g',
|
||||||
|
'--cpus',
|
||||||
|
'2',
|
||||||
|
...networkArgs(),
|
||||||
|
'-v',
|
||||||
|
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||||
|
'-w',
|
||||||
|
args.containerHome,
|
||||||
|
env.jobImage,
|
||||||
|
'sleep',
|
||||||
|
'infinity',
|
||||||
|
],
|
||||||
|
{ stdin: 'ignore' },
|
||||||
|
);
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const streamExecInContainer = async (args: {
|
||||||
|
containerName: string;
|
||||||
|
command: string[];
|
||||||
|
environment: Record<string, string>;
|
||||||
|
containerCwd: string;
|
||||||
|
redact: (value: string) => string;
|
||||||
|
timeoutMs: number;
|
||||||
|
onStdoutLine?: (line: string) => Promise<void>;
|
||||||
|
onStderrLine?: (line: string) => Promise<void>;
|
||||||
|
}): Promise<CommandResult> => {
|
||||||
|
const subprocess = execa(
|
||||||
|
containerRuntime(),
|
||||||
|
[
|
||||||
|
'exec',
|
||||||
|
...environmentArgs(args.environment),
|
||||||
|
'-w',
|
||||||
|
args.containerCwd,
|
||||||
|
args.containerName,
|
||||||
|
...args.command,
|
||||||
|
],
|
||||||
|
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
|
||||||
|
);
|
||||||
|
return streamSubprocess(
|
||||||
|
subprocess,
|
||||||
|
args.redact,
|
||||||
|
args.onStdoutLine,
|
||||||
|
args.onStderrLine,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runExecInContainer = async (args: {
|
||||||
|
containerName: string;
|
||||||
|
command: string[];
|
||||||
|
environment: Record<string, string>;
|
||||||
|
containerCwd: string;
|
||||||
|
redact: (value: string) => string;
|
||||||
|
timeoutMs: number;
|
||||||
|
}): Promise<CommandResult> => {
|
||||||
|
const result = await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
[
|
||||||
|
'exec',
|
||||||
|
...environmentArgs(args.environment),
|
||||||
|
'-w',
|
||||||
|
args.containerCwd,
|
||||||
|
args.containerName,
|
||||||
|
...args.command,
|
||||||
|
],
|
||||||
|
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
|
||||||
|
);
|
||||||
|
return normalizeRunResult(result, result.all, args.redact);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stopWorkspaceContainer = async (containerName: string) => {
|
||||||
|
await execa(containerRuntime(), ['rm', '-f', containerName], {
|
||||||
|
reject: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const inspectWorkspaceContainer = async (containerName: string) => {
|
||||||
|
const result = await execa(containerRuntime(), ['inspect', containerName], {
|
||||||
|
all: true,
|
||||||
|
reject: false,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
exists: result.exitCode === 0,
|
||||||
|
output: result.all,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listWorkspaceContainerNames = async (prefix: string) => {
|
||||||
|
const result = await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
['ps', '-a', '--format', '{{.Names}}'],
|
||||||
|
{ all: true, reject: false },
|
||||||
|
);
|
||||||
|
if (result.exitCode !== 0) return [];
|
||||||
|
return result.all
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.startsWith(prefix));
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { createServer } from 'node:http';
|
||||||
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
|
||||||
|
import { env } from './env';
|
||||||
|
import { attachTerminalServer } from './terminal';
|
||||||
|
import {
|
||||||
|
abortWorkspaceAgent,
|
||||||
|
cleanupOrphanedWorkspaces,
|
||||||
|
getWorkerHealth,
|
||||||
|
getWorkspaceAgentStatus,
|
||||||
|
getWorkspaceDiff,
|
||||||
|
listWorkspaceTree,
|
||||||
|
openWorkspacePullRequest,
|
||||||
|
readWorkspaceFile,
|
||||||
|
replyToInteraction,
|
||||||
|
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' && request.method === 'GET') {
|
||||||
|
sendJson(response, 200, await getWorkerHealth());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/cleanup' && request.method === 'POST') {
|
||||||
|
sendJson(response, 200, await cleanupOrphanedWorkspaces());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const route = jobRoute(url.pathname);
|
||||||
|
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 === 'GET' && route.action === 'agent/status') {
|
||||||
|
sendJson(response, 200, getWorkspaceAgentStatus(route.jobId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && route.action === 'agent/abort') {
|
||||||
|
sendJson(response, 200, await abortWorkspaceAgent(route.jobId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const interactionMatch = /^interactions\/([^/]+)\/reply$/.exec(
|
||||||
|
route.action,
|
||||||
|
);
|
||||||
|
if (request.method === 'POST' && interactionMatch?.[1]) {
|
||||||
|
const body = await parseJson<{
|
||||||
|
externalRequestId?: string;
|
||||||
|
response?: string;
|
||||||
|
}>(request);
|
||||||
|
sendJson(
|
||||||
|
response,
|
||||||
|
200,
|
||||||
|
await replyToInteraction(route.jobId, {
|
||||||
|
interactionId: decodeURIComponent(
|
||||||
|
interactionMatch[1],
|
||||||
|
) as Id<'agentInteractionRequests'>,
|
||||||
|
externalRequestId: body.externalRequestId ?? '',
|
||||||
|
response: body.response ?? 'once',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && route.action === 'run-command') {
|
||||||
|
const body = await parseJson<{ command?: string }>(request);
|
||||||
|
sendJson(
|
||||||
|
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);
|
||||||
|
console.error(
|
||||||
|
`Worker HTTP ${request.method ?? 'UNKNOWN'} ${request.url ?? '/'} failed: ${message}`,
|
||||||
|
);
|
||||||
|
const status =
|
||||||
|
message === 'Unauthorized'
|
||||||
|
? 401
|
||||||
|
: message.includes('not supported')
|
||||||
|
? 409
|
||||||
|
: 500;
|
||||||
|
sendJson(response, status, {
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
attachTerminalServer(server);
|
||||||
|
server.listen(env.httpPort, () => {
|
||||||
|
console.log(
|
||||||
|
`Spoon agent worker HTTP server listening on port ${env.httpPort}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
// Short-lived, job-scoped token authorizing a browser terminal connection.
|
||||||
|
// Minted server-side by the Next app (which has verified job ownership) and
|
||||||
|
// verified here so the browser never sees the shared worker secret. Format:
|
||||||
|
// `${expiresAtMs}.${jobId}.${hmacSha256Hex}`
|
||||||
|
const signature = (payload: string, secret: string) =>
|
||||||
|
createHmac('sha256', secret).update(payload).digest('hex');
|
||||||
|
|
||||||
|
export const verifyTerminalToken = (
|
||||||
|
token: string,
|
||||||
|
jobId: string,
|
||||||
|
secret: string,
|
||||||
|
): boolean => {
|
||||||
|
if (!token || !secret) return false;
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return false;
|
||||||
|
const [expRaw, tokenJobId, provided] = parts;
|
||||||
|
if (tokenJobId !== jobId) return false;
|
||||||
|
const exp = Number.parseInt(expRaw ?? '', 10);
|
||||||
|
if (!Number.isFinite(exp) || Date.now() > exp) return false;
|
||||||
|
const expected = signature(`${expRaw}.${tokenJobId}`, secret);
|
||||||
|
const providedBuf = Buffer.from(provided ?? '', 'hex');
|
||||||
|
const expectedBuf = Buffer.from(expected, 'hex');
|
||||||
|
return (
|
||||||
|
providedBuf.length === expectedBuf.length &&
|
||||||
|
timingSafeEqual(providedBuf, expectedBuf)
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||||
|
import type { Server } from 'node:http';
|
||||||
|
import type { WebSocket } from 'ws';
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
import { env } from './env';
|
||||||
|
import { verifyTerminalToken } from './terminal-token';
|
||||||
|
import { acquireUserBox, releaseUserBox } from './user-container';
|
||||||
|
import { getTerminalWorkspace } from './worker';
|
||||||
|
|
||||||
|
const clampDimension = (value: unknown) => {
|
||||||
|
const n = Math.trunc(Number(value));
|
||||||
|
if (!Number.isFinite(n)) return undefined;
|
||||||
|
return Math.min(Math.max(n, 1), 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single-quote a string for a POSIX shell.
|
||||||
|
const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`;
|
||||||
|
|
||||||
|
const bridge = async (ws: WebSocket, jobId: string) => {
|
||||||
|
const workspace = getTerminalWorkspace(jobId);
|
||||||
|
if (!workspace) {
|
||||||
|
ws.close(1011, 'Workspace is not active.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// bun can't load node-pty (native ABI mismatch) and dockerode can't attach to
|
||||||
|
// podman, so we drive the runtime CLI (`<runtime> exec -i`) and allocate the PTY
|
||||||
|
// *inside* the container with `script`, bridging the plain pipes to the socket.
|
||||||
|
//
|
||||||
|
// Register the message handler immediately and buffer input/size until the exec
|
||||||
|
// is ready (acquiring the box can take seconds on first connect), so the initial
|
||||||
|
// resize and early keystrokes aren't dropped.
|
||||||
|
const procHolder: { current?: ChildProcessWithoutNullStreams } = {};
|
||||||
|
const pendingInput: Buffer[] = [];
|
||||||
|
let cols = 80;
|
||||||
|
let rows = 24;
|
||||||
|
|
||||||
|
ws.on('message', (data: Buffer, isBinary: boolean) => {
|
||||||
|
if (!isBinary) {
|
||||||
|
// Text frames are control messages (resize); anything else is raw input.
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(data.toString('utf8')) as {
|
||||||
|
type?: string;
|
||||||
|
cols?: number;
|
||||||
|
rows?: number;
|
||||||
|
};
|
||||||
|
if (message.type === 'resize') {
|
||||||
|
const c = clampDimension(message.cols);
|
||||||
|
const r = clampDimension(message.rows);
|
||||||
|
if (c && r) {
|
||||||
|
cols = c;
|
||||||
|
rows = r;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through: treat as raw input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (procHolder.current) procHolder.current.stdin.write(data);
|
||||||
|
else pendingInput.push(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
let acquired = false;
|
||||||
|
let released = false;
|
||||||
|
// Read through a function so TS doesn't narrow `released` to a constant — the
|
||||||
|
// cleanup handler flips it asynchronously when the socket closes.
|
||||||
|
const isReleased = () => released;
|
||||||
|
const cleanup = () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
procHolder.current?.kill();
|
||||||
|
if (acquired) releaseUserBox(workspace.username);
|
||||||
|
};
|
||||||
|
ws.on('close', cleanup);
|
||||||
|
ws.on('error', cleanup);
|
||||||
|
|
||||||
|
// Hold the per-user box open while this terminal is connected; the agent and
|
||||||
|
// the terminal share the exact same container (Phase 2).
|
||||||
|
let boxName: string;
|
||||||
|
try {
|
||||||
|
boxName = await acquireUserBox({
|
||||||
|
username: workspace.username,
|
||||||
|
workdir: workspace.workdir,
|
||||||
|
containerHome: workspace.containerHome,
|
||||||
|
});
|
||||||
|
acquired = true;
|
||||||
|
} catch (error) {
|
||||||
|
ws.close(
|
||||||
|
1011,
|
||||||
|
`Failed to start terminal: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReleased()) return; // client disconnected during startup; cleanup ran
|
||||||
|
|
||||||
|
// Reattach a persistent tmux session across reconnects when available, else a
|
||||||
|
// plain login shell. `stty` sizes the PTY to the client's viewport up front.
|
||||||
|
const launcher =
|
||||||
|
`stty rows ${rows} cols ${cols} 2>/dev/null; ` +
|
||||||
|
// Reattach a persistent tmux session when tmux is present; otherwise fall back
|
||||||
|
// to an interactive login shell (`-i` so it prints a prompt and line-edits).
|
||||||
|
// Check with `command -v` rather than `exec tmux || …`: a failed `exec` makes a
|
||||||
|
// non-interactive shell exit before the `||`, so the fallback never runs.
|
||||||
|
'if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s spoon; ' +
|
||||||
|
'else exec bash -il; fi';
|
||||||
|
const envFlags = [
|
||||||
|
'-e',
|
||||||
|
'TERM=xterm-256color',
|
||||||
|
'-e',
|
||||||
|
`HOME=${workspace.containerHome}`,
|
||||||
|
...workspace.secrets.flatMap((s) => ['-e', `${s.name}=${s.value}`]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const proc = spawn(
|
||||||
|
env.containerRuntime,
|
||||||
|
[
|
||||||
|
'exec',
|
||||||
|
'-i',
|
||||||
|
...envFlags,
|
||||||
|
'-w',
|
||||||
|
workspace.containerRepo,
|
||||||
|
boxName,
|
||||||
|
'/bin/bash',
|
||||||
|
'-lc',
|
||||||
|
`exec script -qfc ${shellQuote(launcher)} /dev/null`,
|
||||||
|
],
|
||||||
|
{ stdio: ['pipe', 'pipe', 'pipe'] },
|
||||||
|
);
|
||||||
|
procHolder.current = proc;
|
||||||
|
|
||||||
|
// Replay any keystrokes the client sent before the process was ready.
|
||||||
|
for (const buffered of pendingInput) proc.stdin.write(buffered);
|
||||||
|
pendingInput.length = 0;
|
||||||
|
|
||||||
|
const forward = (chunk: Buffer) => {
|
||||||
|
if (ws.readyState === ws.OPEN) ws.send(chunk, { binary: true });
|
||||||
|
};
|
||||||
|
proc.stdout.on('data', forward);
|
||||||
|
proc.stderr.on('data', forward);
|
||||||
|
proc.on('exit', () => {
|
||||||
|
if (ws.readyState === ws.OPEN) ws.close();
|
||||||
|
});
|
||||||
|
proc.on('error', () => {
|
||||||
|
if (ws.readyState === ws.OPEN) ws.close();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches the interactive-terminal WebSocket endpoint to the worker's HTTP
|
||||||
|
* server. Browser connects to `/jobs/:jobId/terminal?token=…` with a short-lived
|
||||||
|
* token minted by the Next app (which has already verified job ownership).
|
||||||
|
*/
|
||||||
|
export const attachTerminalServer = (server: Server) => {
|
||||||
|
if (env.runtime !== 'docker') return;
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
server.on('upgrade', (request, socket, head) => {
|
||||||
|
const url = new URL(request.url ?? '', `http://localhost:${env.httpPort}`);
|
||||||
|
const match = /^\/jobs\/([^/]+)\/terminal$/.exec(url.pathname);
|
||||||
|
if (!match?.[1]) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const jobId = decodeURIComponent(match[1]);
|
||||||
|
const token = url.searchParams.get('token') ?? '';
|
||||||
|
if (!verifyTerminalToken(token, jobId, env.terminalSecret)) {
|
||||||
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
void bridge(ws, jobId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Spoon agent worker terminal WebSocket endpoint enabled.');
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { env } from './env';
|
||||||
|
import {
|
||||||
|
ensureUserContainer,
|
||||||
|
stopWorkspaceContainer,
|
||||||
|
userContainerName,
|
||||||
|
} from './runtime/docker';
|
||||||
|
|
||||||
|
// Phase 2: one persistent "box" container per user that all of their threads
|
||||||
|
// (agent turns + terminal + commands) exec into. Reference-counted so it stays
|
||||||
|
// up while any thread workspace is active or a terminal is connected, and is
|
||||||
|
// reaped after an idle period once nothing holds it.
|
||||||
|
type Box = { refs: number; idleTimer?: NodeJS.Timeout };
|
||||||
|
const boxes = new Map<string, Box>();
|
||||||
|
|
||||||
|
export const acquireUserBox = async (args: {
|
||||||
|
username: string;
|
||||||
|
workdir: string;
|
||||||
|
containerHome: string;
|
||||||
|
}): Promise<string> => {
|
||||||
|
const name = await ensureUserContainer(args);
|
||||||
|
const box = boxes.get(args.username) ?? { refs: 0 };
|
||||||
|
if (box.idleTimer) {
|
||||||
|
clearTimeout(box.idleTimer);
|
||||||
|
box.idleTimer = undefined;
|
||||||
|
}
|
||||||
|
box.refs += 1;
|
||||||
|
boxes.set(args.username, box);
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const releaseUserBox = (username: string) => {
|
||||||
|
const box = boxes.get(username);
|
||||||
|
if (!box) return;
|
||||||
|
box.refs = Math.max(0, box.refs - 1);
|
||||||
|
if (box.refs > 0) return;
|
||||||
|
box.idleTimer = setTimeout(() => {
|
||||||
|
void stopWorkspaceContainer(userContainerName(username));
|
||||||
|
boxes.delete(username);
|
||||||
|
}, env.boxIdleMs);
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { chmod, mkdir, readFile, writeFile } 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 { env } from './env';
|
||||||
|
import { runExecInContainer } from './runtime/docker';
|
||||||
|
|
||||||
|
const client = new ConvexHttpClient(env.convexUrl);
|
||||||
|
|
||||||
|
export type UserEnvironment = {
|
||||||
|
username: string;
|
||||||
|
enabled: boolean;
|
||||||
|
dotfilesRepoUrl?: string;
|
||||||
|
dotfilesRepoRef?: string;
|
||||||
|
setupCommand?: string;
|
||||||
|
files: { path: string; content: string; isExecutable: boolean }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The job owner's resolved environment (username + dotfiles, decrypted). */
|
||||||
|
export const fetchUserEnvironment = async (
|
||||||
|
jobId: Id<'agentJobs'>,
|
||||||
|
): Promise<UserEnvironment | null> =>
|
||||||
|
await client.action(api.userDotfilesNode.getEnvironmentForJob, {
|
||||||
|
workerToken: env.workerToken,
|
||||||
|
jobId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||||
|
|
||||||
|
// Keep a written path inside the home directory.
|
||||||
|
const safeHomeJoin = (homeDir: string, relPath: string) => {
|
||||||
|
const target = path.resolve(homeDir, relPath);
|
||||||
|
const root = path.resolve(homeDir);
|
||||||
|
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
|
||||||
|
throw new Error(`Refusing to write dotfile outside home: ${relPath}`);
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Materializes the persistent per-user home: a `.bash_profile` so login shells
|
||||||
|
* load `~/.bashrc`; (when configured and changed) a clone of the public dotfiles
|
||||||
|
* repo + the setup command, run inside the job image so the user's tools/paths
|
||||||
|
* apply; then the editable overlay files (which win over the repo). Idempotent
|
||||||
|
* via a hash marker so the repo/setup only re-runs when the config changes.
|
||||||
|
*/
|
||||||
|
export const materializeUserHome = async (args: {
|
||||||
|
homeDir: string;
|
||||||
|
containerHome: string;
|
||||||
|
boxName: string;
|
||||||
|
userEnv: UserEnvironment;
|
||||||
|
redact: (value: string) => string;
|
||||||
|
}): Promise<void> => {
|
||||||
|
const { homeDir, containerHome, boxName, userEnv, redact } = args;
|
||||||
|
await mkdir(homeDir, { recursive: true });
|
||||||
|
|
||||||
|
// A mounted home has no /etc/skel, so ensure login shells source ~/.bashrc.
|
||||||
|
const bashProfile = path.join(homeDir, '.bash_profile');
|
||||||
|
await readFile(bashProfile, 'utf8').catch(async () => {
|
||||||
|
await writeFile(
|
||||||
|
bashProfile,
|
||||||
|
'# Spoon: load ~/.bashrc for login shells.\n[ -f ~/.bashrc ] && . ~/.bashrc\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userEnv.enabled) return;
|
||||||
|
|
||||||
|
// Public dotfiles repo + setup command, only re-run when the config changes.
|
||||||
|
if (userEnv.dotfilesRepoUrl) {
|
||||||
|
const configHash = createHash('sha256')
|
||||||
|
.update(
|
||||||
|
JSON.stringify({
|
||||||
|
repo: userEnv.dotfilesRepoUrl,
|
||||||
|
ref: userEnv.dotfilesRepoRef ?? '',
|
||||||
|
setup: userEnv.setupCommand ?? '',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.digest('hex');
|
||||||
|
const markerPath = path.join(homeDir, '.spoon', 'env-hash');
|
||||||
|
const previous = await readFile(markerPath, 'utf8').catch(() => '');
|
||||||
|
if (previous.trim() !== configHash) {
|
||||||
|
const branch = userEnv.dotfilesRepoRef
|
||||||
|
? `--branch ${shellQuote(userEnv.dotfilesRepoRef)} `
|
||||||
|
: '';
|
||||||
|
const script = [
|
||||||
|
'set -e',
|
||||||
|
'rm -rf ~/.dotfiles',
|
||||||
|
`git clone --depth 1 ${branch}${shellQuote(userEnv.dotfilesRepoUrl)} ~/.dotfiles`,
|
||||||
|
userEnv.setupCommand
|
||||||
|
? `cd ~/.dotfiles && bash ${shellQuote(userEnv.setupCommand)}`
|
||||||
|
: '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
await runExecInContainer({
|
||||||
|
containerName: boxName,
|
||||||
|
command: ['bash', '-lc', script],
|
||||||
|
containerCwd: containerHome,
|
||||||
|
environment: { HOME: containerHome },
|
||||||
|
redact,
|
||||||
|
timeoutMs: env.jobTimeoutMs,
|
||||||
|
});
|
||||||
|
await mkdir(path.dirname(markerPath), { recursive: true });
|
||||||
|
await writeFile(markerPath, configHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editable overlay tree (wins over the repo/setup output).
|
||||||
|
for (const file of userEnv.files) {
|
||||||
|
const target = safeHomeJoin(homeDir, file.path);
|
||||||
|
await mkdir(path.dirname(target), { recursive: true });
|
||||||
|
await writeFile(target, file.content);
|
||||||
|
if (file.isExecutable) await chmod(target, 0o755);
|
||||||
|
}
|
||||||
|
};
|
||||||
+1666
-125
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,294 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeCodexJsonLine,
|
||||||
|
normalizeOpenCodeEvent,
|
||||||
|
} from '../../src/agent-events';
|
||||||
|
|
||||||
|
describe('agent event normalization', () => {
|
||||||
|
test('normalizes Codex assistant deltas and session ids', () => {
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'session.created',
|
||||||
|
session_id: 'codex-session-1',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'session', sessionId: 'codex-session-1' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'response.output_text.delta',
|
||||||
|
delta: 'hello',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'assistant_delta', content: 'hello' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes legacy codex-rs msg-wrapped events', () => {
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
id: '0',
|
||||||
|
msg: { type: 'agent_message', message: 'hello there' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'assistant_delta', content: 'hello there\n\n' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
id: '1',
|
||||||
|
msg: { type: 'error', message: 'usage limit reached' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'error', message: 'usage limit reached' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({ id: '2', msg: { type: 'task_complete' } }),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'assistant_completed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes Codex CLI thread lifecycle events', () => {
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'thread.started',
|
||||||
|
thread_id: '019ef701-f7d7-76a0-a96b-15c059631dd9',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'session',
|
||||||
|
sessionId: '019ef701-f7d7-76a0-a96b-15c059631dd9',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'turn.started',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'status', status: 'turn.started' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'turn.completed',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'assistant_completed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes Codex command and file events', () => {
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'command.completed',
|
||||||
|
command: 'bun test',
|
||||||
|
output: 'ok',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'command_executed',
|
||||||
|
command: 'bun test',
|
||||||
|
output: 'ok',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'file.edited',
|
||||||
|
path: 'src/app.ts',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'file_edited', path: 'src/app.ts' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes current Codex item events', () => {
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: {
|
||||||
|
id: 'item-1',
|
||||||
|
type: 'agent_message',
|
||||||
|
text: 'I updated the auth provider.',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: 'I updated the auth provider.\n\n',
|
||||||
|
externalMessageId: 'item-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: {
|
||||||
|
id: 'item-2',
|
||||||
|
type: 'error',
|
||||||
|
message: 'sandbox failed',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'error',
|
||||||
|
message: 'sandbox failed',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'turn.failed',
|
||||||
|
error: { message: 'request failed' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'error',
|
||||||
|
message: '{\n "message": "request failed"\n}',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: {
|
||||||
|
id: 'item-warning',
|
||||||
|
type: 'error',
|
||||||
|
message:
|
||||||
|
'`[features].codex_hooks` is deprecated. Use `[features].hooks` instead.',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'status',
|
||||||
|
status:
|
||||||
|
'`[features].codex_hooks` is deprecated. Use `[features].hooks` instead.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes Codex tool item lifecycle events', () => {
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.started',
|
||||||
|
item: {
|
||||||
|
id: 'tool-1',
|
||||||
|
type: 'local_shell_call',
|
||||||
|
command: ['bash', '-lc', 'rg Authentik'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'tool_started',
|
||||||
|
name: 'Command',
|
||||||
|
input: 'bash -lc rg Authentik',
|
||||||
|
externalMessageId: 'tool-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: {
|
||||||
|
id: 'tool-1',
|
||||||
|
type: 'local_shell_call',
|
||||||
|
command: ['bash', '-lc', 'rg Authentik'],
|
||||||
|
output: 'apps/web/auth.ts',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: 'Command',
|
||||||
|
output: 'apps/web/auth.ts',
|
||||||
|
externalMessageId: 'tool-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: {
|
||||||
|
id: 'tool-2',
|
||||||
|
type: 'exec_command',
|
||||||
|
command: 'cat package.json',
|
||||||
|
aggregated_output: '{"scripts":{"build":"turbo build"}}',
|
||||||
|
exit_code: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: 'Command',
|
||||||
|
output: '{"scripts":{"build":"turbo build"}}',
|
||||||
|
externalMessageId: 'tool-2',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes OpenCode assistant, tool, and permission events', () => {
|
||||||
|
expect(
|
||||||
|
normalizeOpenCodeEvent({
|
||||||
|
type: 'message.part.delta',
|
||||||
|
properties: {
|
||||||
|
part: { text: 'streamed' },
|
||||||
|
messageID: 'message-1',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: 'streamed',
|
||||||
|
externalMessageId: 'message-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeOpenCodeEvent({
|
||||||
|
type: 'tool.started',
|
||||||
|
properties: { tool: 'edit', input: { path: 'README.md' } },
|
||||||
|
}),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'tool_started',
|
||||||
|
name: 'edit',
|
||||||
|
input: '{\n "path": "README.md"\n}',
|
||||||
|
externalMessageId: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeOpenCodeEvent({
|
||||||
|
type: 'permission.asked',
|
||||||
|
properties: {
|
||||||
|
permissionID: 'perm-1',
|
||||||
|
message: 'Run bun test?',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'permission_requested',
|
||||||
|
externalRequestId: 'perm-1',
|
||||||
|
title: 'Permission requested',
|
||||||
|
body: 'Run bun test?',
|
||||||
|
metadata:
|
||||||
|
'{\n "permissionID": "perm-1",\n "message": "Run bun test?"\n}',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeOpenCodeEvent({
|
||||||
|
type: 'tool.output',
|
||||||
|
properties: {
|
||||||
|
tool: 'read',
|
||||||
|
output: 'apps/web/auth.ts',
|
||||||
|
messageID: 'message-2',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: 'read',
|
||||||
|
output: 'apps/web/auth.ts',
|
||||||
|
externalMessageId: 'message-2',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
readFile,
|
||||||
|
rm,
|
||||||
|
stat,
|
||||||
|
writeFile,
|
||||||
|
} from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { prepareCodexWorkspaceFiles } from '../../src/codex-runtime';
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
const mode = async (filePath: string) => (await stat(filePath)).mode & 0o777;
|
||||||
|
|
||||||
|
describe('Codex runtime preparation', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
tempDirs.map((dir) => rm(dir, { force: true, recursive: true })),
|
||||||
|
);
|
||||||
|
tempDirs.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepares writable Codex directories and preserves project config contents', async () => {
|
||||||
|
const workdir = await mkdtemp(path.join(os.tmpdir(), 'spoon-codex-'));
|
||||||
|
tempDirs.push(workdir);
|
||||||
|
const repoDir = path.join(workdir, 'repo');
|
||||||
|
await mkdir(path.join(repoDir, '.codex'), { recursive: true });
|
||||||
|
const projectConfig = path.join(repoDir, '.codex', 'config.toml');
|
||||||
|
await writeFile(projectConfig, '[features]\ncodex_hooks = true\n');
|
||||||
|
|
||||||
|
await prepareCodexWorkspaceFiles({ workdir, repoDir });
|
||||||
|
|
||||||
|
await expect(readFile(projectConfig, 'utf8')).resolves.toBe(
|
||||||
|
'[features]\ncodex_hooks = true\n',
|
||||||
|
);
|
||||||
|
await expect(mode(workdir)).resolves.toBe(0o755);
|
||||||
|
await expect(mode(repoDir)).resolves.toBe(0o755);
|
||||||
|
await expect(mode(path.join(workdir, '.codex'))).resolves.toBe(0o755);
|
||||||
|
await expect(mode(path.join(workdir, '.config'))).resolves.toBe(0o755);
|
||||||
|
await expect(mode(path.join(workdir, '.local', 'share'))).resolves.toBe(
|
||||||
|
0o755,
|
||||||
|
);
|
||||||
|
await expect(mode(path.join(repoDir, '.codex'))).resolves.toBe(0o755);
|
||||||
|
await expect(mode(projectConfig)).resolves.toBe(0o644);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
const loadVolumeSpec = async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
|
||||||
|
process.env.GITHUB_APP_ID = '123';
|
||||||
|
process.env.GITHUB_APP_PRIVATE_KEY =
|
||||||
|
'-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----';
|
||||||
|
return await import('../../src/runtime/docker');
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Docker runtime', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.SPOON_AGENT_CONTAINER_RUNTIME;
|
||||||
|
delete process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS;
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds SELinux relabel option for Podman workspace mounts by default', async () => {
|
||||||
|
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'podman';
|
||||||
|
const { jobWorkspaceVolumeSpec } = await loadVolumeSpec();
|
||||||
|
|
||||||
|
expect(jobWorkspaceVolumeSpec('/tmp/spoon-job')).toBe(
|
||||||
|
'/tmp/spoon-job:/workspace:Z',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not add Podman volume options for Docker by default', async () => {
|
||||||
|
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'docker';
|
||||||
|
const { jobWorkspaceVolumeSpec } = await loadVolumeSpec();
|
||||||
|
|
||||||
|
expect(jobWorkspaceVolumeSpec('/tmp/spoon-job')).toBe(
|
||||||
|
'/tmp/spoon-job:/workspace',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows explicit workspace mount options', async () => {
|
||||||
|
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'podman';
|
||||||
|
process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS = 'z';
|
||||||
|
const { jobWorkspaceVolumeSpec } = await loadVolumeSpec();
|
||||||
|
|
||||||
|
expect(jobWorkspaceVolumeSpec('/tmp/spoon-job')).toBe(
|
||||||
|
'/tmp/spoon-job:/workspace:z',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('treats a spawn failure (no exitCode) as a non-zero exit, not empty success', async () => {
|
||||||
|
const { normalizeRunResult } = await loadVolumeSpec();
|
||||||
|
// This is what execa returns with `reject: false` when the runtime binary is
|
||||||
|
// missing (e.g. no `docker` CLI in the worker image): exitCode is undefined.
|
||||||
|
const result = normalizeRunResult(
|
||||||
|
{ exitCode: undefined, shortMessage: 'spawn docker ENOENT' },
|
||||||
|
undefined,
|
||||||
|
(value) => value,
|
||||||
|
);
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.output).toContain('spawn docker ENOENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passes through a normal command result unchanged', async () => {
|
||||||
|
const { normalizeRunResult } = await loadVolumeSpec();
|
||||||
|
const result = normalizeRunResult(
|
||||||
|
{ exitCode: 0, shortMessage: undefined },
|
||||||
|
'hello',
|
||||||
|
(value) => value,
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ exitCode: 0, output: 'hello' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
|
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
type TestWorkspace = {
|
||||||
|
binDir: string;
|
||||||
|
homeDir: string;
|
||||||
|
localFile: string;
|
||||||
|
projectDir: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scriptPath = fileURLToPath(
|
||||||
|
new URL('../../../../scripts/infisical-account', import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
let workspaces: TestWorkspace[] = [];
|
||||||
|
|
||||||
|
const createWorkspace = async (): Promise<TestWorkspace> => {
|
||||||
|
const root = await realpathTemp();
|
||||||
|
const homeDir = path.join(root, 'home');
|
||||||
|
const projectDir = path.join(root, 'project');
|
||||||
|
const binDir = path.join(root, 'bin');
|
||||||
|
const localFile = path.join(projectDir, '.local', 'infisical.env');
|
||||||
|
|
||||||
|
await mkdir(path.join(homeDir, '.infisical'), { recursive: true });
|
||||||
|
await mkdir(path.dirname(localFile), { recursive: true });
|
||||||
|
await mkdir(binDir, { recursive: true });
|
||||||
|
|
||||||
|
const fakeInfisical = path.join(binDir, 'infisical');
|
||||||
|
await writeFile(fakeInfisical, '#!/usr/bin/env sh\nexit 0\n');
|
||||||
|
await chmod(fakeInfisical, 0o755);
|
||||||
|
|
||||||
|
const workspace = { binDir, homeDir, localFile, projectDir };
|
||||||
|
workspaces.push(workspace);
|
||||||
|
return workspace;
|
||||||
|
};
|
||||||
|
|
||||||
|
const realpathTemp = async (): Promise<string> => {
|
||||||
|
const base = path.join(tmpdir(), 'spoon-infisical-account-');
|
||||||
|
const { mkdtemp } = await import('node:fs/promises');
|
||||||
|
return mkdtemp(base);
|
||||||
|
};
|
||||||
|
|
||||||
|
const configPath = (workspace: TestWorkspace) =>
|
||||||
|
path.join(workspace.homeDir, '.infisical', 'infisical-config.json');
|
||||||
|
|
||||||
|
const writeConfig = async (
|
||||||
|
workspace: TestWorkspace,
|
||||||
|
config: Record<string, unknown> | string,
|
||||||
|
) => {
|
||||||
|
const content =
|
||||||
|
typeof config === 'string'
|
||||||
|
? config
|
||||||
|
: `${JSON.stringify(config, null, 2)}\n`;
|
||||||
|
await writeFile(configPath(workspace), content);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readConfig = async (
|
||||||
|
workspace: TestWorkspace,
|
||||||
|
): Promise<Record<string, unknown>> =>
|
||||||
|
JSON.parse(await readFile(configPath(workspace), 'utf8')) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
|
||||||
|
const envFor = (workspace: TestWorkspace): NodeJS.ProcessEnv => ({
|
||||||
|
...process.env,
|
||||||
|
HOME: workspace.homeDir,
|
||||||
|
PATH: `${workspace.binDir}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`,
|
||||||
|
SPOON_INFISICAL_LOCAL_FILE: workspace.localFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runEnsure = (workspace: TestWorkspace) =>
|
||||||
|
spawnSync(scriptPath, ['ensure'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: envFor(workspace),
|
||||||
|
});
|
||||||
|
|
||||||
|
const writeLocalEmail = async (workspace: TestWorkspace, emailLine: string) => {
|
||||||
|
await mkdir(path.dirname(workspace.localFile), { recursive: true });
|
||||||
|
await writeFile(workspace.localFile, `${emailLine}\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const twoAccountConfig = {
|
||||||
|
loggedInUsers: [
|
||||||
|
{ email: 'work@example.com', domain: 'https://app.infisical.com' },
|
||||||
|
{ email: 'home@example.com', domain: 'https://infisical.gbrown.org' },
|
||||||
|
],
|
||||||
|
loggedInUserEmail: 'work@example.com',
|
||||||
|
LoggedInUserDomain: 'https://app.infisical.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
workspaces = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
workspaces.map((workspace) =>
|
||||||
|
rm(path.dirname(workspace.homeDir), { force: true, recursive: true }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('infisical-account', () => {
|
||||||
|
test('single account no-ops without local file', async () => {
|
||||||
|
const workspace = await createWorkspace();
|
||||||
|
await writeConfig(workspace, {
|
||||||
|
loggedInUsers: [
|
||||||
|
{ email: 'work@example.com', domain: 'https://app.infisical.com' },
|
||||||
|
],
|
||||||
|
loggedInUserEmail: 'work@example.com',
|
||||||
|
LoggedInUserDomain: 'https://app.infisical.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = runEnsure(workspace);
|
||||||
|
const config = await readConfig(workspace);
|
||||||
|
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
expect(config.loggedInUserEmail).toBe('work@example.com');
|
||||||
|
expect(config.LoggedInUserDomain).toBe('https://app.infisical.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple accounts require local project config', async () => {
|
||||||
|
const workspace = await createWorkspace();
|
||||||
|
await writeConfig(workspace, twoAccountConfig);
|
||||||
|
|
||||||
|
const result = runEnsure(workspace);
|
||||||
|
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.stderr).toContain('.local/infisical.env');
|
||||||
|
expect(result.stderr).toContain('work@example.com');
|
||||||
|
expect(result.stderr).toContain('home@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple accounts switch to configured email', async () => {
|
||||||
|
const workspace = await createWorkspace();
|
||||||
|
await writeConfig(workspace, twoAccountConfig);
|
||||||
|
await writeLocalEmail(workspace, 'INFISICAL_EMAIL=home@example.com');
|
||||||
|
|
||||||
|
const result = runEnsure(workspace);
|
||||||
|
const config = await readConfig(workspace);
|
||||||
|
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
expect(config.loggedInUserEmail).toBe('home@example.com');
|
||||||
|
expect(config.LoggedInUserDomain).toBe('https://infisical.gbrown.org');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configured email missing from local accounts fails clearly', async () => {
|
||||||
|
const workspace = await createWorkspace();
|
||||||
|
await writeConfig(workspace, twoAccountConfig);
|
||||||
|
await writeLocalEmail(workspace, 'INFISICAL_EMAIL=missing@example.com');
|
||||||
|
|
||||||
|
const result = runEnsure(workspace);
|
||||||
|
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.stderr).toContain(
|
||||||
|
'not logged in locally: missing@example.com',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
'INFISICAL_EMAIL="home@example.com"',
|
||||||
|
"INFISICAL_EMAIL='home@example.com'",
|
||||||
|
])('quoted email parses correctly: %s', async (line) => {
|
||||||
|
const workspace = await createWorkspace();
|
||||||
|
await writeConfig(workspace, twoAccountConfig);
|
||||||
|
await writeLocalEmail(workspace, line);
|
||||||
|
|
||||||
|
const result = runEnsure(workspace);
|
||||||
|
const config = await readConfig(workspace);
|
||||||
|
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
expect(config.loggedInUserEmail).toBe('home@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty email fails clearly', async () => {
|
||||||
|
const workspace = await createWorkspace();
|
||||||
|
await writeConfig(workspace, twoAccountConfig);
|
||||||
|
await writeLocalEmail(workspace, 'INFISICAL_EMAIL=');
|
||||||
|
|
||||||
|
const result = runEnsure(workspace);
|
||||||
|
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.stderr).toContain(
|
||||||
|
'.local/infisical.env must contain INFISICAL_EMAIL',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('corrupt config fails clearly', async () => {
|
||||||
|
const workspace = await createWorkspace();
|
||||||
|
await writeConfig(workspace, '{not-json');
|
||||||
|
|
||||||
|
const result = runEnsure(workspace);
|
||||||
|
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.stderr).toContain(
|
||||||
|
'Infisical config is invalid or missing loggedInUsers',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent ensure calls do not corrupt config', async () => {
|
||||||
|
const workspace = await createWorkspace();
|
||||||
|
await writeConfig(workspace, twoAccountConfig);
|
||||||
|
await writeLocalEmail(workspace, 'INFISICAL_EMAIL=home@example.com');
|
||||||
|
|
||||||
|
const run = () =>
|
||||||
|
new Promise<{ status: number | null; stderr: string }>((resolve) => {
|
||||||
|
const child = spawn(scriptPath, ['ensure'], { env: envFor(workspace) });
|
||||||
|
let stderr = '';
|
||||||
|
child.stderr.on('data', (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString('utf8');
|
||||||
|
});
|
||||||
|
child.on('close', (status) => {
|
||||||
|
resolve({ status, stderr });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const [first, second] = await Promise.all([run(), run()]);
|
||||||
|
const config = await readConfig(workspace);
|
||||||
|
|
||||||
|
expect(first).toEqual({ status: 0, stderr: '' });
|
||||||
|
expect(second).toEqual({ status: 0, stderr: '' });
|
||||||
|
expect(config.loggedInUserEmail).toBe('home@example.com');
|
||||||
|
expect(config.LoggedInUserDomain).toBe('https://infisical.gbrown.org');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { verifyTerminalToken } from '../../src/terminal-token';
|
||||||
|
|
||||||
|
const mint = (jobId: string, expiresAt: number, secret: string) => {
|
||||||
|
const payload = `${expiresAt}.${jobId}`;
|
||||||
|
const sig = createHmac('sha256', secret).update(payload).digest('hex');
|
||||||
|
return `${payload}.${sig}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('verifyTerminalToken', () => {
|
||||||
|
const secret = 'test-secret';
|
||||||
|
|
||||||
|
test('accepts a valid, unexpired, job-matched token', () => {
|
||||||
|
const token = mint('job1', Date.now() + 60_000, secret);
|
||||||
|
expect(verifyTerminalToken(token, 'job1', secret)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects an expired token', () => {
|
||||||
|
const token = mint('job1', Date.now() - 1, secret);
|
||||||
|
expect(verifyTerminalToken(token, 'job1', secret)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a token minted for another job', () => {
|
||||||
|
const token = mint('job1', Date.now() + 60_000, secret);
|
||||||
|
expect(verifyTerminalToken(token, 'job2', secret)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a token signed with a different secret', () => {
|
||||||
|
const token = mint('job1', Date.now() + 60_000, 'other-secret');
|
||||||
|
expect(verifyTerminalToken(token, 'job1', secret)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects malformed input and an empty secret', () => {
|
||||||
|
expect(verifyTerminalToken('garbage', 'job1', secret)).toBe(false);
|
||||||
|
expect(verifyTerminalToken('', 'job1', secret)).toBe(false);
|
||||||
|
expect(
|
||||||
|
verifyTerminalToken(mint('job1', Date.now() + 1000, ''), 'job1', ''),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,6 @@
|
|||||||
"lib": ["ES2022", "DOM"],
|
"lib": ["ES2022", "DOM"],
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src", "eslint.config.ts", "vitest.config.ts"],
|
"include": ["src", "tests", "eslint.config.ts", "vitest.config.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore --ignore-path .prettierignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore --ignore-path .prettierignore",
|
||||||
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
||||||
|
"test:unit": "vitest run --project unit",
|
||||||
|
"test:integration": "vitest run --project integration --passWithNoTests",
|
||||||
|
"test:component": "vitest run --project component",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --"
|
"with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --"
|
||||||
},
|
},
|
||||||
@@ -27,6 +30,7 @@
|
|||||||
"convex": "catalog:convex",
|
"convex": "catalog:convex",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-apple-authentication": "~8.0.8",
|
"expo-apple-authentication": "~8.0.8",
|
||||||
|
"expo-clipboard": "~8.0.8",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
"expo-dev-client": "~6.0.20",
|
"expo-dev-client": "~6.0.20",
|
||||||
"expo-font": "~14.0.11",
|
"expo-font": "~14.0.11",
|
||||||
@@ -57,11 +61,14 @@
|
|||||||
"@spoon/prettier-config": "workspace:*",
|
"@spoon/prettier-config": "workspace:*",
|
||||||
"@spoon/tailwind-config": "workspace:*",
|
"@spoon/tailwind-config": "workspace:*",
|
||||||
"@spoon/tsconfig": "workspace:*",
|
"@spoon/tsconfig": "workspace:*",
|
||||||
|
"@spoon/vitest-config": "workspace:*",
|
||||||
|
"@testing-library/react": "catalog:test",
|
||||||
"@types/react": "catalog:react19",
|
"@types/react": "catalog:react19",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:",
|
||||||
|
"vitest": "catalog:test"
|
||||||
},
|
},
|
||||||
"prettier": "@spoon/prettier-config"
|
"prettier": "@spoon/prettier-config"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useColorScheme } from 'react-native';
|
||||||
|
import { Redirect, Tabs } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useConvexAuth } from 'convex/react';
|
||||||
|
|
||||||
|
import { LoadingState } from '~/components/ui/loading-state';
|
||||||
|
|
||||||
|
const iconName = (route: string, focused: boolean) => {
|
||||||
|
if (route === 'dashboard') return focused ? 'grid' : 'grid-outline';
|
||||||
|
if (route === 'spoons') return focused ? 'git-branch' : 'git-branch-outline';
|
||||||
|
if (route === 'threads')
|
||||||
|
return focused ? 'chatbubbles' : 'chatbubbles-outline';
|
||||||
|
return focused ? 'settings' : 'settings-outline';
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppTabs = () => {
|
||||||
|
const { isAuthenticated, isLoading } = useConvexAuth();
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Keeps the auth subscription warm while tab routes mount.
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingState />;
|
||||||
|
if (!isAuthenticated) return <Redirect href='/sign-in' />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={({ route }) => ({
|
||||||
|
headerShown: false,
|
||||||
|
tabBarActiveTintColor: '#0f766e',
|
||||||
|
tabBarInactiveTintColor: colorScheme === 'dark' ? '#94a3b8' : '#64748b',
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: colorScheme === 'dark' ? '#111827' : '#f8fafc',
|
||||||
|
borderTopColor: colorScheme === 'dark' ? '#334155' : '#e2e8f0',
|
||||||
|
},
|
||||||
|
tabBarIcon: ({ color, focused, size }) => (
|
||||||
|
<Ionicons
|
||||||
|
color={color}
|
||||||
|
name={iconName(route.name, focused)}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Tabs.Screen name='dashboard' options={{ title: 'Dashboard' }} />
|
||||||
|
<Tabs.Screen name='spoons' options={{ title: 'Spoons' }} />
|
||||||
|
<Tabs.Screen name='threads' options={{ title: 'Threads' }} />
|
||||||
|
<Tabs.Screen name='workspace/[jobId]' options={{ href: null }} />
|
||||||
|
<Tabs.Screen name='settings' options={{ title: 'Settings' }} />
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppTabs;
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
import { Link, Stack, useRouter } from 'expo-router';
|
||||||
|
import { useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { SpoonListRow } from '~/components/spoons/spoon-list-row';
|
||||||
|
import { ThreadListRow } from '~/components/threads/thread-list-row';
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { EmptyState } from '~/components/ui/empty-state';
|
||||||
|
import { MetricCard } from '~/components/ui/metric-card';
|
||||||
|
import { titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
const openThreadStatuses = ['resolved', 'ignored', 'failed', 'cancelled'];
|
||||||
|
|
||||||
|
const DashboardRoute = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||||
|
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
|
||||||
|
const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
|
||||||
|
const active = spoons.filter((spoon) => spoon.status === 'active').length;
|
||||||
|
const behind = spoons.filter((spoon) => spoon.syncStatus === 'behind').length;
|
||||||
|
const diverged = spoons.filter(
|
||||||
|
(spoon) => spoon.syncStatus === 'diverged',
|
||||||
|
).length;
|
||||||
|
const openThreads = threads.filter(
|
||||||
|
(thread) => !openThreadStatuses.includes(thread.status),
|
||||||
|
);
|
||||||
|
const upstreamWaiting = spoons.reduce(
|
||||||
|
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const softRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
setTimeout(() => setRefreshing(false), 600);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen onRefresh={softRefresh} refreshing={refreshing}>
|
||||||
|
<Stack.Screen options={{ title: 'Dashboard' }} />
|
||||||
|
<View className='flex-row items-start justify-between gap-3'>
|
||||||
|
<View className='min-w-0 flex-1'>
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>Dashboard</Text>
|
||||||
|
<Text className='text-muted-foreground mt-1'>
|
||||||
|
Managed forks, upstream drift, and open maintenance threads.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Link href='/spoons/new' asChild>
|
||||||
|
<Button>New</Button>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='gap-3'>
|
||||||
|
<View className='flex-row gap-3'>
|
||||||
|
<MetricCard
|
||||||
|
label='Spoons'
|
||||||
|
note={`${active} active`}
|
||||||
|
value={spoons.length}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label='Behind'
|
||||||
|
note={`${diverged} diverged`}
|
||||||
|
value={behind}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className='flex-row gap-3'>
|
||||||
|
<MetricCard label='Open threads' value={openThreads.length} />
|
||||||
|
<MetricCard label='Upstream commits' value={upstreamWaiting} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='gap-3'>
|
||||||
|
<Text className='text-foreground text-lg font-semibold'>
|
||||||
|
Maintenance queue
|
||||||
|
</Text>
|
||||||
|
{openThreads.length ? (
|
||||||
|
openThreads
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((thread) => (
|
||||||
|
<ThreadListRow
|
||||||
|
key={thread._id}
|
||||||
|
thread={thread}
|
||||||
|
onPress={() => router.push(`/threads/${thread._id}`)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
description='Threads appear when you request work or upstream changes need review.'
|
||||||
|
title='No open maintenance threads'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='gap-3'>
|
||||||
|
<Text className='text-foreground text-lg font-semibold'>
|
||||||
|
Recent Spoons
|
||||||
|
</Text>
|
||||||
|
{spoons.length ? (
|
||||||
|
spoons
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((spoon) => (
|
||||||
|
<SpoonListRow
|
||||||
|
key={spoon._id}
|
||||||
|
spoon={spoon}
|
||||||
|
onPress={() => router.push(`/spoons/${spoon._id}`)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
description='Create your first managed fork to start tracking upstream drift.'
|
||||||
|
title='No Spoons yet'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='gap-3'>
|
||||||
|
<Text className='text-foreground text-lg font-semibold'>
|
||||||
|
Recent activity
|
||||||
|
</Text>
|
||||||
|
<Card className='gap-3'>
|
||||||
|
{syncRuns.length ? (
|
||||||
|
syncRuns.map((run) => (
|
||||||
|
<View key={run._id} className='border-border border-b pb-3'>
|
||||||
|
<Text className='text-foreground font-medium'>
|
||||||
|
{titleize(run.kind)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
{titleize(run.status)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
Upstream checks will appear here.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</View>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DashboardRoute;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
const SettingsLayout = () => <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
|
||||||
|
export default SettingsLayout;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, Text } from 'react-native';
|
||||||
|
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { AiProviderProfileForm } from '~/components/settings/ai-provider-profile-form';
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
|
||||||
|
const AiProviderFormRoute = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { profileId: rawProfileId } = useLocalSearchParams<{
|
||||||
|
profileId?: string;
|
||||||
|
}>();
|
||||||
|
const profileId = rawProfileId as Id<'aiProviderProfiles'> | undefined;
|
||||||
|
const existing = useQuery(
|
||||||
|
api.aiProviderProfiles.get,
|
||||||
|
profileId ? { profileId } : 'skip',
|
||||||
|
);
|
||||||
|
const save = useAction(api.aiProviderProfilesNode.save);
|
||||||
|
const updateMetadata = useMutation(api.aiProviderProfiles.updateMetadata);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (values: Parameters<typeof save>[0]) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (profileId && !values.secret) {
|
||||||
|
await updateMetadata({
|
||||||
|
baseUrl: values.baseUrl,
|
||||||
|
defaultModel: values.defaultModel,
|
||||||
|
enabled: values.enabled,
|
||||||
|
modelOptions: values.modelOptions,
|
||||||
|
name: values.name,
|
||||||
|
profileId,
|
||||||
|
reasoningEffort: values.reasoningEffort,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await save({ ...values, profileId });
|
||||||
|
}
|
||||||
|
router.replace('/settings/ai-providers');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not save AI provider.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (profileId && !existing) {
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<Stack.Screen options={{ title: 'Edit provider' }} />
|
||||||
|
<Text className='text-muted-foreground'>Loading provider...</Text>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{ title: profileId ? 'Edit provider' : 'New provider' }}
|
||||||
|
/>
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>
|
||||||
|
{profileId ? 'Edit provider' : 'New provider'}
|
||||||
|
</Text>
|
||||||
|
<AiProviderProfileForm
|
||||||
|
existing={existing ?? undefined}
|
||||||
|
saving={saving}
|
||||||
|
onSubmit={submit}
|
||||||
|
/>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AiProviderFormRoute;
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Alert, Text, View } from 'react-native';
|
||||||
|
import { Link, Stack, useRouter } from 'expo-router';
|
||||||
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { EmptyState } from '~/components/ui/empty-state';
|
||||||
|
import { ListRow } from '~/components/ui/list-row';
|
||||||
|
import { titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
const AiProvidersRoute = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||||
|
const setDefault = useMutation(api.aiProviderProfiles.setDefault);
|
||||||
|
const remove = useMutation(api.aiProviderProfiles.remove);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<Stack.Screen options={{ title: 'AI providers' }} />
|
||||||
|
<View className='flex-row items-start justify-between gap-3'>
|
||||||
|
<View className='min-w-0 flex-1'>
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>
|
||||||
|
AI providers
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground mt-1'>
|
||||||
|
Provider profiles for OpenCode workspaces.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Link href='/settings/ai-provider-form' asChild>
|
||||||
|
<Button>New</Button>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
{profiles.length ? (
|
||||||
|
profiles.map((profile) => (
|
||||||
|
<ListRow
|
||||||
|
key={profile._id}
|
||||||
|
subtitle={`${titleize(profile.provider)} · ${profile.defaultModel}`}
|
||||||
|
title={profile.name}
|
||||||
|
onPress={() =>
|
||||||
|
router.push(`/settings/ai-provider-form?profileId=${profile._id}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
<Badge
|
||||||
|
label={profile.configured ? 'configured' : 'missing credential'}
|
||||||
|
tone={profile.configured ? 'success' : 'warning'}
|
||||||
|
/>
|
||||||
|
{profile.isDefault ? (
|
||||||
|
<Badge label='default' tone='primary' />
|
||||||
|
) : null}
|
||||||
|
<Badge label={profile.enabled ? 'enabled' : 'disabled'} />
|
||||||
|
</View>
|
||||||
|
<View className='mt-3 flex-row gap-2'>
|
||||||
|
<Button
|
||||||
|
disabled={!profile.configured || !profile.enabled}
|
||||||
|
variant='outline'
|
||||||
|
onPress={() => void setDefault({ profileId: profile._id })}
|
||||||
|
>
|
||||||
|
Set default
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='danger'
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert('Remove provider', `Remove ${profile.name}?`, [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Remove',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => void remove({ profileId: profile._id }),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</ListRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
description='Add an OpenAI, Codex/OpenCode, Anthropic, OpenRouter, or compatible provider before queueing agent work.'
|
||||||
|
title='No AI providers'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AiProvidersRoute;
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Alert, Text } from 'react-native';
|
||||||
|
import { Link, Stack } from 'expo-router';
|
||||||
|
import { useAuthActions } from '@convex-dev/auth/react';
|
||||||
|
import { useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { ListRow } from '~/components/ui/list-row';
|
||||||
|
|
||||||
|
const SettingsRoute = () => {
|
||||||
|
const { signOut } = useAuthActions();
|
||||||
|
const user = useQuery(api.auth.getUser, {});
|
||||||
|
const connection = useQuery(api.github.getConnection, {});
|
||||||
|
const providers = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||||
|
const defaultProvider = providers.find((provider) => provider.isDefault);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<Stack.Screen options={{ title: 'Settings' }} />
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>Settings</Text>
|
||||||
|
<Link href='/settings/profile' asChild>
|
||||||
|
<ListRow
|
||||||
|
subtitle={
|
||||||
|
user?.email ?? 'Name, email, provider, and password settings'
|
||||||
|
}
|
||||||
|
title='Profile'
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<Link href='/settings/integrations' asChild>
|
||||||
|
<ListRow
|
||||||
|
subtitle={
|
||||||
|
connection
|
||||||
|
? `GitHub connected as ${connection.displayName}`
|
||||||
|
: 'GitHub App connection and accessible repositories'
|
||||||
|
}
|
||||||
|
title='Integrations'
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<Link href='/settings/ai-providers' asChild>
|
||||||
|
<ListRow
|
||||||
|
subtitle={
|
||||||
|
defaultProvider
|
||||||
|
? `${providers.length} provider${providers.length === 1 ? '' : 's'}, default ${defaultProvider.name}`
|
||||||
|
: 'OpenCode, Codex auth, API keys, and default models'
|
||||||
|
}
|
||||||
|
title='AI providers'
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
variant='danger'
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert('Sign out', 'Sign out of Spoon on this device?', [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Sign out',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => void signOut(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsRoute;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Text } from 'react-native';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { useAction, useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { GitHubIntegrationPanel } from '~/components/settings/github-integration-panel';
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
|
||||||
|
const IntegrationsRoute = () => {
|
||||||
|
const installUrl = useQuery(api.github.getInstallUrl, {});
|
||||||
|
const connection = useQuery(api.github.getConnection, {});
|
||||||
|
const status = useQuery(api.integrations.getStatus, {});
|
||||||
|
const syncInstallation = useAction(api.githubNode.syncConfiguredInstallation);
|
||||||
|
const repositories = useAction(api.githubNode.listInstallationRepositories);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [loadingRepos, setLoadingRepos] = useState(false);
|
||||||
|
|
||||||
|
const sync = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
try {
|
||||||
|
await syncInstallation({});
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listRepos = async () => {
|
||||||
|
setLoadingRepos(true);
|
||||||
|
try {
|
||||||
|
const result = await repositories({});
|
||||||
|
return result.map((repo) => repo.fullName);
|
||||||
|
} finally {
|
||||||
|
setLoadingRepos(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen onRefresh={() => void sync()} refreshing={syncing}>
|
||||||
|
<Stack.Screen options={{ title: 'Integrations' }} />
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>Integrations</Text>
|
||||||
|
<GitHubIntegrationPanel
|
||||||
|
connection={connection}
|
||||||
|
installUrl={installUrl}
|
||||||
|
loadingRepos={loadingRepos}
|
||||||
|
runtimeStatus={status}
|
||||||
|
syncing={syncing}
|
||||||
|
onListRepos={listRepos}
|
||||||
|
onSync={sync}
|
||||||
|
/>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IntegrationsRoute;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, Text } from 'react-native';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { Field } from '~/components/ui/field';
|
||||||
|
import { titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
const ProfileRoute = () => {
|
||||||
|
const user = useQuery(api.auth.getUser, {});
|
||||||
|
const provider = useQuery(api.auth.getUserProvider, {});
|
||||||
|
const updateUser = useMutation(api.auth.updateUser);
|
||||||
|
const updatePassword = useAction(api.auth.updateUserPassword);
|
||||||
|
const [name, setName] = useState(user?.name ?? '');
|
||||||
|
const [email, setEmail] = useState(user?.email ?? '');
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [savingProfile, setSavingProfile] = useState(false);
|
||||||
|
const [savingPassword, setSavingPassword] = useState(false);
|
||||||
|
|
||||||
|
const saveProfile = async () => {
|
||||||
|
setSavingProfile(true);
|
||||||
|
try {
|
||||||
|
await updateUser({ name, email });
|
||||||
|
Alert.alert('Saved', 'Profile updated.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not save profile.');
|
||||||
|
} finally {
|
||||||
|
setSavingProfile(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePassword = async () => {
|
||||||
|
setSavingPassword(true);
|
||||||
|
try {
|
||||||
|
await updatePassword({ currentPassword, newPassword });
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
Alert.alert('Saved', 'Password updated.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not update password.');
|
||||||
|
} finally {
|
||||||
|
setSavingPassword(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<Stack.Screen options={{ title: 'Profile' }} />
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>Profile</Text>
|
||||||
|
<Card className='gap-4'>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
Email is currently managed by {titleize(provider ?? 'your provider')}.
|
||||||
|
</Text>
|
||||||
|
<Field label='Name' value={name} onChangeText={setName} />
|
||||||
|
<Field
|
||||||
|
keyboardType='email-address'
|
||||||
|
label='Email'
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
/>
|
||||||
|
<Button disabled={savingProfile} onPress={() => void saveProfile()}>
|
||||||
|
{savingProfile ? 'Saving...' : 'Save profile'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
{provider === 'password' ? (
|
||||||
|
<Card className='gap-4'>
|
||||||
|
<Text className='text-foreground font-semibold'>Password</Text>
|
||||||
|
<Field
|
||||||
|
label='Current password'
|
||||||
|
secureTextEntry
|
||||||
|
value={currentPassword}
|
||||||
|
onChangeText={setCurrentPassword}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='New password'
|
||||||
|
secureTextEntry
|
||||||
|
value={newPassword}
|
||||||
|
onChangeText={setNewPassword}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
disabled={savingPassword}
|
||||||
|
variant='outline'
|
||||||
|
onPress={() => void savePassword()}
|
||||||
|
>
|
||||||
|
{savingPassword ? 'Updating...' : 'Update password'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<Text className='text-muted-foreground text-sm leading-5'>
|
||||||
|
Password changes are hidden because this account is currently using{' '}
|
||||||
|
{titleize(provider ?? 'an OAuth provider')}.
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileRoute;
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, Text, View } from 'react-native';
|
||||||
|
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import type { SpoonDetailSegment } from '~/components/spoons/segment-control';
|
||||||
|
import { SegmentControl } from '~/components/spoons/segment-control';
|
||||||
|
import { SpoonDetailFork } from '~/components/spoons/spoon-detail-fork';
|
||||||
|
import { SpoonDetailOverview } from '~/components/spoons/spoon-detail-overview';
|
||||||
|
import { SpoonDetailPrs } from '~/components/spoons/spoon-detail-prs';
|
||||||
|
import { SpoonDetailSettings } from '~/components/spoons/spoon-detail-settings';
|
||||||
|
import { SpoonDetailThreads } from '~/components/spoons/spoon-detail-threads';
|
||||||
|
import { SpoonDetailUpstream } from '~/components/spoons/spoon-detail-upstream';
|
||||||
|
import { SpoonStatusBadge } from '~/components/spoons/spoon-status-badge';
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
|
||||||
|
const SpoonDetailRoute = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { spoonId: rawSpoonId } = useLocalSearchParams<{ spoonId: string }>();
|
||||||
|
const spoonId = rawSpoonId as Id<'spoons'>;
|
||||||
|
const [segment, setSegment] = useState<SpoonDetailSegment>('overview');
|
||||||
|
const [threadPrompt, setThreadPrompt] = useState('');
|
||||||
|
const [pending, setPending] = useState<string | undefined>();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const details = useQuery(api.spoons.getDetails, { spoonId });
|
||||||
|
const upstreamCommits =
|
||||||
|
useQuery(api.spoonCommits.listForSpoon, {
|
||||||
|
limit: 50,
|
||||||
|
side: 'upstream',
|
||||||
|
spoonId,
|
||||||
|
}) ?? [];
|
||||||
|
const forkCommits =
|
||||||
|
useQuery(api.spoonCommits.listForSpoon, {
|
||||||
|
limit: 50,
|
||||||
|
side: 'fork',
|
||||||
|
spoonId,
|
||||||
|
}) ?? [];
|
||||||
|
const pullRequests =
|
||||||
|
useQuery(api.spoonPullRequests.listForSpoon, { limit: 50, spoonId }) ?? [];
|
||||||
|
const remotes = useQuery(api.spoonRemotes.listForSpoon, { spoonId }) ?? [];
|
||||||
|
const threads =
|
||||||
|
useQuery(api.threads.listForSpoon, { limit: 25, spoonId }) ?? [];
|
||||||
|
const spoonSettings = useQuery(api.spoonSettings.getForSpoon, { spoonId });
|
||||||
|
const agentSettings = useQuery(api.spoonAgentSettings.getForSpoon, {
|
||||||
|
spoonId,
|
||||||
|
});
|
||||||
|
const secrets = useQuery(api.spoonSecrets.listForSpoon, { spoonId }) ?? [];
|
||||||
|
const providerProfiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||||
|
|
||||||
|
const refresh = useAction(api.githubSync.refreshSpoonGithubState);
|
||||||
|
const sync = useAction(api.githubSync.syncForkWithUpstream);
|
||||||
|
const updateSpoonSettings = useMutation(api.spoons.updateSettings);
|
||||||
|
const updateMaintenanceSettings = useMutation(api.spoonSettings.update);
|
||||||
|
const updateAgentSettings = useMutation(api.spoonAgentSettings.update);
|
||||||
|
const createThread = useMutation(api.threads.createUserThread);
|
||||||
|
const createSecret = useAction(api.spoonSecretsNode.create);
|
||||||
|
const removeSecretMutation = useMutation(api.spoonSecrets.remove);
|
||||||
|
const createRemote = useMutation(api.spoonRemotes.create);
|
||||||
|
const removeRemoteMutation = useMutation(api.spoonRemotes.remove);
|
||||||
|
|
||||||
|
const runRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
setPending('refresh');
|
||||||
|
try {
|
||||||
|
await refresh({ spoonId });
|
||||||
|
Alert.alert('Refresh started', 'Spoon is checking GitHub state.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not refresh this Spoon.');
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runSync = async () => {
|
||||||
|
setPending('sync');
|
||||||
|
try {
|
||||||
|
await sync({ spoonId });
|
||||||
|
Alert.alert('Sync started', 'Spoon is syncing the fork.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not sync this Spoon.');
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitThread = async () => {
|
||||||
|
if (!threadPrompt.trim()) return;
|
||||||
|
setPending('thread');
|
||||||
|
try {
|
||||||
|
const threadId = await createThread({
|
||||||
|
envFilePath:
|
||||||
|
agentSettings?.envFilePath === 'custom'
|
||||||
|
? agentSettings.customEnvFilePath
|
||||||
|
: agentSettings?.envFilePath,
|
||||||
|
materializeEnvFile: agentSettings?.materializeEnvFileByDefault,
|
||||||
|
prompt: threadPrompt,
|
||||||
|
spoonId,
|
||||||
|
});
|
||||||
|
setThreadPrompt('');
|
||||||
|
router.push(`/threads/${threadId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not create thread.');
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!details) {
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<Stack.Screen options={{ title: 'Spoon' }} />
|
||||||
|
<Text className='text-muted-foreground'>Loading Spoon...</Text>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { effectiveUpstreamAheadBy, spoon } = details;
|
||||||
|
const canSync =
|
||||||
|
spoon.provider === 'github' &&
|
||||||
|
(spoon.syncStatus === 'behind' || spoon.syncStatus === 'up_to_date') &&
|
||||||
|
(spoon.forkAheadBy ?? 0) === 0;
|
||||||
|
|
||||||
|
const settingsActions = {
|
||||||
|
addRemote: async (label: string, url: string) => {
|
||||||
|
setPending('addRemote');
|
||||||
|
try {
|
||||||
|
await createRemote({ label, spoonId, url });
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addSecret: async (name: string, value: string) => {
|
||||||
|
setPending('addSecret');
|
||||||
|
try {
|
||||||
|
await createSecret({ name, spoonId, value });
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
importSecrets: async (items: { name: string; value: string }[]) => {
|
||||||
|
setPending('importSecrets');
|
||||||
|
let failed = 0;
|
||||||
|
try {
|
||||||
|
for (const item of items) {
|
||||||
|
try {
|
||||||
|
await createSecret({ name: item.name, spoonId, value: item.value });
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1;
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`${items.length - failed} imported, ${failed} failed.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeRemote: async (remoteId: string) => {
|
||||||
|
setPending(`remote:${remoteId}`);
|
||||||
|
try {
|
||||||
|
await removeRemoteMutation({
|
||||||
|
remoteId: remoteId as Id<'spoonRemotes'>,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeSecret: async (secretId: string) => {
|
||||||
|
setPending(`secret:${secretId}`);
|
||||||
|
try {
|
||||||
|
await removeSecretMutation({
|
||||||
|
secretId: secretId as Id<'spoonSecrets'>,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateAgent: async (patch: Record<string, unknown>) => {
|
||||||
|
setPending('settings');
|
||||||
|
try {
|
||||||
|
await updateAgentSettings({ spoonId, ...patch });
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateMaintenance: async (patch: Record<string, unknown>) => {
|
||||||
|
setPending('settings');
|
||||||
|
try {
|
||||||
|
await updateMaintenanceSettings({ spoonId, ...patch });
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateSpoon: async (patch: Record<string, unknown>) => {
|
||||||
|
setPending('settings');
|
||||||
|
try {
|
||||||
|
await updateSpoonSettings({ spoonId, ...patch });
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen onRefresh={() => void runRefresh()} refreshing={refreshing}>
|
||||||
|
<Stack.Screen options={{ title: spoon.name }} />
|
||||||
|
<View className='gap-2'>
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>{spoon.name}</Text>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
<SpoonStatusBadge status={spoon.syncStatus ?? spoon.status} />
|
||||||
|
<SpoonStatusBadge status={spoon.status} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='flex-row gap-3'>
|
||||||
|
<Button
|
||||||
|
disabled={pending === 'refresh'}
|
||||||
|
onPress={() => void runRefresh()}
|
||||||
|
>
|
||||||
|
{pending === 'refresh' ? 'Refreshing...' : 'Refresh'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!canSync || pending === 'sync'}
|
||||||
|
variant='outline'
|
||||||
|
onPress={() => void runSync()}
|
||||||
|
>
|
||||||
|
{pending === 'sync' ? 'Syncing...' : 'Sync fork'}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<SegmentControl value={segment} onChange={setSegment} />
|
||||||
|
|
||||||
|
{segment === 'overview' ? (
|
||||||
|
<SpoonDetailOverview
|
||||||
|
effectiveUpstreamAheadBy={effectiveUpstreamAheadBy}
|
||||||
|
remotes={remotes}
|
||||||
|
spoon={spoon}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{segment === 'upstream' ? (
|
||||||
|
<SpoonDetailUpstream commits={upstreamCommits} />
|
||||||
|
) : null}
|
||||||
|
{segment === 'fork' ? <SpoonDetailFork commits={forkCommits} /> : null}
|
||||||
|
{segment === 'prs' ? (
|
||||||
|
<SpoonDetailPrs pullRequests={pullRequests} />
|
||||||
|
) : null}
|
||||||
|
{segment === 'threads' ? (
|
||||||
|
<SpoonDetailThreads
|
||||||
|
creating={pending === 'thread'}
|
||||||
|
prompt={threadPrompt}
|
||||||
|
setPrompt={setThreadPrompt}
|
||||||
|
threads={threads}
|
||||||
|
onCreate={() => void submitThread()}
|
||||||
|
onOpenThread={(threadId) => router.push(`/threads/${threadId}`)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{segment === 'settings' ? (
|
||||||
|
<SpoonDetailSettings
|
||||||
|
actions={settingsActions}
|
||||||
|
agentSettings={agentSettings ?? undefined}
|
||||||
|
maintenanceSettings={spoonSettings ?? undefined}
|
||||||
|
pending={{
|
||||||
|
addingRemote: pending === 'addRemote',
|
||||||
|
addingSecret: pending === 'addSecret',
|
||||||
|
importingSecrets: pending === 'importSecrets',
|
||||||
|
removingRemoteId: pending?.startsWith('remote:')
|
||||||
|
? pending.slice('remote:'.length)
|
||||||
|
: undefined,
|
||||||
|
removingSecretId: pending?.startsWith('secret:')
|
||||||
|
? pending.slice('secret:'.length)
|
||||||
|
: undefined,
|
||||||
|
savingSettings: pending === 'settings',
|
||||||
|
}}
|
||||||
|
providerProfiles={providerProfiles}
|
||||||
|
remotes={remotes}
|
||||||
|
secrets={secrets}
|
||||||
|
spoon={spoon}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpoonDetailRoute;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
const SpoonsLayout = () => <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
|
||||||
|
export default SpoonsLayout;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
import { Link, Stack, useRouter } from 'expo-router';
|
||||||
|
import { useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { SpoonListRow } from '~/components/spoons/spoon-list-row';
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { EmptyState } from '~/components/ui/empty-state';
|
||||||
|
import { MetricCard } from '~/components/ui/metric-card';
|
||||||
|
|
||||||
|
const openThreadStatuses = ['resolved', 'ignored', 'failed', 'cancelled'];
|
||||||
|
|
||||||
|
const SpoonsRoute = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||||
|
const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
|
||||||
|
const active = spoons.filter((spoon) => spoon.status === 'active').length;
|
||||||
|
const upstreamWaiting = spoons.reduce(
|
||||||
|
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const openThreadsFor = (spoonId: string) =>
|
||||||
|
threads.filter(
|
||||||
|
(thread) =>
|
||||||
|
thread.spoonId === spoonId &&
|
||||||
|
!openThreadStatuses.includes(thread.status),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const softRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
setTimeout(() => setRefreshing(false), 600);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen onRefresh={softRefresh} refreshing={refreshing}>
|
||||||
|
<Stack.Screen options={{ title: 'Spoons' }} />
|
||||||
|
<View className='flex-row items-start justify-between gap-3'>
|
||||||
|
<View className='min-w-0 flex-1'>
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>Spoons</Text>
|
||||||
|
<Text className='text-muted-foreground mt-1'>
|
||||||
|
Managed forks and their relationship with upstream.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Link href='/spoons/new' asChild>
|
||||||
|
<Button>New</Button>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='flex-row gap-3'>
|
||||||
|
<MetricCard label='Managed' value={spoons.length} />
|
||||||
|
<MetricCard label='Active' value={active} />
|
||||||
|
<MetricCard label='Waiting' value={upstreamWaiting} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='gap-3'>
|
||||||
|
{spoons.length ? (
|
||||||
|
spoons.map((spoon) => (
|
||||||
|
<SpoonListRow
|
||||||
|
key={spoon._id}
|
||||||
|
openThreads={openThreadsFor(spoon._id)}
|
||||||
|
spoon={spoon}
|
||||||
|
onPress={() => router.push(`/spoons/${spoon._id}`)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
description='Create a manual Spoon record to start shaping fork maintenance.'
|
||||||
|
title='No managed forks yet'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpoonsRoute;
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, Linking, Text, View } from 'react-native';
|
||||||
|
import { Stack, useRouter } from 'expo-router';
|
||||||
|
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { Field } from '~/components/ui/field';
|
||||||
|
import { FormSection } from '~/components/ui/form-section';
|
||||||
|
import { PillTabs } from '~/components/ui/pill-tabs';
|
||||||
|
import { SheetSelect } from '~/components/ui/sheet-select';
|
||||||
|
|
||||||
|
type CreateMode = 'manual' | 'github';
|
||||||
|
type Provider = 'github' | 'gitea' | 'gitlab' | 'other';
|
||||||
|
type Visibility = 'public' | 'private' | 'internal' | 'unknown';
|
||||||
|
type MaintenanceMode = 'watch' | 'auto_pr' | 'paused';
|
||||||
|
type SyncCadence = 'daily' | 'weekly' | 'manual';
|
||||||
|
type ProductionRefStrategy =
|
||||||
|
| 'default_branch'
|
||||||
|
| 'latest_release'
|
||||||
|
| 'tag_pattern';
|
||||||
|
|
||||||
|
type Repository = Awaited<
|
||||||
|
ReturnType<
|
||||||
|
ReturnType<
|
||||||
|
typeof useAction<typeof api.githubNode.listInstallationRepositories>
|
||||||
|
>
|
||||||
|
>
|
||||||
|
>[number];
|
||||||
|
|
||||||
|
const NewSpoonRoute = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const createManual = useMutation(api.spoons.createManual);
|
||||||
|
const syncInstallation = useAction(api.githubNode.syncConfiguredInstallation);
|
||||||
|
const listRepositories = useAction(
|
||||||
|
api.githubNode.listInstallationRepositories,
|
||||||
|
);
|
||||||
|
const installUrl = useQuery(api.github.getInstallUrl, {});
|
||||||
|
const connection = useQuery(api.github.getConnection, {});
|
||||||
|
const [mode, setMode] = useState<CreateMode>('manual');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [provider, setProvider] = useState<Provider>('github');
|
||||||
|
const [upstreamOwner, setUpstreamOwner] = useState('');
|
||||||
|
const [upstreamRepo, setUpstreamRepo] = useState('');
|
||||||
|
const [upstreamDefaultBranch, setUpstreamDefaultBranch] = useState('main');
|
||||||
|
const [upstreamUrl, setUpstreamUrl] = useState('');
|
||||||
|
const [forkOwner, setForkOwner] = useState('');
|
||||||
|
const [forkRepo, setForkRepo] = useState('');
|
||||||
|
const [forkDefaultBranch, setForkDefaultBranch] = useState('main');
|
||||||
|
const [forkUrl, setForkUrl] = useState('');
|
||||||
|
const [visibility, setVisibility] = useState<Visibility>('unknown');
|
||||||
|
const [maintenanceMode, setMaintenanceMode] =
|
||||||
|
useState<MaintenanceMode>('watch');
|
||||||
|
const [syncCadence, setSyncCadence] = useState<SyncCadence>('daily');
|
||||||
|
const [productionRefStrategy, setProductionRefStrategy] =
|
||||||
|
useState<ProductionRefStrategy>('default_branch');
|
||||||
|
const [tagPattern, setTagPattern] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [loadingRepos, setLoadingRepos] = useState(false);
|
||||||
|
const [repositories, setRepositories] = useState<Repository[]>([]);
|
||||||
|
|
||||||
|
const submitManual = async () => {
|
||||||
|
if (!name || !upstreamOwner || !upstreamRepo || !upstreamUrl) {
|
||||||
|
Alert.alert('Missing fields', 'Name and upstream metadata are required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const spoonId = await createManual({
|
||||||
|
description: description || undefined,
|
||||||
|
forkDefaultBranch: forkDefaultBranch || undefined,
|
||||||
|
forkOwner: forkOwner || undefined,
|
||||||
|
forkRepo: forkRepo || undefined,
|
||||||
|
forkUrl: forkUrl || undefined,
|
||||||
|
maintenanceMode,
|
||||||
|
name,
|
||||||
|
productionRefStrategy,
|
||||||
|
provider,
|
||||||
|
syncCadence,
|
||||||
|
tagPattern: tagPattern || undefined,
|
||||||
|
upstreamDefaultBranch,
|
||||||
|
upstreamOwner,
|
||||||
|
upstreamRepo,
|
||||||
|
upstreamUrl,
|
||||||
|
visibility,
|
||||||
|
});
|
||||||
|
router.replace(`/spoons/${spoonId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not create Spoon', 'Check the fields and try again.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRepos = async () => {
|
||||||
|
setLoadingRepos(true);
|
||||||
|
try {
|
||||||
|
const result = await listRepositories({});
|
||||||
|
setRepositories(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not list repositories.');
|
||||||
|
} finally {
|
||||||
|
setLoadingRepos(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFromRepo = async (repo: Repository) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const upstreamOwnerValue = upstreamOwner.trim() || repo.owner;
|
||||||
|
const upstreamRepoValue = upstreamRepo.trim() || repo.name;
|
||||||
|
const upstreamUrlValue = upstreamUrl.trim() || repo.url;
|
||||||
|
const spoonId = await createManual({
|
||||||
|
forkDefaultBranch: repo.defaultBranch,
|
||||||
|
forkOwner: repo.owner,
|
||||||
|
forkRepo: repo.name,
|
||||||
|
forkUrl: repo.url,
|
||||||
|
maintenanceMode: 'watch',
|
||||||
|
name: repo.name,
|
||||||
|
productionRefStrategy: 'default_branch',
|
||||||
|
provider: 'github',
|
||||||
|
syncCadence: 'daily',
|
||||||
|
upstreamDefaultBranch: repo.defaultBranch,
|
||||||
|
upstreamOwner: upstreamOwnerValue,
|
||||||
|
upstreamRepo: upstreamRepoValue,
|
||||||
|
upstreamUrl: upstreamUrlValue,
|
||||||
|
visibility: repo.private ? 'private' : 'public',
|
||||||
|
});
|
||||||
|
router.replace(`/spoons/${spoonId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not create Spoon from repository.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmCreateFromRepo = (repo: Repository) => {
|
||||||
|
const message = repo.fork
|
||||||
|
? 'GitHub did not provide parent repository metadata here. Add upstream fields above if you want Spoon to compare against the original project immediately.'
|
||||||
|
: 'This will create a manual Spoon record using this repository as both upstream and fork unless you add upstream fields above.';
|
||||||
|
|
||||||
|
Alert.alert('Create Spoon from repository metadata?', message, [
|
||||||
|
{ style: 'cancel', text: 'Cancel' },
|
||||||
|
{
|
||||||
|
onPress: () => void createFromRepo(repo),
|
||||||
|
text: 'Create Spoon',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncGithub = async () => {
|
||||||
|
setLoadingRepos(true);
|
||||||
|
try {
|
||||||
|
await syncInstallation({});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not sync GitHub installation.');
|
||||||
|
} finally {
|
||||||
|
setLoadingRepos(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<Stack.Screen options={{ title: 'New Spoon' }} />
|
||||||
|
<View>
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>New Spoon</Text>
|
||||||
|
<Text className='text-muted-foreground mt-1'>
|
||||||
|
Create a managed fork record manually or from GitHub.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<PillTabs
|
||||||
|
tabs={[
|
||||||
|
{ label: 'Manual', value: 'manual' },
|
||||||
|
{ label: 'GitHub', value: 'github' },
|
||||||
|
]}
|
||||||
|
value={mode}
|
||||||
|
onChange={setMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{mode === 'manual' ? (
|
||||||
|
<>
|
||||||
|
<FormSection title='Basics'>
|
||||||
|
<Field label='Spoon name' value={name} onChangeText={setName} />
|
||||||
|
<Field
|
||||||
|
label='Description'
|
||||||
|
multiline
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
label='Git provider'
|
||||||
|
options={[
|
||||||
|
{ label: 'GitHub', value: 'github' },
|
||||||
|
{ label: 'Gitea', value: 'gitea' },
|
||||||
|
{ label: 'GitLab', value: 'gitlab' },
|
||||||
|
{ label: 'Other', value: 'other' },
|
||||||
|
]}
|
||||||
|
value={provider}
|
||||||
|
onChange={setProvider}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
<FormSection title='Upstream'>
|
||||||
|
<Field
|
||||||
|
label='Owner/org'
|
||||||
|
value={upstreamOwner}
|
||||||
|
onChangeText={setUpstreamOwner}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='Repository'
|
||||||
|
value={upstreamRepo}
|
||||||
|
onChangeText={setUpstreamRepo}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='Default branch'
|
||||||
|
value={upstreamDefaultBranch}
|
||||||
|
onChangeText={setUpstreamDefaultBranch}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
keyboardType='url'
|
||||||
|
label='Upstream URL'
|
||||||
|
value={upstreamUrl}
|
||||||
|
onChangeText={setUpstreamUrl}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
<FormSection title='Fork'>
|
||||||
|
<Field
|
||||||
|
label='Owner/org'
|
||||||
|
value={forkOwner}
|
||||||
|
onChangeText={setForkOwner}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='Repository'
|
||||||
|
value={forkRepo}
|
||||||
|
onChangeText={setForkRepo}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='Default branch'
|
||||||
|
value={forkDefaultBranch}
|
||||||
|
onChangeText={setForkDefaultBranch}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
keyboardType='url'
|
||||||
|
label='Fork URL'
|
||||||
|
value={forkUrl}
|
||||||
|
onChangeText={setForkUrl}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
<FormSection title='Maintenance'>
|
||||||
|
<SheetSelect
|
||||||
|
label='Visibility'
|
||||||
|
options={[
|
||||||
|
{ label: 'Unknown', value: 'unknown' },
|
||||||
|
{ label: 'Public', value: 'public' },
|
||||||
|
{ label: 'Private', value: 'private' },
|
||||||
|
{ label: 'Internal', value: 'internal' },
|
||||||
|
]}
|
||||||
|
value={visibility}
|
||||||
|
onChange={setVisibility}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
label='Maintenance mode'
|
||||||
|
options={[
|
||||||
|
{ label: 'Watch', value: 'watch' },
|
||||||
|
{ label: 'Auto PR', value: 'auto_pr' },
|
||||||
|
{ label: 'Paused', value: 'paused' },
|
||||||
|
]}
|
||||||
|
value={maintenanceMode}
|
||||||
|
onChange={setMaintenanceMode}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
label='Sync cadence'
|
||||||
|
options={[
|
||||||
|
{ label: 'Daily', value: 'daily' },
|
||||||
|
{ label: 'Weekly', value: 'weekly' },
|
||||||
|
{ label: 'Manual', value: 'manual' },
|
||||||
|
]}
|
||||||
|
value={syncCadence}
|
||||||
|
onChange={setSyncCadence}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
label='Production ref'
|
||||||
|
options={[
|
||||||
|
{ label: 'Default branch', value: 'default_branch' },
|
||||||
|
{ label: 'Latest release', value: 'latest_release' },
|
||||||
|
{ label: 'Tag pattern', value: 'tag_pattern' },
|
||||||
|
]}
|
||||||
|
value={productionRefStrategy}
|
||||||
|
onChange={setProductionRefStrategy}
|
||||||
|
/>
|
||||||
|
{productionRefStrategy === 'tag_pattern' ? (
|
||||||
|
<Field
|
||||||
|
label='Tag pattern'
|
||||||
|
value={tagPattern}
|
||||||
|
onChangeText={setTagPattern}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</FormSection>
|
||||||
|
<Button disabled={submitting} onPress={() => void submitManual()}>
|
||||||
|
{submitting ? 'Creating...' : 'Create Spoon'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<FormSection
|
||||||
|
description='Repository listing is read from the GitHub App installation.'
|
||||||
|
title='GitHub'
|
||||||
|
>
|
||||||
|
<View className='flex-row items-center justify-between'>
|
||||||
|
<Text className='text-foreground font-medium'>Connection</Text>
|
||||||
|
<Badge
|
||||||
|
label={connection?.status ?? 'not connected'}
|
||||||
|
tone={connection ? 'success' : 'warning'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{installUrl ? (
|
||||||
|
<Button onPress={() => void Linking.openURL(installUrl)}>
|
||||||
|
Install or manage GitHub App
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<View className='flex-row gap-3'>
|
||||||
|
<Button
|
||||||
|
disabled={loadingRepos}
|
||||||
|
variant='outline'
|
||||||
|
onPress={() => void syncGithub()}
|
||||||
|
>
|
||||||
|
Sync
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!connection || loadingRepos}
|
||||||
|
onPress={() => void loadRepos()}
|
||||||
|
>
|
||||||
|
{loadingRepos ? 'Loading...' : 'Load repositories'}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
<Text className='text-muted-foreground text-sm leading-5'>
|
||||||
|
Optional upstream fields are used when the selected repository is a
|
||||||
|
fork. If you leave them blank, Spoon tracks the selected repository
|
||||||
|
as both upstream and fork until you correct it later.
|
||||||
|
</Text>
|
||||||
|
<Field
|
||||||
|
label='Upstream owner/org'
|
||||||
|
value={upstreamOwner}
|
||||||
|
onChangeText={setUpstreamOwner}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='Upstream repository'
|
||||||
|
value={upstreamRepo}
|
||||||
|
onChangeText={setUpstreamRepo}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
keyboardType='url'
|
||||||
|
label='Upstream URL'
|
||||||
|
value={upstreamUrl}
|
||||||
|
onChangeText={setUpstreamUrl}
|
||||||
|
/>
|
||||||
|
{!loadingRepos && connection && repositories.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<Text className='text-muted-foreground text-sm leading-5'>
|
||||||
|
Load accessible repositories to create a Spoon from GitHub
|
||||||
|
metadata.
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
{repositories.map((repo) => (
|
||||||
|
<Card key={repo.id} className='gap-2'>
|
||||||
|
<Text className='text-foreground font-semibold'>
|
||||||
|
{repo.fullName}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground text-xs'>
|
||||||
|
{repo.private ? 'Private' : 'Public'} ·{' '}
|
||||||
|
{repo.fork ? 'Fork' : 'Repository'} · {repo.defaultBranch}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
disabled={submitting}
|
||||||
|
variant='outline'
|
||||||
|
onPress={() => confirmCreateFromRepo(repo)}
|
||||||
|
>
|
||||||
|
Create Spoon from metadata
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</FormSection>
|
||||||
|
)}
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NewSpoonRoute;
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, Linking, Text, View } from 'react-native';
|
||||||
|
import { Link, Stack, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { ThreadMessageList } from '~/components/threads/thread-message-list';
|
||||||
|
import { ThreadStatusBadge } from '~/components/threads/thread-status-badge';
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { ConfirmButton } from '~/components/ui/confirm-button';
|
||||||
|
import { Field } from '~/components/ui/field';
|
||||||
|
import { formatDateTime, titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
const ThreadDetailRoute = () => {
|
||||||
|
const { threadId: rawThreadId } = useLocalSearchParams<{
|
||||||
|
threadId: string;
|
||||||
|
}>();
|
||||||
|
const threadId = rawThreadId 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);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [pending, setPending] = useState<string | undefined>();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
if (!message.trim()) return;
|
||||||
|
setPending('send');
|
||||||
|
try {
|
||||||
|
await appendMessage({ threadId, content: message });
|
||||||
|
setMessage('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not send message.');
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const softRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
setTimeout(() => setRefreshing(false), 600);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveThread = async () => {
|
||||||
|
setPending('resolve');
|
||||||
|
try {
|
||||||
|
await markResolved({ threadId });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not resolve thread.');
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelThread = async () => {
|
||||||
|
setPending('cancel');
|
||||||
|
try {
|
||||||
|
await cancel({ threadId });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not cancel thread.');
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!details) {
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<Stack.Screen options={{ title: 'Thread' }} />
|
||||||
|
<Text className='text-muted-foreground'>Loading thread...</Text>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { thread, spoon, latestJob } = details;
|
||||||
|
const pullRequestUrl = latestJob?.pullRequestUrl;
|
||||||
|
const completed = ['resolved', 'ignored', 'failed', 'cancelled'].includes(
|
||||||
|
thread.status,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen onRefresh={softRefresh} refreshing={refreshing}>
|
||||||
|
<Stack.Screen options={{ title: thread.title }} />
|
||||||
|
<View className='gap-2'>
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>
|
||||||
|
{thread.title}
|
||||||
|
</Text>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
<ThreadStatusBadge status={thread.status} />
|
||||||
|
<Badge label={titleize(thread.source)} />
|
||||||
|
{thread.maintenanceOutcome ? (
|
||||||
|
<Badge label={titleize(thread.maintenanceOutcome)} tone='primary' />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
Updated {formatDateTime(thread.updatedAt)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{spoon ? (
|
||||||
|
<Card>
|
||||||
|
<Text className='text-muted-foreground text-xs'>Spoon</Text>
|
||||||
|
<Text className='text-foreground mt-1 font-semibold'>
|
||||||
|
{spoon.name}
|
||||||
|
</Text>
|
||||||
|
<Link href={`/spoons/${spoon._id}`} asChild>
|
||||||
|
<Button variant='outline'>Open Spoon</Button>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{latestJob ? (
|
||||||
|
<Card className='gap-3'>
|
||||||
|
<Text className='text-foreground font-semibold'>Latest job</Text>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
{titleize(latestJob.status)} · {titleize(latestJob.workspaceStatus)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
Branch: {latestJob.workBranch}
|
||||||
|
</Text>
|
||||||
|
<Link href={`/workspace/${latestJob._id}`} asChild>
|
||||||
|
<Button variant='outline'>Open workspace review</Button>
|
||||||
|
</Link>
|
||||||
|
{pullRequestUrl ? (
|
||||||
|
<Button onPress={() => void Linking.openURL(pullRequestUrl)}>
|
||||||
|
Open draft PR
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ThreadMessageList messages={messages} />
|
||||||
|
|
||||||
|
<Card className='gap-3'>
|
||||||
|
<Text className='text-foreground font-semibold'>Reply</Text>
|
||||||
|
<Field
|
||||||
|
label='Message'
|
||||||
|
multiline
|
||||||
|
value={message}
|
||||||
|
onChangeText={setMessage}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
disabled={completed || pending === 'send'}
|
||||||
|
onPress={() => void send()}
|
||||||
|
>
|
||||||
|
{pending === 'send' ? 'Sending...' : 'Send message'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<View className='flex-row gap-3'>
|
||||||
|
<Button
|
||||||
|
disabled={completed || pending === 'resolve'}
|
||||||
|
variant='outline'
|
||||||
|
onPress={() => void resolveThread()}
|
||||||
|
>
|
||||||
|
{pending === 'resolve' ? 'Resolving...' : 'Resolve'}
|
||||||
|
</Button>
|
||||||
|
<ConfirmButton
|
||||||
|
confirmLabel='Cancel thread'
|
||||||
|
destructive
|
||||||
|
disabled={completed || pending === 'cancel'}
|
||||||
|
message='Cancel this thread?'
|
||||||
|
title='Cancel thread'
|
||||||
|
onConfirm={() => void cancelThread()}
|
||||||
|
>
|
||||||
|
{pending === 'cancel' ? 'Cancelling...' : 'Cancel'}
|
||||||
|
</ConfirmButton>
|
||||||
|
</View>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThreadDetailRoute;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
const ThreadsLayout = () => <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
|
||||||
|
export default ThreadsLayout;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
import { Stack, useRouter } from 'expo-router';
|
||||||
|
import { useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import type { PillTab } from '~/components/ui/pill-tabs';
|
||||||
|
import { ThreadListRow } from '~/components/threads/thread-list-row';
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { EmptyState } from '~/components/ui/empty-state';
|
||||||
|
import { PillTabs } from '~/components/ui/pill-tabs';
|
||||||
|
|
||||||
|
type StatusFilter =
|
||||||
|
| 'all'
|
||||||
|
| 'open'
|
||||||
|
| 'running'
|
||||||
|
| 'waiting_for_user'
|
||||||
|
| 'resolved';
|
||||||
|
|
||||||
|
const filters: PillTab<StatusFilter>[] = [
|
||||||
|
{ label: 'All', value: 'all' },
|
||||||
|
{ label: 'Open', value: 'open' },
|
||||||
|
{ label: 'Running', value: 'running' },
|
||||||
|
{ label: 'Waiting', value: 'waiting_for_user' },
|
||||||
|
{ label: 'Resolved', value: 'resolved' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ThreadsRoute = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [status, setStatus] = useState<StatusFilter>('all');
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const threads =
|
||||||
|
useQuery(api.threads.listMine, {
|
||||||
|
limit: 50,
|
||||||
|
status,
|
||||||
|
}) ?? [];
|
||||||
|
|
||||||
|
const softRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
setTimeout(() => setRefreshing(false), 600);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen onRefresh={softRefresh} refreshing={refreshing}>
|
||||||
|
<Stack.Screen options={{ title: 'Threads' }} />
|
||||||
|
<View>
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>Threads</Text>
|
||||||
|
<Text className='text-muted-foreground mt-1'>
|
||||||
|
Maintenance decisions, user requests, and workspace handoffs.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<PillTabs onChange={setStatus} tabs={filters} value={status} />
|
||||||
|
<View className='gap-3'>
|
||||||
|
{threads.length ? (
|
||||||
|
threads.map((thread) => (
|
||||||
|
<ThreadListRow
|
||||||
|
key={thread._id}
|
||||||
|
thread={thread}
|
||||||
|
onPress={() => router.push(`/threads/${thread._id}`)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
description='Threads appear when you ask Spoon to change a fork or upstream changes need review.'
|
||||||
|
title='No threads'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThreadsRoute;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, Text, View } from 'react-native';
|
||||||
|
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import type { PillTab } from '~/components/ui/pill-tabs';
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { PillTabs } from '~/components/ui/pill-tabs';
|
||||||
|
import { WorkspaceArtifacts } from '~/components/workspace/workspace-artifacts';
|
||||||
|
import { WorkspaceEvents } from '~/components/workspace/workspace-events';
|
||||||
|
import { WorkspaceMessages } from '~/components/workspace/workspace-messages';
|
||||||
|
import { WorkspaceSummary } from '~/components/workspace/workspace-summary';
|
||||||
|
|
||||||
|
type WorkspaceTab = 'status' | 'messages' | 'diffs' | 'events' | 'artifacts';
|
||||||
|
|
||||||
|
const tabs: PillTab<WorkspaceTab>[] = [
|
||||||
|
{ label: 'Status', value: 'status' },
|
||||||
|
{ label: 'Messages', value: 'messages' },
|
||||||
|
{ label: 'Diffs', value: 'diffs' },
|
||||||
|
{ label: 'Events', value: 'events' },
|
||||||
|
{ label: 'Artifacts', value: 'artifacts' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const WorkspaceRoute = () => {
|
||||||
|
const { jobId: rawJobId } = useLocalSearchParams<{ jobId: string }>();
|
||||||
|
const jobId = rawJobId as Id<'agentJobs'>;
|
||||||
|
const [tab, setTab] = useState<WorkspaceTab>('status');
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [cancelling, setCancelling] = useState(false);
|
||||||
|
const job = useQuery(api.agentJobs.get, { jobId });
|
||||||
|
const messages = useQuery(api.agentJobs.listMessages, { jobId }) ?? [];
|
||||||
|
const events =
|
||||||
|
useQuery(api.agentJobs.listEvents, { jobId, limit: 200 }) ?? [];
|
||||||
|
const artifacts = useQuery(api.agentJobs.listArtifacts, { jobId }) ?? [];
|
||||||
|
const cancel = useMutation(api.agentJobs.cancel);
|
||||||
|
|
||||||
|
const softRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
setTimeout(() => setRefreshing(false), 600);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelJob = async () => {
|
||||||
|
setCancelling(true);
|
||||||
|
try {
|
||||||
|
await cancel({ jobId });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not cancel job.');
|
||||||
|
} finally {
|
||||||
|
setCancelling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<Stack.Screen options={{ title: 'Workspace' }} />
|
||||||
|
<Text className='text-muted-foreground'>Loading workspace...</Text>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen onRefresh={softRefresh} refreshing={refreshing}>
|
||||||
|
<Stack.Screen options={{ title: 'Workspace' }} />
|
||||||
|
<View className='gap-2'>
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>
|
||||||
|
Workspace review
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground'>
|
||||||
|
Inspect the active job without exposing worker internals to mobile.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<PillTabs onChange={setTab} tabs={tabs} value={tab} />
|
||||||
|
{tab === 'status' ? (
|
||||||
|
<WorkspaceSummary
|
||||||
|
cancelling={cancelling}
|
||||||
|
job={job}
|
||||||
|
onCancel={() => void cancelJob()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{tab === 'messages' ? <WorkspaceMessages messages={messages} /> : null}
|
||||||
|
{tab === 'diffs' ? (
|
||||||
|
<WorkspaceArtifacts artifacts={artifacts} mode='diffs' />
|
||||||
|
) : null}
|
||||||
|
{tab === 'events' ? <WorkspaceEvents events={events} /> : null}
|
||||||
|
{tab === 'artifacts' ? (
|
||||||
|
<WorkspaceArtifacts artifacts={artifacts} mode='artifacts' />
|
||||||
|
) : null}
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WorkspaceRoute;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
const WorkspaceLayout = () => <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
|
||||||
|
export default WorkspaceLayout;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
import { SignInScreen } from '~/components/auth/sign-in-screen';
|
||||||
|
|
||||||
|
const SignInRoute = () => (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Sign in' }} />
|
||||||
|
<SignInScreen />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SignInRoute;
|
||||||
+17
-168
@@ -1,179 +1,28 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Alert, Pressable, Text, TextInput, View } from 'react-native';
|
import { Stack, useRouter } from 'expo-router';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { useConvexAuth } from 'convex/react';
|
||||||
import * as Linking from 'expo-linking';
|
|
||||||
import { Stack } from 'expo-router';
|
|
||||||
import * as WebBrowser from 'expo-web-browser';
|
|
||||||
import { useAuthActions } from '@convex-dev/auth/react';
|
|
||||||
import { useConvexAuth, useQuery } from 'convex/react';
|
|
||||||
|
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
import { LoadingState } from '~/components/ui/loading-state';
|
||||||
|
|
||||||
WebBrowser.maybeCompleteAuthSession();
|
const IndexRoute = () => {
|
||||||
|
|
||||||
const Stat = ({ label, value }: { label: string; value: number }) => (
|
|
||||||
<View className='border-border bg-card flex-1 rounded-lg border p-4'>
|
|
||||||
<Text className='text-muted-foreground text-xs'>{label}</Text>
|
|
||||||
<Text className='text-foreground mt-2 text-2xl font-bold'>{value}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
const Index = () => {
|
|
||||||
const { isAuthenticated, isLoading } = useConvexAuth();
|
const { isAuthenticated, isLoading } = useConvexAuth();
|
||||||
const { signIn, signOut } = useAuthActions();
|
const router = useRouter();
|
||||||
const user = useQuery(api.auth.getUser, isAuthenticated ? {} : 'skip');
|
|
||||||
const spoons =
|
|
||||||
useQuery(api.spoons.listMine, isAuthenticated ? {} : 'skip') ?? [];
|
|
||||||
const syncRuns =
|
|
||||||
useQuery(
|
|
||||||
api.syncRuns.listRecent,
|
|
||||||
isAuthenticated ? { limit: 5 } : 'skip',
|
|
||||||
) ?? [];
|
|
||||||
const agentRequests =
|
|
||||||
useQuery(
|
|
||||||
api.agentRequests.listRecent,
|
|
||||||
isAuthenticated ? { limit: 5 } : 'skip',
|
|
||||||
) ?? [];
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const redirectTo = useMemo(() => Linking.createURL(''), []);
|
|
||||||
|
|
||||||
const handlePasswordSignIn = async () => {
|
useEffect(() => {
|
||||||
setSubmitting(true);
|
if (isLoading) return;
|
||||||
try {
|
if (isAuthenticated) {
|
||||||
await signIn('password', { email, password, flow: 'signIn' });
|
router.replace('/dashboard');
|
||||||
} catch (error) {
|
} else {
|
||||||
console.error(error);
|
router.replace('/sign-in');
|
||||||
Alert.alert('Sign in failed', 'Check your email and password.');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
}
|
||||||
};
|
}, [isAuthenticated, isLoading, router]);
|
||||||
|
|
||||||
const handleAuthentikSignIn = async () => {
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
const result = await signIn('authentik', { redirectTo });
|
|
||||||
if (!result.redirect) return;
|
|
||||||
const authResult = await WebBrowser.openAuthSessionAsync(
|
|
||||||
result.redirect.toString(),
|
|
||||||
redirectTo,
|
|
||||||
);
|
|
||||||
if (authResult.type !== 'success') return;
|
|
||||||
const parsed = Linking.parse(authResult.url);
|
|
||||||
const code = parsed.queryParams?.code;
|
|
||||||
if (typeof code !== 'string') {
|
|
||||||
Alert.alert('Sign in failed', 'Authentik did not return a code.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await signIn('authentik', { code });
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
Alert.alert('Sign in failed', 'Could not complete Authentik sign in.');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className='bg-background flex-1'>
|
<>
|
||||||
<Stack.Screen options={{ title: 'Spoon' }} />
|
<Stack.Screen options={{ title: 'Spoon' }} />
|
||||||
<View className='flex-1 gap-5 p-6'>
|
<LoadingState label='Opening Spoon...' />
|
||||||
<View>
|
</>
|
||||||
<Text className='text-foreground text-4xl font-bold'>Spoon</Text>
|
|
||||||
<Text className='text-muted-foreground mt-2 text-base leading-6'>
|
|
||||||
Fork freely. Stay close to upstream.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<Text className='text-muted-foreground'>Loading...</Text>
|
|
||||||
) : isAuthenticated ? (
|
|
||||||
<View className='gap-5'>
|
|
||||||
<View>
|
|
||||||
<Text className='text-foreground text-xl font-semibold'>
|
|
||||||
Welcome{user?.name ? `, ${user.name}` : ''}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-muted-foreground mt-1'>
|
|
||||||
Monitor your managed forks from anywhere.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className='flex-row gap-3'>
|
|
||||||
<Stat label='Spoons' value={spoons.length} />
|
|
||||||
<Stat label='Updates' value={syncRuns.length} />
|
|
||||||
<Stat label='Agents' value={agentRequests.length} />
|
|
||||||
</View>
|
|
||||||
<View className='border-border bg-card rounded-lg border p-4'>
|
|
||||||
<Text className='text-foreground font-semibold'>
|
|
||||||
Recent Spoons
|
|
||||||
</Text>
|
|
||||||
{spoons.length ? (
|
|
||||||
spoons.slice(0, 4).map((spoon) => (
|
|
||||||
<Text key={spoon._id} className='text-muted-foreground mt-3'>
|
|
||||||
{spoon.name} - {spoon.status.replaceAll('_', ' ')}
|
|
||||||
</Text>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Text className='text-muted-foreground mt-3'>
|
|
||||||
Create your first Spoon from the web dashboard.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<Pressable
|
|
||||||
className='bg-primary items-center rounded-md p-3'
|
|
||||||
onPress={() => void signOut()}
|
|
||||||
>
|
|
||||||
<Text className='text-primary-foreground font-semibold'>
|
|
||||||
Sign out
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View className='gap-4'>
|
|
||||||
<TextInput
|
|
||||||
className='border-input text-foreground rounded-md border px-3 py-3'
|
|
||||||
autoCapitalize='none'
|
|
||||||
keyboardType='email-address'
|
|
||||||
placeholder='Email'
|
|
||||||
placeholderTextColor='#64748b'
|
|
||||||
value={email}
|
|
||||||
onChangeText={setEmail}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
className='border-input text-foreground rounded-md border px-3 py-3'
|
|
||||||
secureTextEntry
|
|
||||||
placeholder='Password'
|
|
||||||
placeholderTextColor='#64748b'
|
|
||||||
value={password}
|
|
||||||
onChangeText={setPassword}
|
|
||||||
/>
|
|
||||||
<Pressable
|
|
||||||
className='bg-primary items-center rounded-md p-3 disabled:opacity-60'
|
|
||||||
disabled={submitting}
|
|
||||||
onPress={() => void handlePasswordSignIn()}
|
|
||||||
>
|
|
||||||
<Text className='text-primary-foreground font-semibold'>
|
|
||||||
Sign in with password
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
className='border-border items-center rounded-md border p-3 disabled:opacity-60'
|
|
||||||
disabled={submitting}
|
|
||||||
onPress={() => void handleAuthentikSignIn()}
|
|
||||||
>
|
|
||||||
<Text className='text-foreground font-semibold'>
|
|
||||||
Continue with Authentik
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Text className='text-muted-foreground text-sm'>
|
|
||||||
Register the native redirect URI based on spoon:// in Authentik.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Index;
|
export default IndexRoute;
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Text, View } from 'react-native';
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
import { Stack, useLocalSearchParams } from 'expo-router';
|
|
||||||
|
|
||||||
const Post = () => {
|
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView className='bg-background flex-1'>
|
|
||||||
<Stack.Screen options={{ title: 'Post' }} />
|
|
||||||
<View className='flex-1 p-4'>
|
|
||||||
<Text className='text-foreground text-2xl font-bold'>Post {id}</Text>
|
|
||||||
<Text className='text-muted-foreground mt-2'>
|
|
||||||
Implement your post detail screen here using Convex queries.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Post;
|
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Alert, Text, View } from 'react-native';
|
||||||
|
import * as Linking from 'expo-linking';
|
||||||
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
|
import { useAuthActions } from '@convex-dev/auth/react';
|
||||||
|
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { Field } from '~/components/ui/field';
|
||||||
|
|
||||||
|
WebBrowser.maybeCompleteAuthSession();
|
||||||
|
|
||||||
|
type OAuthProvider = 'github' | 'authentik';
|
||||||
|
|
||||||
|
export const SignInScreen = () => {
|
||||||
|
const { signIn } = useAuthActions();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const redirectTo = useMemo(() => Linking.createURL(''), []);
|
||||||
|
|
||||||
|
const signInWithPassword = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await signIn('password', { email, password, flow: 'signIn' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Sign in failed', 'Check your email and password.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const signInWithOAuth = async (provider: OAuthProvider) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await signIn(provider, { redirectTo });
|
||||||
|
if (!result.redirect) return;
|
||||||
|
const authResult = await WebBrowser.openAuthSessionAsync(
|
||||||
|
result.redirect.toString(),
|
||||||
|
redirectTo,
|
||||||
|
);
|
||||||
|
if (authResult.type !== 'success') return;
|
||||||
|
const parsed = Linking.parse(authResult.url);
|
||||||
|
const code = parsed.queryParams?.code;
|
||||||
|
if (typeof code !== 'string') {
|
||||||
|
Alert.alert('Sign in failed', 'The provider did not return a code.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await signIn(provider, { code });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Sign in failed', `Could not complete ${provider} sign in.`);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<View className='gap-2'>
|
||||||
|
<Text className='text-foreground text-4xl font-bold'>Spoon</Text>
|
||||||
|
<Text className='text-muted-foreground text-base leading-6'>
|
||||||
|
Fork freely & keep them close to upstream.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Card className='gap-3'>
|
||||||
|
<Button
|
||||||
|
disabled={submitting}
|
||||||
|
onPress={() => void signInWithOAuth('github')}
|
||||||
|
>
|
||||||
|
Continue with GitHub
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={submitting}
|
||||||
|
variant='outline'
|
||||||
|
onPress={() => void signInWithOAuth('authentik')}
|
||||||
|
>
|
||||||
|
Continue with Authentik
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className='gap-4'>
|
||||||
|
<Text className='text-foreground font-semibold'>
|
||||||
|
Sign in with email
|
||||||
|
</Text>
|
||||||
|
<Field
|
||||||
|
keyboardType='email-address'
|
||||||
|
label='Email'
|
||||||
|
placeholder='you@example.com'
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='Password'
|
||||||
|
placeholder='Password'
|
||||||
|
secureTextEntry
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
/>
|
||||||
|
<Button disabled={submitting} onPress={() => void signInWithPassword()}>
|
||||||
|
Sign in with email
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Text className='text-muted-foreground text-sm leading-5'>
|
||||||
|
Native OAuth callbacks should allow the `spoon://` redirect scheme.
|
||||||
|
</Text>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Text } from 'react-native';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Field } from '~/components/ui/field';
|
||||||
|
import { FormSection } from '~/components/ui/form-section';
|
||||||
|
import { SheetSelect } from '~/components/ui/sheet-select';
|
||||||
|
import { SwitchRow } from '~/components/ui/switch-row';
|
||||||
|
import { Textarea } from '~/components/ui/textarea';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
type ExistingProfile = {
|
||||||
|
_id: Id<'aiProviderProfiles'>;
|
||||||
|
authType: AuthType;
|
||||||
|
baseUrl?: string;
|
||||||
|
defaultModel: string;
|
||||||
|
enabled: boolean;
|
||||||
|
modelOptions?: string[];
|
||||||
|
name: string;
|
||||||
|
provider: Provider;
|
||||||
|
reasoningEffort: ReasoningEffort;
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerDefaults: Record<
|
||||||
|
Provider,
|
||||||
|
{ authType: AuthType; model: string; name: string }
|
||||||
|
> = {
|
||||||
|
anthropic: {
|
||||||
|
authType: 'api_key',
|
||||||
|
model: 'claude-sonnet-4-5',
|
||||||
|
name: 'Anthropic',
|
||||||
|
},
|
||||||
|
cloudflare_ai_gateway: {
|
||||||
|
authType: 'api_key',
|
||||||
|
model: 'gpt-5.1-codex',
|
||||||
|
name: 'Cloudflare AI Gateway',
|
||||||
|
},
|
||||||
|
custom_openai_compatible: {
|
||||||
|
authType: 'api_key',
|
||||||
|
model: 'gpt-5.1-codex',
|
||||||
|
name: 'Custom compatible',
|
||||||
|
},
|
||||||
|
google: { authType: 'api_key', model: 'gemini-2.5-pro', name: 'Google' },
|
||||||
|
litellm: { authType: 'api_key', model: 'gpt-5.1-codex', name: 'LiteLLM' },
|
||||||
|
opencode_openai_login: {
|
||||||
|
authType: 'opencode_auth_json',
|
||||||
|
model: 'gpt-5.1-codex',
|
||||||
|
name: 'OpenCode provider',
|
||||||
|
},
|
||||||
|
openai: { authType: 'api_key', model: 'gpt-5.1-codex', name: 'OpenAI' },
|
||||||
|
openrouter: {
|
||||||
|
authType: 'api_key',
|
||||||
|
model: 'openai/gpt-5.1-codex',
|
||||||
|
name: 'OpenRouter',
|
||||||
|
},
|
||||||
|
requesty: {
|
||||||
|
authType: 'api_key',
|
||||||
|
model: 'openai/gpt-5.1-codex',
|
||||||
|
name: 'Requesty',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseModelOptions = (text: string) =>
|
||||||
|
text
|
||||||
|
.split(/\r?\n|,/)
|
||||||
|
.map((model) => model.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
export const AiProviderProfileForm = ({
|
||||||
|
existing,
|
||||||
|
onSubmit,
|
||||||
|
saving,
|
||||||
|
}: {
|
||||||
|
existing?: ExistingProfile;
|
||||||
|
onSubmit: (values: {
|
||||||
|
authType: AuthType;
|
||||||
|
baseUrl?: string;
|
||||||
|
defaultModel: string;
|
||||||
|
enabled: boolean;
|
||||||
|
modelOptions: string[];
|
||||||
|
name: string;
|
||||||
|
provider: Provider;
|
||||||
|
reasoningEffort: ReasoningEffort;
|
||||||
|
secret?: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
saving: boolean;
|
||||||
|
}) => {
|
||||||
|
const [name, setName] = useState(existing?.name ?? 'OpenCode provider');
|
||||||
|
const [provider, setProvider] = useState<Provider>(
|
||||||
|
existing?.provider ?? 'opencode_openai_login',
|
||||||
|
);
|
||||||
|
const [authType, setAuthType] = useState<AuthType>(
|
||||||
|
existing?.authType ?? 'opencode_auth_json',
|
||||||
|
);
|
||||||
|
const [secret, setSecret] = useState('');
|
||||||
|
const [baseUrl, setBaseUrl] = useState(existing?.baseUrl ?? '');
|
||||||
|
const [modelOptions, setModelOptions] = useState(
|
||||||
|
(existing?.modelOptions?.length
|
||||||
|
? existing.modelOptions
|
||||||
|
: [existing?.defaultModel ?? 'gpt-5.1-codex']
|
||||||
|
).join('\n'),
|
||||||
|
);
|
||||||
|
const models = useMemo(() => parseModelOptions(modelOptions), [modelOptions]);
|
||||||
|
const [defaultModel, setDefaultModel] = useState(
|
||||||
|
existing?.defaultModel ?? 'gpt-5.1-codex',
|
||||||
|
);
|
||||||
|
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
|
||||||
|
existing?.reasoningEffort ?? 'medium',
|
||||||
|
);
|
||||||
|
const [enabled, setEnabled] = useState(existing?.enabled ?? true);
|
||||||
|
|
||||||
|
const changeProvider = (nextProvider: Provider) => {
|
||||||
|
const defaults = providerDefaults[nextProvider];
|
||||||
|
setProvider(nextProvider);
|
||||||
|
setName(defaults.name);
|
||||||
|
setAuthType(defaults.authType);
|
||||||
|
setDefaultModel(defaults.model);
|
||||||
|
setModelOptions(defaults.model);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = () =>
|
||||||
|
void onSubmit({
|
||||||
|
authType,
|
||||||
|
baseUrl: baseUrl || undefined,
|
||||||
|
defaultModel: models.includes(defaultModel)
|
||||||
|
? defaultModel
|
||||||
|
: (models[0] ?? defaultModel),
|
||||||
|
enabled,
|
||||||
|
modelOptions: models,
|
||||||
|
name,
|
||||||
|
provider,
|
||||||
|
reasoningEffort,
|
||||||
|
secret: secret || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSection title={existing ? 'Edit provider' : 'New provider'}>
|
||||||
|
<Field label='Name' value={name} onChangeText={setName} />
|
||||||
|
<SheetSelect
|
||||||
|
label='Provider'
|
||||||
|
options={[
|
||||||
|
{ label: 'Codex ChatGPT login', value: 'opencode_openai_login' },
|
||||||
|
{ label: 'OpenAI', value: 'openai' },
|
||||||
|
{ label: 'Anthropic', value: 'anthropic' },
|
||||||
|
{ label: 'Google', value: 'google' },
|
||||||
|
{ label: 'OpenRouter', value: 'openrouter' },
|
||||||
|
{ label: 'Requesty', value: 'requesty' },
|
||||||
|
{ label: 'LiteLLM', value: 'litellm' },
|
||||||
|
{ label: 'Cloudflare AI Gateway', value: 'cloudflare_ai_gateway' },
|
||||||
|
{
|
||||||
|
label: 'Custom OpenAI compatible',
|
||||||
|
value: 'custom_openai_compatible',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
value={provider}
|
||||||
|
onChange={changeProvider}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
label='Auth type'
|
||||||
|
options={[
|
||||||
|
{ label: 'API key', value: 'api_key' },
|
||||||
|
{ label: 'Codex auth JSON', value: 'opencode_auth_json' },
|
||||||
|
{ label: 'None', value: 'none' },
|
||||||
|
]}
|
||||||
|
value={authType}
|
||||||
|
onChange={setAuthType}
|
||||||
|
/>
|
||||||
|
{authType === 'opencode_auth_json' ? (
|
||||||
|
<Text className='text-muted-foreground text-sm leading-5'>
|
||||||
|
Copy auth.json from your Codex auth folder, for example
|
||||||
|
~/.codex/auth.json, and paste it here. Spoon writes it into isolated
|
||||||
|
agent workspaces for Codex CLI runs.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{authType !== 'none' ? (
|
||||||
|
<Field
|
||||||
|
label={authType === 'api_key' ? 'API key' : 'Auth JSON'}
|
||||||
|
multiline={authType === 'opencode_auth_json'}
|
||||||
|
secureTextEntry={authType === 'api_key'}
|
||||||
|
value={secret}
|
||||||
|
onChangeText={setSecret}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Field label='Base URL' value={baseUrl} onChangeText={setBaseUrl} />
|
||||||
|
<Textarea
|
||||||
|
label='Model options'
|
||||||
|
value={modelOptions}
|
||||||
|
onChangeText={setModelOptions}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
disabled={!models.length}
|
||||||
|
label='Default model'
|
||||||
|
options={
|
||||||
|
models.length
|
||||||
|
? models.map((model) => ({ label: model, value: model }))
|
||||||
|
: [{ label: 'Add model options first', value: '' }]
|
||||||
|
}
|
||||||
|
value={models.includes(defaultModel) ? defaultModel : (models[0] ?? '')}
|
||||||
|
onChange={setDefaultModel}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
label='Reasoning effort'
|
||||||
|
options={[
|
||||||
|
{ label: 'None', value: 'none' },
|
||||||
|
{ label: 'Minimal', value: 'minimal' },
|
||||||
|
{ label: 'Low', value: 'low' },
|
||||||
|
{ label: 'Medium', value: 'medium' },
|
||||||
|
{ label: 'High', value: 'high' },
|
||||||
|
{ label: 'XHigh', value: 'xhigh' },
|
||||||
|
]}
|
||||||
|
value={reasoningEffort}
|
||||||
|
onChange={setReasoningEffort}
|
||||||
|
/>
|
||||||
|
<SwitchRow label='Enabled' value={enabled} onValueChange={setEnabled} />
|
||||||
|
<Button disabled={saving || !models.length} onPress={submit}>
|
||||||
|
{saving ? 'Saving...' : 'Save provider'}
|
||||||
|
</Button>
|
||||||
|
</FormSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Alert, Linking, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { EmptyState } from '~/components/ui/empty-state';
|
||||||
|
|
||||||
|
export const GitHubIntegrationPanel = ({
|
||||||
|
connection,
|
||||||
|
installUrl,
|
||||||
|
loadingRepos,
|
||||||
|
onListRepos,
|
||||||
|
onSync,
|
||||||
|
runtimeStatus,
|
||||||
|
syncing,
|
||||||
|
}: {
|
||||||
|
connection?: {
|
||||||
|
displayName?: string;
|
||||||
|
installationId?: string;
|
||||||
|
status?: string;
|
||||||
|
} | null;
|
||||||
|
installUrl?: string | null;
|
||||||
|
loadingRepos: boolean;
|
||||||
|
onListRepos: () => Promise<string[]>;
|
||||||
|
onSync: () => Promise<void>;
|
||||||
|
runtimeStatus?: { encryptionConfigured?: boolean } | null;
|
||||||
|
syncing: boolean;
|
||||||
|
}) => {
|
||||||
|
const showRepos = async () => {
|
||||||
|
try {
|
||||||
|
const repos = await onListRepos();
|
||||||
|
Alert.alert(
|
||||||
|
'Accessible repositories',
|
||||||
|
repos.slice(0, 20).join('\n') || 'No repositories returned.',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not list repositories.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sync = async () => {
|
||||||
|
try {
|
||||||
|
await onSync();
|
||||||
|
Alert.alert('GitHub synced', 'Installation metadata was refreshed.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not sync GitHub installation.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className='gap-3'>
|
||||||
|
<View className='flex-row items-center justify-between'>
|
||||||
|
<Text className='text-foreground font-semibold'>GitHub App</Text>
|
||||||
|
<Badge
|
||||||
|
label={connection?.status ?? 'not connected'}
|
||||||
|
tone={connection ? 'success' : 'warning'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{connection ? (
|
||||||
|
<>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
{connection.displayName}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground text-xs'>
|
||||||
|
Installation {connection.installationId ?? 'unknown'}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
Connect GitHub so Spoon can create forks, compare branches, and open
|
||||||
|
draft PRs.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{installUrl ? (
|
||||||
|
<Button onPress={() => void Linking.openURL(installUrl)}>
|
||||||
|
Install or manage GitHub App
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
disabled={syncing}
|
||||||
|
variant='outline'
|
||||||
|
onPress={() => void sync()}
|
||||||
|
>
|
||||||
|
{syncing ? 'Syncing...' : 'Sync installation'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={loadingRepos}
|
||||||
|
variant='outline'
|
||||||
|
onPress={() => void showRepos()}
|
||||||
|
>
|
||||||
|
{loadingRepos ? 'Loading...' : 'List repositories'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<Text className='text-foreground font-semibold'>Runtime status</Text>
|
||||||
|
<Text className='text-muted-foreground mt-2 text-sm'>
|
||||||
|
Encryption configured:{' '}
|
||||||
|
{runtimeStatus?.encryptionConfigured ? 'yes' : 'not reported'}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
{!connection ? (
|
||||||
|
<EmptyState
|
||||||
|
description='Install the GitHub App, then sync the installation.'
|
||||||
|
title='GitHub is not connected yet'
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { PillTab } from '~/components/ui/pill-tabs';
|
||||||
|
import { PillTabs } from '~/components/ui/pill-tabs';
|
||||||
|
|
||||||
|
export type SpoonDetailSegment =
|
||||||
|
| 'overview'
|
||||||
|
| 'upstream'
|
||||||
|
| 'fork'
|
||||||
|
| 'prs'
|
||||||
|
| 'threads'
|
||||||
|
| 'settings';
|
||||||
|
|
||||||
|
const tabs: PillTab<SpoonDetailSegment>[] = [
|
||||||
|
{ label: 'Overview', value: 'overview' },
|
||||||
|
{ label: 'Upstream', value: 'upstream' },
|
||||||
|
{ label: 'Fork', value: 'fork' },
|
||||||
|
{ label: 'PRs', value: 'prs' },
|
||||||
|
{ label: 'Threads', value: 'threads' },
|
||||||
|
{ label: 'Settings', value: 'settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SegmentControl = ({
|
||||||
|
onChange,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
onChange: (value: SpoonDetailSegment) => void;
|
||||||
|
value: SpoonDetailSegment;
|
||||||
|
}) => <PillTabs onChange={onChange} tabs={tabs} value={value} />;
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { Alert, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
|
||||||
|
import { Field } from '~/components/ui/field';
|
||||||
|
import { FormSection } from '~/components/ui/form-section';
|
||||||
|
import { SheetSelect } from '~/components/ui/sheet-select';
|
||||||
|
import { SwitchRow } from '~/components/ui/switch-row';
|
||||||
|
|
||||||
|
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||||
|
|
||||||
|
type ProviderProfile = {
|
||||||
|
_id: Id<'aiProviderProfiles'>;
|
||||||
|
defaultModel: string;
|
||||||
|
enabled: boolean;
|
||||||
|
isDefault?: boolean;
|
||||||
|
modelOptions?: string[];
|
||||||
|
name: string;
|
||||||
|
reasoningEffort: ReasoningEffort;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SpoonAgentSettingsForm = ({
|
||||||
|
agent,
|
||||||
|
onUpdate,
|
||||||
|
profiles,
|
||||||
|
}: {
|
||||||
|
agent?: {
|
||||||
|
agentModel: string;
|
||||||
|
aiProviderProfileId?: Id<'aiProviderProfiles'>;
|
||||||
|
autoDetectCommands?: boolean;
|
||||||
|
branchPrefix: string;
|
||||||
|
checkCommand?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
envFilePath?: string;
|
||||||
|
installCommand?: string;
|
||||||
|
materializeEnvFileByDefault?: boolean;
|
||||||
|
reasoningEffort: ReasoningEffort;
|
||||||
|
testCommand?: string;
|
||||||
|
};
|
||||||
|
onUpdate: (patch: {
|
||||||
|
agentModel?: string;
|
||||||
|
aiProviderProfileId?: Id<'aiProviderProfiles'>;
|
||||||
|
autoDetectCommands?: boolean;
|
||||||
|
branchPrefix?: string;
|
||||||
|
checkCommand?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
envFilePath?: string;
|
||||||
|
installCommand?: string;
|
||||||
|
materializeEnvFileByDefault?: boolean;
|
||||||
|
reasoningEffort?: ReasoningEffort;
|
||||||
|
testCommand?: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
profiles: ProviderProfile[];
|
||||||
|
}) => {
|
||||||
|
const enabledProfiles = profiles.filter((profile) => profile.enabled);
|
||||||
|
const selectedProfile =
|
||||||
|
enabledProfiles.find(
|
||||||
|
(profile) => profile._id === agent?.aiProviderProfileId,
|
||||||
|
) ??
|
||||||
|
enabledProfiles.find((profile) => profile.isDefault) ??
|
||||||
|
enabledProfiles[0];
|
||||||
|
const models = Array.from(
|
||||||
|
new Set(
|
||||||
|
selectedProfile
|
||||||
|
? [
|
||||||
|
selectedProfile.defaultModel,
|
||||||
|
...(selectedProfile.modelOptions ?? []),
|
||||||
|
].filter(Boolean)
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const currentModel =
|
||||||
|
models.find((model) => model === agent?.agentModel) ??
|
||||||
|
selectedProfile?.defaultModel ??
|
||||||
|
'';
|
||||||
|
|
||||||
|
const save = (patch: Parameters<typeof onUpdate>[0]) =>
|
||||||
|
void onUpdate(patch).catch((error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not save agent settings.');
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSection
|
||||||
|
description='Mobile can configure the runtime, but code editing still happens from the web workspace.'
|
||||||
|
title='Agent settings'
|
||||||
|
>
|
||||||
|
<SwitchRow
|
||||||
|
label='Enabled'
|
||||||
|
value={agent?.enabled ?? true}
|
||||||
|
onValueChange={(enabled) => save({ enabled })}
|
||||||
|
/>
|
||||||
|
<SwitchRow
|
||||||
|
label='Auto-detect commands'
|
||||||
|
value={agent?.autoDetectCommands ?? true}
|
||||||
|
onValueChange={(autoDetectCommands) => save({ autoDetectCommands })}
|
||||||
|
/>
|
||||||
|
<SwitchRow
|
||||||
|
label='Materialize env file'
|
||||||
|
value={agent?.materializeEnvFileByDefault ?? false}
|
||||||
|
onValueChange={(materializeEnvFileByDefault) =>
|
||||||
|
save({ materializeEnvFileByDefault })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
disabled={!enabledProfiles.length}
|
||||||
|
label='AI provider'
|
||||||
|
options={
|
||||||
|
enabledProfiles.length
|
||||||
|
? enabledProfiles.map((profile) => ({
|
||||||
|
label: profile.isDefault
|
||||||
|
? `${profile.name} (default)`
|
||||||
|
: profile.name,
|
||||||
|
value: profile._id,
|
||||||
|
}))
|
||||||
|
: [{ label: 'Configure an AI provider in Settings', value: '' }]
|
||||||
|
}
|
||||||
|
value={selectedProfile?._id ?? ''}
|
||||||
|
onChange={(aiProviderProfileId) => {
|
||||||
|
const profile = enabledProfiles.find(
|
||||||
|
(item) => item._id === aiProviderProfileId,
|
||||||
|
);
|
||||||
|
if (profile) {
|
||||||
|
save({
|
||||||
|
agentModel: profile.defaultModel,
|
||||||
|
aiProviderProfileId: profile._id,
|
||||||
|
reasoningEffort: profile.reasoningEffort,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
disabled={!models.length}
|
||||||
|
label='Model'
|
||||||
|
options={
|
||||||
|
models.length
|
||||||
|
? models.map((model) => ({ label: model, value: model }))
|
||||||
|
: [{ label: 'No models available', value: '' }]
|
||||||
|
}
|
||||||
|
value={currentModel}
|
||||||
|
onChange={(agentModel) => save({ agentModel })}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
label='Reasoning effort'
|
||||||
|
options={[
|
||||||
|
{ label: 'None', value: 'none' },
|
||||||
|
{ label: 'Minimal', value: 'minimal' },
|
||||||
|
{ label: 'Low', value: 'low' },
|
||||||
|
{ label: 'Medium', value: 'medium' },
|
||||||
|
{ label: 'High', value: 'high' },
|
||||||
|
{ label: 'XHigh', value: 'xhigh' },
|
||||||
|
]}
|
||||||
|
value={
|
||||||
|
agent?.reasoningEffort ?? selectedProfile?.reasoningEffort ?? 'medium'
|
||||||
|
}
|
||||||
|
onChange={(reasoningEffort) => save({ reasoningEffort })}
|
||||||
|
/>
|
||||||
|
{!enabledProfiles.length ? (
|
||||||
|
<Text className='text-muted-foreground text-sm leading-5'>
|
||||||
|
Configure an AI provider in Settings before queueing agent work.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<View className='gap-3'>
|
||||||
|
<Field
|
||||||
|
label='Branch prefix'
|
||||||
|
value={agent?.branchPrefix ?? 'spoon/agent'}
|
||||||
|
onChangeText={(branchPrefix) => save({ branchPrefix })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='Install command'
|
||||||
|
value={agent?.installCommand ?? ''}
|
||||||
|
onChangeText={(installCommand) => save({ installCommand })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='Check command'
|
||||||
|
value={agent?.checkCommand ?? ''}
|
||||||
|
onChangeText={(checkCommand) => save({ checkCommand })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='Test command'
|
||||||
|
value={agent?.testCommand ?? ''}
|
||||||
|
onChangeText={(testCommand) => save({ testCommand })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='Env file path'
|
||||||
|
value={agent?.envFilePath ?? '.env.local'}
|
||||||
|
onChangeText={(envFilePath) => save({ envFilePath })}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</FormSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Linking, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { EmptyState } from '~/components/ui/empty-state';
|
||||||
|
import { formatDateTime, truncate } from '~/utils/format';
|
||||||
|
|
||||||
|
type Commit = {
|
||||||
|
_id: string;
|
||||||
|
authorLogin?: string;
|
||||||
|
authorName?: string;
|
||||||
|
committedAt?: number;
|
||||||
|
htmlUrl?: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SpoonCommitList = ({
|
||||||
|
commits,
|
||||||
|
emptyDescription,
|
||||||
|
emptyTitle,
|
||||||
|
intro,
|
||||||
|
showOpenButton = false,
|
||||||
|
}: {
|
||||||
|
commits: Commit[];
|
||||||
|
emptyDescription: string;
|
||||||
|
emptyTitle: string;
|
||||||
|
intro?: string;
|
||||||
|
showOpenButton?: boolean;
|
||||||
|
}) => (
|
||||||
|
<View className='gap-3'>
|
||||||
|
{intro ? (
|
||||||
|
<Text className='text-muted-foreground text-sm'>{intro}</Text>
|
||||||
|
) : null}
|
||||||
|
{commits.length ? (
|
||||||
|
commits.map((commit) => (
|
||||||
|
<Card key={commit._id}>
|
||||||
|
<Text className='text-foreground font-medium'>
|
||||||
|
{truncate(commit.message, 100)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground mt-2 text-xs'>
|
||||||
|
{commit.authorLogin ?? commit.authorName ?? 'unknown'} ·{' '}
|
||||||
|
{formatDateTime(commit.committedAt)}
|
||||||
|
</Text>
|
||||||
|
{showOpenButton && commit.htmlUrl ? (
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
onPress={() => void Linking.openURL(commit.htmlUrl ?? '')}
|
||||||
|
>
|
||||||
|
Open commit
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState description={emptyDescription} title={emptyTitle} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { SpoonCommitList } from './spoon-commit-list';
|
||||||
|
|
||||||
|
export const SpoonDetailFork = ({
|
||||||
|
commits,
|
||||||
|
}: {
|
||||||
|
commits: Parameters<typeof SpoonCommitList>[0]['commits'];
|
||||||
|
}) => (
|
||||||
|
<SpoonCommitList
|
||||||
|
commits={commits}
|
||||||
|
emptyDescription='Fork-only commits appear after Spoon compares your fork with upstream.'
|
||||||
|
emptyTitle='No fork-only commits cached'
|
||||||
|
intro='Fork-only commits are customizations Spoon should preserve.'
|
||||||
|
/>
|
||||||
|
);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { CopyRow } from '~/components/ui/copy-row';
|
||||||
|
import { MetricCard } from '~/components/ui/metric-card';
|
||||||
|
import { formatDate, titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
type SpoonOverview = {
|
||||||
|
description?: string;
|
||||||
|
forkAheadBy?: number;
|
||||||
|
forkOwner?: string;
|
||||||
|
forkRepo?: string;
|
||||||
|
forkUrl?: string;
|
||||||
|
lastCheckedAt?: number;
|
||||||
|
syncCadence: string;
|
||||||
|
upstreamAheadBy?: number;
|
||||||
|
upstreamOwner: string;
|
||||||
|
upstreamRepo: string;
|
||||||
|
upstreamUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SpoonDetailOverview = ({
|
||||||
|
effectiveUpstreamAheadBy,
|
||||||
|
remotes,
|
||||||
|
spoon,
|
||||||
|
}: {
|
||||||
|
effectiveUpstreamAheadBy: number;
|
||||||
|
remotes: { _id: string; label: string; url: string }[];
|
||||||
|
spoon: SpoonOverview;
|
||||||
|
}) => (
|
||||||
|
<View className='gap-4'>
|
||||||
|
<View className='flex-row gap-3'>
|
||||||
|
<MetricCard label='Raw upstream' value={spoon.upstreamAheadBy ?? 0} />
|
||||||
|
<MetricCard label='Effective' value={effectiveUpstreamAheadBy} />
|
||||||
|
<MetricCard label='Fork-only' value={spoon.forkAheadBy ?? 0} />
|
||||||
|
</View>
|
||||||
|
{spoon.description ? (
|
||||||
|
<Card>
|
||||||
|
<Text className='text-foreground font-semibold'>Description</Text>
|
||||||
|
<Text className='text-muted-foreground mt-2 text-sm leading-5'>
|
||||||
|
{spoon.description}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
<Card>
|
||||||
|
<CopyRow label='Upstream' value={spoon.upstreamUrl} />
|
||||||
|
<CopyRow label='Fork clone URL' value={spoon.forkUrl} />
|
||||||
|
{remotes.map((remote) => (
|
||||||
|
<CopyRow key={remote._id} label={remote.label} value={remote.url} />
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<Text className='text-foreground font-semibold'>Details</Text>
|
||||||
|
<Text className='text-muted-foreground mt-2 text-sm'>
|
||||||
|
Last checked: {formatDate(spoon.lastCheckedAt)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
Cadence: {titleize(spoon.syncCadence)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
Upstream: {spoon.upstreamOwner}/{spoon.upstreamRepo}
|
||||||
|
</Text>
|
||||||
|
{spoon.forkOwner && spoon.forkRepo ? (
|
||||||
|
<Text className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
Fork: {spoon.forkOwner}/{spoon.forkRepo}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Linking, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { EmptyState } from '~/components/ui/empty-state';
|
||||||
|
import { titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
type PullRequest = {
|
||||||
|
_id: string;
|
||||||
|
htmlUrl: string;
|
||||||
|
number: number;
|
||||||
|
repoFullName: string;
|
||||||
|
state: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SpoonDetailPrs = ({
|
||||||
|
pullRequests,
|
||||||
|
}: {
|
||||||
|
pullRequests: PullRequest[];
|
||||||
|
}) => (
|
||||||
|
<View className='gap-3'>
|
||||||
|
{pullRequests.length ? (
|
||||||
|
pullRequests.map((pullRequest) => (
|
||||||
|
<Card key={pullRequest._id}>
|
||||||
|
<Text className='text-foreground font-medium'>
|
||||||
|
#{pullRequest.number} {pullRequest.title}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground mt-2 text-xs'>
|
||||||
|
{titleize(pullRequest.state)} · {pullRequest.repoFullName}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
onPress={() => void Linking.openURL(pullRequest.htmlUrl)}
|
||||||
|
>
|
||||||
|
Open PR
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
description='Cached fork and upstream pull requests appear here.'
|
||||||
|
title='No pull requests cached'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { View } from 'react-native';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
|
||||||
|
import { SpoonAgentSettingsForm } from './spoon-agent-settings-form';
|
||||||
|
import { SpoonMaintenanceSettingsForm } from './spoon-maintenance-settings-form';
|
||||||
|
import { SpoonRemotesPanel } from './spoon-remotes-panel';
|
||||||
|
import { SpoonSecretsPanel } from './spoon-secrets-panel';
|
||||||
|
|
||||||
|
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||||
|
|
||||||
|
export const SpoonDetailSettings = ({
|
||||||
|
actions,
|
||||||
|
agentSettings,
|
||||||
|
maintenanceSettings,
|
||||||
|
pending,
|
||||||
|
providerProfiles,
|
||||||
|
remotes,
|
||||||
|
secrets,
|
||||||
|
spoon,
|
||||||
|
}: {
|
||||||
|
actions: {
|
||||||
|
addRemote: (label: string, url: string) => Promise<void>;
|
||||||
|
addSecret: (name: string, value: string) => Promise<void>;
|
||||||
|
importSecrets: (
|
||||||
|
secrets: { name: string; value: string }[],
|
||||||
|
) => Promise<void>;
|
||||||
|
removeRemote: (remoteId: string) => Promise<void>;
|
||||||
|
removeSecret: (secretId: string) => Promise<void>;
|
||||||
|
updateAgent: (patch: Record<string, unknown>) => Promise<void>;
|
||||||
|
updateMaintenance: (patch: Record<string, unknown>) => Promise<void>;
|
||||||
|
updateSpoon: (patch: Record<string, unknown>) => Promise<void>;
|
||||||
|
};
|
||||||
|
agentSettings?: {
|
||||||
|
agentModel: string;
|
||||||
|
aiProviderProfileId?: Id<'aiProviderProfiles'>;
|
||||||
|
autoDetectCommands?: boolean;
|
||||||
|
branchPrefix: string;
|
||||||
|
checkCommand?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
envFilePath?: string;
|
||||||
|
installCommand?: string;
|
||||||
|
materializeEnvFileByDefault?: boolean;
|
||||||
|
reasoningEffort: ReasoningEffort;
|
||||||
|
testCommand?: string;
|
||||||
|
};
|
||||||
|
maintenanceSettings?: {
|
||||||
|
autoRefreshEnabled: boolean;
|
||||||
|
autoReviewEnabled: boolean;
|
||||||
|
autoSyncEnabled: boolean;
|
||||||
|
};
|
||||||
|
pending: {
|
||||||
|
addingRemote: boolean;
|
||||||
|
addingSecret: boolean;
|
||||||
|
importingSecrets: boolean;
|
||||||
|
removingRemoteId?: string;
|
||||||
|
removingSecretId?: string;
|
||||||
|
savingSettings: boolean;
|
||||||
|
};
|
||||||
|
providerProfiles: {
|
||||||
|
_id: Id<'aiProviderProfiles'>;
|
||||||
|
defaultModel: string;
|
||||||
|
enabled: boolean;
|
||||||
|
isDefault?: boolean;
|
||||||
|
modelOptions?: string[];
|
||||||
|
name: string;
|
||||||
|
reasoningEffort: ReasoningEffort;
|
||||||
|
}[];
|
||||||
|
remotes: { _id: string; label: string; url: string }[];
|
||||||
|
secrets: { _id: string; name: string; valuePreview?: string }[];
|
||||||
|
spoon: {
|
||||||
|
maintenanceMode: 'watch' | 'auto_pr' | 'paused';
|
||||||
|
syncCadence: 'daily' | 'weekly' | 'manual';
|
||||||
|
};
|
||||||
|
}) => (
|
||||||
|
<View className='gap-4'>
|
||||||
|
<SpoonMaintenanceSettingsForm
|
||||||
|
maintenance={maintenanceSettings}
|
||||||
|
saving={pending.savingSettings}
|
||||||
|
spoon={spoon}
|
||||||
|
onUpdateMaintenance={actions.updateMaintenance}
|
||||||
|
onUpdateSpoon={actions.updateSpoon}
|
||||||
|
/>
|
||||||
|
<SpoonAgentSettingsForm
|
||||||
|
agent={agentSettings}
|
||||||
|
profiles={providerProfiles}
|
||||||
|
onUpdate={actions.updateAgent}
|
||||||
|
/>
|
||||||
|
<SpoonSecretsPanel
|
||||||
|
adding={pending.addingSecret}
|
||||||
|
importing={pending.importingSecrets}
|
||||||
|
removingId={pending.removingSecretId}
|
||||||
|
secrets={secrets}
|
||||||
|
onAddSecret={actions.addSecret}
|
||||||
|
onImportSecrets={actions.importSecrets}
|
||||||
|
onRemoveSecret={actions.removeSecret}
|
||||||
|
/>
|
||||||
|
<SpoonRemotesPanel
|
||||||
|
adding={pending.addingRemote}
|
||||||
|
remotes={remotes}
|
||||||
|
removingId={pending.removingRemoteId}
|
||||||
|
onAddRemote={actions.addRemote}
|
||||||
|
onRemoveRemote={actions.removeRemote}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { ThreadListRow } from '~/components/threads/thread-list-row';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { EmptyState } from '~/components/ui/empty-state';
|
||||||
|
import { Textarea } from '~/components/ui/textarea';
|
||||||
|
|
||||||
|
type Thread = Parameters<typeof ThreadListRow>[0]['thread'];
|
||||||
|
|
||||||
|
export const SpoonDetailThreads = ({
|
||||||
|
creating,
|
||||||
|
onCreate,
|
||||||
|
onOpenThread,
|
||||||
|
prompt,
|
||||||
|
setPrompt,
|
||||||
|
threads,
|
||||||
|
}: {
|
||||||
|
creating: boolean;
|
||||||
|
onCreate: () => void;
|
||||||
|
onOpenThread: (threadId: string) => void;
|
||||||
|
prompt: string;
|
||||||
|
setPrompt: (prompt: string) => void;
|
||||||
|
threads: Thread[];
|
||||||
|
}) => (
|
||||||
|
<View className='gap-3'>
|
||||||
|
<Card className='gap-3'>
|
||||||
|
<Text className='text-foreground font-semibold'>New thread</Text>
|
||||||
|
<Textarea
|
||||||
|
label='Prompt'
|
||||||
|
placeholder='Ask Spoon to review or change this fork...'
|
||||||
|
value={prompt}
|
||||||
|
onChangeText={setPrompt}
|
||||||
|
/>
|
||||||
|
<Button disabled={creating || !prompt.trim()} onPress={onCreate}>
|
||||||
|
{creating ? 'Creating...' : 'Create thread'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
{threads.length ? (
|
||||||
|
threads.map((thread) => (
|
||||||
|
<ThreadListRow
|
||||||
|
key={thread._id}
|
||||||
|
thread={thread}
|
||||||
|
onPress={() => onOpenThread(thread._id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
description='Create a thread when this fork needs review or code.'
|
||||||
|
title='No threads yet'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { SpoonCommitList } from './spoon-commit-list';
|
||||||
|
|
||||||
|
export const SpoonDetailUpstream = ({
|
||||||
|
commits,
|
||||||
|
}: {
|
||||||
|
commits: Parameters<typeof SpoonCommitList>[0]['commits'];
|
||||||
|
}) => (
|
||||||
|
<SpoonCommitList
|
||||||
|
commits={commits}
|
||||||
|
emptyDescription='Upstream commits waiting for this fork will appear after refresh.'
|
||||||
|
emptyTitle='No upstream commits cached'
|
||||||
|
showOpenButton
|
||||||
|
/>
|
||||||
|
);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
|
||||||
|
import { ListRow } from '~/components/ui/list-row';
|
||||||
|
import { formatDate } from '~/utils/format';
|
||||||
|
import { SpoonStatusBadge } from './spoon-status-badge';
|
||||||
|
|
||||||
|
export const SpoonListRow = ({
|
||||||
|
spoon,
|
||||||
|
openThreads,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
spoon: Doc<'spoons'>;
|
||||||
|
openThreads?: number;
|
||||||
|
onPress: () => void;
|
||||||
|
}) => (
|
||||||
|
<ListRow
|
||||||
|
meta={formatDate(spoon.lastCheckedAt)}
|
||||||
|
subtitle={`${spoon.upstreamOwner}/${spoon.upstreamRepo}`}
|
||||||
|
title={spoon.name}
|
||||||
|
onPress={onPress}
|
||||||
|
>
|
||||||
|
<View className='gap-3'>
|
||||||
|
<View className='flex-row flex-wrap items-center gap-2'>
|
||||||
|
<SpoonStatusBadge status={spoon.syncStatus ?? spoon.status} />
|
||||||
|
{spoon.forkOwner && spoon.forkRepo ? (
|
||||||
|
<Text className='text-muted-foreground text-xs'>
|
||||||
|
fork {spoon.forkOwner}/{spoon.forkRepo}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text className='text-muted-foreground text-xs'>missing fork</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className='flex-row gap-4'>
|
||||||
|
<Text className='text-muted-foreground text-xs'>
|
||||||
|
{spoon.upstreamAheadBy ?? 0} upstream
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground text-xs'>
|
||||||
|
{spoon.forkAheadBy ?? 0} fork-only
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground text-xs'>
|
||||||
|
{openThreads ?? 0} threads
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ListRow>
|
||||||
|
);
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { Alert, Text } from 'react-native';
|
||||||
|
|
||||||
|
import { FormSection } from '~/components/ui/form-section';
|
||||||
|
import { SheetSelect } from '~/components/ui/sheet-select';
|
||||||
|
import { SwitchRow } from '~/components/ui/switch-row';
|
||||||
|
|
||||||
|
type Cadence = 'daily' | 'weekly' | 'manual';
|
||||||
|
type MaintenanceMode = 'watch' | 'auto_pr' | 'paused';
|
||||||
|
|
||||||
|
export const SpoonMaintenanceSettingsForm = ({
|
||||||
|
maintenance,
|
||||||
|
onUpdateMaintenance,
|
||||||
|
onUpdateSpoon,
|
||||||
|
saving,
|
||||||
|
spoon,
|
||||||
|
}: {
|
||||||
|
maintenance?: {
|
||||||
|
autoRefreshEnabled: boolean;
|
||||||
|
autoReviewEnabled: boolean;
|
||||||
|
autoSyncEnabled: boolean;
|
||||||
|
};
|
||||||
|
onUpdateMaintenance: (patch: {
|
||||||
|
autoRefreshEnabled?: boolean;
|
||||||
|
autoReviewEnabled?: boolean;
|
||||||
|
autoSyncEnabled?: boolean;
|
||||||
|
}) => Promise<void>;
|
||||||
|
onUpdateSpoon: (patch: {
|
||||||
|
maintenanceMode?: MaintenanceMode;
|
||||||
|
syncCadence?: Cadence;
|
||||||
|
}) => Promise<void>;
|
||||||
|
saving: boolean;
|
||||||
|
spoon: { maintenanceMode: MaintenanceMode; syncCadence: Cadence };
|
||||||
|
}) => {
|
||||||
|
const updateMaintenance = (
|
||||||
|
patch: Parameters<typeof onUpdateMaintenance>[0],
|
||||||
|
) =>
|
||||||
|
void onUpdateMaintenance(patch).catch((error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not save maintenance settings.');
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSpoon = (patch: Parameters<typeof onUpdateSpoon>[0]) =>
|
||||||
|
void onUpdateSpoon(patch).catch((error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not save Spoon settings.');
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormSection
|
||||||
|
description='These settings control scheduled checks and safe automation.'
|
||||||
|
title='Maintenance settings'
|
||||||
|
>
|
||||||
|
<SwitchRow
|
||||||
|
description='Let scheduled checks consider this Spoon.'
|
||||||
|
label='Auto refresh'
|
||||||
|
value={maintenance?.autoRefreshEnabled ?? true}
|
||||||
|
onValueChange={(autoRefreshEnabled) =>
|
||||||
|
updateMaintenance({ autoRefreshEnabled })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SwitchRow
|
||||||
|
label='Auto review'
|
||||||
|
value={maintenance?.autoReviewEnabled ?? true}
|
||||||
|
onValueChange={(autoReviewEnabled) =>
|
||||||
|
updateMaintenance({ autoReviewEnabled })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SwitchRow
|
||||||
|
label='Auto sync'
|
||||||
|
value={maintenance?.autoSyncEnabled ?? false}
|
||||||
|
onValueChange={(autoSyncEnabled) =>
|
||||||
|
updateMaintenance({ autoSyncEnabled })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{saving ? (
|
||||||
|
<Text className='text-muted-foreground text-xs'>Saving...</Text>
|
||||||
|
) : null}
|
||||||
|
</FormSection>
|
||||||
|
<FormSection title='Spoon settings'>
|
||||||
|
<SheetSelect
|
||||||
|
label='Cadence'
|
||||||
|
options={[
|
||||||
|
{ label: 'Daily', value: 'daily' },
|
||||||
|
{ label: 'Weekly', value: 'weekly' },
|
||||||
|
{ label: 'Manual', value: 'manual' },
|
||||||
|
]}
|
||||||
|
value={spoon.syncCadence}
|
||||||
|
onChange={(syncCadence) => updateSpoon({ syncCadence })}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
label='Maintenance mode'
|
||||||
|
options={[
|
||||||
|
{ label: 'Watch', value: 'watch' },
|
||||||
|
{ label: 'Auto PR', value: 'auto_pr' },
|
||||||
|
{ label: 'Paused', value: 'paused' },
|
||||||
|
]}
|
||||||
|
value={spoon.maintenanceMode}
|
||||||
|
onChange={(maintenanceMode) => updateSpoon({ maintenanceMode })}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { ConfirmButton } from '~/components/ui/confirm-button';
|
||||||
|
import { Field } from '~/components/ui/field';
|
||||||
|
import { FormSection } from '~/components/ui/form-section';
|
||||||
|
|
||||||
|
export const SpoonRemotesPanel = ({
|
||||||
|
adding,
|
||||||
|
onAddRemote,
|
||||||
|
onRemoveRemote,
|
||||||
|
remotes,
|
||||||
|
removingId,
|
||||||
|
}: {
|
||||||
|
adding: boolean;
|
||||||
|
onAddRemote: (label: string, url: string) => Promise<void>;
|
||||||
|
onRemoveRemote: (remoteId: string) => Promise<void>;
|
||||||
|
remotes: { _id: string; label: string; url: string }[];
|
||||||
|
removingId?: string;
|
||||||
|
}) => {
|
||||||
|
const [label, setLabel] = useState('');
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
|
||||||
|
const add = async () => {
|
||||||
|
if (!label.trim() || !url.trim()) return;
|
||||||
|
try {
|
||||||
|
await onAddRemote(label.trim(), url.trim());
|
||||||
|
setLabel('');
|
||||||
|
setUrl('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not add remote.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSection title='Additional remotes'>
|
||||||
|
{remotes.map((remote) => (
|
||||||
|
<View
|
||||||
|
key={remote._id}
|
||||||
|
className='border-border flex-row items-center justify-between gap-3 border-b py-2'
|
||||||
|
>
|
||||||
|
<View className='min-w-0 flex-1'>
|
||||||
|
<Text className='text-foreground font-medium'>{remote.label}</Text>
|
||||||
|
<Text className='text-muted-foreground text-xs'>{remote.url}</Text>
|
||||||
|
</View>
|
||||||
|
<ConfirmButton
|
||||||
|
confirmLabel='Remove'
|
||||||
|
destructive
|
||||||
|
disabled={removingId === remote._id}
|
||||||
|
message={`Remove ${remote.label} from this Spoon?`}
|
||||||
|
title='Remove remote'
|
||||||
|
onConfirm={() => void onRemoveRemote(remote._id)}
|
||||||
|
>
|
||||||
|
{removingId === remote._id ? 'Removing...' : 'Remove'}
|
||||||
|
</ConfirmButton>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<Field label='Label' value={label} onChangeText={setLabel} />
|
||||||
|
<Field keyboardType='url' label='URL' value={url} onChangeText={setUrl} />
|
||||||
|
<Button disabled={adding || !label.trim() || !url.trim()} onPress={add}>
|
||||||
|
{adding ? 'Adding...' : 'Add remote'}
|
||||||
|
</Button>
|
||||||
|
</FormSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Alert, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { ConfirmButton } from '~/components/ui/confirm-button';
|
||||||
|
import { Field } from '~/components/ui/field';
|
||||||
|
import { FormSection } from '~/components/ui/form-section';
|
||||||
|
import { Textarea } from '~/components/ui/textarea';
|
||||||
|
import { parseEnvText } from '~/utils/env';
|
||||||
|
|
||||||
|
export const SpoonSecretsPanel = ({
|
||||||
|
adding,
|
||||||
|
importing,
|
||||||
|
onAddSecret,
|
||||||
|
onImportSecrets,
|
||||||
|
onRemoveSecret,
|
||||||
|
removingId,
|
||||||
|
secrets,
|
||||||
|
}: {
|
||||||
|
adding: boolean;
|
||||||
|
importing: boolean;
|
||||||
|
onAddSecret: (name: string, value: string) => Promise<void>;
|
||||||
|
onImportSecrets: (
|
||||||
|
secrets: { name: string; value: string }[],
|
||||||
|
) => Promise<void>;
|
||||||
|
onRemoveSecret: (secretId: string) => Promise<void>;
|
||||||
|
removingId?: string;
|
||||||
|
secrets: { _id: string; name: string; valuePreview?: string }[];
|
||||||
|
}) => {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const [envText, setEnvText] = useState('');
|
||||||
|
const parsed = useMemo(() => parseEnvText(envText), [envText]);
|
||||||
|
const preview = parsed.slice(0, 25);
|
||||||
|
|
||||||
|
const add = async () => {
|
||||||
|
if (!name.trim() || !value.trim()) return;
|
||||||
|
try {
|
||||||
|
await onAddSecret(name.trim(), value);
|
||||||
|
setName('');
|
||||||
|
setValue('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not save secret.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const importAll = async () => {
|
||||||
|
if (!parsed.length) return;
|
||||||
|
try {
|
||||||
|
await onImportSecrets(parsed);
|
||||||
|
setEnvText('');
|
||||||
|
Alert.alert('Secrets imported', `${parsed.length} secrets were saved.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert(
|
||||||
|
'Could not import every secret',
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Some secrets may have been saved. Review the list and try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSection
|
||||||
|
description='Secret values are encrypted and never shown after saving.'
|
||||||
|
title='Secrets'
|
||||||
|
>
|
||||||
|
{secrets.map((secret) => (
|
||||||
|
<View
|
||||||
|
key={secret._id}
|
||||||
|
className='border-border flex-row items-center justify-between gap-3 border-b py-2'
|
||||||
|
>
|
||||||
|
<View className='min-w-0 flex-1'>
|
||||||
|
<Text className='text-foreground font-medium'>{secret.name}</Text>
|
||||||
|
<Text className='text-muted-foreground text-xs'>
|
||||||
|
{secret.valuePreview ?? 'configured'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<ConfirmButton
|
||||||
|
confirmLabel='Remove'
|
||||||
|
destructive
|
||||||
|
disabled={removingId === secret._id}
|
||||||
|
message={`Remove ${secret.name} from this Spoon?`}
|
||||||
|
title='Remove secret'
|
||||||
|
onConfirm={() => void onRemoveSecret(secret._id)}
|
||||||
|
>
|
||||||
|
{removingId === secret._id ? 'Removing...' : 'Remove'}
|
||||||
|
</ConfirmButton>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<View className='gap-3'>
|
||||||
|
<Text className='text-foreground font-semibold'>Add one secret</Text>
|
||||||
|
<Field label='Name' value={name} onChangeText={setName} />
|
||||||
|
<Field
|
||||||
|
label='Value'
|
||||||
|
secureTextEntry
|
||||||
|
value={value}
|
||||||
|
onChangeText={setValue}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
disabled={adding || !name.trim() || !value.trim()}
|
||||||
|
onPress={add}
|
||||||
|
>
|
||||||
|
{adding ? 'Adding...' : 'Add secret'}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
<View className='gap-3'>
|
||||||
|
<Text className='text-foreground font-semibold'>Import .env</Text>
|
||||||
|
<Textarea
|
||||||
|
label='.env contents'
|
||||||
|
placeholder='AUTH_SECRET=...'
|
||||||
|
value={envText}
|
||||||
|
onChangeText={setEnvText}
|
||||||
|
/>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
{parsed.length
|
||||||
|
? `${parsed.length} valid secrets found: ${preview
|
||||||
|
.map((secret) => secret.name)
|
||||||
|
.join(', ')}${parsed.length > preview.length ? ', ...' : ''}`
|
||||||
|
: 'Paste .env contents to preview secret names.'}
|
||||||
|
</Text>
|
||||||
|
<View className='flex-row gap-3'>
|
||||||
|
<Button
|
||||||
|
disabled={importing || !parsed.length}
|
||||||
|
onPress={() => void importAll()}
|
||||||
|
>
|
||||||
|
{importing ? 'Importing...' : 'Import secrets'}
|
||||||
|
</Button>
|
||||||
|
<Button variant='outline' onPress={() => setEnvText('')}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</FormSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import { titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
const toneForStatus = (status?: string) => {
|
||||||
|
if (status === 'up_to_date' || status === 'active') return 'success';
|
||||||
|
if (status === 'behind' || status === 'diverged' || status === 'conflict') {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
if (status === 'error' || status === 'archived') return 'danger';
|
||||||
|
if (status === 'checking') return 'primary';
|
||||||
|
return 'neutral';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SpoonStatusBadge = ({ status }: { status?: string }) => (
|
||||||
|
<Badge label={titleize(status)} tone={toneForStatus(status)} />
|
||||||
|
);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
|
||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import { ListRow } from '~/components/ui/list-row';
|
||||||
|
import { formatDateTime, titleize, truncate } from '~/utils/format';
|
||||||
|
import { ThreadStatusBadge } from './thread-status-badge';
|
||||||
|
|
||||||
|
export const ThreadListRow = ({
|
||||||
|
thread,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
thread: Doc<'threads'>;
|
||||||
|
onPress: () => void;
|
||||||
|
}) => (
|
||||||
|
<ListRow
|
||||||
|
meta={formatDateTime(thread.updatedAt)}
|
||||||
|
subtitle={thread.summary ? truncate(thread.summary, 90) : undefined}
|
||||||
|
title={thread.title}
|
||||||
|
onPress={onPress}
|
||||||
|
>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
<ThreadStatusBadge status={thread.status} />
|
||||||
|
<Badge label={titleize(thread.source)} />
|
||||||
|
{thread.maintenanceOutcome ? (
|
||||||
|
<Badge label={titleize(thread.maintenanceOutcome)} tone='primary' />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{thread.upstreamTo ? (
|
||||||
|
<Text className='text-muted-foreground mt-3 text-xs'>
|
||||||
|
upstream {thread.upstreamTo.slice(0, 12)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</ListRow>
|
||||||
|
);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
|
||||||
|
import { titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
export const ThreadMessageList = ({
|
||||||
|
messages,
|
||||||
|
}: {
|
||||||
|
messages: Doc<'threadMessages'>[];
|
||||||
|
}) => (
|
||||||
|
<View className='gap-3'>
|
||||||
|
{messages.map((message) => (
|
||||||
|
<View
|
||||||
|
key={message._id}
|
||||||
|
className={
|
||||||
|
message.role === 'user'
|
||||||
|
? 'border-primary/30 bg-primary/10 rounded-lg border p-3'
|
||||||
|
: 'border-border bg-card rounded-lg border p-3'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text className='text-muted-foreground text-xs'>
|
||||||
|
{titleize(message.role)} · {titleize(message.status)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-foreground mt-2 leading-5'>
|
||||||
|
{message.content}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import { titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
const toneForStatus = (status?: string) => {
|
||||||
|
if (status === 'resolved' || status === 'ignored') return 'success';
|
||||||
|
if (status === 'failed' || status === 'cancelled') return 'danger';
|
||||||
|
if (status === 'waiting_for_user' || status === 'changes_ready') {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
if (status === 'running' || status === 'queued') return 'primary';
|
||||||
|
return 'neutral';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ThreadStatusBadge = ({ status }: { status?: string }) => (
|
||||||
|
<Badge label={titleize(status)} tone={toneForStatus(status)} />
|
||||||
|
);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { PressableProps } from 'react-native';
|
||||||
|
import { Pressable, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
export const ActionRow = ({
|
||||||
|
detail,
|
||||||
|
label,
|
||||||
|
...props
|
||||||
|
}: PressableProps & {
|
||||||
|
detail?: string;
|
||||||
|
label: string;
|
||||||
|
}) => (
|
||||||
|
<Pressable
|
||||||
|
className='border-border min-h-14 flex-row items-center justify-between gap-3 border-b py-3'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<View className='min-w-0 flex-1'>
|
||||||
|
<Text className='text-foreground font-medium'>{label}</Text>
|
||||||
|
{detail ? (
|
||||||
|
<Text className='text-muted-foreground mt-1 text-xs'>{detail}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Text className='text-muted-foreground text-lg'>›</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { RefreshControl, ScrollView, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
export const AppScreen = ({
|
||||||
|
children,
|
||||||
|
onRefresh,
|
||||||
|
refreshing = false,
|
||||||
|
scroll = true,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
refreshing?: boolean;
|
||||||
|
scroll?: boolean;
|
||||||
|
}) => {
|
||||||
|
if (!scroll) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView className='bg-background flex-1'>
|
||||||
|
<View className='flex-1 p-4'>{children}</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className='bg-background flex-1'>
|
||||||
|
<ScrollView
|
||||||
|
className='flex-1'
|
||||||
|
contentContainerClassName='gap-4 p-4 pb-10'
|
||||||
|
keyboardShouldPersistTaps='handled'
|
||||||
|
refreshControl={
|
||||||
|
onRefresh ? (
|
||||||
|
<RefreshControl onRefresh={onRefresh} refreshing={refreshing} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Text } from 'react-native';
|
||||||
|
|
||||||
|
export const Badge = ({
|
||||||
|
label,
|
||||||
|
tone = 'neutral',
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
tone?: 'neutral' | 'primary' | 'success' | 'warning' | 'danger';
|
||||||
|
}) => {
|
||||||
|
const toneClass =
|
||||||
|
tone === 'primary'
|
||||||
|
? 'bg-primary/10 text-primary'
|
||||||
|
: tone === 'success'
|
||||||
|
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
|
||||||
|
: tone === 'warning'
|
||||||
|
? 'bg-amber-500/10 text-amber-700 dark:text-amber-300'
|
||||||
|
: tone === 'danger'
|
||||||
|
? 'bg-red-500/10 text-red-700 dark:text-red-300'
|
||||||
|
: 'bg-muted text-muted-foreground';
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
className={`self-start rounded-md px-2 py-1 text-xs font-semibold capitalize ${toneClass}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ComponentProps, ReactNode } from 'react';
|
||||||
|
import { Pressable, Text } from 'react-native';
|
||||||
|
|
||||||
|
export const Button = ({
|
||||||
|
children,
|
||||||
|
onPress,
|
||||||
|
variant = 'primary',
|
||||||
|
disabled = false,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
onPress?: () => void;
|
||||||
|
variant?: 'primary' | 'outline' | 'danger' | 'ghost';
|
||||||
|
disabled?: boolean;
|
||||||
|
} & Omit<ComponentProps<typeof Pressable>, 'children'>) => {
|
||||||
|
const variantClass =
|
||||||
|
variant === 'outline'
|
||||||
|
? 'border-border border bg-transparent'
|
||||||
|
: variant === 'danger'
|
||||||
|
? 'bg-red-600'
|
||||||
|
: variant === 'ghost'
|
||||||
|
? 'bg-transparent'
|
||||||
|
: 'bg-primary';
|
||||||
|
const textClass =
|
||||||
|
variant === 'outline' || variant === 'ghost'
|
||||||
|
? 'text-foreground'
|
||||||
|
: 'text-primary-foreground';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
className={`items-center rounded-md px-4 py-3 disabled:opacity-50 ${variantClass}`}
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={onPress}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Text className={`font-semibold ${textClass}`}>{children}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
|
||||||
|
export const Card = ({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<View className={`border-border bg-card rounded-lg border p-4 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Pressable, ScrollView, Text } from 'react-native';
|
||||||
|
|
||||||
|
export const ChipRow = <T extends string>({
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
options: { label: string; value: T }[];
|
||||||
|
value: T;
|
||||||
|
}) => (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
contentContainerClassName='gap-2'
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{options.map((option) => {
|
||||||
|
const active = option.value === value;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={option.value}
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? 'bg-primary rounded-md px-3 py-2'
|
||||||
|
: 'bg-muted rounded-md px-3 py-2'
|
||||||
|
}
|
||||||
|
onPress={() => onChange(option.value)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? 'text-primary-foreground text-xs font-semibold'
|
||||||
|
: 'text-muted-foreground text-xs font-semibold'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Alert } from 'react-native';
|
||||||
|
|
||||||
|
import { Button } from './button';
|
||||||
|
|
||||||
|
export const ConfirmButton = ({
|
||||||
|
children,
|
||||||
|
confirmLabel,
|
||||||
|
destructive = false,
|
||||||
|
disabled = false,
|
||||||
|
message,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
children: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
destructive?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
message: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
title: string;
|
||||||
|
}) => (
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
variant={destructive ? 'danger' : 'outline'}
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert(title, message, [
|
||||||
|
{ style: 'cancel', text: 'Cancel' },
|
||||||
|
{
|
||||||
|
onPress: onConfirm,
|
||||||
|
style: destructive ? 'destructive' : 'default',
|
||||||
|
text: confirmLabel,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Alert, Pressable, Text, View } from 'react-native';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
|
||||||
|
export const CopyRow = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
}) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const copy = async () => {
|
||||||
|
await Clipboard.setStringAsync(value);
|
||||||
|
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
Alert.alert('Copied', `${label} copied to clipboard.`);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Pressable className='border-border border-b py-3' onPress={copy}>
|
||||||
|
<Text className='text-muted-foreground text-xs'>{label}</Text>
|
||||||
|
<View className='mt-1 flex-row items-center justify-between gap-3'>
|
||||||
|
<Text className='text-foreground min-w-0 flex-1 text-sm'>{value}</Text>
|
||||||
|
<Text className='text-primary text-sm font-semibold'>Copy</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Text } from 'react-native';
|
||||||
|
|
||||||
|
import { Card } from './card';
|
||||||
|
|
||||||
|
export const EmptyState = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}) => (
|
||||||
|
<Card>
|
||||||
|
<Text className='text-foreground font-semibold'>{title}</Text>
|
||||||
|
<Text className='text-muted-foreground mt-2 text-sm leading-5'>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Text } from 'react-native';
|
||||||
|
|
||||||
|
import { Card } from './card';
|
||||||
|
|
||||||
|
export const ErrorState = ({ message }: { message: string }) => (
|
||||||
|
<Card>
|
||||||
|
<Text className='font-semibold text-red-600'>Something went wrong</Text>
|
||||||
|
<Text className='text-muted-foreground mt-2 text-sm'>{message}</Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Text, TextInput, View } from 'react-native';
|
||||||
|
|
||||||
|
export const Field = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
placeholder,
|
||||||
|
multiline = false,
|
||||||
|
secureTextEntry = false,
|
||||||
|
keyboardType,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChangeText: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
multiline?: boolean;
|
||||||
|
secureTextEntry?: boolean;
|
||||||
|
keyboardType?: 'default' | 'email-address' | 'url';
|
||||||
|
}) => (
|
||||||
|
<View className='gap-2'>
|
||||||
|
<Text className='text-foreground text-sm font-medium'>{label}</Text>
|
||||||
|
<TextInput
|
||||||
|
className='border-input text-foreground rounded-md border px-3 py-3'
|
||||||
|
keyboardType={keyboardType}
|
||||||
|
multiline={multiline}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor='#64748b'
|
||||||
|
secureTextEntry={secureTextEntry}
|
||||||
|
textAlignVertical={multiline ? 'top' : 'center'}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Text } from 'react-native';
|
||||||
|
|
||||||
|
import { Card } from './card';
|
||||||
|
|
||||||
|
export const FormSection = ({
|
||||||
|
children,
|
||||||
|
description,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
description?: string;
|
||||||
|
title: string;
|
||||||
|
}) => (
|
||||||
|
<Card className='gap-4'>
|
||||||
|
<Text className='text-foreground font-semibold'>{title}</Text>
|
||||||
|
{description ? (
|
||||||
|
<Text className='text-muted-foreground -mt-2 text-sm leading-5'>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { ComponentProps, ReactNode } from 'react';
|
||||||
|
import { Pressable, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
export const ListRow = ({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
meta,
|
||||||
|
children,
|
||||||
|
onPress,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
meta?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
onPress?: () => void;
|
||||||
|
} & Omit<ComponentProps<typeof Pressable>, 'children'>) => (
|
||||||
|
<Pressable
|
||||||
|
className='border-border bg-card rounded-lg border p-4'
|
||||||
|
onPress={onPress}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<View className='flex-row items-start justify-between gap-3'>
|
||||||
|
<View className='min-w-0 flex-1'>
|
||||||
|
<Text className='text-foreground font-semibold'>{title}</Text>
|
||||||
|
{subtitle ? (
|
||||||
|
<Text className='text-muted-foreground mt-1 text-sm'>{subtitle}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{meta ? (
|
||||||
|
<Text className='text-muted-foreground text-xs'>{meta}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{children ? <View className='mt-3'>{children}</View> : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
export const LoadingState = ({ label = 'Loading...' }: { label?: string }) => (
|
||||||
|
<View className='flex-1 items-center justify-center p-6'>
|
||||||
|
<Text className='text-muted-foreground'>{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Text } from 'react-native';
|
||||||
|
|
||||||
|
import { Card } from './card';
|
||||||
|
|
||||||
|
export const MetricCard = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
note,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
note?: string;
|
||||||
|
}) => (
|
||||||
|
<Card className='flex-1'>
|
||||||
|
<Text className='text-muted-foreground text-xs'>{label}</Text>
|
||||||
|
<Text className='text-foreground mt-2 text-2xl font-bold'>{value}</Text>
|
||||||
|
{note ? (
|
||||||
|
<Text className='text-muted-foreground mt-1 text-xs'>{note}</Text>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Pressable, ScrollView, Text } from 'react-native';
|
||||||
|
|
||||||
|
export type PillTab<T extends string> = {
|
||||||
|
badge?: number | string;
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PillTabs = <T extends string>({
|
||||||
|
onChange,
|
||||||
|
tabs,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
tabs: PillTab<T>[];
|
||||||
|
value: T;
|
||||||
|
}) => (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
className='-mx-1'
|
||||||
|
contentContainerClassName='gap-2 px-1'
|
||||||
|
keyboardShouldPersistTaps='handled'
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const active = tab.value === value;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={tab.value}
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? 'bg-primary min-h-9 flex-row items-center gap-2 rounded-md px-3'
|
||||||
|
: 'bg-muted min-h-9 flex-row items-center gap-2 rounded-md px-3'
|
||||||
|
}
|
||||||
|
onPress={() => onChange(tab.value)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? 'text-primary-foreground text-xs font-semibold'
|
||||||
|
: 'text-muted-foreground text-xs font-semibold'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Text>
|
||||||
|
{tab.badge === undefined ? null : (
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? 'text-primary-foreground text-xs'
|
||||||
|
: 'text-muted-foreground text-xs'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tab.badge}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Pressable, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
export type RadioOption<T extends string> = {
|
||||||
|
description?: string;
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RadioList = <T extends string>({
|
||||||
|
label,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
options: RadioOption<T>[];
|
||||||
|
value: T;
|
||||||
|
}) => (
|
||||||
|
<View className='gap-2'>
|
||||||
|
<Text className='text-muted-foreground text-xs font-medium'>{label}</Text>
|
||||||
|
<View className='gap-2'>
|
||||||
|
{options.map((option) => {
|
||||||
|
const active = option.value === value;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={option.value}
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? 'border-primary bg-primary/10 rounded-md border p-3'
|
||||||
|
: 'border-border rounded-md border p-3'
|
||||||
|
}
|
||||||
|
onPress={() => onChange(option.value)}
|
||||||
|
>
|
||||||
|
<Text className='text-foreground font-medium'>{option.label}</Text>
|
||||||
|
{option.description ? (
|
||||||
|
<Text className='text-muted-foreground mt-1 text-xs'>
|
||||||
|
{option.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Modal, Pressable, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Button } from './button';
|
||||||
|
|
||||||
|
export type SheetSelectOption<T extends string> = {
|
||||||
|
description?: string;
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SheetSelect = <T extends string>({
|
||||||
|
disabled = false,
|
||||||
|
label,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
disabled?: boolean;
|
||||||
|
label: string;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
options: SheetSelectOption<T>[];
|
||||||
|
value: T;
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const selected = options.find((option) => option.value === value);
|
||||||
|
|
||||||
|
const choose = (nextValue: T) => {
|
||||||
|
onChange(nextValue);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='gap-2'>
|
||||||
|
<Text className='text-muted-foreground text-xs font-medium'>{label}</Text>
|
||||||
|
<Pressable
|
||||||
|
className={
|
||||||
|
disabled
|
||||||
|
? 'border-border bg-muted/50 rounded-md border px-3 py-3 opacity-60'
|
||||||
|
: 'border-border bg-background rounded-md border px-3 py-3'
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Text className='text-foreground font-medium'>
|
||||||
|
{selected?.label ?? 'Select'}
|
||||||
|
</Text>
|
||||||
|
{selected?.description ? (
|
||||||
|
<Text className='text-muted-foreground mt-1 text-xs'>
|
||||||
|
{selected.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
<Modal
|
||||||
|
animationType='slide'
|
||||||
|
onRequestClose={() => setOpen(false)}
|
||||||
|
transparent
|
||||||
|
visible={open}
|
||||||
|
>
|
||||||
|
<View className='flex-1 justify-end bg-black/40'>
|
||||||
|
<View className='bg-background border-border max-h-[80%] gap-3 rounded-t-lg border-t p-4'>
|
||||||
|
<View className='flex-row items-center justify-between'>
|
||||||
|
<Text className='text-foreground text-lg font-semibold'>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Button variant='ghost' onPress={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
<View className='gap-2'>
|
||||||
|
{options.map((option) => {
|
||||||
|
const active = option.value === value;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={option.value}
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? 'border-primary bg-primary/10 rounded-md border p-3'
|
||||||
|
: 'border-border rounded-md border p-3'
|
||||||
|
}
|
||||||
|
onPress={() => choose(option.value)}
|
||||||
|
>
|
||||||
|
<Text className='text-foreground font-medium'>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
{option.description ? (
|
||||||
|
<Text className='text-muted-foreground mt-1 text-xs'>
|
||||||
|
{option.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Switch, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
export const SwitchRow = ({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
value: boolean;
|
||||||
|
onValueChange: (value: boolean) => void;
|
||||||
|
}) => (
|
||||||
|
<View className='border-border flex-row items-center justify-between gap-4 border-b py-3'>
|
||||||
|
<View className='min-w-0 flex-1'>
|
||||||
|
<Text className='text-foreground font-medium'>{label}</Text>
|
||||||
|
{description ? (
|
||||||
|
<Text className='text-muted-foreground mt-1 text-xs'>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Switch value={value} onValueChange={onValueChange} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { TextInputProps } from 'react-native';
|
||||||
|
import { Text, TextInput, View } from 'react-native';
|
||||||
|
|
||||||
|
export const Textarea = ({
|
||||||
|
label,
|
||||||
|
...props
|
||||||
|
}: TextInputProps & { label: string }) => (
|
||||||
|
<View className='gap-2'>
|
||||||
|
<Text className='text-muted-foreground text-xs font-medium'>{label}</Text>
|
||||||
|
<TextInput
|
||||||
|
className='border-border bg-background text-foreground min-h-28 rounded-md border px-3 py-3 align-top'
|
||||||
|
multiline
|
||||||
|
placeholderTextColor='#71717a'
|
||||||
|
textAlignVertical='top'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
|
||||||
|
export const DiffPreview = ({
|
||||||
|
content,
|
||||||
|
initialLines = 120,
|
||||||
|
}: {
|
||||||
|
content: string;
|
||||||
|
initialLines?: number;
|
||||||
|
}) => {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const visibleLines = expanded ? lines : lines.slice(0, initialLines);
|
||||||
|
const hiddenCount = Math.max(lines.length - visibleLines.length, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='gap-3 rounded-lg bg-zinc-950 p-3'>
|
||||||
|
<View>
|
||||||
|
{visibleLines.map((line, index) => {
|
||||||
|
const color = line.startsWith('+')
|
||||||
|
? 'text-emerald-300'
|
||||||
|
: line.startsWith('-')
|
||||||
|
? 'text-red-300'
|
||||||
|
: line.startsWith('@@')
|
||||||
|
? 'text-sky-300'
|
||||||
|
: 'text-zinc-100';
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={`${index}-${line.slice(0, 12)}`}
|
||||||
|
className={`font-mono text-xs leading-5 ${color}`}
|
||||||
|
>
|
||||||
|
{line || ' '}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
{hiddenCount > 0 ? (
|
||||||
|
<Button variant='outline' onPress={() => setExpanded(true)}>
|
||||||
|
Show {hiddenCount} more lines
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { DiffPreview } from './diff-preview';
|
||||||
|
|
||||||
|
type Artifact = {
|
||||||
|
_id: string;
|
||||||
|
content: string;
|
||||||
|
contentType: string;
|
||||||
|
kind: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkspaceArtifacts = ({
|
||||||
|
artifacts,
|
||||||
|
mode,
|
||||||
|
}: {
|
||||||
|
artifacts: Artifact[];
|
||||||
|
mode: 'diffs' | 'artifacts';
|
||||||
|
}) => {
|
||||||
|
const diffArtifacts = artifacts.filter(
|
||||||
|
(artifact) =>
|
||||||
|
artifact.contentType === 'text/x-diff' || artifact.kind === 'diff',
|
||||||
|
);
|
||||||
|
const visible =
|
||||||
|
mode === 'diffs'
|
||||||
|
? diffArtifacts
|
||||||
|
: artifacts.filter((artifact) => !diffArtifacts.includes(artifact));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className='gap-3'>
|
||||||
|
<Text className='text-foreground font-semibold'>
|
||||||
|
{mode === 'diffs' ? 'Diffs' : 'Artifacts'}
|
||||||
|
</Text>
|
||||||
|
{visible.length ? (
|
||||||
|
visible.map((artifact) => (
|
||||||
|
<View key={artifact._id} className='gap-2'>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
{artifact.title}
|
||||||
|
</Text>
|
||||||
|
{mode === 'diffs' ? (
|
||||||
|
<DiffPreview content={artifact.content} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text className='bg-muted text-foreground rounded-md p-3 font-mono text-xs leading-5'>
|
||||||
|
{artifact.content.slice(0, 2_000)}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
onPress={() =>
|
||||||
|
void Clipboard.setStringAsync(artifact.content)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Copy artifact
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
{mode === 'diffs'
|
||||||
|
? 'Diff artifacts will appear here when the worker records them.'
|
||||||
|
: 'No non-diff artifacts recorded.'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { ChipRow } from '~/components/ui/chip-row';
|
||||||
|
import { formatDateTime, titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
type Event = {
|
||||||
|
_id: string;
|
||||||
|
createdAt: number;
|
||||||
|
level: string;
|
||||||
|
message: string;
|
||||||
|
phase: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkspaceEvents = ({ events }: { events: Event[] }) => {
|
||||||
|
const [level, setLevel] = useState<'all' | 'info' | 'warn' | 'error'>('all');
|
||||||
|
const filtered =
|
||||||
|
level === 'all' ? events : events.filter((event) => event.level === level);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className='gap-3'>
|
||||||
|
<Text className='text-foreground font-semibold'>Events</Text>
|
||||||
|
<ChipRow
|
||||||
|
options={[
|
||||||
|
{ label: 'All', value: 'all' },
|
||||||
|
{ label: 'Info', value: 'info' },
|
||||||
|
{ label: 'Warn', value: 'warn' },
|
||||||
|
{ label: 'Error', value: 'error' },
|
||||||
|
]}
|
||||||
|
value={level}
|
||||||
|
onChange={setLevel}
|
||||||
|
/>
|
||||||
|
{filtered.length ? (
|
||||||
|
filtered.map((event) => (
|
||||||
|
<View key={event._id} className='border-border border-b pb-2'>
|
||||||
|
<Text className='text-muted-foreground text-xs'>
|
||||||
|
{formatDateTime(event.createdAt)} · {titleize(event.phase)} ·{' '}
|
||||||
|
{titleize(event.level)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-foreground mt-1'>{event.message}</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text className='text-muted-foreground text-sm'>No events.</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
export const WorkspaceMessages = ({
|
||||||
|
messages,
|
||||||
|
}: {
|
||||||
|
messages: { _id: string; content: string; role: string; status: string }[];
|
||||||
|
}) => (
|
||||||
|
<Card className='gap-3'>
|
||||||
|
<Text className='text-foreground font-semibold'>Messages</Text>
|
||||||
|
{messages.length ? (
|
||||||
|
messages.map((message) => (
|
||||||
|
<View key={message._id} className='border-border border-b pb-2'>
|
||||||
|
<Text className='text-muted-foreground text-xs'>
|
||||||
|
{titleize(message.role)} · {titleize(message.status)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-foreground mt-1'>{message.content}</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text className='text-muted-foreground text-sm'>No messages yet.</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Linking, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { ConfirmButton } from '~/components/ui/confirm-button';
|
||||||
|
import { CopyRow } from '~/components/ui/copy-row';
|
||||||
|
import { formatDateTime, titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
export const WorkspaceSummary = ({
|
||||||
|
cancelling,
|
||||||
|
job,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
cancelling: boolean;
|
||||||
|
job: {
|
||||||
|
completedAt?: number;
|
||||||
|
model: string;
|
||||||
|
pullRequestUrl?: string;
|
||||||
|
reasoningEffort: string;
|
||||||
|
startedAt?: number;
|
||||||
|
status: string;
|
||||||
|
workBranch: string;
|
||||||
|
workspaceStatus?: string;
|
||||||
|
};
|
||||||
|
onCancel: () => void;
|
||||||
|
}) => (
|
||||||
|
<Card className='gap-3'>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
<Badge label={titleize(job.status)} tone='primary' />
|
||||||
|
<Badge label={titleize(job.workspaceStatus ?? 'not_started')} />
|
||||||
|
</View>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
Branch: {job.workBranch}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground text-sm'>Model: {job.model}</Text>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
Reasoning: {titleize(job.reasoningEffort)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
Started: {formatDateTime(job.startedAt)}
|
||||||
|
</Text>
|
||||||
|
{job.completedAt ? (
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
Completed: {formatDateTime(job.completedAt)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<CopyRow label='Draft PR' value={job.pullRequestUrl} />
|
||||||
|
{job.pullRequestUrl ? (
|
||||||
|
<Button onPress={() => void Linking.openURL(job.pullRequestUrl ?? '')}>
|
||||||
|
Open draft PR
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<ConfirmButton
|
||||||
|
confirmLabel='Cancel job'
|
||||||
|
destructive
|
||||||
|
disabled={
|
||||||
|
cancelling ||
|
||||||
|
['cancelled', 'draft_pr_opened', 'failed'].includes(job.status)
|
||||||
|
}
|
||||||
|
message='Cancel this workspace job? Running work will be stopped where possible.'
|
||||||
|
title='Cancel job'
|
||||||
|
onConfirm={onCancel}
|
||||||
|
>
|
||||||
|
{cancelling ? 'Cancelling...' : 'Cancel job'}
|
||||||
|
</ConfirmButton>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export type ParsedEnvSecret = {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseEnvText = (text: string): ParsedEnvSecret[] => {
|
||||||
|
const secrets: ParsedEnvSecret[] = [];
|
||||||
|
for (const rawLine of text.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('#')) continue;
|
||||||
|
const normalized = line.startsWith('export ') ? line.slice(7).trim() : line;
|
||||||
|
const separator = normalized.indexOf('=');
|
||||||
|
if (separator <= 0) continue;
|
||||||
|
const name = normalized.slice(0, separator).trim();
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue;
|
||||||
|
let value = normalized.slice(separator + 1).trim();
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
secrets.push({ name: name.toUpperCase(), value });
|
||||||
|
}
|
||||||
|
return secrets;
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export const formatDate = (value?: number) =>
|
||||||
|
value
|
||||||
|
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
|
||||||
|
: 'Never';
|
||||||
|
|
||||||
|
export const formatDateTime = (value?: number) =>
|
||||||
|
value
|
||||||
|
? new Intl.DateTimeFormat('en', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(value)
|
||||||
|
: 'Never';
|
||||||
|
|
||||||
|
export const titleize = (value?: string) =>
|
||||||
|
value?.replaceAll('_', ' ') ?? 'unknown';
|
||||||
|
|
||||||
|
export const truncate = (value: string, length = 80) =>
|
||||||
|
value.length > length ? `${value.slice(0, length - 3)}...` : value;
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { AiProviderProfileForm } from '../../src/components/settings/ai-provider-profile-form';
|
||||||
|
import { SpoonAgentSettingsForm } from '../../src/components/spoons/spoon-agent-settings-form';
|
||||||
|
import { SpoonSecretsPanel } from '../../src/components/spoons/spoon-secrets-panel';
|
||||||
|
|
||||||
|
describe('mobile forms', () => {
|
||||||
|
test('SpoonSecretsPanel previews secret names only and imports parsed env values', async () => {
|
||||||
|
const onImportSecrets = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SpoonSecretsPanel
|
||||||
|
adding={false}
|
||||||
|
importing={false}
|
||||||
|
removingId={undefined}
|
||||||
|
secrets={[]}
|
||||||
|
onAddSecret={vi.fn()}
|
||||||
|
onImportSecrets={onImportSecrets}
|
||||||
|
onRemoveSecret={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('AUTH_SECRET=...'), {
|
||||||
|
target: {
|
||||||
|
value: 'AUTH_SECRET=super-secret\nexport AUTHENTIK_CLIENT_ID=client',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getAllByText(/AUTH_SECRET/).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText(/AUTHENTIK_CLIENT_ID/).length).toBeGreaterThan(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/valid secrets found/).textContent).not.toContain(
|
||||||
|
'super-secret',
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Import secrets'));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onImportSecrets).toHaveBeenCalledWith([
|
||||||
|
{ name: 'AUTH_SECRET', value: 'super-secret' },
|
||||||
|
{ name: 'AUTHENTIK_CLIENT_ID', value: 'client' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SpoonSecretsPanel disables import with no parsed secrets', () => {
|
||||||
|
render(
|
||||||
|
<SpoonSecretsPanel
|
||||||
|
adding={false}
|
||||||
|
importing={false}
|
||||||
|
removingId={undefined}
|
||||||
|
secrets={[]}
|
||||||
|
onAddSecret={vi.fn()}
|
||||||
|
onImportSecrets={vi.fn()}
|
||||||
|
onRemoveSecret={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Import secrets').closest('button')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AiProviderProfileForm selects default model from model options', async () => {
|
||||||
|
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AiProviderProfileForm
|
||||||
|
saving={false}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
existing={{
|
||||||
|
_id: 'profile' as never,
|
||||||
|
authType: 'api_key',
|
||||||
|
defaultModel: 'gpt-5.1-codex',
|
||||||
|
enabled: true,
|
||||||
|
modelOptions: ['gpt-5.1-codex', 'gpt-5.5'],
|
||||||
|
name: 'OpenAI',
|
||||||
|
provider: 'openai',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('gpt-5.1-codex'));
|
||||||
|
fireEvent.click(screen.getByText('gpt-5.5'));
|
||||||
|
fireEvent.click(screen.getByText('Save provider'));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ defaultModel: 'gpt-5.5' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AiProviderProfileForm shows Codex auth JSON instructions', () => {
|
||||||
|
render(
|
||||||
|
<AiProviderProfileForm
|
||||||
|
saving={false}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
existing={{
|
||||||
|
_id: 'profile' as never,
|
||||||
|
authType: 'opencode_auth_json',
|
||||||
|
defaultModel: 'gpt-5.1-codex',
|
||||||
|
enabled: true,
|
||||||
|
modelOptions: ['gpt-5.1-codex'],
|
||||||
|
name: 'Codex',
|
||||||
|
provider: 'opencode_openai_login',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/~\/.codex\/auth.json/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SpoonAgentSettingsForm disables provider/model controls without provider profiles', () => {
|
||||||
|
render(
|
||||||
|
<SpoonAgentSettingsForm
|
||||||
|
profiles={[]}
|
||||||
|
onUpdate={vi.fn()}
|
||||||
|
agent={{
|
||||||
|
agentModel: '',
|
||||||
|
autoDetectCommands: true,
|
||||||
|
branchPrefix: 'spoon/agent',
|
||||||
|
enabled: true,
|
||||||
|
materializeEnvFileByDefault: false,
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText('Configure an AI provider in Settings'),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
screen.getByText('No models available').closest('button'),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SpoonAgentSettingsForm applies selected provider defaults', async () => {
|
||||||
|
const onUpdate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SpoonAgentSettingsForm
|
||||||
|
agent={{
|
||||||
|
agentModel: 'gpt-5.1-codex',
|
||||||
|
autoDetectCommands: true,
|
||||||
|
branchPrefix: 'spoon/agent',
|
||||||
|
enabled: true,
|
||||||
|
materializeEnvFileByDefault: false,
|
||||||
|
reasoningEffort: 'high',
|
||||||
|
}}
|
||||||
|
profiles={[
|
||||||
|
{
|
||||||
|
_id: 'profile-a' as never,
|
||||||
|
defaultModel: 'gpt-5.1-codex',
|
||||||
|
enabled: true,
|
||||||
|
modelOptions: ['gpt-5.1-codex'],
|
||||||
|
name: 'OpenAI',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: 'profile-b' as never,
|
||||||
|
defaultModel: 'claude-sonnet-4-5',
|
||||||
|
enabled: true,
|
||||||
|
modelOptions: ['claude-sonnet-4-5'],
|
||||||
|
name: 'Anthropic',
|
||||||
|
reasoningEffort: 'low',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('OpenAI'));
|
||||||
|
fireEvent.click(screen.getByText('Anthropic'));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onUpdate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
agentModel: 'claude-sonnet-4-5',
|
||||||
|
reasoningEffort: 'low',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import DashboardRoute from '../../src/app/(app)/dashboard';
|
||||||
|
import SettingsRoute from '../../src/app/(app)/settings';
|
||||||
|
import SpoonsRoute from '../../src/app/(app)/spoons';
|
||||||
|
import ThreadsRoute from '../../src/app/(app)/threads';
|
||||||
|
import WorkspaceRoute from '../../src/app/(app)/workspace/[jobId]';
|
||||||
|
import { mockedUseQuery } from '../setup';
|
||||||
|
|
||||||
|
describe('mobile route smoke tests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockedUseQuery.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Dashboard renders metrics from mocked Convex data', () => {
|
||||||
|
mockedUseQuery
|
||||||
|
.mockReturnValueOnce([
|
||||||
|
{
|
||||||
|
_id: 'spoon-1',
|
||||||
|
status: 'active',
|
||||||
|
syncStatus: 'behind',
|
||||||
|
upstreamAheadBy: 3,
|
||||||
|
},
|
||||||
|
] as never)
|
||||||
|
.mockReturnValueOnce([] as never)
|
||||||
|
.mockReturnValueOnce([
|
||||||
|
{
|
||||||
|
_id: 'thread-1',
|
||||||
|
source: 'user_request',
|
||||||
|
status: 'open',
|
||||||
|
title: 'Update auth',
|
||||||
|
updatedAt: Date.UTC(2026, 0, 1),
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
|
||||||
|
render(<DashboardRoute />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Dashboard')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Update auth')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Upstream commits')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Spoons list renders empty state and one row', () => {
|
||||||
|
mockedUseQuery
|
||||||
|
.mockReturnValueOnce([
|
||||||
|
{
|
||||||
|
_id: 'spoon-1',
|
||||||
|
forkOwner: 'gib',
|
||||||
|
forkRepo: 'usesend',
|
||||||
|
name: 'usesend-authentik',
|
||||||
|
status: 'active',
|
||||||
|
syncStatus: 'up_to_date',
|
||||||
|
upstreamAheadBy: 0,
|
||||||
|
upstreamOwner: 'usesend',
|
||||||
|
upstreamRepo: 'usesend',
|
||||||
|
},
|
||||||
|
] as never)
|
||||||
|
.mockReturnValueOnce([] as never);
|
||||||
|
|
||||||
|
render(<SpoonsRoute />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Spoons')).toBeTruthy();
|
||||||
|
expect(screen.getByText('usesend-authentik')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Threads list renders filters and rows', () => {
|
||||||
|
mockedUseQuery.mockReturnValueOnce([
|
||||||
|
{
|
||||||
|
_id: 'thread-1',
|
||||||
|
source: 'upstream_update',
|
||||||
|
status: 'waiting_for_user',
|
||||||
|
title: 'Upstream auth changes landed',
|
||||||
|
updatedAt: Date.UTC(2026, 0, 1),
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
|
||||||
|
render(<ThreadsRoute />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Waiting')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Upstream auth changes landed')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Workspace route renders tabs and job status', () => {
|
||||||
|
mockedUseQuery
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
_id: 'job-1',
|
||||||
|
model: 'gpt-5.1-codex',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
status: 'running',
|
||||||
|
workBranch: 'spoon/thread/example',
|
||||||
|
workspaceStatus: 'active',
|
||||||
|
} as never)
|
||||||
|
.mockReturnValueOnce([] as never)
|
||||||
|
.mockReturnValueOnce([] as never)
|
||||||
|
.mockReturnValueOnce([] as never);
|
||||||
|
|
||||||
|
render(<WorkspaceRoute />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Workspace review')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Messages')).toBeTruthy();
|
||||||
|
expect(screen.getByText('running')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Settings index renders GitHub and AI provider summaries', () => {
|
||||||
|
mockedUseQuery
|
||||||
|
.mockReturnValueOnce({ email: 'gib@example.com' } as never)
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
displayName: 'gibbyb',
|
||||||
|
status: 'active',
|
||||||
|
} as never)
|
||||||
|
.mockReturnValueOnce([
|
||||||
|
{
|
||||||
|
_id: 'provider-1',
|
||||||
|
isDefault: true,
|
||||||
|
name: 'Codex',
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
|
||||||
|
render(<SettingsRoute />);
|
||||||
|
|
||||||
|
expect(screen.getByText('gib@example.com')).toBeTruthy();
|
||||||
|
expect(screen.getByText('GitHub connected as gibbyb')).toBeTruthy();
|
||||||
|
expect(screen.getByText('1 provider, default Codex')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { Alert } from 'react-native';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { SpoonStatusBadge } from '../../src/components/spoons/spoon-status-badge';
|
||||||
|
import { ThreadStatusBadge } from '../../src/components/threads/thread-status-badge';
|
||||||
|
import { ConfirmButton } from '../../src/components/ui/confirm-button';
|
||||||
|
import { PillTabs } from '../../src/components/ui/pill-tabs';
|
||||||
|
import { SheetSelect } from '../../src/components/ui/sheet-select';
|
||||||
|
import { DiffPreview } from '../../src/components/workspace/diff-preview';
|
||||||
|
|
||||||
|
describe('mobile UI primitives', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PillTabs renders labels and changes selection', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PillTabs
|
||||||
|
tabs={[
|
||||||
|
{ label: 'Overview', value: 'overview' },
|
||||||
|
{ label: 'Settings', value: 'settings' },
|
||||||
|
]}
|
||||||
|
value='overview'
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Settings'));
|
||||||
|
|
||||||
|
expect(screen.getByText('Overview')).toBeTruthy();
|
||||||
|
expect(onChange).toHaveBeenCalledWith('settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SheetSelect opens and chooses an option', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SheetSelect
|
||||||
|
label='Provider'
|
||||||
|
options={[
|
||||||
|
{ label: 'OpenAI', value: 'openai' },
|
||||||
|
{ label: 'Anthropic', value: 'anthropic' },
|
||||||
|
]}
|
||||||
|
value='openai'
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('OpenAI'));
|
||||||
|
fireEvent.click(screen.getByText('Anthropic'));
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith('anthropic');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SheetSelect respects disabled state', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SheetSelect
|
||||||
|
disabled
|
||||||
|
label='Provider'
|
||||||
|
options={[{ label: 'OpenAI', value: 'openai' }]}
|
||||||
|
value='openai'
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('OpenAI'));
|
||||||
|
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ConfirmButton delegates confirmation to Alert', () => {
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ConfirmButton
|
||||||
|
confirmLabel='Delete'
|
||||||
|
message='Delete this?'
|
||||||
|
title='Delete'
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</ConfirmButton>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Remove'));
|
||||||
|
const calls = vi.mocked(Alert.alert).mock.calls;
|
||||||
|
const confirm = calls[0]?.[2]?.[1];
|
||||||
|
confirm?.onPress?.();
|
||||||
|
|
||||||
|
expect(onConfirm).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DiffPreview truncates and expands long diffs', () => {
|
||||||
|
const diff = Array.from({ length: 125 }, (_, index) =>
|
||||||
|
index % 2 === 0 ? `+added ${index}` : `-removed ${index}`,
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
render(<DiffPreview content={diff} initialLines={3} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('+added 0')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('-removed 5')).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Show 122 more lines'));
|
||||||
|
|
||||||
|
expect(screen.getByText('-removed 5')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status badges render readable labels', () => {
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<SpoonStatusBadge status='up_to_date' />
|
||||||
|
<ThreadStatusBadge status='waiting_for_user' />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('up to date')).toBeTruthy();
|
||||||
|
expect(screen.getByText('waiting for user')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
|
import { cleanup } from '@testing-library/react';
|
||||||
|
import { afterEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, '__DEV__', {
|
||||||
|
configurable: true,
|
||||||
|
value: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createElement =
|
||||||
|
(tag: string) =>
|
||||||
|
({
|
||||||
|
children,
|
||||||
|
onChangeText,
|
||||||
|
onPress,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
onChangeText?: (value: string) => void;
|
||||||
|
onPress?: () => void;
|
||||||
|
value?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}) => {
|
||||||
|
const safeProps: Record<string, unknown> = {
|
||||||
|
...props,
|
||||||
|
className:
|
||||||
|
typeof props.className === 'string' ? props.className : undefined,
|
||||||
|
disabled: props.disabled as boolean | undefined,
|
||||||
|
onChange: onChangeText
|
||||||
|
? (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||||
|
onChangeText(event.currentTarget.value)
|
||||||
|
: undefined,
|
||||||
|
onClick: onPress,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
delete safeProps.keyboardType;
|
||||||
|
delete safeProps.keyboardShouldPersistTaps;
|
||||||
|
delete safeProps.placeholderTextColor;
|
||||||
|
delete safeProps.secureTextEntry;
|
||||||
|
delete safeProps.showsHorizontalScrollIndicator;
|
||||||
|
delete safeProps.textAlignVertical;
|
||||||
|
|
||||||
|
return React.createElement(tag, safeProps, children);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TextInput = ({
|
||||||
|
multiline,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
multiline?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}) => createElement(multiline ? 'textarea' : 'input')(props);
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
alert: vi.fn(),
|
||||||
|
useAction: vi.fn(() => vi.fn()),
|
||||||
|
useMutation: vi.fn(() => vi.fn()),
|
||||||
|
useQuery: vi.fn(() => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-native', () => ({
|
||||||
|
Alert: { alert: mocks.alert },
|
||||||
|
Linking: { openURL: vi.fn() },
|
||||||
|
Modal: ({
|
||||||
|
children,
|
||||||
|
visible,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
visible?: boolean;
|
||||||
|
}) => (visible ? React.createElement('div', {}, children) : null),
|
||||||
|
Pressable: createElement('button'),
|
||||||
|
Platform: {
|
||||||
|
OS: 'web',
|
||||||
|
select: (values: Record<string, unknown>) => values.web ?? values.default,
|
||||||
|
},
|
||||||
|
RefreshControl: createElement('div'),
|
||||||
|
ScrollView: createElement('div'),
|
||||||
|
Switch: createElement('input'),
|
||||||
|
Text: createElement('span'),
|
||||||
|
TextInput,
|
||||||
|
TurboModuleRegistry: {
|
||||||
|
get: vi.fn(() => undefined),
|
||||||
|
getEnforcing: vi.fn(() => ({})),
|
||||||
|
},
|
||||||
|
View: createElement('div'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('expo-clipboard', () => ({
|
||||||
|
setStringAsync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('expo-haptics', () => ({
|
||||||
|
impactAsync: vi.fn(),
|
||||||
|
notificationAsync: vi.fn(),
|
||||||
|
selectionAsync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-native-safe-area-context', () => ({
|
||||||
|
SafeAreaView: createElement('div'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('expo-router', () => ({
|
||||||
|
Link: ({ children }: { children?: React.ReactNode }) => children,
|
||||||
|
Stack: {
|
||||||
|
Screen: () => null,
|
||||||
|
},
|
||||||
|
useLocalSearchParams: () => ({}),
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('convex/react', () => ({
|
||||||
|
useAction: mocks.useAction,
|
||||||
|
useMutation: mocks.useMutation,
|
||||||
|
useQuery: mocks.useQuery,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@convex-dev/auth/react', () => ({
|
||||||
|
useAuthActions: () => ({
|
||||||
|
signIn: vi.fn(),
|
||||||
|
signOut: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const mockedAlert = mocks.alert;
|
||||||
|
export const mockedUseAction = mocks.useAction;
|
||||||
|
export const mockedUseMutation = mocks.useMutation;
|
||||||
|
export const mockedUseQuery = mocks.useQuery;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { parseEnvText } from '../../src/utils/env';
|
||||||
|
|
||||||
|
describe('parseEnvText', () => {
|
||||||
|
test('parses dotenv content without exposing invalid rows', () => {
|
||||||
|
expect(
|
||||||
|
parseEnvText(`
|
||||||
|
# comment
|
||||||
|
AUTH_SECRET="secret=value"
|
||||||
|
export authentik_client_id='client'
|
||||||
|
1INVALID=nope
|
||||||
|
EMPTY=
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
{ name: 'AUTH_SECRET', value: 'secret=value' },
|
||||||
|
{ name: 'AUTHENTIK_CLIENT_ID', value: 'client' },
|
||||||
|
{ name: 'EMPTY', value: '' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores blank lines and strips matching quotes only', () => {
|
||||||
|
expect(
|
||||||
|
parseEnvText(`
|
||||||
|
|
||||||
|
PLAIN=value
|
||||||
|
QUOTED="value"
|
||||||
|
SINGLE='value'
|
||||||
|
UNMATCHED="value
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
{ name: 'PLAIN', value: 'value' },
|
||||||
|
{ name: 'QUOTED', value: 'value' },
|
||||||
|
{ name: 'SINGLE', value: 'value' },
|
||||||
|
{ name: 'UNMATCHED', value: '"value' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatDate,
|
||||||
|
formatDateTime,
|
||||||
|
titleize,
|
||||||
|
truncate,
|
||||||
|
} from '../../src/utils/format';
|
||||||
|
|
||||||
|
describe('format utilities', () => {
|
||||||
|
test('formats missing timestamps as never', () => {
|
||||||
|
expect(formatDate(undefined)).toBe('Never');
|
||||||
|
expect(formatDateTime(undefined)).toBe('Never');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats known timestamps', () => {
|
||||||
|
const value = Date.UTC(2026, 0, 2, 3, 4, 5);
|
||||||
|
|
||||||
|
expect(formatDate(value)).toContain('2026');
|
||||||
|
expect(formatDateTime(value)).toContain('2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('titleizes machine values', () => {
|
||||||
|
expect(titleize('waiting_for_user')).toBe('waiting for user');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('truncates long text', () => {
|
||||||
|
expect(truncate('abcdef', 4)).toBe('a...');
|
||||||
|
expect(truncate('abc', 4)).toBe('abc');
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user