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:
32
.env.example
32
.env.example
@@ -1,20 +1,44 @@
|
||||
PORT=4000
|
||||
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_ENDPOINT=http://localhost:6420
|
||||
|
||||
# Docker images used for per-user containers
|
||||
# Per-user container images
|
||||
GITEA_IMAGE=gitea/gitea:1.22
|
||||
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
|
||||
|
||||
# Workspace root on the host that gets bind-mounted into per-user containers.
|
||||
# Each user gets a subdir: ${USER_DATA_ROOT}/<userId>/{gitea,workspace}
|
||||
# Workspace root on the host. Each user gets a subdir.
|
||||
USER_DATA_ROOT=./.data/users
|
||||
|
||||
# Port range allocated to spawned per-user containers
|
||||
USER_PORT_RANGE_START=20000
|
||||
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
175
README.md
@@ -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**.
|
||||
- A **dedicated OpenCode Docker** container — every sub-agent workflow executes through it.
|
||||
- A **dedicated Gitea Docker** container — backs the agent's memory and project repos.
|
||||
- A frontend (Next.js, separate repo) that pulls the Rivet Kit React SDK and talks to the Grow Agent over the actor connection.
|
||||
- **Auth**: Clerk (frontend + backend JWT verification).
|
||||
- **DB**: Postgres + Drizzle (users, actor registry, container mappings, repos, OpenCode sessions, events).
|
||||
- **Actors**: Rivet Kit — `growAgent` per user (master) and `subAgent` (worker), with a real Anthropic Claude tool-use loop.
|
||||
- **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 — ~20–40s).
|
||||
3. Connect the Grow Agent chat to your dedicated Rivet actor.
|
||||
4. Stream agent + sub-agent events back to the UI.
|
||||
|
||||
## 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
|
||||
│
|
||||
└─▶ Docker daemon (host)
|
||||
├─ growqr-gitea-<userId> (one per user, spawned on demand)
|
||||
└─ growqr-opencode-<userId> (one per user, spawned on demand)
|
||||
├─ Anthropic (Opus 4.7 + tool use)
|
||||
├─ commit_memory ────▶ Gitea container (per user)
|
||||
└─ spawn_sub_agent ────▶ OpenCode container (per user)
|
||||
(multiplexed sessions)
|
||||
```
|
||||
|
||||
The backend mounts the host Docker socket so it can spawn the per-user
|
||||
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`.
|
||||
## Useful commands
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
npm install
|
||||
docker compose up -d rivet-engine # start Rivet engine
|
||||
npm run dev # start backend on :4000
|
||||
npm run typecheck # backend
|
||||
npm run db:generate # diff schema → new migration
|
||||
npm run db:studio # browse Postgres via Drizzle Studio
|
||||
|
||||
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 10–20s 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
|
||||
# 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"}'
|
||||
## PRD status
|
||||
|
||||
# Send a message
|
||||
curl -X POST localhost:4000/actors/u_alice/message \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"text":"hello"}'
|
||||
All MVP items in `docs/PRD.md` §9 are implemented:
|
||||
|
||||
# Spawn a sub-agent
|
||||
curl -X POST localhost:4000/actors/u_alice/sub-agents \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"type":"coding","channelId":"ch-coding-1"}'
|
||||
- [x] User auth (Clerk)
|
||||
- [x] Actor registry (Postgres + Rivet)
|
||||
- [x] One Grow Agent Rivet Kit actor per user
|
||||
- [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
|
||||
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.
|
||||
Payments and the full quest/pathway runner are deferred to v2 (PRD §10).
|
||||
|
||||
@@ -1,4 +1,24 @@
|
||||
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.
|
||||
# Per the PRD, the Grow Agent + sub-agents are durable actors running on Rivet.
|
||||
rivet-engine:
|
||||
@@ -21,24 +41,41 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: growqr-backend
|
||||
depends_on:
|
||||
- rivet-engine
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
rivet-engine:
|
||||
condition: service_started
|
||||
ports:
|
||||
- "4000:4000"
|
||||
environment:
|
||||
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
|
||||
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}
|
||||
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_PORT_RANGE_START: 20000
|
||||
USER_PORT_RANGE_END: 29999
|
||||
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
|
||||
volumes:
|
||||
# Docker-out-of-Docker: backend uses host Docker to spawn user containers.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# Shared host dir that per-user containers will also bind-mount their
|
||||
# workspace from (so backend and spawned containers see the same files).
|
||||
- ./.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
|
||||
|
||||
# Note: per-user OpenCode + Gitea containers are NOT defined here.
|
||||
@@ -47,3 +84,4 @@ services:
|
||||
|
||||
volumes:
|
||||
rivet-data:
|
||||
postgres-data:
|
||||
|
||||
394
docs/architecture.html
Normal file
394
docs/architecture.html
Normal 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
13
drizzle.config.ts
Normal 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
81
drizzle/0000_init.sql
Normal 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");
|
||||
598
drizzle/meta/0000_snapshot.json
Normal file
598
drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal 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
1441
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,22 +8,30 @@
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"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:down": "docker compose down"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.96.0",
|
||||
"@clerk/backend": "^1.21.0",
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"dockerode": "^4.0.7",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.36.4",
|
||||
"hono": "^4.6.14",
|
||||
"pino": "^9.5.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"postgres": "^3.4.5",
|
||||
"rivetkit": "^2.2.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dockerode": "^3.3.32",
|
||||
"@types/node": "^22.10.5",
|
||||
"drizzle-kit": "^0.31.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
|
||||
@@ -1,110 +1,333 @@
|
||||
import { actor } from "rivetkit";
|
||||
import type Anthropic from "@anthropic-ai/sdk";
|
||||
import { log } from "../log.js";
|
||||
import { config } from "../config.js";
|
||||
import {
|
||||
anthropic,
|
||||
GROW_AGENT_SYSTEM,
|
||||
growAgentTools,
|
||||
} from "../lib/anthropic.js";
|
||||
import {
|
||||
provisionUserStack,
|
||||
getUserStack,
|
||||
stopUserStack,
|
||||
type UserStack,
|
||||
giteaClientFor,
|
||||
} 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 SubAgentRef = {
|
||||
id: string;
|
||||
type: string;
|
||||
status: "pending" | "running" | "done" | "error";
|
||||
channelId: string;
|
||||
startedAt: number;
|
||||
type ChatTurn = {
|
||||
role: "user" | "assistant";
|
||||
// Anthropic content blocks; "user" turns may also be plain strings.
|
||||
content: string | Anthropic.ContentBlockParam[];
|
||||
};
|
||||
|
||||
type GrowAgentState = {
|
||||
userId: string;
|
||||
profileSummary: string;
|
||||
goals: string[];
|
||||
memory: Memory[];
|
||||
subAgents: Record<string, SubAgentRef>;
|
||||
stack: UserStack | null;
|
||||
history: ChatTurn[];
|
||||
// Trimmed once it grows past N turns; long history is delegated to memory repo.
|
||||
maxHistory: number;
|
||||
};
|
||||
|
||||
const initialState: GrowAgentState = {
|
||||
userId: "",
|
||||
profileSummary: "",
|
||||
goals: [],
|
||||
memory: [],
|
||||
subAgents: {},
|
||||
stack: null,
|
||||
history: [],
|
||||
maxHistory: 40,
|
||||
};
|
||||
|
||||
// The Grow Agent is the user's master orchestrator (PRD §3.2).
|
||||
// One instance per user. It owns the per-user OpenCode + Gitea Docker stack
|
||||
// and routes sub-agent workflow execution through that OpenCode Docker.
|
||||
const MEMORY_REPO_PATH_LIMIT = 1024;
|
||||
|
||||
// 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({
|
||||
state: initialState,
|
||||
actions: {
|
||||
// Idempotent. Provisions the per-user OpenCode + Gitea stack if missing.
|
||||
init: async (c, input: { userId: string }) => {
|
||||
if (c.state.userId && c.state.userId !== input.userId) {
|
||||
throw new Error("Grow Agent already bound to a different user");
|
||||
}
|
||||
c.state.userId = input.userId;
|
||||
if (!c.state.stack) {
|
||||
c.state.stack = await provisionUserStack(input.userId);
|
||||
log.info({ userId: input.userId }, "Grow Agent provisioned stack");
|
||||
}
|
||||
return c.state.stack;
|
||||
const stack = await provisionUserStack(input.userId);
|
||||
|
||||
await db
|
||||
.insert(actorsTable)
|
||||
.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 }) => {
|
||||
c.state.memory.push({
|
||||
timestamp: Date.now(),
|
||||
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;
|
||||
if (!c.state.userId) {
|
||||
throw new Error("Grow Agent not initialized");
|
||||
}
|
||||
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) => {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
103
src/actors/sub-agent-runner.ts
Normal file
103
src/actors/sub-agent-runner.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,33 @@
|
||||
import { actor } from "rivetkit";
|
||||
import { log } from "../log.js";
|
||||
import { getUserStack } from "../docker/manager.js";
|
||||
import { db } from "../db/client.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 = {
|
||||
parentUserId: string;
|
||||
type: string;
|
||||
status: "idle" | "running" | "done" | "error";
|
||||
workspacePath: string;
|
||||
channelId: string;
|
||||
logs: { ts: number; level: "info" | "warn" | "error"; msg: string }[];
|
||||
logs: LogEntry[];
|
||||
};
|
||||
|
||||
const initialState: SubAgentState = {
|
||||
parentUserId: "",
|
||||
type: "generic",
|
||||
status: "idle",
|
||||
workspacePath: "/workspace",
|
||||
channelId: "",
|
||||
logs: [],
|
||||
};
|
||||
|
||||
// Sub-agents are Rivet Kit worker actors owned by a Grow Agent.
|
||||
// They DO NOT spawn their own containers — workflows execute by opening
|
||||
// sessions against the parent Grow Agent's OpenCode Docker (PRD §3.3).
|
||||
// Sub-agent actor mainly exposes status + logs for the UI. The actual task
|
||||
// execution lives in sub-agent-runner.ts, invoked by the Grow Agent's tool
|
||||
// dispatch path (PRD §3.3).
|
||||
export const subAgent = actor({
|
||||
state: initialState,
|
||||
actions: {
|
||||
@@ -33,37 +38,46 @@ export const subAgent = actor({
|
||||
c.state.parentUserId = input.parentUserId;
|
||||
c.state.type = input.type;
|
||||
c.state.channelId = input.channelId;
|
||||
c.state.status = "idle";
|
||||
},
|
||||
|
||||
runTask: async (c, input: { prompt: string }) => {
|
||||
const stack = getUserStack(c.state.parentUserId);
|
||||
if (!stack) throw new Error("Parent Grow Agent has no active stack");
|
||||
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" });
|
||||
appendLog: async (c, entry: LogEntry) => {
|
||||
c.state.logs.push(entry);
|
||||
c.broadcast("log", entry);
|
||||
},
|
||||
|
||||
// TODO: real HTTP call into the OpenCode Docker management surface.
|
||||
const result = {
|
||||
sessionId: `mock-${Date.now()}`,
|
||||
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;
|
||||
setStatus: async (c, status: SubAgentState["status"]) => {
|
||||
c.state.status = status;
|
||||
c.broadcast("status", { status });
|
||||
},
|
||||
|
||||
getLogs: async (c) => c.state.logs,
|
||||
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
97
src/auth/clerk.ts
Normal 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();
|
||||
});
|
||||
@@ -1,13 +1,56 @@
|
||||
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 = {
|
||||
port: Number(process.env.PORT ?? 4000),
|
||||
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",
|
||||
|
||||
// Per-user container images.
|
||||
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",
|
||||
userDataRoot: process.env.USER_DATA_ROOT ?? "./.data/users",
|
||||
userPortRangeStart: Number(process.env.USER_PORT_RANGE_START ?? 20000),
|
||||
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;
|
||||
|
||||
14
src/db/client.ts
Normal file
14
src/db/client.ts
Normal 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
20
src/db/migrate.ts
Normal 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
174
src/db/schema.ts
Normal 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;
|
||||
@@ -1,31 +1,39 @@
|
||||
import Docker from "dockerode";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { and, eq, isNotNull } from "drizzle-orm";
|
||||
import { config } from "../config.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 = {
|
||||
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";
|
||||
};
|
||||
export type { UserStack };
|
||||
|
||||
const docker = new Docker();
|
||||
|
||||
// In-memory state of allocated host ports + running user stacks.
|
||||
// Replace with the backend DB in §5.2 actor registry when wired up.
|
||||
const stacks = new Map<string, UserStack>();
|
||||
// Allocated host ports kept in-memory; rehydrated from the DB on boot so
|
||||
// we don't double-allocate across restarts.
|
||||
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 {
|
||||
for (let p = config.userPortRangeStart; p <= config.userPortRangeEnd; p++) {
|
||||
if (!allocatedPorts.has(p)) {
|
||||
@@ -36,8 +44,8 @@ function pickPort(): number {
|
||||
throw new Error("No free ports in USER_PORT_RANGE");
|
||||
}
|
||||
|
||||
function releasePort(port: number) {
|
||||
allocatedPorts.delete(port);
|
||||
function releasePort(port: number | null | undefined) {
|
||||
if (port != null) allocatedPorts.delete(port);
|
||||
}
|
||||
|
||||
async function ensureImage(image: string) {
|
||||
@@ -62,127 +70,386 @@ function userDataDir(userId: string) {
|
||||
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);
|
||||
const httpPort = pickPort();
|
||||
const sshPort = pickPort();
|
||||
const name = `growqr-gitea-${userId}`;
|
||||
const dataDir = path.join(userDataDir(userId), "gitea");
|
||||
const name = safeContainerName("growqr-gitea", opts.userId);
|
||||
const dataDir = path.join(userDataDir(opts.userId), "gitea");
|
||||
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({
|
||||
name,
|
||||
Image: config.giteaImage,
|
||||
Env: [
|
||||
"USER_UID=1000",
|
||||
"USER_GID=1000",
|
||||
"GITEA__server__ROOT_URL=" +
|
||||
`http://${config.userContainerHost}:${httpPort}/`,
|
||||
`GITEA__server__SSH_PORT=${sshPort}`,
|
||||
`GITEA__server__ROOT_URL=http://${config.userContainerHost}:${opts.httpPort}/`,
|
||||
`GITEA__server__SSH_PORT=${opts.sshPort}`,
|
||||
"GITEA__security__INSTALL_LOCK=true",
|
||||
"GITEA__service__DISABLE_REGISTRATION=true",
|
||||
],
|
||||
HostConfig: {
|
||||
Binds: [`${dataDir}:/data`],
|
||||
PortBindings: {
|
||||
"3000/tcp": [{ HostPort: String(httpPort) }],
|
||||
"22/tcp": [{ HostPort: String(sshPort) }],
|
||||
"3000/tcp": [{ HostPort: String(opts.httpPort) }],
|
||||
"22/tcp": [{ HostPort: String(opts.sshPort) }],
|
||||
},
|
||||
RestartPolicy: { Name: "unless-stopped" },
|
||||
Memory: 1 * 1024 * 1024 * 1024, // 1 GB cap
|
||||
NanoCpus: 1_000_000_000, // 1 CPU
|
||||
Memory: 1 * 1024 * 1024 * 1024,
|
||||
NanoCpus: 1_000_000_000,
|
||||
},
|
||||
ExposedPorts: { "3000/tcp": {}, "22/tcp": {} },
|
||||
Labels: {
|
||||
"growqr.userId": userId,
|
||||
"growqr.userId": opts.userId,
|
||||
"growqr.role": "gitea",
|
||||
},
|
||||
});
|
||||
await container.start();
|
||||
log.info({ userId, name }, "started Gitea container");
|
||||
return {
|
||||
id: container.id,
|
||||
name,
|
||||
image: config.giteaImage,
|
||||
host: config.userContainerHost,
|
||||
ports: { http: httpPort, ssh: sshPort },
|
||||
status: "running",
|
||||
};
|
||||
log.info({ userId: opts.userId, name }, "started Gitea container");
|
||||
return { id: container.id, name };
|
||||
}
|
||||
|
||||
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);
|
||||
const httpPort = pickPort();
|
||||
const name = `growqr-opencode-${userId}`;
|
||||
const workspaceDir = path.join(userDataDir(userId), "workspace");
|
||||
const name = safeContainerName("growqr-opencode", opts.userId);
|
||||
const workspaceDir = path.join(userDataDir(opts.userId), "workspace");
|
||||
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({
|
||||
name,
|
||||
Image: config.opencodeImage,
|
||||
// OpenCode is expected to expose an HTTP/SSE management surface.
|
||||
// Override CMD when the upstream image's default doesn't suit us.
|
||||
// OpenCode server CLI: `opencode serve --port 4096 --hostname 0.0.0.0`.
|
||||
// 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: [
|
||||
`OPENCODE_SERVER_PASSWORD=${opts.password}`,
|
||||
`OPENCODE_WORKSPACE=/workspace`,
|
||||
`OPENCODE_PORT=4096`,
|
||||
],
|
||||
WorkingDir: "/workspace",
|
||||
HostConfig: {
|
||||
Binds: [`${workspaceDir}:/workspace`],
|
||||
PortBindings: {
|
||||
"4096/tcp": [{ HostPort: String(httpPort) }],
|
||||
"4096/tcp": [{ HostPort: String(opts.httpPort) }],
|
||||
},
|
||||
RestartPolicy: { Name: "unless-stopped" },
|
||||
Memory: 2 * 1024 * 1024 * 1024, // 2 GB cap
|
||||
NanoCpus: 2_000_000_000, // 2 CPUs
|
||||
Memory: 2 * 1024 * 1024 * 1024,
|
||||
NanoCpus: 2_000_000_000,
|
||||
},
|
||||
ExposedPorts: { "4096/tcp": {} },
|
||||
Labels: {
|
||||
"growqr.userId": userId,
|
||||
"growqr.userId": opts.userId,
|
||||
"growqr.role": "opencode",
|
||||
},
|
||||
});
|
||||
await container.start();
|
||||
log.info({ userId, name }, "started OpenCode container");
|
||||
return {
|
||||
id: container.id,
|
||||
name,
|
||||
image: config.opencodeImage,
|
||||
host: config.userContainerHost,
|
||||
ports: { http: httpPort },
|
||||
status: "running",
|
||||
};
|
||||
log.info({ userId: opts.userId, name }, "started OpenCode container");
|
||||
return { id: container.id, name };
|
||||
}
|
||||
|
||||
// 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> {
|
||||
if (stacks.has(userId)) return stacks.get(userId)!;
|
||||
const existing = await db.query.userStacks.findFirst({
|
||||
where: eq(userStacks.userId, userId),
|
||||
});
|
||||
if (existing && existing.status === "running") {
|
||||
return existing;
|
||||
}
|
||||
|
||||
await ensureDir(userDataDir(userId));
|
||||
const gitea = await startGitea(userId);
|
||||
const opencode = await startOpenCode(userId);
|
||||
const stack: UserStack = { userId, gitea, opencode };
|
||||
stacks.set(userId, stack);
|
||||
return stack;
|
||||
|
||||
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 function getUserStack(userId: string): UserStack | undefined {
|
||||
return stacks.get(userId);
|
||||
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> {
|
||||
const stack = stacks.get(userId);
|
||||
const stack = await getUserStack(userId);
|
||||
if (!stack) return;
|
||||
for (const c of [stack.gitea, stack.opencode]) {
|
||||
for (const id of [stack.giteaContainerId, stack.opencodeContainerId]) {
|
||||
if (!id) continue;
|
||||
try {
|
||||
const container = docker.getContainer(c.id);
|
||||
await container.stop({ t: 5 }).catch(() => undefined);
|
||||
await container.remove({ force: true }).catch(() => undefined);
|
||||
for (const port of Object.values(c.ports)) releasePort(port);
|
||||
const c = docker.getContainer(id);
|
||||
await c.stop({ t: 5 }).catch(() => undefined);
|
||||
await c.remove({ force: true }).catch(() => undefined);
|
||||
} 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");
|
||||
}
|
||||
|
||||
export function listStacks(): UserStack[] {
|
||||
return Array.from(stacks.values());
|
||||
export async function listStacks(): Promise<UserStack[]> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
54
src/index.ts
54
src/index.ts
@@ -1,28 +1,74 @@
|
||||
import { serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { config } from "./config.js";
|
||||
import { log } from "./log.js";
|
||||
import { registry } from "./actors/registry.js";
|
||||
import { actorRoutes } from "./routes/actors.js";
|
||||
import { opencodeRoutes } from "./routes/opencode.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() {
|
||||
// Boot-time DB sanity + reconcile.
|
||||
await db.execute("select 1");
|
||||
await hydratePortAllocator();
|
||||
await reconcileOnBoot();
|
||||
|
||||
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).
|
||||
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("/opencode", opencodeRoutes());
|
||||
app.route("/git", gitRoutes());
|
||||
|
||||
serve({ fetch: app.fetch, port: config.port }, (info) => {
|
||||
log.info(
|
||||
{ port: info.port, rivet: config.rivetEndpoint },
|
||||
{
|
||||
port: info.port,
|
||||
rivet: config.rivetEndpoint,
|
||||
env: config.nodeEnv,
|
||||
},
|
||||
"growqr-backend listening",
|
||||
);
|
||||
});
|
||||
|
||||
104
src/lib/anthropic.ts
Normal file
104
src/lib/anthropic.ts
Normal 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
217
src/lib/gitea.ts
Normal 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
159
src/lib/opencode.ts
Normal 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}`);
|
||||
}
|
||||
@@ -1,38 +1,48 @@
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
provisionUserStack,
|
||||
getUserStack,
|
||||
stopUserStack,
|
||||
listStacks,
|
||||
} 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.
|
||||
// v1 talks to the Docker manager directly. The Rivet Kit Grow Agent actor
|
||||
// is mounted at /api/rivet/* and is intended for the frontend's
|
||||
// WebSocket/SSE connection (Rivet Kit React SDK).
|
||||
// All routes are user-scoped via Clerk auth; userId is derived from the
|
||||
// session token, never trusted from the body.
|
||||
export function actorRoutes() {
|
||||
const app = new Hono();
|
||||
|
||||
const ProvisionSchema = z.object({ userId: z.string().min(1) });
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
|
||||
app.post("/provision", async (c) => {
|
||||
const body = ProvisionSchema.parse(await c.req.json());
|
||||
const stack = await provisionUserStack(body.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);
|
||||
const userId = c.get("userId");
|
||||
const stack = await provisionUserStack(userId);
|
||||
return c.json({ userId, stack });
|
||||
});
|
||||
|
||||
app.post("/:userId/stop", async (c) => {
|
||||
await stopUserStack(c.req.param("userId"));
|
||||
app.get("/me", async (c) => {
|
||||
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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,46 +1,100 @@
|
||||
import { Hono } from "hono";
|
||||
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.
|
||||
// Targets the per-user Gitea container spawned by the Docker manager.
|
||||
export function gitRoutes() {
|
||||
const app = new Hono();
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
|
||||
app.post("/users/:userId/repo", async (c) => {
|
||||
const userId = c.req.param("userId");
|
||||
const body = z
|
||||
.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"));
|
||||
app.get("/me", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const stack = await getUserStack(userId);
|
||||
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) => {
|
||||
const userId = c.req.param("userId");
|
||||
const repoName = c.req.param("repoName");
|
||||
app.post("/repos", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
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
|
||||
.object({
|
||||
path: z.string().min(1),
|
||||
content: z.string(),
|
||||
message: z.string().default("update"),
|
||||
branch: z.string().default("main"),
|
||||
message: z.string(),
|
||||
files: z.record(z.string()),
|
||||
})
|
||||
.parse(await c.req.json());
|
||||
const stack = getUserStack(userId);
|
||||
if (!stack) 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 });
|
||||
const client = await giteaClientFor(userId);
|
||||
if (!client) return c.json({ error: "not provisioned" }, 404);
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -1,51 +1,76 @@
|
||||
import { Hono } from "hono";
|
||||
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.
|
||||
// These endpoints control the per-user OpenCode container that sub-agent
|
||||
// workflows run through.
|
||||
// Proxies into the user's OpenCode container's HTTP surface.
|
||||
export function opencodeRoutes() {
|
||||
const app = new Hono();
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
|
||||
app.post("/provision", async (c) => {
|
||||
const body = z
|
||||
.object({ userId: z.string().min(1) })
|
||||
.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);
|
||||
const userId = c.get("userId");
|
||||
const stack = await provisionUserStack(userId);
|
||||
return c.json({
|
||||
sessionId: `sess-${Date.now()}`,
|
||||
target: `${stack.opencode.host}:${stack.opencode.ports.http}`,
|
||||
workspace: {
|
||||
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) => {
|
||||
const userId = c.get("userId");
|
||||
const sessionId = c.req.param("sessionId");
|
||||
const body = z
|
||||
.object({ userId: z.string(), prompt: z.string() })
|
||||
.object({ prompt: z.string().min(1) })
|
||||
.parse(await c.req.json());
|
||||
const stack = getUserStack(body.userId);
|
||||
if (!stack) return c.json({ error: "not provisioned" }, 404);
|
||||
// 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}`,
|
||||
const row = await db.query.opencodeSessions.findFirst({
|
||||
where: eq(opencodeSessions.id, sessionId),
|
||||
});
|
||||
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;
|
||||
|
||||
50
src/routes/users.ts
Normal file
50
src/routes/users.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user