Wire production stack: Clerk + Postgres + Anthropic + per-user containers

Brings the backend from a scaffold to a working end-to-end MVP — real auth,
persistent actor registry, Anthropic tool-use loop in the Grow Agent, and
per-user Gitea+OpenCode provisioning. Also adds the client-facing
architecture diagram under docs/architecture.html.
This commit is contained in:
sai karthik
2026-05-19 22:17:40 +05:30
parent 5eaf52b8a5
commit ff0bf5e5f0
27 changed files with 4599 additions and 358 deletions

View File

@@ -1,20 +1,44 @@
PORT=4000 PORT=4000
LOG_LEVEL=info LOG_LEVEL=info
NODE_ENV=development
# Postgres (started by docker-compose; defaults match the compose service)
DATABASE_URL=postgres://growqr:growqr@localhost:5432/growqr
POSTGRES_USER=growqr
POSTGRES_PASSWORD=growqr
POSTGRES_DB=growqr
# Clerk auth — get from dashboard.clerk.com → API Keys
CLERK_SECRET_KEY=sk_test_REPLACE_ME
CLERK_PUBLISHABLE_KEY=pk_test_REPLACE_ME
# Anthropic — get from console.anthropic.com → API Keys
ANTHROPIC_API_KEY=sk-ant-REPLACE_ME
GROW_AGENT_MODEL=claude-opus-4-7
SUB_AGENT_MODEL=claude-sonnet-4-6
MAX_AGENT_TOKENS=4096
# Shared secret for actor → backend service calls (rotate in prod)
SERVICE_TOKEN=dev-service-token-REPLACE_ME
# Rivet Kit engine (self-hosted in docker-compose) # Rivet Kit engine (self-hosted in docker-compose)
RIVET_ENDPOINT=http://localhost:6420 RIVET_ENDPOINT=http://localhost:6420
# Docker images used for per-user containers # Per-user container images
GITEA_IMAGE=gitea/gitea:1.22 GITEA_IMAGE=gitea/gitea:1.22
OPENCODE_IMAGE=ghcr.io/sst/opencode:latest OPENCODE_IMAGE=ghcr.io/sst/opencode:latest
# Host where spawned containers expose their ports (usually localhost in dev) # Host where spawned containers expose their ports.
# - localhost in dev
# - host.docker.internal when the backend runs inside docker-compose
USER_CONTAINER_HOST=127.0.0.1 USER_CONTAINER_HOST=127.0.0.1
# Workspace root on the host that gets bind-mounted into per-user containers. # Workspace root on the host. Each user gets a subdir.
# Each user gets a subdir: ${USER_DATA_ROOT}/<userId>/{gitea,workspace}
USER_DATA_ROOT=./.data/users USER_DATA_ROOT=./.data/users
# Port range allocated to spawned per-user containers # Port range allocated to spawned per-user containers
USER_PORT_RANGE_START=20000 USER_PORT_RANGE_START=20000
USER_PORT_RANGE_END=29999 USER_PORT_RANGE_END=29999
# CORS origin(s) for the Next.js frontend (comma-separated for multiple)
FRONTEND_ORIGIN=http://localhost:3000

175
README.md
View File

@@ -1,101 +1,116 @@
# growqr-backend # GrowQR — backend + frontend
Backend for the Grow Agent Platform (see [`docs/PRD.md`](docs/PRD.md)). A multi-agent platform where every user gets a private Grow Agent (Rivet Kit actor) that orchestrates sub-agents and owns a per-user OpenCode + Gitea Docker stack. See `docs/PRD.md` for the product spec.
Per the PRD, every user gets: ## What's wired up
- A **Grow Agent** (Rivet Kit actor) that orchestrates **sub-agent actors**. - **Auth**: Clerk (frontend + backend JWT verification).
- A **dedicated OpenCode Docker** container — every sub-agent workflow executes through it. - **DB**: Postgres + Drizzle (users, actor registry, container mappings, repos, OpenCode sessions, events).
- A **dedicated Gitea Docker** container — backs the agent's memory and project repos. - **Actors**: Rivet Kit — `growAgent` per user (master) and `subAgent` (worker), with a real Anthropic Claude tool-use loop.
- A frontend (Next.js, separate repo) that pulls the Rivet Kit React SDK and talks to the Grow Agent over the actor connection. - **Per-user containers**: Gitea (memory repo) + OpenCode (workflow execution), spawned via `dockerode`, with admin user + access token bootstrap, ports allocated from a managed pool, lifecycle reconciled on backend boot.
- **Frontend**: Next.js 16 with `@clerk/nextjs` for auth and `rivetkit/client` for direct actor connections + event streaming.
- **Tool surface available to the Grow Agent**: `spawn_sub_agent`, `commit_memory`, `read_memory`, `list_memory`.
## One-time setup
You need three external accounts before running:
1. **Clerk** — create an app at https://dashboard.clerk.com → copy the publishable + secret keys.
2. **Anthropic** — create an API key at https://console.anthropic.com.
3. **Docker** — Docker Desktop (or any daemon `dockerode` can reach via `/var/run/docker.sock`).
Then:
```bash
# Backend env
cp .env.example .env
# fill in CLERK_SECRET_KEY, CLERK_PUBLISHABLE_KEY, ANTHROPIC_API_KEY
# Frontend env
cd growqr-frontend
cp .env.example .env.local
# fill in NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY
cd ..
```
## Running
```bash
# 1. Start Postgres + Rivet engine (per-user OpenCode/Gitea containers are
# spawned dynamically by the backend when a user signs in).
docker compose up -d postgres rivet-engine
# 2. Install + migrate
npm install
npm run db:migrate
# 3. Backend
npm run dev # http://localhost:4000
# 4. Frontend (separate terminal)
cd growqr-frontend
npm install
npm run dev # http://localhost:3000
```
Open http://localhost:3000, sign up, verify your email, and the home page will:
1. Mirror your Clerk user into Postgres.
2. Spawn your dedicated Gitea + OpenCode containers (first run pulls images — ~2040s).
3. Connect the Grow Agent chat to your dedicated Rivet actor.
4. Stream agent + sub-agent events back to the UI.
## Architecture ## Architecture
``` ```
Frontend (Next.js + Rivet Kit React SDK) Browser
│ Clerk JWT
Next.js (3000) ──fetch──▶ /users/bootstrap, /users/me (Hono on 4000)
└─Rivet client──▶ /api/rivet/* (Hono → registry.handler)
Backend (Hono + Rivet Kit client + dockerode) Grow Agent actor (one per user)
├─▶ rivet-engine (compose service) → Grow Agent + Sub-Agent actors ├─ Anthropic (Opus 4.7 + tool use)
├─ commit_memory ────▶ Gitea container (per user)
└─▶ Docker daemon (host) └─ spawn_sub_agent ────▶ OpenCode container (per user)
├─ growqr-gitea-<userId> (one per user, spawned on demand) (multiplexed sessions)
└─ growqr-opencode-<userId> (one per user, spawned on demand)
``` ```
The backend mounts the host Docker socket so it can spawn the per-user ## Useful commands
container pair via `dockerode` on `POST /actors/provision`.
## Layout
```
src/
config.ts env config
log.ts pino logger
index.ts Hono app entrypoint
docker/manager.ts dockerode wrapper — spawns Gitea + OpenCode per user
actors/
grow-agent.ts Rivet Kit master actor (one per user)
sub-agent.ts Rivet Kit worker actor (workflows → OpenCode Docker)
registry.ts Rivet Kit actor registry setup
routes/
actors.ts PRD §5.2 — actor registry HTTP API
opencode.ts PRD §5.3 — OpenCode Docker management
git.ts PRD §5.4 — Gitea Docker management
```
## Local setup
Prereqs: Node 22+, Docker, `docker compose`.
```bash ```bash
cp .env.example .env npm run typecheck # backend
npm install npm run db:generate # diff schema → new migration
docker compose up -d rivet-engine # start Rivet engine npm run db:studio # browse Postgres via Drizzle Studio
npm run dev # start backend on :4000
cd growqr-frontend
npx tsc --noEmit # frontend types
npm run lint
``` ```
The backend pulls the Gitea + OpenCode images on first user provision (no need to pre-pull). ## Troubleshooting
## Smoke test - **"missing bearer token"** from `/users/bootstrap` — Clerk session not attached. Sign out and back in.
- **`Gitea did not become ready`** during provisioning — Gitea takes 1020s on first pull. Wait, then `POST /actors/provision` (the frontend retries via polling).
- **OpenCode container exits immediately** — check `OPENCODE_IMAGE`. The compose env passes `Cmd: ["serve", ...]`; if you swap to a different image, ensure it exposes the `opencode serve` HTTP surface on `:4096`.
- **`No free ports in USER_PORT_RANGE`** — bump `USER_PORT_RANGE_END` in `.env` or stop unused user stacks via `POST /actors/stop`.
```bash ## PRD status
# Provision a Grow Agent + spawn its OpenCode + Gitea Docker
curl -X POST localhost:4000/actors/provision \
-H 'content-type: application/json' \
-d '{"userId":"u_alice"}'
# Send a message All MVP items in `docs/PRD.md` §9 are implemented:
curl -X POST localhost:4000/actors/u_alice/message \
-H 'content-type: application/json' \
-d '{"text":"hello"}'
# Spawn a sub-agent - [x] User auth (Clerk)
curl -X POST localhost:4000/actors/u_alice/sub-agents \ - [x] Actor registry (Postgres + Rivet)
-H 'content-type: application/json' \ - [x] One Grow Agent Rivet Kit actor per user
-d '{"type":"coding","channelId":"ch-coding-1"}' - [x] Sub-agent registration under the Grow Agent
- [x] Per-user Gitea Docker + memory repo
- [x] Memory commits into the user's Gitea
- [x] Per-user OpenCode Docker + session API
- [x] Message endpoint from frontend to Grow Agent (via Rivet)
- [x] Event stream from agent to frontend (via Rivet broadcast)
- [x] Frontend: login, master chat, sub-agent progress, channel-style logs
# Run a workflow through OpenCode Docker Payments and the full quest/pathway runner are deferred to v2 (PRD §10).
curl -X POST localhost:4000/actors/u_alice/sub-agents/coding-<id>/run \
-H 'content-type: application/json' \
-d '{"prompt":"scaffold a Hello World"}'
# Stop the user's stack
curl -X POST localhost:4000/actors/u_alice/stop
```
`docker ps` should show `growqr-gitea-u_alice` and `growqr-opencode-u_alice` after provision.
## What's stubbed
These are wired structurally but not yet talking to real upstream APIs:
- OpenCode HTTP/SSE session API (the `routes/opencode.ts` forwarder is a stub).
- Gitea repo/commit calls (the `routes/git.ts` handlers are stubs).
- Auth (PRD §5.1) and payments (PRD §5.5).
- Persistent registry — currently in-memory; replace with a real DB.
## Frontend
The Next.js frontend lives at `../growqr-frontend` (currently cloned as a nested dir at `./growqr-frontend` — git-ignored from this repo). It will install `@rivetkit/react` and connect to the Grow Agent actor for chat + streaming events.

View File

@@ -1,4 +1,24 @@
services: services:
# Postgres for backend metadata (users, actor registry, billing,
# repo/container mappings). PRD §11.
postgres:
image: postgres:16-alpine
container_name: growqr-postgres
environment:
POSTGRES_USER: ${POSTGRES_USER:-growqr}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-growqr}
POSTGRES_DB: ${POSTGRES_DB:-growqr}
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-growqr}"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
# Self-hosted Rivet engine. The backend's Rivet Kit client connects here. # Self-hosted Rivet engine. The backend's Rivet Kit client connects here.
# Per the PRD, the Grow Agent + sub-agents are durable actors running on Rivet. # Per the PRD, the Grow Agent + sub-agents are durable actors running on Rivet.
rivet-engine: rivet-engine:
@@ -21,24 +41,41 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: growqr-backend container_name: growqr-backend
depends_on: depends_on:
- rivet-engine postgres:
condition: service_healthy
rivet-engine:
condition: service_started
ports: ports:
- "4000:4000" - "4000:4000"
environment: environment:
PORT: 4000 PORT: 4000
NODE_ENV: ${NODE_ENV:-production}
DATABASE_URL: postgres://${POSTGRES_USER:-growqr}:${POSTGRES_PASSWORD:-growqr}@postgres:5432/${POSTGRES_DB:-growqr}
RIVET_ENDPOINT: http://rivet-engine:6420 RIVET_ENDPOINT: http://rivet-engine:6420
CLERK_SECRET_KEY: ${CLERK_SECRET_KEY}
CLERK_PUBLISHABLE_KEY: ${CLERK_PUBLISHABLE_KEY}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
GROW_AGENT_MODEL: ${GROW_AGENT_MODEL:-claude-opus-4-7}
SUB_AGENT_MODEL: ${SUB_AGENT_MODEL:-claude-sonnet-4-6}
SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token}
GITEA_IMAGE: ${GITEA_IMAGE:-gitea/gitea:1.22} GITEA_IMAGE: ${GITEA_IMAGE:-gitea/gitea:1.22}
OPENCODE_IMAGE: ${OPENCODE_IMAGE:-ghcr.io/sst/opencode:latest} OPENCODE_IMAGE: ${OPENCODE_IMAGE:-ghcr.io/sst/opencode:latest}
USER_CONTAINER_HOST: ${USER_CONTAINER_HOST:-127.0.0.1} USER_CONTAINER_HOST: ${USER_CONTAINER_HOST:-host.docker.internal}
USER_DATA_ROOT: /data/users USER_DATA_ROOT: /data/users
USER_PORT_RANGE_START: 20000 USER_PORT_RANGE_START: 20000
USER_PORT_RANGE_END: 29999 USER_PORT_RANGE_END: 29999
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
volumes: volumes:
# Docker-out-of-Docker: backend uses host Docker to spawn user containers. # Docker-out-of-Docker: backend uses host Docker to spawn user containers.
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
# Shared host dir that per-user containers will also bind-mount their # Shared host dir that per-user containers will also bind-mount their
# workspace from (so backend and spawned containers see the same files). # workspace from (so backend and spawned containers see the same files).
- ./.data/users:/data/users - ./.data/users:/data/users
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:4000/healthz || exit 1"]
interval: 10s
timeout: 5s
retries: 6
restart: unless-stopped restart: unless-stopped
# Note: per-user OpenCode + Gitea containers are NOT defined here. # Note: per-user OpenCode + Gitea containers are NOT defined here.
@@ -47,3 +84,4 @@ services:
volumes: volumes:
rivet-data: rivet-data:
postgres-data:

394
docs/architecture.html Normal file
View File

@@ -0,0 +1,394 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>GrowQR — Architecture</title>
<style>
:root {
--bg: #f7f8fa;
--ink: #0d1117;
--muted: #5b636e;
--line: #1f2328;
--mono: ui-monospace, "JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
/* Per-service palette */
--c-ui: #2563eb; --c-ui-bg: #eff6ff;
--c-auth: #7c3aed; --c-auth-bg: #f5f3ff;
--c-pay: #db2777; --c-pay-bg: #fdf2f8;
--c-threads: #0d9488; --c-threads-bg: #f0fdfa;
--c-actor: #d97706; --c-actor-bg: #fffbeb;
--c-runtime: #059669; --c-runtime-bg: #ecfdf5;
--c-memory: #4f46e5; --c-memory-bg: #eef2ff;
--c-git: #e11d48; --c-git-bg: #fff1f2;
--c-db: #475569; --c-db-bg: #f1f5f9;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: var(--sans);
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: 1280px;
margin: 32px auto 64px;
padding: 0 24px;
}
header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid #d6dbe1;
padding-bottom: 16px;
margin-bottom: 24px;
}
h1 {
margin: 0;
font-family: var(--mono);
font-size: 22px;
letter-spacing: -0.01em;
}
header .meta {
color: var(--muted);
font-family: var(--mono);
font-size: 12px;
}
.lede {
color: var(--muted);
font-size: 14px;
line-height: 1.55;
max-width: 920px;
margin: 0 0 28px;
}
.card {
background: #ffffff;
border: 1px solid #d6dbe1;
border-radius: 12px;
padding: 28px;
box-shadow: 0 1px 0 rgba(13,17,23,0.02), 0 6px 22px rgba(13,17,23,0.05);
}
svg.diagram {
width: 100%;
height: auto;
display: block;
font-family: var(--mono);
}
.legend {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 18px 28px;
margin-top: 28px;
font-size: 13.5px;
line-height: 1.5;
}
.legend .item { display: flex; gap: 10px; align-items: flex-start; }
.legend .swatch {
width: 14px; height: 14px; border-radius: 3px;
margin-top: 4px; flex: 0 0 14px;
border: 1.5px solid currentColor;
}
.legend h3 {
font-family: var(--mono);
font-size: 13px;
margin: 0 0 4px;
letter-spacing: 0.02em;
}
.legend p { margin: 0; color: var(--muted); }
footer {
margin-top: 28px;
color: var(--muted);
font-size: 12px;
font-family: var(--mono);
text-align: right;
}
@media print {
body { background: white; }
.card { box-shadow: none; border-color: #000; }
header { border-color: #000; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>GrowQR — Architectural Diagram</h1>
<div class="meta">v1.0</div>
</header>
<p class="lede">
Every user gets their own private <strong>Grow Agent</strong> (a Rivet Kit actor) that orchestrates sub-agents
and owns a dedicated sandboxed runtime — an OpenCode container for tool execution and a Gitea container for
long-term memory. The frontend talks to the actor backend over a persistent connection; agents stream events,
commit memory to the user's private git, and read/write structured state in the shared database.
</p>
<div class="card">
<svg class="diagram" viewBox="0 0 1240 800" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="GrowQR architecture diagram">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#1f2328"/>
</marker>
<style>
.lbl { font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 12.5px; fill: #0d1117; }
.lbl-sm { font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 11px; fill: #0d1117; }
.lbl-tiny { font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 10.5px; fill: #5b636e; }
.lbl-title { font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 13.5px; fill: #0d1117; font-weight: 700; }
.edge { fill: none; stroke: #1f2328; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
.pill { font-family: ui-monospace, "JetBrains Mono", Menlo, monospace; font-size: 10px; fill: #5b636e; }
.pill-bg { fill: #ffffff; }
</style>
</defs>
<!-- ============================================================ -->
<!-- UI (frontend cluster) -->
<!-- ============================================================ -->
<g>
<rect x="60" y="160" width="230" height="260" rx="8"
fill="#eff6ff" stroke="#2563eb" stroke-width="1.6"/>
<text class="lbl-tiny" x="175" y="410" text-anchor="middle" fill="#2563eb">Vercel / OpenNext</text>
<rect x="80" y="180" width="190" height="60" rx="5"
fill="#ffffff" stroke="#2563eb" stroke-width="1.2"/>
<text class="lbl-title" x="175" y="208" text-anchor="middle">UI</text>
<text class="lbl-tiny" x="175" y="226" text-anchor="middle">Next.js 16 · React 19</text>
<rect x="80" y="255" width="190" height="135" rx="5"
fill="#ffffff" stroke="#2563eb" stroke-width="1.2"/>
<text class="lbl" x="92" y="276">frontend JS</text>
<text class="lbl-sm" x="92" y="298"> auth</text>
<text class="lbl-sm" x="92" y="316"> actors mgmt</text>
<text class="lbl-sm" x="92" y="334"> chat / event stream</text>
<text class="lbl-sm" x="92" y="352"> payments</text>
</g>
<!-- auth -->
<g>
<rect x="60" y="450" width="110" height="58" rx="6"
fill="#f5f3ff" stroke="#7c3aed" stroke-width="1.6"/>
<text class="lbl" x="115" y="476" text-anchor="middle">auth</text>
<text class="lbl-tiny" x="115" y="494" text-anchor="middle" fill="#7c3aed">Clerk v6</text>
</g>
<!-- payments -->
<g>
<rect x="180" y="450" width="110" height="58" rx="6"
fill="#fdf2f8" stroke="#db2777" stroke-width="1.6"/>
<text class="lbl" x="235" y="476" text-anchor="middle">payments</text>
<text class="lbl-tiny" x="235" y="494" text-anchor="middle" fill="#db2777">Stripe</text>
</g>
<!-- ============================================================ -->
<!-- Threads API -->
<!-- ============================================================ -->
<g>
<rect x="470" y="30" width="260" height="100" rx="8"
fill="#f0fdfa" stroke="#0d9488" stroke-width="1.6"/>
<text class="lbl-title" x="490" y="58">Threads API</text>
<text class="lbl-sm" x="490" y="82"> session tracking</text>
<text class="lbl-sm" x="490" y="100"> message logs</text>
<text class="lbl-tiny" x="490" y="120" fill="#0d9488">Hono · /api/rivet/*</text>
</g>
<!-- ============================================================ -->
<!-- Actor Backend -->
<!-- ============================================================ -->
<g>
<rect x="470" y="200" width="260" height="200" rx="8"
fill="#fffbeb" stroke="#d97706" stroke-width="1.6"/>
<text class="lbl-tiny" x="600" y="392" text-anchor="middle" fill="#d97706">Actor Backend · Hono + Rivet Kit</text>
<rect x="490" y="220" width="220" height="135" rx="5"
fill="#ffffff" stroke="#d97706" stroke-width="1.2"/>
<text class="lbl-tiny" x="600" y="348" text-anchor="middle">Actor manager</text>
<text class="lbl" x="510" y="248">Actor Runner</text>
<text class="lbl" x="510" y="272">Actor Engine</text>
<text class="lbl" x="510" y="296">Actor Storage</text>
<text class="lbl-tiny" x="510" y="320" fill="#d97706">growAgent · subAgent</text>
</g>
<!-- ============================================================ -->
<!-- Agent Runtime -->
<!-- ============================================================ -->
<g>
<rect x="810" y="200" width="260" height="220" rx="8"
fill="#ecfdf5" stroke="#059669" stroke-width="1.6"/>
<text class="lbl-tiny" x="940" y="395" text-anchor="middle" fill="#059669">SandBoxed Runtime · per-user Docker</text>
<text class="lbl-tiny" x="940" y="412" text-anchor="middle" fill="#059669">service scale</text>
<rect x="830" y="220" width="220" height="145" rx="5"
fill="#ffffff" stroke="#059669" stroke-width="1.2"/>
<text class="lbl-tiny" x="940" y="358" text-anchor="middle">Agent runtime · OpenCode</text>
<text class="lbl-title" x="850" y="248">Agent Runtime</text>
<text class="lbl-sm" x="850" y="274"> sub-agents</text>
<text class="lbl-sm" x="850" y="294"> skills loading</text>
<text class="lbl-sm" x="850" y="314"> tool execution</text>
<text class="lbl-sm" x="850" y="334"> dockerized</text>
</g>
<!-- ============================================================ -->
<!-- Memory API -->
<!-- ============================================================ -->
<g>
<rect x="390" y="470" width="260" height="120" rx="8"
fill="#eef2ff" stroke="#4f46e5" stroke-width="1.6"/>
<text class="lbl-title" x="410" y="498">Memory API</text>
<text class="lbl-sm" x="410" y="524"> tracking memory</text>
<text class="lbl-sm" x="410" y="544"> 3 layers of memory</text>
<text class="lbl-tiny" x="410" y="572" fill="#4f46e5">commit_memory · read_memory · list_memory</text>
</g>
<!-- ============================================================ -->
<!-- Git manager -->
<!-- ============================================================ -->
<g>
<rect x="810" y="470" width="260" height="170" rx="8"
fill="#fff1f2" stroke="#e11d48" stroke-width="1.6"/>
<text class="lbl-tiny" x="940" y="630" text-anchor="middle" fill="#e11d48">service Scale</text>
<rect x="830" y="490" width="220" height="60" rx="5"
fill="#ffffff" stroke="#e11d48" stroke-width="1.2"/>
<text class="lbl-title" x="940" y="524" text-anchor="middle">Users-repos</text>
<text class="lbl-tiny" x="940" y="568" text-anchor="middle">Git manager · per-user Gitea</text>
<text class="lbl-tiny" x="940" y="586" text-anchor="middle">growqr-memory.git</text>
</g>
<!-- ============================================================ -->
<!-- Database -->
<!-- ============================================================ -->
<g>
<rect x="320" y="680" width="700" height="80" rx="10"
fill="#f1f5f9" stroke="#475569" stroke-width="1.8"/>
<text class="lbl-title" x="670" y="720" text-anchor="middle" font-size="17">DB / PG / AWS RDS</text>
<text class="lbl-tiny" x="670" y="742" text-anchor="middle">users · user_stacks · actors · repos · opencode_sessions · events</text>
</g>
<!-- ============================================================ -->
<!-- Orthogonal edges -->
<!-- ============================================================ -->
<!-- UI → Threads API (up then right) -->
<path class="edge" d="M 175,160 L 175,80 L 470,80" marker-end="url(#arrow)"/>
<rect class="pill-bg" x="220" y="68" width="78" height="14" rx="2"/>
<text class="pill" x="259" y="78" text-anchor="middle">fetch · JWT</text>
<!-- UI → Actor Backend (straight horizontal) -->
<path class="edge" d="M 290,290 L 470,290" marker-end="url(#arrow)"/>
<rect class="pill-bg" x="320" y="278" width="80" height="14" rx="2"/>
<text class="pill" x="360" y="288" text-anchor="middle">rivet-client</text>
<!-- UI → auth (down) -->
<path class="edge" d="M 115,420 L 115,450" marker-end="url(#arrow)"/>
<!-- UI → payments (down) -->
<path class="edge" d="M 235,420 L 235,450" marker-end="url(#arrow)"/>
<!-- Actor Backend → Threads API (straight up) -->
<path class="edge" d="M 600,200 L 600,130" marker-end="url(#arrow)"/>
<!-- Actor Backend → Agent Runtime (right) -->
<path class="edge" d="M 730,280 L 810,280" marker-end="url(#arrow)"/>
<rect class="pill-bg" x="732" y="266" width="80" height="14" rx="2"/>
<text class="pill" x="772" y="276" text-anchor="middle">spawn_sub_agent</text>
<!-- Agent Runtime → Actor Backend (left, SSE back) -->
<path class="edge" d="M 810,340 L 730,340" marker-end="url(#arrow)"/>
<rect class="pill-bg" x="732" y="346" width="90" height="14" rx="2"/>
<text class="pill" x="777" y="356" text-anchor="middle">SSE events</text>
<!-- Actor Backend → Memory API (down) -->
<path class="edge" d="M 550,400 L 550,470" marker-end="url(#arrow)"/>
<!-- Memory API → Git manager (right) -->
<path class="edge" d="M 650,520 L 810,520" marker-end="url(#arrow)"/>
<rect class="pill-bg" x="690" y="508" width="74" height="14" rx="2"/>
<text class="pill" x="727" y="518" text-anchor="middle">Gitea REST</text>
<!-- Actor Backend → DB (down, x=660) -->
<path class="edge" d="M 660,400 L 660,680" marker-end="url(#arrow)"/>
<rect class="pill-bg" x="668" y="528" width="56" height="14" rx="2"/>
<text class="pill" x="696" y="538" text-anchor="middle">drizzle</text>
<!-- Git manager → DB (down, x=940) -->
<path class="edge" d="M 940,640 L 940,680" marker-end="url(#arrow)"/>
</svg>
</div>
<div class="legend">
<div class="item" style="color: var(--c-ui)">
<span class="swatch" style="background: var(--c-ui-bg)"></span>
<div>
<h3>UI</h3>
<p style="color: var(--muted)">Next.js 16 + React 19 on Vercel / OpenNext. Auth flows, chat composer, event console, and actor management. Talks to the actor backend over the Rivet Kit client and REST endpoints with a Clerk JWT.</p>
</div>
</div>
<div class="item" style="color: var(--c-auth)">
<span class="swatch" style="background: var(--c-auth-bg)"></span>
<div>
<h3>auth</h3>
<p style="color: var(--muted)">Clerk on browser and server. JWT is verified on every request; users are mirrored into Postgres on first sight.</p>
</div>
</div>
<div class="item" style="color: var(--c-pay)">
<span class="swatch" style="background: var(--c-pay-bg)"></span>
<div>
<h3>payments</h3>
<p style="color: var(--muted)">Stripe billing — plans, metering, webhooks, and customer portal. Flows through the same JWT identity as the rest of the app.</p>
</div>
</div>
<div class="item" style="color: var(--c-threads)">
<span class="swatch" style="background: var(--c-threads-bg)"></span>
<div>
<h3>Threads API</h3>
<p style="color: var(--muted)">Hono routes for session listing and message logs. All persistent state flows through Postgres; the Rivet handler is mounted at <code>/api/rivet/*</code>.</p>
</div>
</div>
<div class="item" style="color: var(--c-actor)">
<span class="swatch" style="background: var(--c-actor-bg)"></span>
<div>
<h3>Actor Backend</h3>
<p style="color: var(--muted)">Rivet Kit actors orchestrated by Hono. Two actor types — <em>growAgent</em> (one master per user) and <em>subAgent</em> (workers). Runner, Engine, and Storage are provided by Rivet; durable state mirrors into Postgres.</p>
</div>
</div>
<div class="item" style="color: var(--c-runtime)">
<span class="swatch" style="background: var(--c-runtime-bg)"></span>
<div>
<h3>Agent Runtime</h3>
<p style="color: var(--muted)">Per-user OpenCode container spawned via <code>dockerode</code> on first sign-in. Hosts sub-agent sessions, skill loading, and sandboxed tool execution. Streams events back over SSE which the Grow Agent re-broadcasts to the UI.</p>
</div>
</div>
<div class="item" style="color: var(--c-memory)">
<span class="swatch" style="background: var(--c-memory-bg)"></span>
<div>
<h3>Memory API</h3>
<p style="color: var(--muted)">Three-layer memory surface exposed to Claude as tools (<code>commit_memory</code>, <code>read_memory</code>, <code>list_memory</code>). L1 in-actor state, L2 session in Postgres, L3 long-term in the user's Gitea repo.</p>
</div>
</div>
<div class="item" style="color: var(--c-git)">
<span class="swatch" style="background: var(--c-git-bg)"></span>
<div>
<h3>Git manager · Users-repos</h3>
<p style="color: var(--muted)">Per-user Gitea container. Backend creates an admin user, mints an access token, and bootstraps a private <code>growqr-memory</code> repo. Long-term memory commits land here as plain markdown.</p>
</div>
</div>
<div class="item" style="color: var(--c-db)">
<span class="swatch" style="background: var(--c-db-bg)"></span>
<div>
<h3>DB / PG / AWS RDS</h3>
<p style="color: var(--muted)">Postgres + Drizzle ORM. Tables: <code>users</code>, <code>user_stacks</code>, <code>actors</code>, <code>repos</code>, <code>opencode_sessions</code>, <code>events</code>. In production this is AWS RDS; in development it's the Postgres service in docker-compose.</p>
</div>
</div>
</div>
<footer>
GrowQR · architecture overview
</footer>
</div>
</body>
</html>

13
drizzle.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url:
process.env.DATABASE_URL ??
"postgres://growqr:growqr@localhost:5432/growqr",
},
});

81
drizzle/0000_init.sql Normal file
View File

@@ -0,0 +1,81 @@
CREATE TABLE "actors" (
"actor_id" text NOT NULL,
"user_id" text NOT NULL,
"kind" text NOT NULL,
"sub_type" text,
"status" text DEFAULT 'idle' NOT NULL,
"channel_id" text,
"parent_actor_id" text,
"last_activity_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "actors_user_id_actor_id_pk" PRIMARY KEY("user_id","actor_id")
);
--> statement-breakpoint
CREATE TABLE "events" (
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
"user_id" text NOT NULL,
"actor_id" text,
"type" text NOT NULL,
"payload" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "opencode_sessions" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"actor_id" text,
"title" text,
"parent_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "repos" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"name" text NOT NULL,
"role" text NOT NULL,
"gitea_owner" text NOT NULL,
"gitea_name" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user_stacks" (
"user_id" text PRIMARY KEY NOT NULL,
"status" text DEFAULT 'provisioning' NOT NULL,
"gitea_container_id" text,
"gitea_container_name" text,
"gitea_host" text,
"gitea_http_port" integer,
"gitea_ssh_port" integer,
"gitea_admin_user" text,
"gitea_admin_token" text,
"gitea_memory_repo" text,
"opencode_container_id" text,
"opencode_container_name" text,
"opencode_host" text,
"opencode_port" integer,
"opencode_password" text,
"workspace_path" text,
"last_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" text PRIMARY KEY NOT NULL,
"email" text NOT NULL,
"display_name" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "actors" ADD CONSTRAINT "actors_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "opencode_sessions" ADD CONSTRAINT "opencode_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "repos" ADD CONSTRAINT "repos_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_stacks" ADD CONSTRAINT "user_stacks_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "actors_user_kind_idx" ON "actors" USING btree ("user_id","kind");--> statement-breakpoint
CREATE INDEX "events_user_idx" ON "events" USING btree ("user_id","created_at");--> statement-breakpoint
CREATE INDEX "opencode_sessions_user_idx" ON "opencode_sessions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "repos_user_role_idx" ON "repos" USING btree ("user_id","role");--> statement-breakpoint
CREATE INDEX "user_stacks_status_idx" ON "user_stacks" USING btree ("status");--> statement-breakpoint
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");

View File

@@ -0,0 +1,598 @@
{
"id": "85ed4865-0f8a-4720-8290-91b8e4f2efec",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.actors": {
"name": "actors",
"schema": "",
"columns": {
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sub_type": {
"name": "sub_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'idle'"
},
"channel_id": {
"name": "channel_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"parent_actor_id": {
"name": "parent_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"last_activity_at": {
"name": "last_activity_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"actors_user_kind_idx": {
"name": "actors_user_kind_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "kind",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"actors_user_id_users_id_fk": {
"name": "actors_user_id_users_id_fk",
"tableFrom": "actors",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"actors_user_id_actor_id_pk": {
"name": "actors_user_id_actor_id_pk",
"columns": [
"user_id",
"actor_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.events": {
"name": "events",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()::text"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"payload": {
"name": "payload",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"events_user_idx": {
"name": "events_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.opencode_sessions": {
"name": "opencode_sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"opencode_sessions_user_idx": {
"name": "opencode_sessions_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"opencode_sessions_user_id_users_id_fk": {
"name": "opencode_sessions_user_id_users_id_fk",
"tableFrom": "opencode_sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.repos": {
"name": "repos",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true
},
"gitea_owner": {
"name": "gitea_owner",
"type": "text",
"primaryKey": false,
"notNull": true
},
"gitea_name": {
"name": "gitea_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"repos_user_role_idx": {
"name": "repos_user_role_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "role",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"repos_user_id_users_id_fk": {
"name": "repos_user_id_users_id_fk",
"tableFrom": "repos",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_stacks": {
"name": "user_stacks",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'provisioning'"
},
"gitea_container_id": {
"name": "gitea_container_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"gitea_container_name": {
"name": "gitea_container_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"gitea_host": {
"name": "gitea_host",
"type": "text",
"primaryKey": false,
"notNull": false
},
"gitea_http_port": {
"name": "gitea_http_port",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"gitea_ssh_port": {
"name": "gitea_ssh_port",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"gitea_admin_user": {
"name": "gitea_admin_user",
"type": "text",
"primaryKey": false,
"notNull": false
},
"gitea_admin_token": {
"name": "gitea_admin_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"gitea_memory_repo": {
"name": "gitea_memory_repo",
"type": "text",
"primaryKey": false,
"notNull": false
},
"opencode_container_id": {
"name": "opencode_container_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"opencode_container_name": {
"name": "opencode_container_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"opencode_host": {
"name": "opencode_host",
"type": "text",
"primaryKey": false,
"notNull": false
},
"opencode_port": {
"name": "opencode_port",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"opencode_password": {
"name": "opencode_password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"workspace_path": {
"name": "workspace_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"user_stacks_status_idx": {
"name": "user_stacks_status_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_stacks_user_id_users_id_fk": {
"name": "user_stacks_user_id_users_id_fk",
"tableFrom": "user_stacks",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"users_email_idx": {
"name": "users_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1779161128463,
"tag": "0000_init",
"breakpoints": true
}
]
}

1441
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,22 +8,30 @@
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"start": "node dist/index.js", "start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:studio": "drizzle-kit studio",
"compose:up": "docker compose up -d", "compose:up": "docker compose up -d",
"compose:down": "docker compose down" "compose:down": "docker compose down"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.96.0",
"@clerk/backend": "^1.21.0",
"@hono/node-server": "^1.13.7", "@hono/node-server": "^1.13.7",
"dockerode": "^4.0.7", "dockerode": "^4.0.7",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"drizzle-orm": "^0.36.4",
"hono": "^4.6.14", "hono": "^4.6.14",
"pino": "^9.5.0", "pino": "^9.5.0",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"postgres": "^3.4.5",
"rivetkit": "^2.2.1", "rivetkit": "^2.2.1",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@types/dockerode": "^3.3.32", "@types/dockerode": "^3.3.32",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"drizzle-kit": "^0.31.2",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }

View File

@@ -1,110 +1,333 @@
import { actor } from "rivetkit"; import { actor } from "rivetkit";
import type Anthropic from "@anthropic-ai/sdk";
import { log } from "../log.js"; import { log } from "../log.js";
import { config } from "../config.js";
import {
anthropic,
GROW_AGENT_SYSTEM,
growAgentTools,
} from "../lib/anthropic.js";
import { import {
provisionUserStack, provisionUserStack,
getUserStack, getUserStack,
stopUserStack, stopUserStack,
type UserStack, giteaClientFor,
} from "../docker/manager.js"; } from "../docker/manager.js";
import { runSubAgentTask } from "./sub-agent-runner.js";
import { db } from "../db/client.js";
import { actors as actorsTable, events as eventsTable } from "../db/schema.js";
type Memory = { timestamp: number; role: "user" | "agent"; text: string }; type ChatTurn = {
role: "user" | "assistant";
type SubAgentRef = { // Anthropic content blocks; "user" turns may also be plain strings.
id: string; content: string | Anthropic.ContentBlockParam[];
type: string;
status: "pending" | "running" | "done" | "error";
channelId: string;
startedAt: number;
}; };
type GrowAgentState = { type GrowAgentState = {
userId: string; userId: string;
profileSummary: string;
goals: string[]; goals: string[];
memory: Memory[]; history: ChatTurn[];
subAgents: Record<string, SubAgentRef>; // Trimmed once it grows past N turns; long history is delegated to memory repo.
stack: UserStack | null; maxHistory: number;
}; };
const initialState: GrowAgentState = { const initialState: GrowAgentState = {
userId: "", userId: "",
profileSummary: "",
goals: [], goals: [],
memory: [], history: [],
subAgents: {}, maxHistory: 40,
stack: null,
}; };
// The Grow Agent is the user's master orchestrator (PRD §3.2). const MEMORY_REPO_PATH_LIMIT = 1024;
// One instance per user. It owns the per-user OpenCode + Gitea Docker stack
// and routes sub-agent workflow execution through that OpenCode Docker. // One Grow Agent actor instance per user (key the actor by userId).
// Owns the user's Docker stack + LLM conversation loop.
export const growAgent = actor({ export const growAgent = actor({
state: initialState, state: initialState,
actions: { actions: {
// Idempotent. Provisions the per-user OpenCode + Gitea stack if missing.
init: async (c, input: { userId: string }) => { init: async (c, input: { userId: string }) => {
if (c.state.userId && c.state.userId !== input.userId) { if (c.state.userId && c.state.userId !== input.userId) {
throw new Error("Grow Agent already bound to a different user"); throw new Error("Grow Agent already bound to a different user");
} }
c.state.userId = input.userId; c.state.userId = input.userId;
if (!c.state.stack) { const stack = await provisionUserStack(input.userId);
c.state.stack = await provisionUserStack(input.userId);
log.info({ userId: input.userId }, "Grow Agent provisioned stack"); await db
} .insert(actorsTable)
return c.state.stack; .values({
actorId: `grow-${input.userId}`,
userId: input.userId,
kind: "grow",
status: "idle",
lastActivityAt: new Date(),
})
.onConflictDoNothing();
c.broadcast("stack-ready", {
userId: input.userId,
opencode: `${stack.opencodeHost}:${stack.opencodePort}`,
gitea: `${stack.giteaHost}:${stack.giteaHttpPort}`,
memoryRepo: stack.giteaMemoryRepo,
});
return stack;
}, },
// Main chat entry point. Runs the full agentic loop with Claude.
receiveMessage: async (c, msg: { text: string }) => { receiveMessage: async (c, msg: { text: string }) => {
c.state.memory.push({ if (!c.state.userId) {
timestamp: Date.now(), throw new Error("Grow Agent not initialized");
role: "user",
text: msg.text,
});
c.broadcast("message", { role: "user", text: msg.text });
const reply = `Got it. (stack: ${c.state.stack?.opencode.name ?? "none"})`;
c.state.memory.push({
timestamp: Date.now(),
role: "agent",
text: reply,
});
c.broadcast("message", { role: "agent", text: reply });
return { reply };
},
spawnSubAgent: async (c, input: { type: string; channelId: string }) => {
const id = `${input.type}-${Date.now()}`;
const ref: SubAgentRef = {
id,
type: input.type,
status: "pending",
channelId: input.channelId,
startedAt: Date.now(),
};
c.state.subAgents[id] = ref;
c.broadcast("sub-agent-spawned", ref);
return ref;
},
updateSubAgent: async (
c,
input: { id: string; status: SubAgentRef["status"] },
) => {
const ref = c.state.subAgents[input.id];
if (!ref) throw new Error(`Unknown sub-agent ${input.id}`);
ref.status = input.status;
c.broadcast("sub-agent-updated", ref);
return ref;
},
getStack: async (c) => {
if (!c.state.stack && c.state.userId) {
c.state.stack = getUserStack(c.state.userId) ?? null;
} }
return c.state.stack;
const userTurn: ChatTurn = { role: "user", content: msg.text };
c.state.history.push(userTurn);
c.broadcast("message", { role: "user", text: msg.text });
const assistantText = await runAgentLoop(c, c.state.userId);
// Trim history to maxHistory turns; long-term context lives in Gitea.
while (c.state.history.length > c.state.maxHistory) {
c.state.history.shift();
}
await db
.insert(eventsTable)
.values({
userId: c.state.userId,
actorId: `grow-${c.state.userId}`,
type: "grow.message",
payload: { userText: msg.text, assistantText },
});
return { reply: assistantText };
}, },
// Sub-agent status updates fan back in via this action; the Grow Agent
// broadcasts them so the frontend's sidebar can render them under the
// right channel.
subAgentEvent: async (
c,
input: {
subAgentId: string;
type: "started" | "progress" | "done" | "error";
message?: string;
result?: unknown;
},
) => {
c.broadcast("sub-agent-event", input);
},
getHistory: async (c) => c.state.history,
getGoals: async (c) => c.state.goals,
shutdown: async (c) => { shutdown: async (c) => {
if (c.state.userId) await stopUserStack(c.state.userId); if (c.state.userId) await stopUserStack(c.state.userId);
c.state.stack = null;
}, },
}, },
}); });
// The agentic loop. Keeps calling Claude with tools until stop_reason === "end_turn".
async function runAgentLoop(
c: {
state: GrowAgentState;
broadcast: (event: string, data: unknown) => void;
},
userId: string,
): Promise<string> {
if (!config.anthropicApiKey) {
const reply =
"ANTHROPIC_API_KEY is not configured on the backend — set it to enable the Grow Agent.";
c.state.history.push({ role: "assistant", content: reply });
c.broadcast("message", { role: "agent", text: reply });
return reply;
}
c.broadcast("agent-thinking", { state: "running" });
const MAX_ITERATIONS = 8;
let assistantTextOut = "";
for (let i = 0; i < MAX_ITERATIONS; i++) {
const response = await anthropic.messages.create({
model: config.growAgentModel,
max_tokens: config.maxAgentTokens,
system: [
{
type: "text",
text: GROW_AGENT_SYSTEM,
cache_control: { type: "ephemeral" },
},
],
tools: growAgentTools,
thinking: { type: "adaptive" },
messages: messagesForApi(c.state.history),
});
// Capture assistant text for streaming-style broadcast.
for (const block of response.content) {
if (block.type === "text" && block.text) {
assistantTextOut += (assistantTextOut ? "\n\n" : "") + block.text;
c.broadcast("message", { role: "agent", text: block.text });
}
}
// Persist the full assistant turn (so subsequent loops keep tool_use blocks).
c.state.history.push({
role: "assistant",
content: response.content as Anthropic.ContentBlockParam[],
});
if (response.stop_reason !== "tool_use") {
break;
}
const toolUses = response.content.filter(
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use",
);
if (toolUses.length === 0) break;
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const call of toolUses) {
try {
const result = await dispatchTool(c, userId, call);
toolResults.push({
type: "tool_result",
tool_use_id: call.id,
content: typeof result === "string" ? result : JSON.stringify(result),
});
} catch (err) {
log.error({ err, tool: call.name }, "tool dispatch failed");
toolResults.push({
type: "tool_result",
tool_use_id: call.id,
content: `Error: ${err instanceof Error ? err.message : String(err)}`,
is_error: true,
});
}
}
c.state.history.push({ role: "user", content: toolResults });
}
c.broadcast("agent-thinking", { state: "idle" });
return assistantTextOut || "(no response)";
}
function messagesForApi(
history: ChatTurn[],
): Anthropic.MessageParam[] {
return history.map((t) => ({
role: t.role,
content: t.content,
})) as Anthropic.MessageParam[];
}
async function dispatchTool(
c: {
broadcast: (event: string, data: unknown) => void;
state: GrowAgentState;
},
userId: string,
call: Anthropic.ToolUseBlock,
): Promise<unknown> {
const input = call.input as Record<string, unknown>;
switch (call.name) {
case "spawn_sub_agent": {
const type = String(input.type ?? "generic");
const prompt = String(input.prompt ?? "");
const channelId =
typeof input.channelId === "string"
? input.channelId
: `${type}-${Date.now()}`;
const id = `sub-${type}-${Date.now()}`;
await db
.insert(actorsTable)
.values({
actorId: id,
userId,
kind: "sub",
subType: type,
status: "running",
channelId,
parentActorId: `grow-${userId}`,
lastActivityAt: new Date(),
});
c.broadcast("sub-agent-spawned", { id, type, channelId, prompt });
// Fire-and-forget; the runner updates DB + broadcasts via the actor.
void runSubAgentTask({
userId,
subAgentId: id,
type,
prompt,
channelId,
onEvent: (event, data) => c.broadcast(event, data),
});
return { id, type, channelId, status: "running" };
}
case "commit_memory": {
const path = String(input.path ?? "").slice(0, MEMORY_REPO_PATH_LIMIT);
const content = String(input.content ?? "");
const message = String(input.message ?? "memory update");
const client = await giteaClientFor(userId);
const stack = await getUserStack(userId);
if (!client || !stack?.giteaMemoryRepo) {
return { ok: false, error: "memory repo not provisioned" };
}
const [owner, repo] = stack.giteaMemoryRepo.split("/") as [string, string];
const result = await client.putFile({
owner,
repo,
path,
contentUtf8: content,
message,
});
c.broadcast("memory-committed", { path, message });
return { ok: true, path, commitSha: result.commitSha };
}
case "read_memory": {
const path = String(input.path ?? "");
const client = await giteaClientFor(userId);
const stack = await getUserStack(userId);
if (!client || !stack?.giteaMemoryRepo) return null;
const [owner, repo] = stack.giteaMemoryRepo.split("/") as [string, string];
const text = await client.readFile({ owner, repo, path });
return text;
}
case "list_memory": {
const pathPrefix = String(input.pathPrefix ?? "");
const client = await giteaClientFor(userId);
const stack = await getUserStack(userId);
if (!client || !stack?.giteaMemoryRepo) return [];
const [owner, repo] = stack.giteaMemoryRepo.split("/") as [string, string];
// Gitea contents API on a directory returns an array of entries.
try {
const res = await fetch(
`http://${stack.giteaHost}:${stack.giteaHttpPort}/api/v1/repos/${owner}/${repo}/contents/${encodeURI(pathPrefix)}`,
{
headers: {
authorization: `token ${stack.giteaAdminToken}`,
accept: "application/json",
},
},
);
if (!res.ok) return [];
const entries = (await res.json()) as Array<{
name: string;
path: string;
type: string;
}>;
return entries.map((e) => ({ name: e.name, path: e.path, type: e.type }));
} catch {
return [];
}
}
default:
throw new Error(`unknown tool: ${call.name}`);
}
}

View File

@@ -0,0 +1,103 @@
import { eq, and } from "drizzle-orm";
import { db } from "../db/client.js";
import { actors as actorsTable, opencodeSessions } from "../db/schema.js";
import { log } from "../log.js";
import { OpencodeClient } from "../lib/opencode.js";
import { opencodeUrlFor } from "../docker/manager.js";
export type SubAgentRunInput = {
userId: string;
subAgentId: string;
type: string;
prompt: string;
channelId: string;
onEvent: (event: string, data: unknown) => void;
};
// Runs a single sub-agent task by opening an OpenCode session and forwarding
// the user-provided prompt. Streams events back to the caller (the Grow Agent
// actor's broadcast surface) and updates the actors table on completion.
//
// Sub-agents do NOT spawn their own containers — they multiplex through the
// parent Grow Agent's OpenCode container (PRD §3.3).
export async function runSubAgentTask(input: SubAgentRunInput): Promise<void> {
const { userId, subAgentId, type, prompt, channelId, onEvent } = input;
try {
const target = await opencodeUrlFor(userId);
if (!target) {
throw new Error("OpenCode container not provisioned for user");
}
const client = new OpencodeClient(target.baseUrl, target.password);
const session = await client.createSession({
title: `${type} :: ${subAgentId}`,
});
await db.insert(opencodeSessions).values({
id: session.id,
userId,
actorId: subAgentId,
title: session.title ?? null,
});
onEvent("sub-agent-event", {
subAgentId,
type: "started",
channelId,
sessionId: session.id,
});
// Open SSE stream for live progress.
const aborter = client.streamEvents((ev) => {
onEvent("sub-agent-event", {
subAgentId,
type: "progress",
channelId,
event: ev.event,
data: ev.data,
});
});
// Send the prompt synchronously and capture the final response text.
const result = await client.sendMessage({
sessionId: session.id,
text: prompt,
});
aborter.abort();
await db
.update(actorsTable)
.set({ status: "done", lastActivityAt: new Date() })
.where(
and(
eq(actorsTable.userId, userId),
eq(actorsTable.actorId, subAgentId),
),
);
onEvent("sub-agent-event", {
subAgentId,
type: "done",
channelId,
result,
});
log.info({ subAgentId, sessionId: session.id }, "sub-agent done");
} catch (err) {
log.error({ err, subAgentId }, "sub-agent failed");
await db
.update(actorsTable)
.set({ status: "error", lastActivityAt: new Date() })
.where(
and(
eq(actorsTable.userId, userId),
eq(actorsTable.actorId, subAgentId),
),
)
.catch(() => undefined);
onEvent("sub-agent-event", {
subAgentId,
type: "error",
channelId,
message: err instanceof Error ? err.message : String(err),
});
}
}

View File

@@ -1,28 +1,33 @@
import { actor } from "rivetkit"; import { actor } from "rivetkit";
import { log } from "../log.js"; import { db } from "../db/client.js";
import { getUserStack } from "../docker/manager.js"; import { actors as actorsTable, events as eventsTable } from "../db/schema.js";
import { and, eq, desc } from "drizzle-orm";
type LogEntry = {
ts: number;
level: "info" | "warn" | "error";
msg: string;
};
type SubAgentState = { type SubAgentState = {
parentUserId: string; parentUserId: string;
type: string; type: string;
status: "idle" | "running" | "done" | "error"; status: "idle" | "running" | "done" | "error";
workspacePath: string;
channelId: string; channelId: string;
logs: { ts: number; level: "info" | "warn" | "error"; msg: string }[]; logs: LogEntry[];
}; };
const initialState: SubAgentState = { const initialState: SubAgentState = {
parentUserId: "", parentUserId: "",
type: "generic", type: "generic",
status: "idle", status: "idle",
workspacePath: "/workspace",
channelId: "", channelId: "",
logs: [], logs: [],
}; };
// Sub-agents are Rivet Kit worker actors owned by a Grow Agent. // Sub-agent actor mainly exposes status + logs for the UI. The actual task
// They DO NOT spawn their own containers — workflows execute by opening // execution lives in sub-agent-runner.ts, invoked by the Grow Agent's tool
// sessions against the parent Grow Agent's OpenCode Docker (PRD §3.3). // dispatch path (PRD §3.3).
export const subAgent = actor({ export const subAgent = actor({
state: initialState, state: initialState,
actions: { actions: {
@@ -33,37 +38,46 @@ export const subAgent = actor({
c.state.parentUserId = input.parentUserId; c.state.parentUserId = input.parentUserId;
c.state.type = input.type; c.state.type = input.type;
c.state.channelId = input.channelId; c.state.channelId = input.channelId;
c.state.status = "idle";
}, },
runTask: async (c, input: { prompt: string }) => { appendLog: async (c, entry: LogEntry) => {
const stack = getUserStack(c.state.parentUserId); c.state.logs.push(entry);
if (!stack) throw new Error("Parent Grow Agent has no active stack"); c.broadcast("log", entry);
c.state.status = "running"; },
c.state.logs.push({
ts: Date.now(),
level: "info",
msg: `routing prompt to ${stack.opencode.name}`,
});
c.broadcast("status", { status: "running" });
// TODO: real HTTP call into the OpenCode Docker management surface. setStatus: async (c, status: SubAgentState["status"]) => {
const result = { c.state.status = status;
sessionId: `mock-${Date.now()}`, c.broadcast("status", { status });
prompt: input.prompt,
opencode: `${stack.opencode.host}:${stack.opencode.ports.http}`,
};
c.state.status = "done";
c.state.logs.push({
ts: Date.now(),
level: "info",
msg: "task done (mock)",
});
c.broadcast("status", { status: "done", result });
log.info({ subAgent: c.state.type, result }, "sub-agent task done");
return result;
}, },
getLogs: async (c) => c.state.logs, getLogs: async (c) => c.state.logs,
getStatus: async (c) => c.state.status, getStatus: async (c) => c.state.status,
// Pulls historical events from the DB so a returning user sees prior runs.
getHistory: async (c, input: { subAgentId: string }) => {
const rows = await db
.select()
.from(eventsTable)
.where(
and(
eq(eventsTable.userId, c.state.parentUserId),
eq(eventsTable.actorId, input.subAgentId),
),
)
.orderBy(desc(eventsTable.createdAt))
.limit(50);
return rows;
},
getActorRow: async (c, input: { subAgentId: string }) => {
const row = await db.query.actors.findFirst({
where: and(
eq(actorsTable.userId, c.state.parentUserId),
eq(actorsTable.actorId, input.subAgentId),
),
});
return row;
},
}, },
}); });

97
src/auth/clerk.ts Normal file
View File

@@ -0,0 +1,97 @@
import { createClerkClient, verifyToken } from "@clerk/backend";
import { createMiddleware } from "hono/factory";
import { HTTPException } from "hono/http-exception";
import { config } from "../config.js";
import { db } from "../db/client.js";
import { users } from "../db/schema.js";
import { eq } from "drizzle-orm";
export type AuthContext = {
Variables: {
userId: string;
userEmail: string;
};
};
export const clerk = config.clerkSecretKey
? createClerkClient({ secretKey: config.clerkSecretKey })
: null;
// Verifies a Clerk session JWT from the Authorization header.
// Falls back to allowing the configured SERVICE_TOKEN for actor → backend calls.
//
// Bootstraps a row in `users` on first sight so downstream code can FK to it.
export const requireUser = createMiddleware<AuthContext>(async (c, next) => {
const auth = c.req.header("authorization") ?? "";
const token = auth.replace(/^Bearer\s+/i, "").trim();
// Service-to-service path (Grow Agent actor calling backend).
// Header `x-growqr-user` is REQUIRED so we can scope the call.
if (
token &&
config.serviceToken &&
token === config.serviceToken &&
c.req.header("x-growqr-user")
) {
const userId = c.req.header("x-growqr-user")!;
const row = await db.query.users.findFirst({ where: eq(users.id, userId) });
if (!row) {
throw new HTTPException(401, { message: "service token references unknown user" });
}
c.set("userId", row.id);
c.set("userEmail", row.email);
return next();
}
if (!token) {
throw new HTTPException(401, { message: "missing bearer token" });
}
if (!clerk) {
throw new HTTPException(500, { message: "Clerk not configured" });
}
let payload: Awaited<ReturnType<typeof verifyToken>>;
try {
payload = await verifyToken(token, {
secretKey: config.clerkSecretKey,
});
} catch {
throw new HTTPException(401, { message: "invalid clerk token" });
}
const userId = payload.sub;
if (!userId) {
throw new HTTPException(401, { message: "clerk token missing subject" });
}
// Lazy-mirror Clerk user → users table.
let row = await db.query.users.findFirst({ where: eq(users.id, userId) });
if (!row) {
const clerkUser = await clerk.users.getUser(userId);
const email =
clerkUser.primaryEmailAddress?.emailAddress ??
clerkUser.emailAddresses[0]?.emailAddress ??
`${userId}@unknown.local`;
const displayName =
[clerkUser.firstName, clerkUser.lastName].filter(Boolean).join(" ") ||
clerkUser.username ||
null;
const inserted = await db
.insert(users)
.values({ id: userId, email, displayName })
.onConflictDoUpdate({
target: users.id,
set: { email, displayName, updatedAt: new Date() },
})
.returning();
row = inserted[0];
if (!row) {
throw new HTTPException(500, { message: "failed to upsert user" });
}
}
c.set("userId", row.id);
c.set("userEmail", row.email);
return next();
});

View File

@@ -1,13 +1,56 @@
import "dotenv/config"; import "dotenv/config";
function required(name: string, fallback?: string): string {
const v = process.env[name] ?? fallback;
if (!v) {
throw new Error(`Missing required env var: ${name}`);
}
return v;
}
export const config = { export const config = {
port: Number(process.env.PORT ?? 4000), port: Number(process.env.PORT ?? 4000),
logLevel: process.env.LOG_LEVEL ?? "info", logLevel: process.env.LOG_LEVEL ?? "info",
nodeEnv: process.env.NODE_ENV ?? "development",
// Postgres metadata DB (users, registry, container mappings).
databaseUrl:
process.env.DATABASE_URL ??
"postgres://growqr:growqr@localhost:5432/growqr",
// Clerk auth.
clerkSecretKey: process.env.CLERK_SECRET_KEY ?? "",
clerkPublishableKey: process.env.CLERK_PUBLISHABLE_KEY ?? "",
// Optional: lock service-to-service calls (actor → backend).
serviceToken: process.env.SERVICE_TOKEN ?? "",
// Anthropic for Grow Agent + sub-agent LLM calls.
anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? "",
growAgentModel:
process.env.GROW_AGENT_MODEL ?? "claude-opus-4-7",
subAgentModel:
process.env.SUB_AGENT_MODEL ?? "claude-sonnet-4-6",
// Rivet Kit engine endpoint (self-hosted in docker-compose).
rivetEndpoint: process.env.RIVET_ENDPOINT ?? "http://localhost:6420", rivetEndpoint: process.env.RIVET_ENDPOINT ?? "http://localhost:6420",
// Per-user container images.
giteaImage: process.env.GITEA_IMAGE ?? "gitea/gitea:1.22", giteaImage: process.env.GITEA_IMAGE ?? "gitea/gitea:1.22",
opencodeImage: process.env.OPENCODE_IMAGE ?? "ghcr.io/sst/opencode:latest", opencodeImage:
process.env.OPENCODE_IMAGE ?? "ghcr.io/sst/opencode:latest",
// Host that user containers expose ports on (the host running Docker).
userContainerHost: process.env.USER_CONTAINER_HOST ?? "127.0.0.1", userContainerHost: process.env.USER_CONTAINER_HOST ?? "127.0.0.1",
userDataRoot: process.env.USER_DATA_ROOT ?? "./.data/users", userDataRoot: process.env.USER_DATA_ROOT ?? "./.data/users",
userPortRangeStart: Number(process.env.USER_PORT_RANGE_START ?? 20000), userPortRangeStart: Number(process.env.USER_PORT_RANGE_START ?? 20000),
userPortRangeEnd: Number(process.env.USER_PORT_RANGE_END ?? 29999), userPortRangeEnd: Number(process.env.USER_PORT_RANGE_END ?? 29999),
// CORS for the Next.js frontend.
frontendOrigin:
process.env.FRONTEND_ORIGIN ?? "http://localhost:3000",
// Used by Anthropic SDK extended thinking / streaming budgets.
maxAgentTokens: Number(process.env.MAX_AGENT_TOKENS ?? 4096),
required, // exported so other modules can fail fast on boot
} as const; } as const;

14
src/db/client.ts Normal file
View File

@@ -0,0 +1,14 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { config } from "../config.js";
import * as schema from "./schema.js";
const queryClient = postgres(config.databaseUrl, {
max: 10,
idle_timeout: 20,
prepare: false,
});
export const db = drizzle(queryClient, { schema });
export { schema };
export type Db = typeof db;

20
src/db/migrate.ts Normal file
View File

@@ -0,0 +1,20 @@
import "dotenv/config";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
import { config } from "../config.js";
import { log } from "../log.js";
async function main() {
const sql = postgres(config.databaseUrl, { max: 1 });
const db = drizzle(sql);
log.info({ url: config.databaseUrl.replace(/:[^:@]+@/, ":***@") }, "migrating");
await migrate(db, { migrationsFolder: "./drizzle" });
await sql.end();
log.info("migrations applied");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

174
src/db/schema.ts Normal file
View File

@@ -0,0 +1,174 @@
import { sql } from "drizzle-orm";
import {
pgTable,
text,
timestamp,
integer,
jsonb,
uniqueIndex,
index,
primaryKey,
} from "drizzle-orm/pg-core";
// Users are mirrored from Clerk on first sign-in.
// id = Clerk user id (e.g., "user_2abc..."), email is the canonical Clerk email.
export const users = pgTable(
"users",
{
id: text("id").primaryKey(),
email: text("email").notNull(),
displayName: text("display_name"),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => ({
emailIdx: uniqueIndex("users_email_idx").on(t.email),
}),
);
// One per user. Tracks the user's Grow Agent's container stack + Gitea creds.
// PRD §3.2 + §5.2.
export const userStacks = pgTable(
"user_stacks",
{
userId: text("user_id")
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
status: text("status", {
enum: ["provisioning", "running", "stopped", "error"],
})
.notNull()
.default("provisioning"),
giteaContainerId: text("gitea_container_id"),
giteaContainerName: text("gitea_container_name"),
giteaHost: text("gitea_host"),
giteaHttpPort: integer("gitea_http_port"),
giteaSshPort: integer("gitea_ssh_port"),
giteaAdminUser: text("gitea_admin_user"),
giteaAdminToken: text("gitea_admin_token"),
giteaMemoryRepo: text("gitea_memory_repo"),
opencodeContainerId: text("opencode_container_id"),
opencodeContainerName: text("opencode_container_name"),
opencodeHost: text("opencode_host"),
opencodePort: integer("opencode_port"),
opencodePassword: text("opencode_password"),
workspacePath: text("workspace_path"),
lastError: text("last_error"),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => ({
statusIdx: index("user_stacks_status_idx").on(t.status),
}),
);
// PRD §5.2 actor registry. One Grow Agent row per user; sub-agents are
// child rows keyed by (userId, actorId).
export const actors = pgTable(
"actors",
{
actorId: text("actor_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
kind: text("kind", { enum: ["grow", "sub"] }).notNull(),
subType: text("sub_type"), // for sub-agents: "coding", "repo", "quest", ...
status: text("status", {
enum: ["idle", "running", "done", "error"],
})
.notNull()
.default("idle"),
channelId: text("channel_id"),
parentActorId: text("parent_actor_id"),
lastActivityAt: timestamp("last_activity_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => ({
pk: primaryKey({ columns: [t.userId, t.actorId] }),
kindIdx: index("actors_user_kind_idx").on(t.userId, t.kind),
}),
);
// Per-user repo registry (in addition to the primary memory repo).
export const repos = pgTable(
"repos",
{
id: text("id").primaryKey(), // `${userId}:${name}`
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
role: text("role", { enum: ["memory", "project"] }).notNull(),
giteaOwner: text("gitea_owner").notNull(),
giteaName: text("gitea_name").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => ({
userRoleIdx: index("repos_user_role_idx").on(t.userId, t.role),
}),
);
// OpenCode sessions opened by sub-agents.
export const opencodeSessions = pgTable(
"opencode_sessions",
{
id: text("id").primaryKey(), // OpenCode session id from POST /session
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
actorId: text("actor_id"),
title: text("title"),
parentId: text("parent_id"),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => ({
userIdx: index("opencode_sessions_user_idx").on(t.userId),
}),
);
// Audit/event log — small append-only stream used for debugging + the
// frontend's "task progress timeline" until we move it to Rivet streams only.
export const events = pgTable(
"events",
{
id: text("id")
.primaryKey()
.default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull(),
actorId: text("actor_id"),
type: text("type").notNull(),
payload: jsonb("payload").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => ({
userIdx: index("events_user_idx").on(t.userId, t.createdAt),
}),
);
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type UserStack = typeof userStacks.$inferSelect;
export type NewUserStack = typeof userStacks.$inferInsert;
export type ActorRow = typeof actors.$inferSelect;
export type RepoRow = typeof repos.$inferSelect;
export type OpencodeSessionRow = typeof opencodeSessions.$inferSelect;

View File

@@ -1,31 +1,39 @@
import Docker from "dockerode"; import Docker from "dockerode";
import { mkdir } from "node:fs/promises"; import { mkdir } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { randomBytes } from "node:crypto";
import { and, eq, isNotNull } from "drizzle-orm";
import { config } from "../config.js"; import { config } from "../config.js";
import { log } from "../log.js"; import { log } from "../log.js";
import { db } from "../db/client.js";
import { userStacks, type UserStack } from "../db/schema.js";
import { GiteaClient, waitForGitea } from "../lib/gitea.js";
import { waitForOpencode } from "../lib/opencode.js";
export type UserStack = { export type { UserStack };
userId: string;
gitea: ContainerInfo;
opencode: ContainerInfo;
};
export type ContainerInfo = {
id: string;
name: string;
image: string;
host: string;
ports: Record<string, number>;
status: "running" | "stopped" | "creating" | "error";
};
const docker = new Docker(); const docker = new Docker();
// In-memory state of allocated host ports + running user stacks. // Allocated host ports kept in-memory; rehydrated from the DB on boot so
// Replace with the backend DB in §5.2 actor registry when wired up. // we don't double-allocate across restarts.
const stacks = new Map<string, UserStack>();
const allocatedPorts = new Set<number>(); const allocatedPorts = new Set<number>();
export async function hydratePortAllocator(): Promise<void> {
const rows = await db
.select({
giteaHttp: userStacks.giteaHttpPort,
giteaSsh: userStacks.giteaSshPort,
opencode: userStacks.opencodePort,
})
.from(userStacks);
for (const r of rows) {
for (const p of [r.giteaHttp, r.giteaSsh, r.opencode]) {
if (p) allocatedPorts.add(p);
}
}
log.info({ count: allocatedPorts.size }, "hydrated port allocator");
}
function pickPort(): number { function pickPort(): number {
for (let p = config.userPortRangeStart; p <= config.userPortRangeEnd; p++) { for (let p = config.userPortRangeStart; p <= config.userPortRangeEnd; p++) {
if (!allocatedPorts.has(p)) { if (!allocatedPorts.has(p)) {
@@ -36,8 +44,8 @@ function pickPort(): number {
throw new Error("No free ports in USER_PORT_RANGE"); throw new Error("No free ports in USER_PORT_RANGE");
} }
function releasePort(port: number) { function releasePort(port: number | null | undefined) {
allocatedPorts.delete(port); if (port != null) allocatedPorts.delete(port);
} }
async function ensureImage(image: string) { async function ensureImage(image: string) {
@@ -62,127 +70,386 @@ function userDataDir(userId: string) {
return path.resolve(config.userDataRoot, userId); return path.resolve(config.userDataRoot, userId);
} }
async function startGitea(userId: string): Promise<ContainerInfo> { function safeContainerName(prefix: string, userId: string) {
// Container names must match [a-zA-Z0-9_.-]
return `${prefix}-${userId.replace(/[^a-zA-Z0-9_.-]/g, "_")}`;
}
async function findExistingContainer(name: string) {
const list = await docker.listContainers({
all: true,
filters: { name: [`^/${name}$`] },
});
return list[0];
}
async function startGiteaContainer(opts: {
userId: string;
httpPort: number;
sshPort: number;
}): Promise<{ id: string; name: string }> {
await ensureImage(config.giteaImage); await ensureImage(config.giteaImage);
const httpPort = pickPort(); const name = safeContainerName("growqr-gitea", opts.userId);
const sshPort = pickPort(); const dataDir = path.join(userDataDir(opts.userId), "gitea");
const name = `growqr-gitea-${userId}`;
const dataDir = path.join(userDataDir(userId), "gitea");
await ensureDir(dataDir); await ensureDir(dataDir);
const existing = await findExistingContainer(name);
if (existing) {
if (existing.State !== "running") {
await docker.getContainer(existing.Id).start().catch(() => undefined);
}
return { id: existing.Id, name };
}
const container = await docker.createContainer({ const container = await docker.createContainer({
name, name,
Image: config.giteaImage, Image: config.giteaImage,
Env: [ Env: [
"USER_UID=1000", "USER_UID=1000",
"USER_GID=1000", "USER_GID=1000",
"GITEA__server__ROOT_URL=" + `GITEA__server__ROOT_URL=http://${config.userContainerHost}:${opts.httpPort}/`,
`http://${config.userContainerHost}:${httpPort}/`, `GITEA__server__SSH_PORT=${opts.sshPort}`,
`GITEA__server__SSH_PORT=${sshPort}`,
"GITEA__security__INSTALL_LOCK=true", "GITEA__security__INSTALL_LOCK=true",
"GITEA__service__DISABLE_REGISTRATION=true",
], ],
HostConfig: { HostConfig: {
Binds: [`${dataDir}:/data`], Binds: [`${dataDir}:/data`],
PortBindings: { PortBindings: {
"3000/tcp": [{ HostPort: String(httpPort) }], "3000/tcp": [{ HostPort: String(opts.httpPort) }],
"22/tcp": [{ HostPort: String(sshPort) }], "22/tcp": [{ HostPort: String(opts.sshPort) }],
}, },
RestartPolicy: { Name: "unless-stopped" }, RestartPolicy: { Name: "unless-stopped" },
Memory: 1 * 1024 * 1024 * 1024, // 1 GB cap Memory: 1 * 1024 * 1024 * 1024,
NanoCpus: 1_000_000_000, // 1 CPU NanoCpus: 1_000_000_000,
}, },
ExposedPorts: { "3000/tcp": {}, "22/tcp": {} }, ExposedPorts: { "3000/tcp": {}, "22/tcp": {} },
Labels: { Labels: {
"growqr.userId": userId, "growqr.userId": opts.userId,
"growqr.role": "gitea", "growqr.role": "gitea",
}, },
}); });
await container.start(); await container.start();
log.info({ userId, name }, "started Gitea container"); log.info({ userId: opts.userId, name }, "started Gitea container");
return { return { id: container.id, name };
id: container.id,
name,
image: config.giteaImage,
host: config.userContainerHost,
ports: { http: httpPort, ssh: sshPort },
status: "running",
};
} }
async function startOpenCode(userId: string): Promise<ContainerInfo> { // Runs `gitea admin user create --admin ...` inside the container.
// Idempotent: returns existing creds if the user already exists.
async function ensureGiteaAdmin(opts: {
containerId: string;
username: string;
password: string;
email: string;
}): Promise<void> {
const container = docker.getContainer(opts.containerId);
const exec = await container.exec({
Cmd: [
"su",
"git",
"-c",
`gitea admin user create --admin --username ${opts.username} --password '${opts.password.replace(/'/g, "'\\''")}' --email ${opts.email} --must-change-password=false`,
],
AttachStdout: true,
AttachStderr: true,
WorkingDir: "/var/lib/gitea",
});
const stream = await exec.start({ Detach: false, Tty: false });
await new Promise<void>((resolve) => {
stream.on("end", () => resolve());
stream.on("close", () => resolve());
stream.resume();
});
const info = await exec.inspect();
if (info.ExitCode && info.ExitCode !== 0) {
// Most common non-zero: "user already exists" — that's fine.
log.debug(
{ exitCode: info.ExitCode },
"gitea admin user create returned non-zero (likely already exists)",
);
}
}
async function startOpencodeContainer(opts: {
userId: string;
httpPort: number;
password: string;
}): Promise<{ id: string; name: string }> {
await ensureImage(config.opencodeImage); await ensureImage(config.opencodeImage);
const httpPort = pickPort(); const name = safeContainerName("growqr-opencode", opts.userId);
const name = `growqr-opencode-${userId}`; const workspaceDir = path.join(userDataDir(opts.userId), "workspace");
const workspaceDir = path.join(userDataDir(userId), "workspace");
await ensureDir(workspaceDir); await ensureDir(workspaceDir);
const existing = await findExistingContainer(name);
if (existing) {
if (existing.State !== "running") {
await docker.getContainer(existing.Id).start().catch(() => undefined);
}
return { id: existing.Id, name };
}
const container = await docker.createContainer({ const container = await docker.createContainer({
name, name,
Image: config.opencodeImage, Image: config.opencodeImage,
// OpenCode is expected to expose an HTTP/SSE management surface. // OpenCode server CLI: `opencode serve --port 4096 --hostname 0.0.0.0`.
// Override CMD when the upstream image's default doesn't suit us. // We override the default CMD to make sure it binds to all interfaces
// and uses the per-user password.
Cmd: ["serve", "--port", "4096", "--hostname", "0.0.0.0"],
Env: [ Env: [
`OPENCODE_SERVER_PASSWORD=${opts.password}`,
`OPENCODE_WORKSPACE=/workspace`, `OPENCODE_WORKSPACE=/workspace`,
`OPENCODE_PORT=4096`,
], ],
WorkingDir: "/workspace",
HostConfig: { HostConfig: {
Binds: [`${workspaceDir}:/workspace`], Binds: [`${workspaceDir}:/workspace`],
PortBindings: { PortBindings: {
"4096/tcp": [{ HostPort: String(httpPort) }], "4096/tcp": [{ HostPort: String(opts.httpPort) }],
}, },
RestartPolicy: { Name: "unless-stopped" }, RestartPolicy: { Name: "unless-stopped" },
Memory: 2 * 1024 * 1024 * 1024, // 2 GB cap Memory: 2 * 1024 * 1024 * 1024,
NanoCpus: 2_000_000_000, // 2 CPUs NanoCpus: 2_000_000_000,
}, },
ExposedPorts: { "4096/tcp": {} }, ExposedPorts: { "4096/tcp": {} },
Labels: { Labels: {
"growqr.userId": userId, "growqr.userId": opts.userId,
"growqr.role": "opencode", "growqr.role": "opencode",
}, },
}); });
await container.start(); await container.start();
log.info({ userId, name }, "started OpenCode container"); log.info({ userId: opts.userId, name }, "started OpenCode container");
return { return { id: container.id, name };
id: container.id,
name,
image: config.opencodeImage,
host: config.userContainerHost,
ports: { http: httpPort },
status: "running",
};
} }
// Provisions the per-user stack. Idempotent: returns the existing stack if
// the user already has one in the DB and the containers are running.
//
// Steps:
// 1. Pick ports + allocate.
// 2. Start Gitea + OpenCode containers (or reuse).
// 3. Wait for Gitea HTTP to come up.
// 4. Create the per-user Gitea admin via `gitea admin user create`.
// 5. Mint a long-lived access token for the admin.
// 6. Create the user's memory repo with auto_init.
// 7. Wait for OpenCode to come up.
// 8. Persist everything to user_stacks.
export async function provisionUserStack(userId: string): Promise<UserStack> { export async function provisionUserStack(userId: string): Promise<UserStack> {
if (stacks.has(userId)) return stacks.get(userId)!; const existing = await db.query.userStacks.findFirst({
await ensureDir(userDataDir(userId)); where: eq(userStacks.userId, userId),
const gitea = await startGitea(userId); });
const opencode = await startOpenCode(userId); if (existing && existing.status === "running") {
const stack: UserStack = { userId, gitea, opencode }; return existing;
stacks.set(userId, stack);
return stack;
} }
export function getUserStack(userId: string): UserStack | undefined { await ensureDir(userDataDir(userId));
return stacks.get(userId);
const giteaHttpPort = existing?.giteaHttpPort ?? pickPort();
const giteaSshPort = existing?.giteaSshPort ?? pickPort();
const opencodePort = existing?.opencodePort ?? pickPort();
const opencodePassword =
existing?.opencodePassword ?? randomBytes(24).toString("hex");
const adminUsername =
existing?.giteaAdminUser ?? `gq_${userId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 24).toLowerCase() || "user"}`;
const adminPassword = randomBytes(24).toString("hex");
const adminEmail = `${adminUsername}@growqr.local`;
// Upsert "provisioning" row first so a crash mid-way leaves a recoverable record.
await db
.insert(userStacks)
.values({
userId,
status: "provisioning",
giteaHttpPort,
giteaSshPort,
opencodePort,
opencodePassword,
giteaAdminUser: adminUsername,
giteaHost: config.userContainerHost,
opencodeHost: config.userContainerHost,
workspacePath: userDataDir(userId),
})
.onConflictDoUpdate({
target: userStacks.userId,
set: {
status: "provisioning",
updatedAt: new Date(),
lastError: null,
},
});
try {
const gitea = await startGiteaContainer({
userId,
httpPort: giteaHttpPort,
sshPort: giteaSshPort,
});
const opencode = await startOpencodeContainer({
userId,
httpPort: opencodePort,
password: opencodePassword,
});
const giteaBase = `http://${config.userContainerHost}:${giteaHttpPort}`;
await waitForGitea(giteaBase, 90_000);
// Bootstrap admin user (idempotent — the CLI returns non-zero if exists).
await ensureGiteaAdmin({
containerId: gitea.id,
username: adminUsername,
password: adminPassword,
email: adminEmail,
});
// Mint a token using basic auth.
const basicClient = new GiteaClient(giteaBase, {
kind: "basic",
username: adminUsername,
password: adminPassword,
});
const token = await basicClient.ensureAccessToken({
username: adminUsername,
name: "growqr-backend",
scopes: ["write:repository", "write:user", "write:issue"],
});
// Use the token from here on.
const tokenClient = new GiteaClient(giteaBase, { kind: "token", token });
const memoryRepo = await tokenClient.ensureRepo({
name: "growqr-memory",
description: "Grow Agent memory + state (PRD §3.4)",
autoInit: true,
private: true,
});
// OpenCode readiness.
const opencodeBase = `http://${config.userContainerHost}:${opencodePort}`;
await waitForOpencode(opencodeBase, opencodePassword, 90_000);
const updated = await db
.update(userStacks)
.set({
status: "running",
giteaContainerId: gitea.id,
giteaContainerName: gitea.name,
giteaAdminToken: token,
giteaMemoryRepo: `${memoryRepo.owner}/${memoryRepo.name}`,
opencodeContainerId: opencode.id,
opencodeContainerName: opencode.name,
lastError: null,
updatedAt: new Date(),
})
.where(eq(userStacks.userId, userId))
.returning();
const row = updated[0];
if (!row) throw new Error("user stack row vanished mid-provision");
log.info({ userId }, "user stack provisioned");
return row;
} catch (err) {
log.error({ err, userId }, "provisionUserStack failed");
await db
.update(userStacks)
.set({
status: "error",
lastError: err instanceof Error ? err.message : String(err),
updatedAt: new Date(),
})
.where(eq(userStacks.userId, userId));
throw err;
}
}
export async function getUserStack(userId: string): Promise<UserStack | null> {
const row = await db.query.userStacks.findFirst({
where: eq(userStacks.userId, userId),
});
return row ?? null;
} }
export async function stopUserStack(userId: string): Promise<void> { export async function stopUserStack(userId: string): Promise<void> {
const stack = stacks.get(userId); const stack = await getUserStack(userId);
if (!stack) return; if (!stack) return;
for (const c of [stack.gitea, stack.opencode]) { for (const id of [stack.giteaContainerId, stack.opencodeContainerId]) {
if (!id) continue;
try { try {
const container = docker.getContainer(c.id); const c = docker.getContainer(id);
await container.stop({ t: 5 }).catch(() => undefined); await c.stop({ t: 5 }).catch(() => undefined);
await container.remove({ force: true }).catch(() => undefined); await c.remove({ force: true }).catch(() => undefined);
for (const port of Object.values(c.ports)) releasePort(port);
} catch (err) { } catch (err) {
log.warn({ err, id: c.id }, "failed to stop container"); log.warn({ err, id }, "failed to stop container");
} }
} }
stacks.delete(userId); releasePort(stack.giteaHttpPort);
releasePort(stack.giteaSshPort);
releasePort(stack.opencodePort);
await db
.update(userStacks)
.set({
status: "stopped",
giteaContainerId: null,
opencodeContainerId: null,
updatedAt: new Date(),
})
.where(eq(userStacks.userId, userId));
log.info({ userId }, "stopped user stack"); log.info({ userId }, "stopped user stack");
} }
export function listStacks(): UserStack[] { export async function listStacks(): Promise<UserStack[]> {
return Array.from(stacks.values()); return db.query.userStacks.findMany();
}
// Convenience: build a Gitea client for a user's stack.
export async function giteaClientFor(userId: string): Promise<GiteaClient | null> {
const stack = await getUserStack(userId);
if (!stack?.giteaAdminToken || !stack.giteaHost || !stack.giteaHttpPort) {
return null;
}
return new GiteaClient(
`http://${stack.giteaHost}:${stack.giteaHttpPort}`,
{ kind: "token", token: stack.giteaAdminToken },
);
}
// Convenience: build an OpenCode client for a user's stack.
export async function opencodeUrlFor(
userId: string,
): Promise<{ baseUrl: string; password: string | undefined } | null> {
const stack = await getUserStack(userId);
if (!stack?.opencodeHost || !stack.opencodePort) return null;
return {
baseUrl: `http://${stack.opencodeHost}:${stack.opencodePort}`,
password: stack.opencodePassword ?? undefined,
};
}
// Reconcile DB-tracked running containers with actual Docker state on boot.
// If a container is gone, flip the row to "stopped" so the next provision
// recreates it cleanly.
export async function reconcileOnBoot(): Promise<void> {
const rows = await db
.select()
.from(userStacks)
.where(
and(eq(userStacks.status, "running"), isNotNull(userStacks.giteaContainerId)),
);
for (const row of rows) {
let healthy = true;
for (const id of [row.giteaContainerId, row.opencodeContainerId]) {
if (!id) {
healthy = false;
break;
}
try {
const info = await docker.getContainer(id).inspect();
if (!info.State.Running) healthy = false;
} catch {
healthy = false;
}
}
if (!healthy) {
await db
.update(userStacks)
.set({ status: "stopped", updatedAt: new Date() })
.where(eq(userStacks.userId, row.userId));
log.info({ userId: row.userId }, "stack marked stopped during reconcile");
}
}
} }

View File

@@ -1,28 +1,74 @@
import { serve } from "@hono/node-server"; import { serve } from "@hono/node-server";
import { Hono } from "hono"; import { Hono } from "hono";
import { cors } from "hono/cors";
import { HTTPException } from "hono/http-exception";
import { config } from "./config.js"; import { config } from "./config.js";
import { log } from "./log.js"; import { log } from "./log.js";
import { registry } from "./actors/registry.js"; import { registry } from "./actors/registry.js";
import { actorRoutes } from "./routes/actors.js"; import { actorRoutes } from "./routes/actors.js";
import { opencodeRoutes } from "./routes/opencode.js"; import { opencodeRoutes } from "./routes/opencode.js";
import { gitRoutes } from "./routes/git.js"; import { gitRoutes } from "./routes/git.js";
import { userRoutes } from "./routes/users.js";
import { db } from "./db/client.js";
import { hydratePortAllocator, reconcileOnBoot } from "./docker/manager.js";
async function main() { async function main() {
// Boot-time DB sanity + reconcile.
await db.execute("select 1");
await hydratePortAllocator();
await reconcileOnBoot();
const app = new Hono(); const app = new Hono();
app.get("/", (c) => c.json({ name: "growqr-backend", status: "ok" }));
app.get("/healthz", (c) => c.json({ ok: true })); app.use(
"*",
cors({
origin: config.frontendOrigin.split(",").map((s) => s.trim()),
credentials: true,
allowHeaders: ["authorization", "content-type", "x-growqr-user"],
}),
);
app.onError((err, c) => {
if (err instanceof HTTPException) {
return err.getResponse();
}
log.error({ err }, "unhandled error");
return c.json({ error: "internal" }, 500);
});
app.get("/", (c) =>
c.json({ name: "growqr-backend", status: "ok", env: config.nodeEnv }),
);
app.get("/healthz", async (c) => {
try {
await db.execute("select 1");
return c.json({ ok: true });
} catch (err) {
return c.json(
{ ok: false, error: err instanceof Error ? err.message : String(err) },
503,
);
}
});
// Rivet Kit actor traffic (frontend uses @rivetkit/react against this prefix). // Rivet Kit actor traffic (frontend uses @rivetkit/react against this prefix).
app.all("/api/rivet/*", (c) => registry.handler(c.req.raw)); app.all("/api/rivet/*", (c) => registry.handler(c.req.raw));
// PRD HTTP control plane. // PRD HTTP control plane (auth-gated).
app.route("/users", userRoutes());
app.route("/actors", actorRoutes()); app.route("/actors", actorRoutes());
app.route("/opencode", opencodeRoutes()); app.route("/opencode", opencodeRoutes());
app.route("/git", gitRoutes()); app.route("/git", gitRoutes());
serve({ fetch: app.fetch, port: config.port }, (info) => { serve({ fetch: app.fetch, port: config.port }, (info) => {
log.info( log.info(
{ port: info.port, rivet: config.rivetEndpoint }, {
port: info.port,
rivet: config.rivetEndpoint,
env: config.nodeEnv,
},
"growqr-backend listening", "growqr-backend listening",
); );
}); });

104
src/lib/anthropic.ts Normal file
View File

@@ -0,0 +1,104 @@
import Anthropic from "@anthropic-ai/sdk";
import { config } from "../config.js";
export const anthropic = new Anthropic({
apiKey: config.anthropicApiKey,
});
export const GROW_AGENT_SYSTEM = `You are a Grow Agent — a user's master AI orchestrator on the GrowQR platform.
You own this user's long-running context, memory, and workspace. You coordinate specialized sub-agents (coding, repo, quest, product-flow, etc.), keep durable state in the user's Gitea memory repository, and execute workflows via the user's OpenCode sandbox.
Operating principles:
- Be concise and direct. The user sees your messages in a Slack-like chat.
- Maintain durable memory: commit important decisions, goals, and progress to the user's memory repo using \`commit_memory\`. Read existing context with \`read_memory\` before making suggestions that depend on history.
- For anything that requires code, shell, file edits, or generated artifacts, spawn a sub-agent via \`spawn_sub_agent\`. The sub-agent runs through the user's OpenCode container.
- Track active goals and quests. Surface progress proactively when the user returns.
- Prefer one small commit per meaningful state change over batching.
- Never invent tool names. Only use the tools provided.
`;
export type GrowAgentTool =
| "spawn_sub_agent"
| "commit_memory"
| "read_memory"
| "list_memory";
export const growAgentTools: Anthropic.Tool[] = [
{
name: "spawn_sub_agent",
description:
"Spawn a specialized sub-agent to run a bounded task through the user's OpenCode container. Use for anything that requires running code, editing files, or producing artifacts.",
input_schema: {
type: "object",
properties: {
type: {
type: "string",
description:
"Sub-agent type: 'coding', 'repo', 'migration', 'quest', 'product', 'backend', 'frontend', or another short identifier.",
},
prompt: {
type: "string",
description:
"The full task prompt for the sub-agent. Include all context it needs — sub-agents do not see this conversation.",
},
channelId: {
type: "string",
description:
"Optional channel/thread id the sub-agent should report into. Generated if omitted.",
},
},
required: ["type", "prompt"],
},
},
{
name: "commit_memory",
description:
"Write or update a file in the user's Gitea memory repository. Use for goals, decisions, progress notes, plans, and durable summaries.",
input_schema: {
type: "object",
properties: {
path: {
type: "string",
description:
"Repo-relative path, e.g. 'goals/active.md' or 'decisions/2026-05-19-architecture.md'.",
},
content: {
type: "string",
description: "Full UTF-8 file content to write.",
},
message: {
type: "string",
description: "Commit message describing the change.",
},
},
required: ["path", "content", "message"],
},
},
{
name: "read_memory",
description: "Read a single file from the user's memory repo. Returns null if missing.",
input_schema: {
type: "object",
properties: {
path: { type: "string" },
},
required: ["path"],
},
},
{
name: "list_memory",
description:
"List files at a path prefix in the user's memory repo. Use to discover what context already exists.",
input_schema: {
type: "object",
properties: {
pathPrefix: {
type: "string",
description: "Repo-relative directory, e.g. 'goals' or '' for root.",
},
},
required: ["pathPrefix"],
},
},
];

217
src/lib/gitea.ts Normal file
View File

@@ -0,0 +1,217 @@
import { log } from "../log.js";
// Minimal Gitea API client. Authenticates with either an access token
// (preferred) or basic auth (used once at bootstrap to mint a token).
//
// Reference: docs.gitea.com/api/1.22
export class GiteaClient {
constructor(
private readonly baseUrl: string,
private readonly auth:
| { kind: "token"; token: string }
| { kind: "basic"; username: string; password: string },
) {}
private headers(extra: Record<string, string> = {}): Record<string, string> {
const h: Record<string, string> = {
"content-type": "application/json",
accept: "application/json",
...extra,
};
if (this.auth.kind === "token") {
h["authorization"] = `token ${this.auth.token}`;
} else {
const enc = Buffer.from(
`${this.auth.username}:${this.auth.password}`,
).toString("base64");
h["authorization"] = `Basic ${enc}`;
}
return h;
}
private async req<T>(
method: string,
path: string,
body?: unknown,
): Promise<T> {
const url = `${this.baseUrl}${path}`;
const res = await fetch(url, {
method,
headers: this.headers(),
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(
`gitea ${method} ${path}${res.status} ${res.statusText}: ${text}`,
);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
async ping(): Promise<boolean> {
try {
await fetch(`${this.baseUrl}/api/v1/version`).then((r) => r.ok);
return true;
} catch {
return false;
}
}
// Used with basic auth. Returns the existing token if one with the same
// name exists, otherwise creates a new one.
async ensureAccessToken(opts: {
username: string;
name: string;
scopes?: string[];
}): Promise<string> {
const existing = await this.req<Array<{ id: number; name: string }>>(
"GET",
`/api/v1/users/${encodeURIComponent(opts.username)}/tokens`,
);
const match = existing.find((t) => t.name === opts.name);
if (match) {
// Tokens cannot be re-read; revoke + recreate so we hold the secret.
await this.req(
"DELETE",
`/api/v1/users/${encodeURIComponent(opts.username)}/tokens/${match.id}`,
);
}
const created = await this.req<{ sha1: string; token_last_eight: string }>(
"POST",
`/api/v1/users/${encodeURIComponent(opts.username)}/tokens`,
{
name: opts.name,
scopes: opts.scopes ?? ["write:repository", "write:user"],
},
);
return created.sha1;
}
async ensureRepo(opts: {
name: string;
description?: string;
autoInit?: boolean;
private?: boolean;
}): Promise<{ owner: string; name: string; cloneUrl: string }> {
try {
const repo = await this.req<{
owner: { login: string };
name: string;
clone_url: string;
}>("POST", "/api/v1/user/repos", {
name: opts.name,
description: opts.description ?? "",
auto_init: opts.autoInit ?? true,
private: opts.private ?? true,
default_branch: "main",
});
return {
owner: repo.owner.login,
name: repo.name,
cloneUrl: repo.clone_url,
};
} catch (err) {
// 409 → repo already exists; look it up.
const user = await this.req<{ login: string }>("GET", "/api/v1/user");
const repo = await this.req<{
owner: { login: string };
name: string;
clone_url: string;
}>(
"GET",
`/api/v1/repos/${encodeURIComponent(user.login)}/${encodeURIComponent(opts.name)}`,
);
log.debug({ err }, "ensureRepo fell through to GET");
return {
owner: repo.owner.login,
name: repo.name,
cloneUrl: repo.clone_url,
};
}
}
// Creates or updates a file in a repo. Used for memory commits.
async putFile(opts: {
owner: string;
repo: string;
path: string;
contentUtf8: string;
message: string;
branch?: string;
}): Promise<{ sha: string; commitSha: string }> {
const branch = opts.branch ?? "main";
const contentB64 = Buffer.from(opts.contentUtf8, "utf8").toString("base64");
// Look up existing sha so we can PUT (update). Missing → POST (create).
let existingSha: string | undefined;
try {
const existing = await this.req<{ sha: string }>(
"GET",
`/api/v1/repos/${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.repo)}/contents/${encodeURI(opts.path)}?ref=${encodeURIComponent(branch)}`,
);
existingSha = existing.sha;
} catch {
// not found, that's fine
}
const body: Record<string, unknown> = {
content: contentB64,
message: opts.message,
branch,
};
if (existingSha) body.sha = existingSha;
const result = await this.req<{
content: { sha: string };
commit: { sha: string };
}>(
existingSha ? "PUT" : "POST",
`/api/v1/repos/${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.repo)}/contents/${encodeURI(opts.path)}`,
body,
);
return { sha: result.content.sha, commitSha: result.commit.sha };
}
async readFile(opts: {
owner: string;
repo: string;
path: string;
branch?: string;
}): Promise<string | null> {
const branch = opts.branch ?? "main";
try {
const res = await this.req<{ content: string; encoding: string }>(
"GET",
`/api/v1/repos/${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.repo)}/contents/${encodeURI(opts.path)}?ref=${encodeURIComponent(branch)}`,
);
if (res.encoding === "base64") {
return Buffer.from(res.content, "base64").toString("utf8");
}
return res.content;
} catch {
return null;
}
}
}
// Wait until Gitea answers /api/v1/version. Used right after we start the
// container.
export async function waitForGitea(
baseUrl: string,
timeoutMs = 60_000,
): Promise<void> {
const start = Date.now();
let lastErr: unknown;
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(`${baseUrl}/api/v1/version`);
if (res.ok) return;
} catch (err) {
lastErr = err;
}
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error(`Gitea did not become ready at ${baseUrl}: ${String(lastErr)}`);
}

159
src/lib/opencode.ts Normal file
View File

@@ -0,0 +1,159 @@
// Minimal client for the OpenCode HTTP server (default port 4096).
// Endpoints: POST /session, POST /session/:id/message, GET /event.
// Auth: HTTP Basic with username "opencode" and OPENCODE_SERVER_PASSWORD.
//
// We use the synchronous POST /session/:id/message endpoint and surface
// the full response. Streaming (SSE) is exposed via streamEvents() for
// callers that want to broadcast progress.
export type OpencodeMessage = {
id: string;
role: "user" | "assistant";
parts: Array<{ type: string; text?: string }>;
};
export class OpencodeClient {
constructor(
private readonly baseUrl: string,
private readonly password?: string,
) {}
private headers(): Record<string, string> {
const h: Record<string, string> = {
"content-type": "application/json",
accept: "application/json",
};
if (this.password) {
const enc = Buffer.from(`opencode:${this.password}`).toString("base64");
h["authorization"] = `Basic ${enc}`;
}
return h;
}
private async req<T>(
method: string,
path: string,
body?: unknown,
): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers: this.headers(),
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(
`opencode ${method} ${path}${res.status} ${res.statusText}: ${text}`,
);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
async health(): Promise<{ version?: string } | null> {
try {
const res = await fetch(`${this.baseUrl}/global/health`, {
headers: this.headers(),
});
if (!res.ok) return null;
return (await res.json()) as { version?: string };
} catch {
return null;
}
}
async createSession(opts: {
title?: string;
parentID?: string;
}): Promise<{ id: string; title?: string }> {
return this.req<{ id: string; title?: string }>("POST", "/session", opts);
}
async sendMessage(opts: {
sessionId: string;
text: string;
}): Promise<{ message: OpencodeMessage } | OpencodeMessage> {
return this.req<{ message: OpencodeMessage } | OpencodeMessage>(
"POST",
`/session/${encodeURIComponent(opts.sessionId)}/message`,
{
parts: [{ type: "text", text: opts.text }],
},
);
}
async sendMessageAsync(opts: {
sessionId: string;
text: string;
}): Promise<void> {
await this.req(
"POST",
`/session/${encodeURIComponent(opts.sessionId)}/prompt_async`,
{
parts: [{ type: "text", text: opts.text }],
},
);
}
// Subscribe to the SSE event stream. Caller passes onEvent which is fired
// for every parsed event. Returns an AbortController the caller can use to
// close the stream.
streamEvents(
onEvent: (ev: { event?: string; data: unknown }) => void,
): AbortController {
const controller = new AbortController();
(async () => {
try {
const res = await fetch(`${this.baseUrl}/event`, {
headers: { ...this.headers(), accept: "text/event-stream" },
signal: controller.signal,
});
if (!res.ok || !res.body) return;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buf.indexOf("\n\n")) >= 0) {
const raw = buf.slice(0, idx);
buf = buf.slice(idx + 2);
const lines = raw.split("\n");
let event: string | undefined;
let dataStr = "";
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice(6).trim();
if (line.startsWith("data:")) dataStr += line.slice(5).trim();
}
if (!dataStr) continue;
try {
onEvent({ event, data: JSON.parse(dataStr) });
} catch {
onEvent({ event, data: dataStr });
}
}
}
} catch {
// aborted or network closed
}
})();
return controller;
}
}
export async function waitForOpencode(
baseUrl: string,
password: string | undefined,
timeoutMs = 60_000,
): Promise<void> {
const client = new OpencodeClient(baseUrl, password);
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const h = await client.health();
if (h) return;
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error(`OpenCode did not become ready at ${baseUrl}`);
}

View File

@@ -1,38 +1,48 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { z } from "zod";
import { import {
provisionUserStack, provisionUserStack,
getUserStack, getUserStack,
stopUserStack, stopUserStack,
listStacks, listStacks,
} from "../docker/manager.js"; } from "../docker/manager.js";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import { db } from "../db/client.js";
import { actors as actorsTable } from "../db/schema.js";
import { eq } from "drizzle-orm";
// PRD §5.2 — Actor registry HTTP surface. // PRD §5.2 — Actor registry HTTP surface.
// v1 talks to the Docker manager directly. The Rivet Kit Grow Agent actor // All routes are user-scoped via Clerk auth; userId is derived from the
// is mounted at /api/rivet/* and is intended for the frontend's // session token, never trusted from the body.
// WebSocket/SSE connection (Rivet Kit React SDK).
export function actorRoutes() { export function actorRoutes() {
const app = new Hono(); const app = new Hono<AuthContext>();
app.use("*", requireUser);
const ProvisionSchema = z.object({ userId: z.string().min(1) });
app.post("/provision", async (c) => { app.post("/provision", async (c) => {
const body = ProvisionSchema.parse(await c.req.json()); const userId = c.get("userId");
const stack = await provisionUserStack(body.userId); const stack = await provisionUserStack(userId);
return c.json({ userId: body.userId, stack });
});
app.get("/", (c) => c.json({ stacks: listStacks() }));
app.get("/:userId", (c) => {
const userId = c.req.param("userId");
const stack = getUserStack(userId);
if (!stack) return c.json({ error: "not provisioned" }, 404);
return c.json({ userId, stack }); return c.json({ userId, stack });
}); });
app.post("/:userId/stop", async (c) => { app.get("/me", async (c) => {
await stopUserStack(c.req.param("userId")); const userId = c.get("userId");
const stack = await getUserStack(userId);
const rows = await db
.select()
.from(actorsTable)
.where(eq(actorsTable.userId, userId));
return c.json({ userId, stack, actors: rows });
});
app.get("/", async (c) => {
// Admin/debug — returns the caller's stacks only. Tighten further if needed.
const userId = c.get("userId");
const all = await listStacks();
return c.json({ stacks: all.filter((s) => s.userId === userId) });
});
app.post("/stop", async (c) => {
const userId = c.get("userId");
await stopUserStack(userId);
return c.json({ ok: true }); return c.json({ ok: true });
}); });

View File

@@ -1,46 +1,100 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { z } from "zod"; import { z } from "zod";
import { getUserStack, provisionUserStack } from "../docker/manager.js"; import { getUserStack, giteaClientFor } from "../docker/manager.js";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import { db } from "../db/client.js";
import { repos } from "../db/schema.js";
// PRD §5.4 — Gitea Docker management API. // PRD §5.4 — Gitea Docker management API.
// Targets the per-user Gitea container spawned by the Docker manager.
export function gitRoutes() { export function gitRoutes() {
const app = new Hono(); const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.post("/users/:userId/repo", async (c) => { app.get("/me", async (c) => {
const userId = c.req.param("userId"); const userId = c.get("userId");
const body = z const stack = await getUserStack(userId);
.object({ name: z.string().min(1) })
.parse(await c.req.json());
const stack = await provisionUserStack(userId);
// TODO: call Gitea API at http://${gitea.host}:${gitea.ports.http}/api/v1/user/repos
return c.json({
userId,
repo: body.name,
gitea: `${stack.gitea.host}:${stack.gitea.ports.http}`,
});
});
app.get("/users/:userId", (c) => {
const stack = getUserStack(c.req.param("userId"));
if (!stack) return c.json({ error: "not provisioned" }, 404); if (!stack) return c.json({ error: "not provisioned" }, 404);
return c.json({ gitea: stack.gitea }); return c.json({
gitea: {
host: stack.giteaHost,
port: stack.giteaHttpPort,
sshPort: stack.giteaSshPort,
memoryRepo: stack.giteaMemoryRepo,
},
});
}); });
app.post("/users/:userId/repos/:repoName/commit", async (c) => { app.post("/repos", async (c) => {
const userId = c.req.param("userId"); const userId = c.get("userId");
const repoName = c.req.param("repoName"); const body = z
.object({ name: z.string().min(1).max(64) })
.parse(await c.req.json());
const client = await giteaClientFor(userId);
const stack = await getUserStack(userId);
if (!client || !stack) {
return c.json({ error: "not provisioned" }, 404);
}
const repo = await client.ensureRepo({ name: body.name, autoInit: true });
await db
.insert(repos)
.values({
id: `${userId}:${body.name}`,
userId,
name: body.name,
role: "project",
giteaOwner: repo.owner,
giteaName: repo.name,
})
.onConflictDoNothing();
return c.json({ repo });
});
app.post("/repos/:name/commit", async (c) => {
const userId = c.get("userId");
const repoName = c.req.param("name");
const body = z const body = z
.object({ .object({
path: z.string().min(1),
content: z.string(),
message: z.string().default("update"),
branch: z.string().default("main"), branch: z.string().default("main"),
message: z.string(),
files: z.record(z.string()),
}) })
.parse(await c.req.json()); .parse(await c.req.json());
const stack = getUserStack(userId); const client = await giteaClientFor(userId);
if (!stack) return c.json({ error: "not provisioned" }, 404); if (!client) return c.json({ error: "not provisioned" }, 404);
// TODO: real commit via Gitea content API
return c.json({ ok: true, repoName, branch: body.branch, count: Object.keys(body.files).length }); // Get owner from DB or fall back to memory repo.
const stack = await getUserStack(userId);
const owner = stack?.giteaAdminUser ?? "";
if (!owner) return c.json({ error: "no gitea owner" }, 500);
const result = await client.putFile({
owner,
repo: repoName,
path: body.path,
contentUtf8: body.content,
message: body.message,
branch: body.branch,
});
return c.json({ ok: true, ...result });
});
app.get("/repos/:name/contents/*", async (c) => {
const userId = c.get("userId");
const repoName = c.req.param("name");
const path = c.req.path.split(`/repos/${repoName}/contents/`)[1] ?? "";
const client = await giteaClientFor(userId);
const stack = await getUserStack(userId);
if (!client || !stack?.giteaAdminUser) {
return c.json({ error: "not provisioned" }, 404);
}
const content = await client.readFile({
owner: stack.giteaAdminUser,
repo: repoName,
path,
});
if (content == null) return c.json({ error: "not found" }, 404);
return c.json({ path, content });
}); });
return app; return app;

View File

@@ -1,51 +1,76 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { z } from "zod"; import { z } from "zod";
import { getUserStack, provisionUserStack } from "../docker/manager.js"; import { provisionUserStack, opencodeUrlFor } from "../docker/manager.js";
import { OpencodeClient } from "../lib/opencode.js";
import { db } from "../db/client.js";
import { opencodeSessions } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { requireUser, type AuthContext } from "../auth/clerk.js";
// PRD §5.3 — OpenCode Docker management API. // PRD §5.3 — OpenCode Docker management API.
// These endpoints control the per-user OpenCode container that sub-agent // Proxies into the user's OpenCode container's HTTP surface.
// workflows run through.
export function opencodeRoutes() { export function opencodeRoutes() {
const app = new Hono(); const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.post("/provision", async (c) => { app.post("/provision", async (c) => {
const body = z const userId = c.get("userId");
.object({ userId: z.string().min(1) }) const stack = await provisionUserStack(userId);
.parse(await c.req.json());
const stack = await provisionUserStack(body.userId);
return c.json({ workspace: stack.opencode });
});
app.get("/workspaces/:userId", (c) => {
const stack = getUserStack(c.req.param("userId"));
if (!stack) return c.json({ error: "not provisioned" }, 404);
return c.json({ workspace: stack.opencode });
});
// Session + message endpoints are forwarded into the user's OpenCode
// container's HTTP API. Real client wiring goes here once the upstream
// OpenCode HTTP surface is pinned.
app.post("/workspaces/:userId/sessions", async (c) => {
const stack = getUserStack(c.req.param("userId"));
if (!stack) return c.json({ error: "not provisioned" }, 404);
return c.json({ return c.json({
sessionId: `sess-${Date.now()}`, workspace: {
target: `${stack.opencode.host}:${stack.opencode.ports.http}`, host: stack.opencodeHost,
port: stack.opencodePort,
status: stack.status,
},
}); });
}); });
app.get("/workspace", async (c) => {
const userId = c.get("userId");
const target = await opencodeUrlFor(userId);
if (!target) return c.json({ error: "not provisioned" }, 404);
const client = new OpencodeClient(target.baseUrl, target.password);
const health = await client.health();
return c.json({ workspace: target, health });
});
app.post("/sessions", async (c) => {
const userId = c.get("userId");
const body = z
.object({ title: z.string().optional() })
.parse(await c.req.json().catch(() => ({})));
const target = await opencodeUrlFor(userId);
if (!target) return c.json({ error: "not provisioned" }, 404);
const client = new OpencodeClient(target.baseUrl, target.password);
const session = await client.createSession({ title: body.title });
await db.insert(opencodeSessions).values({
id: session.id,
userId,
title: session.title ?? null,
});
return c.json({ session });
});
app.post("/sessions/:sessionId/messages", async (c) => { app.post("/sessions/:sessionId/messages", async (c) => {
const userId = c.get("userId");
const sessionId = c.req.param("sessionId");
const body = z const body = z
.object({ userId: z.string(), prompt: z.string() }) .object({ prompt: z.string().min(1) })
.parse(await c.req.json()); .parse(await c.req.json());
const stack = getUserStack(body.userId); const row = await db.query.opencodeSessions.findFirst({
if (!stack) return c.json({ error: "not provisioned" }, 404); where: eq(opencodeSessions.id, sessionId),
// TODO: proxy to http://stack.opencode.host:stack.opencode.ports.http
return c.json({
ok: true,
sessionId: c.req.param("sessionId"),
forwardedTo: `${stack.opencode.host}:${stack.opencode.ports.http}`,
}); });
if (!row || row.userId !== userId) {
return c.json({ error: "not found" }, 404);
}
const target = await opencodeUrlFor(userId);
if (!target) return c.json({ error: "not provisioned" }, 404);
const client = new OpencodeClient(target.baseUrl, target.password);
const result = await client.sendMessage({
sessionId,
text: body.prompt,
});
return c.json({ sessionId, result });
}); });
return app; return app;

50
src/routes/users.ts Normal file
View File

@@ -0,0 +1,50 @@
import { Hono } from "hono";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import { db } from "../db/client.js";
import { users, userStacks } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { provisionUserStack } from "../docker/manager.js";
import { log } from "../log.js";
export function userRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
// Called by the frontend right after Clerk sign-in.
// - Ensures a `users` row exists (the auth middleware already lazy-mirrors).
// - Kicks off Grow Agent stack provisioning if not already running.
// - Returns the current stack status so the UI can render a provisioning spinner.
app.post("/bootstrap", async (c) => {
const userId = c.get("userId");
const userRow = await db.query.users.findFirst({
where: eq(users.id, userId),
});
const stack = await db.query.userStacks.findFirst({
where: eq(userStacks.userId, userId),
});
if (!stack || stack.status !== "running") {
// Fire-and-forget; the frontend polls /users/me until status === running.
void provisionUserStack(userId).catch((err) =>
log.error({ err, userId }, "background provision failed"),
);
}
return c.json({
user: userRow,
stack: stack ?? { status: "provisioning" },
});
});
app.get("/me", async (c) => {
const userId = c.get("userId");
const userRow = await db.query.users.findFirst({
where: eq(users.id, userId),
});
const stack = await db.query.userStacks.findFirst({
where: eq(userStacks.userId, userId),
});
return c.json({ user: userRow, stack });
});
return app;
}