Scaffold backend: Hono + Rivet Kit + per-user Docker stack
Backend that provisions per-user OpenCode + Gitea Docker pair via
dockerode and exposes the Grow Agent / sub-agent Rivet Kit actors
described in the PRD. Sub-agent workflows route through the parent
Grow Agent's OpenCode Docker.
- src/docker/manager.ts spawns growqr-gitea-<userId> and growqr-opencode-<userId>
- src/actors/{grow-agent,sub-agent,registry}.ts: Rivet Kit actors
- src/routes/{actors,opencode,git}.ts: PRD section 5.2-5.4 HTTP API
- docker-compose.yml runs rivet-engine + backend (mounts host Docker socket)
- PRD updated to lock in per-user OpenCode/Gitea Docker topology
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
20
.env.example
Normal file
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
PORT=4000
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Rivet Kit engine (self-hosted in docker-compose)
|
||||||
|
RIVET_ENDPOINT=http://localhost:6420
|
||||||
|
|
||||||
|
# Docker images used for per-user containers
|
||||||
|
GITEA_IMAGE=gitea/gitea:1.22
|
||||||
|
OPENCODE_IMAGE=ghcr.io/sst/opencode:latest
|
||||||
|
|
||||||
|
# Host where spawned containers expose their ports (usually localhost in dev)
|
||||||
|
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}
|
||||||
|
USER_DATA_ROOT=./.data/users
|
||||||
|
|
||||||
|
# Port range allocated to spawned per-user containers
|
||||||
|
USER_PORT_RANGE_START=20000
|
||||||
|
USER_PORT_RANGE_END=29999
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Frontend is nested clone — keep it out of backend git for now
|
||||||
|
growqr-frontend
|
||||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM node:22-alpine AS base
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
FROM base AS build
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN npx tsc -p tsconfig.json
|
||||||
|
|
||||||
|
FROM base AS runtime
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY package.json ./
|
||||||
|
EXPOSE 4000
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
101
README.md
Normal file
101
README.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# growqr-backend
|
||||||
|
|
||||||
|
Backend for the Grow Agent Platform (see [`docs/PRD.md`](docs/PRD.md)).
|
||||||
|
|
||||||
|
Per the PRD, every user gets:
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend (Next.js + Rivet Kit React SDK)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Backend (Hono + Rivet Kit client + dockerode)
|
||||||
|
│
|
||||||
|
├─▶ 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)
|
||||||
|
```
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
npm install
|
||||||
|
docker compose up -d rivet-engine # start Rivet engine
|
||||||
|
npm run dev # start backend on :4000
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend pulls the Gitea + OpenCode images on first user provision (no need to pre-pull).
|
||||||
|
|
||||||
|
## Smoke test
|
||||||
|
|
||||||
|
```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"}'
|
||||||
|
|
||||||
|
# Send a message
|
||||||
|
curl -X POST localhost:4000/actors/u_alice/message \
|
||||||
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"text":"hello"}'
|
||||||
|
|
||||||
|
# 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"}'
|
||||||
|
|
||||||
|
# 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.
|
||||||
49
docker-compose.yml
Normal file
49
docker-compose.yml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
services:
|
||||||
|
# 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:
|
||||||
|
image: rivetgg/engine:latest
|
||||||
|
container_name: growqr-rivet
|
||||||
|
ports:
|
||||||
|
- "6420:6420" # API
|
||||||
|
- "6421:6421" # Guard/edge
|
||||||
|
environment:
|
||||||
|
RIVET__AUTH__ADMIN_TOKEN: ${RIVET_ADMIN_TOKEN:-dev-admin-token}
|
||||||
|
volumes:
|
||||||
|
- rivet-data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# The HTTP backend (Hono + Rivet Kit client + Docker manager).
|
||||||
|
# Mounts the host Docker socket so it can spawn per-user containers.
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: growqr-backend
|
||||||
|
depends_on:
|
||||||
|
- rivet-engine
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
environment:
|
||||||
|
PORT: 4000
|
||||||
|
RIVET_ENDPOINT: http://rivet-engine:6420
|
||||||
|
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_DATA_ROOT: /data/users
|
||||||
|
USER_PORT_RANGE_START: 20000
|
||||||
|
USER_PORT_RANGE_END: 29999
|
||||||
|
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
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Note: per-user OpenCode + Gitea containers are NOT defined here.
|
||||||
|
# The backend spawns them dynamically via dockerode on /actors/provision.
|
||||||
|
# See src/docker/manager.ts.
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
rivet-data:
|
||||||
402
docs/PRD.md
Normal file
402
docs/PRD.md
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
# Grow Agent Platform PRD
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
GrowQR is a multi-agent development and personal growth platform where every user gets a dedicated master agent called a Grow Agent. The Grow Agent owns the user's long-running context, memory, workflows, files, repositories, execution state, and progress.
|
||||||
|
|
||||||
|
The platform combines:
|
||||||
|
|
||||||
|
- A Slack-like frontend for chat, progress, and sub-agent channels.
|
||||||
|
- A dedicated Grow Agent per user, implemented as a Rivet Kit actor.
|
||||||
|
- Multiple specialized sub-agents per Grow Agent, also implemented as Rivet Kit actors.
|
||||||
|
- Git-backed memory and state using per-user repositories.
|
||||||
|
- A per-user OpenCode Docker container for coding and workflow execution.
|
||||||
|
- A per-user Gitea Docker container for internal Git hosting.
|
||||||
|
- Rivet Kit actors for durable state, orchestration, and actions.
|
||||||
|
- A frontend that pulls the Rivet Kit React/JS SDK to talk to actors directly.
|
||||||
|
- Backend APIs for auth, actor registry, Docker (OpenCode + Gitea) lifecycle management, payments, and product flows.
|
||||||
|
|
||||||
|
## 2. Product Goals
|
||||||
|
|
||||||
|
- Give every user a persistent AI workspace that can remember, plan, execute, and evolve over time.
|
||||||
|
- Store all user agent state in Git so memory, files, decisions, and workflow history are inspectable and versioned.
|
||||||
|
- Let a master Grow Agent orchestrate specialized sub-agents.
|
||||||
|
- Give each sub-agent isolated execution scope, logs, state, and optionally a Slack-style channel.
|
||||||
|
- Provide a frontend where users can talk to the Grow Agent, watch sub-agent progress, and trigger structured quests or pathways.
|
||||||
|
- Support paid access where payment provisions and starts the user's Grow Agent environment.
|
||||||
|
|
||||||
|
## 3. Core Concepts
|
||||||
|
|
||||||
|
### 3.1 User
|
||||||
|
|
||||||
|
A user is the primary account holder. Each user has:
|
||||||
|
|
||||||
|
- One active Grow Agent.
|
||||||
|
- One or more Git repositories for agent memory and workspace files.
|
||||||
|
- Access to a dedicated OpenCode execution environment.
|
||||||
|
- Access to a Slack-like UI with channels for master and sub-agent activity.
|
||||||
|
|
||||||
|
### 3.2 Grow Agent
|
||||||
|
|
||||||
|
The Grow Agent is the user's master agent and orchestration layer.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- Maintain user-level memory and context.
|
||||||
|
- Decide which sub-agent should handle a task.
|
||||||
|
- Trigger sub-agent workflows, routing every executable workflow through the user's OpenCode Docker.
|
||||||
|
- Track progress and state transitions.
|
||||||
|
- Commit important state changes to the user's Gitea Docker repo.
|
||||||
|
- Coordinate OpenCode sessions for code execution against its dedicated OpenCode Docker.
|
||||||
|
- Expose status to the frontend via the Rivet Kit React SDK.
|
||||||
|
|
||||||
|
The Grow Agent is implemented as a durable Rivet Kit actor. Each Grow Agent owns:
|
||||||
|
|
||||||
|
- Its own OpenCode Docker container (workflows + code execution).
|
||||||
|
- Its own Gitea Docker container (memory + repo storage).
|
||||||
|
- A registry of Rivet Kit sub-agent actors it can spawn and call.
|
||||||
|
|
||||||
|
### 3.3 Sub-Agents
|
||||||
|
|
||||||
|
Sub-agents are specialized Rivet Kit worker actors owned by a Grow Agent. They are orchestrated by the Grow Agent and execute their workflows through the user's OpenCode Docker.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Coding agent using OpenCode.
|
||||||
|
- Repo setup agent using the user's Gitea Docker APIs.
|
||||||
|
- Migration agent.
|
||||||
|
- Product flow agent.
|
||||||
|
- Payment/onboarding agent.
|
||||||
|
- Quest/pathway agent.
|
||||||
|
- Backend CRUD agent.
|
||||||
|
- Frontend planning agent.
|
||||||
|
|
||||||
|
Each sub-agent has:
|
||||||
|
|
||||||
|
- A unique Rivet Kit actor identity.
|
||||||
|
- A bounded workspace directory inside the parent Grow Agent's OpenCode Docker.
|
||||||
|
- A bounded set of capabilities.
|
||||||
|
- A dedicated state object.
|
||||||
|
- A channel/thread in the frontend.
|
||||||
|
- Optional backing microservice integration.
|
||||||
|
|
||||||
|
Sub-agent execution rule: every workflow that needs code, shell, file edits, or generated artifacts is executed via the parent Grow Agent's OpenCode Docker session — sub-agents do not get their own container. The Grow Agent multiplexes OpenCode sessions across its sub-agents.
|
||||||
|
|
||||||
|
### 3.4 Git-Backed State
|
||||||
|
|
||||||
|
Every user gets a Git repository that stores:
|
||||||
|
|
||||||
|
- Agent memory files.
|
||||||
|
- User goals and profile context.
|
||||||
|
- Quest/pathway progress.
|
||||||
|
- Plans, summaries, decisions, and task history.
|
||||||
|
- Generated code/files.
|
||||||
|
- Sub-agent outputs.
|
||||||
|
- System state snapshots where appropriate.
|
||||||
|
|
||||||
|
Gitea is used as the internal Git server, and each Grow Agent runs its own Gitea Docker container so per-user repos are physically isolated.
|
||||||
|
|
||||||
|
Repository model:
|
||||||
|
|
||||||
|
- One Gitea Docker container per Grow Agent (per user).
|
||||||
|
- One primary repo inside that Gitea for Grow Agent memory and workspace state.
|
||||||
|
- Optional child repos inside the same Gitea for generated products or user projects.
|
||||||
|
- All writes from agents go through controlled Git actions against the user's own Gitea Docker.
|
||||||
|
- Important state mutations produce commits with structured messages.
|
||||||
|
|
||||||
|
### 3.5 OpenCode Execution
|
||||||
|
|
||||||
|
OpenCode is used as the code execution and workflow-runtime interface for every sub-agent.
|
||||||
|
|
||||||
|
Topology:
|
||||||
|
|
||||||
|
- Each Grow Agent owns its own OpenCode Docker container (one container per user).
|
||||||
|
- All sub-agent workflows are routed through that OpenCode Docker — it is the single execution surface for the Grow Agent's sub-agents.
|
||||||
|
- OpenCode runs with strict directory boundaries scoped to the user's workspace.
|
||||||
|
- The Grow Agent creates OpenCode sessions per sub-agent task, sends prompts, inspects results, and streams progress.
|
||||||
|
- The container is started on Grow Agent provisioning and stopped on suspension to control cost.
|
||||||
|
|
||||||
|
Open decision (still open):
|
||||||
|
|
||||||
|
- Whether to fork OpenCode and add platform-specific management APIs, or wrap the upstream image behind a backend-controlled management layer (suggested direction in §11).
|
||||||
|
|
||||||
|
## 4. System Architecture
|
||||||
|
|
||||||
|
### 4.1 High-Level Components
|
||||||
|
|
||||||
|
- Frontend app: Slack-like UI, chat interface, quests/pathways, progress views. Pulls the Rivet Kit React SDK for actor connectivity.
|
||||||
|
- Backend API: auth, billing, user management, actor registry, OpenCode Docker lifecycle, Gitea Docker lifecycle.
|
||||||
|
- Rivet Kit actor runtime: durable Grow Agent and sub-agent actors.
|
||||||
|
- Per-user Gitea Docker: one container per Grow Agent, hosting that user's repos.
|
||||||
|
- Per-user OpenCode Docker: one container per Grow Agent, executing all sub-agent workflows.
|
||||||
|
- Docker orchestration layer: provisions, starts, stops, and tears down each user's OpenCode + Gitea container pair.
|
||||||
|
- Database: relational app data such as users, auth, billing, actor registry metadata, repo mappings, and container/host assignments.
|
||||||
|
- Queue/event layer: optional event delivery between backend, actors, OpenCode, and frontend.
|
||||||
|
|
||||||
|
Per-user Docker pair (the "Grow Agent stack"):
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend ──(Rivet Kit React SDK)──▶ Grow Agent Actor (Rivet Kit)
|
||||||
|
│
|
||||||
|
orchestrates sub-agent actors
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────── User N's stack ────────┐
|
||||||
|
│ OpenCode Docker ◀── workflows│
|
||||||
|
│ Gitea Docker ◀── commits │
|
||||||
|
└────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Request Flow
|
||||||
|
|
||||||
|
1. User signs up or logs in.
|
||||||
|
2. Payment or plan check confirms entitlement.
|
||||||
|
3. Backend provisions the user's Grow Agent Rivet Kit actor.
|
||||||
|
4. Backend starts the user's Gitea Docker container and creates the primary memory repo.
|
||||||
|
5. Backend starts the user's OpenCode Docker container and mounts its workspace.
|
||||||
|
6. User sends a message in the frontend (via Rivet Kit React SDK).
|
||||||
|
7. Message is routed to the user's Grow Agent actor.
|
||||||
|
8. Grow Agent updates state, decides whether to spawn/call sub-agent actors, and commits durable context to the user's Gitea Docker when needed.
|
||||||
|
9. Sub-agents run tasks by opening sessions against the user's OpenCode Docker — all workflows flow through this container.
|
||||||
|
10. Progress events stream back to the frontend over the Rivet Kit connection.
|
||||||
|
|
||||||
|
## 5. Backend Requirements
|
||||||
|
|
||||||
|
### 5.1 Auth
|
||||||
|
|
||||||
|
The backend must support:
|
||||||
|
|
||||||
|
- User registration and login.
|
||||||
|
- Session or JWT auth.
|
||||||
|
- Per-user authorization checks for all actor, repo, and OpenCode resources.
|
||||||
|
- Service-to-service auth for actors calling backend APIs.
|
||||||
|
|
||||||
|
Open decision:
|
||||||
|
|
||||||
|
- Auth provider: custom auth, Auth.js/NextAuth, Clerk, Supabase Auth, or another provider.
|
||||||
|
|
||||||
|
### 5.2 Actor Registry API
|
||||||
|
|
||||||
|
The backend needs an actor registry that tracks:
|
||||||
|
|
||||||
|
- User ID.
|
||||||
|
- Grow Agent actor ID.
|
||||||
|
- Sub-agent actor IDs.
|
||||||
|
- Actor type.
|
||||||
|
- Actor status.
|
||||||
|
- Assigned workspace path.
|
||||||
|
- Assigned Gitea repo.
|
||||||
|
- Assigned OpenCode server/workspace.
|
||||||
|
- Last activity timestamp.
|
||||||
|
- Billing entitlement status.
|
||||||
|
|
||||||
|
Initial endpoints:
|
||||||
|
|
||||||
|
- `POST /actors/provision`
|
||||||
|
- `GET /actors/me`
|
||||||
|
- `GET /actors/:actorId`
|
||||||
|
- `POST /actors/:actorId/start`
|
||||||
|
- `POST /actors/:actorId/stop`
|
||||||
|
- `POST /actors/:actorId/message`
|
||||||
|
- `GET /actors/:actorId/events`
|
||||||
|
|
||||||
|
### 5.3 OpenCode Docker Management API
|
||||||
|
|
||||||
|
The backend needs APIs to manage each user's OpenCode Docker container:
|
||||||
|
|
||||||
|
- Provision OpenCode Docker container for a Grow Agent.
|
||||||
|
- Start/stop the OpenCode Docker runtime.
|
||||||
|
- Create coding/workflow sessions inside the container.
|
||||||
|
- Send prompt/action to OpenCode.
|
||||||
|
- Read execution status.
|
||||||
|
- Fetch logs.
|
||||||
|
- Restrict filesystem access to the user's workspace mount.
|
||||||
|
|
||||||
|
Initial endpoints:
|
||||||
|
|
||||||
|
- `POST /opencode/provision`
|
||||||
|
- `GET /opencode/workspaces/:workspaceId`
|
||||||
|
- `POST /opencode/workspaces/:workspaceId/start`
|
||||||
|
- `POST /opencode/workspaces/:workspaceId/stop`
|
||||||
|
- `POST /opencode/workspaces/:workspaceId/sessions`
|
||||||
|
- `POST /opencode/sessions/:sessionId/messages`
|
||||||
|
- `GET /opencode/sessions/:sessionId/events`
|
||||||
|
|
||||||
|
### 5.4 Gitea Docker Management API
|
||||||
|
|
||||||
|
The backend needs APIs for the per-user Gitea Docker container:
|
||||||
|
|
||||||
|
- Provisioning, starting, and stopping the user's Gitea Docker container.
|
||||||
|
- Creating the primary per-user memory repo inside it.
|
||||||
|
- Creating additional project repos inside the same Gitea.
|
||||||
|
- Creating deploy keys or access tokens.
|
||||||
|
- Committing memory/state files.
|
||||||
|
- Reading repository status.
|
||||||
|
- Managing branches.
|
||||||
|
- Creating PRs when needed.
|
||||||
|
|
||||||
|
Initial endpoints:
|
||||||
|
|
||||||
|
- `POST /git/users/:userId/repo`
|
||||||
|
- `GET /git/repos/:repoId`
|
||||||
|
- `POST /git/repos/:repoId/commit`
|
||||||
|
- `POST /git/repos/:repoId/branch`
|
||||||
|
- `POST /git/repos/:repoId/pull-request`
|
||||||
|
|
||||||
|
### 5.5 Payments
|
||||||
|
|
||||||
|
Payment controls whether the user's Grow Agent can be provisioned or run.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- Checkout flow.
|
||||||
|
- Webhook handling.
|
||||||
|
- Subscription status.
|
||||||
|
- Entitlement checks before actor startup.
|
||||||
|
- Graceful suspension when payment fails.
|
||||||
|
|
||||||
|
Open decision:
|
||||||
|
|
||||||
|
- Payment provider, likely Stripe.
|
||||||
|
|
||||||
|
## 6. Actor Requirements
|
||||||
|
|
||||||
|
### 6.1 Grow Agent Actor
|
||||||
|
|
||||||
|
State:
|
||||||
|
|
||||||
|
- User profile summary.
|
||||||
|
- Active goals.
|
||||||
|
- Current quest/pathway.
|
||||||
|
- Memory index.
|
||||||
|
- Sub-agent registry.
|
||||||
|
- Active tasks.
|
||||||
|
- Git repo mapping.
|
||||||
|
- OpenCode workspace mapping.
|
||||||
|
- Recent conversation summary.
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
|
||||||
|
- Receive user message.
|
||||||
|
- Update memory.
|
||||||
|
- Start quest/pathway.
|
||||||
|
- Spawn or call sub-agent.
|
||||||
|
- Commit state to Git.
|
||||||
|
- Request OpenCode session.
|
||||||
|
- Emit progress event.
|
||||||
|
|
||||||
|
### 6.2 Sub-Agent Actor
|
||||||
|
|
||||||
|
State:
|
||||||
|
|
||||||
|
- Parent Grow Agent ID.
|
||||||
|
- Task type.
|
||||||
|
- Current task status.
|
||||||
|
- Workspace path.
|
||||||
|
- Tool permissions.
|
||||||
|
- Channel ID.
|
||||||
|
- Logs/progress.
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
|
||||||
|
- Start task.
|
||||||
|
- Pause/resume task.
|
||||||
|
- Run bounded service action.
|
||||||
|
- Run OpenCode action where allowed.
|
||||||
|
- Commit output to Git.
|
||||||
|
- Emit progress event.
|
||||||
|
|
||||||
|
## 7. Frontend Requirements
|
||||||
|
|
||||||
|
The frontend is next, but the backend should be shaped around this UI. The frontend is pulled in as a separate app that consumes the Rivet Kit React/JS SDK to connect directly to the user's Grow Agent actor (chat + streaming events), and uses the backend REST APIs only for auth, billing, and Docker lifecycle controls.
|
||||||
|
|
||||||
|
Primary screens:
|
||||||
|
|
||||||
|
- Master Grow Agent chat.
|
||||||
|
- Slack-like channel list.
|
||||||
|
- Sub-agent channel/thread view.
|
||||||
|
- Quest/pathway launcher.
|
||||||
|
- Task progress timeline.
|
||||||
|
- Files/repo activity view.
|
||||||
|
- Billing/account state.
|
||||||
|
|
||||||
|
UI behavior:
|
||||||
|
|
||||||
|
- User talks primarily to the Grow Agent.
|
||||||
|
- Grow Agent messages can show spawned sub-agent activity.
|
||||||
|
- Each sub-agent gets a visible channel or thread.
|
||||||
|
- Quests/pathways appear as predefined flows that trigger agent prompts and actions.
|
||||||
|
|
||||||
|
## 8. Security and Isolation
|
||||||
|
|
||||||
|
Hard requirements:
|
||||||
|
|
||||||
|
- Every user must have isolated directories.
|
||||||
|
- Actors can only access assigned workspace roots.
|
||||||
|
- OpenCode Docker must run with strict workspace and command restrictions, and each user's container is network-isolated from other users' containers.
|
||||||
|
- Each user's Gitea Docker is reachable only from that user's Grow Agent + sub-agents and the backend.
|
||||||
|
- Backend must verify user ownership on every actor, repo, and OpenCode request.
|
||||||
|
- Sub-agents should receive only the capabilities needed for their task.
|
||||||
|
- Per-user OpenCode and Gitea Docker containers must have CPU/memory limits to prevent noisy-neighbor and runaway-cost issues.
|
||||||
|
|
||||||
|
## 9. MVP Scope
|
||||||
|
|
||||||
|
### MVP Backend
|
||||||
|
|
||||||
|
- User auth.
|
||||||
|
- Actor registry.
|
||||||
|
- One Grow Agent Rivet Kit actor per user.
|
||||||
|
- Basic sub-agent (Rivet Kit) registration under the Grow Agent.
|
||||||
|
- Per-user Gitea Docker provisioning and primary repo creation.
|
||||||
|
- Basic Git-backed memory commits into the user's Gitea Docker.
|
||||||
|
- Per-user OpenCode Docker provisioning + session API.
|
||||||
|
- Message endpoint from frontend to Grow Agent.
|
||||||
|
- Event stream endpoint for progress.
|
||||||
|
|
||||||
|
### MVP Frontend
|
||||||
|
|
||||||
|
- Pulled as a separate app that consumes the Rivet Kit React SDK.
|
||||||
|
- Login.
|
||||||
|
- Master chat interface (Rivet Kit actor connection to the Grow Agent).
|
||||||
|
- Sub-agent progress sidebar.
|
||||||
|
- Basic quest launcher.
|
||||||
|
- Channel-style task logs.
|
||||||
|
|
||||||
|
### MVP Actors
|
||||||
|
|
||||||
|
- Grow Agent actor with memory and routing.
|
||||||
|
- Coding sub-agent using OpenCode.
|
||||||
|
- Repo sub-agent using Gitea.
|
||||||
|
- Quest sub-agent using predefined prompts.
|
||||||
|
|
||||||
|
## 10. Key Open Questions
|
||||||
|
|
||||||
|
Resolved:
|
||||||
|
|
||||||
|
- ~~Should there be one OpenCode container per user or per active coding sub-agent?~~ → One OpenCode Docker container per Grow Agent (per user). Sub-agents share it via separate sessions.
|
||||||
|
- ~~Gitea topology?~~ → One Gitea Docker container per Grow Agent (per user).
|
||||||
|
- ~~Actor framework?~~ → Rivet Kit for both Grow Agent and sub-agents.
|
||||||
|
- ~~How does the frontend connect to actors?~~ → Frontend pulls and uses the Rivet Kit React/JS SDK.
|
||||||
|
|
||||||
|
Still open:
|
||||||
|
|
||||||
|
- Should OpenCode be forked, wrapped, or used as-is behind a management API?
|
||||||
|
- Should the user memory repo and generated product repos be separate (both inside the same per-user Gitea)?
|
||||||
|
- Which auth provider should be used?
|
||||||
|
- Which database should be used for backend metadata?
|
||||||
|
- How much state should live in Rivet actors versus Git versus database?
|
||||||
|
- Should sub-agent channels map to real Slack channels, an internal Slack-like UI, or both?
|
||||||
|
- What is the exact boundary between a sub-agent actor and a microservice wrapper?
|
||||||
|
- What payment provider and pricing model should be used?
|
||||||
|
- Which Docker orchestrator runs the per-user OpenCode + Gitea pair (plain Docker, Docker Compose per user, Nomad, k8s, Fly Machines, etc.)?
|
||||||
|
|
||||||
|
## 11. Suggested Technical Direction
|
||||||
|
|
||||||
|
- Use the backend database for account, billing, registry, and resource mappings (including per-user OpenCode/Gitea container IDs and hosts).
|
||||||
|
- Use Rivet Kit actors for durable runtime state and orchestration; the Grow Agent is the orchestrator actor and sub-agents are child actors.
|
||||||
|
- Use Git/Gitea (per-user Docker) for long-term memory, files, project artifacts, and auditable history.
|
||||||
|
- Use a per-user OpenCode Docker behind a backend-controlled management layer; sub-agent workflows execute via OpenCode sessions, not by spawning their own containers.
|
||||||
|
- Pull the frontend as a separate app that consumes the Rivet Kit React SDK for actor traffic and the backend REST API for control plane.
|
||||||
|
- Start with an internal Slack-like frontend channel model before integrating real Slack.
|
||||||
|
- Avoid forking OpenCode until the wrapper approach proves insufficient.
|
||||||
|
|
||||||
4879
package-lock.json
generated
Normal file
4879
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "growqr-backend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"compose:up": "docker compose up -d",
|
||||||
|
"compose:down": "docker compose down"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/node-server": "^1.13.7",
|
||||||
|
"dockerode": "^4.0.7",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"hono": "^4.6.14",
|
||||||
|
"pino": "^9.5.0",
|
||||||
|
"pino-pretty": "^13.0.0",
|
||||||
|
"rivetkit": "^2.2.1",
|
||||||
|
"zod": "^3.24.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/dockerode": "^3.3.32",
|
||||||
|
"@types/node": "^22.10.5",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/actors/grow-agent.ts
Normal file
110
src/actors/grow-agent.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { actor } from "rivetkit";
|
||||||
|
import { log } from "../log.js";
|
||||||
|
import {
|
||||||
|
provisionUserStack,
|
||||||
|
getUserStack,
|
||||||
|
stopUserStack,
|
||||||
|
type UserStack,
|
||||||
|
} from "../docker/manager.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 GrowAgentState = {
|
||||||
|
userId: string;
|
||||||
|
profileSummary: string;
|
||||||
|
goals: string[];
|
||||||
|
memory: Memory[];
|
||||||
|
subAgents: Record<string, SubAgentRef>;
|
||||||
|
stack: UserStack | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: GrowAgentState = {
|
||||||
|
userId: "",
|
||||||
|
profileSummary: "",
|
||||||
|
goals: [],
|
||||||
|
memory: [],
|
||||||
|
subAgents: {},
|
||||||
|
stack: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
export const growAgent = actor({
|
||||||
|
state: initialState,
|
||||||
|
actions: {
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return c.state.stack;
|
||||||
|
},
|
||||||
|
|
||||||
|
shutdown: async (c) => {
|
||||||
|
if (c.state.userId) await stopUserStack(c.state.userId);
|
||||||
|
c.state.stack = null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
12
src/actors/registry.ts
Normal file
12
src/actors/registry.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { setup } from "rivetkit";
|
||||||
|
import { growAgent } from "./grow-agent.js";
|
||||||
|
import { subAgent } from "./sub-agent.js";
|
||||||
|
|
||||||
|
export const registry = setup({
|
||||||
|
use: {
|
||||||
|
growAgent,
|
||||||
|
subAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Registry = typeof registry;
|
||||||
69
src/actors/sub-agent.ts
Normal file
69
src/actors/sub-agent.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { actor } from "rivetkit";
|
||||||
|
import { log } from "../log.js";
|
||||||
|
import { getUserStack } from "../docker/manager.js";
|
||||||
|
|
||||||
|
type SubAgentState = {
|
||||||
|
parentUserId: string;
|
||||||
|
type: string;
|
||||||
|
status: "idle" | "running" | "done" | "error";
|
||||||
|
workspacePath: string;
|
||||||
|
channelId: string;
|
||||||
|
logs: { ts: number; level: "info" | "warn" | "error"; msg: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
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).
|
||||||
|
export const subAgent = actor({
|
||||||
|
state: initialState,
|
||||||
|
actions: {
|
||||||
|
init: async (
|
||||||
|
c,
|
||||||
|
input: { parentUserId: string; type: string; channelId: string },
|
||||||
|
) => {
|
||||||
|
c.state.parentUserId = input.parentUserId;
|
||||||
|
c.state.type = input.type;
|
||||||
|
c.state.channelId = input.channelId;
|
||||||
|
},
|
||||||
|
|
||||||
|
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" });
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
},
|
||||||
|
|
||||||
|
getLogs: async (c) => c.state.logs,
|
||||||
|
getStatus: async (c) => c.state.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
13
src/config.ts
Normal file
13
src/config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
port: Number(process.env.PORT ?? 4000),
|
||||||
|
logLevel: process.env.LOG_LEVEL ?? "info",
|
||||||
|
rivetEndpoint: process.env.RIVET_ENDPOINT ?? "http://localhost:6420",
|
||||||
|
giteaImage: process.env.GITEA_IMAGE ?? "gitea/gitea:1.22",
|
||||||
|
opencodeImage: process.env.OPENCODE_IMAGE ?? "ghcr.io/sst/opencode:latest",
|
||||||
|
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),
|
||||||
|
} as const;
|
||||||
188
src/docker/manager.ts
Normal file
188
src/docker/manager.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import Docker from "dockerode";
|
||||||
|
import { mkdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { log } from "../log.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";
|
||||||
|
};
|
||||||
|
|
||||||
|
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>();
|
||||||
|
const allocatedPorts = new Set<number>();
|
||||||
|
|
||||||
|
function pickPort(): number {
|
||||||
|
for (let p = config.userPortRangeStart; p <= config.userPortRangeEnd; p++) {
|
||||||
|
if (!allocatedPorts.has(p)) {
|
||||||
|
allocatedPorts.add(p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("No free ports in USER_PORT_RANGE");
|
||||||
|
}
|
||||||
|
|
||||||
|
function releasePort(port: number) {
|
||||||
|
allocatedPorts.delete(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureImage(image: string) {
|
||||||
|
try {
|
||||||
|
await docker.getImage(image).inspect();
|
||||||
|
} catch {
|
||||||
|
log.info({ image }, "pulling image");
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
docker.pull(image, (err: Error | null, stream: NodeJS.ReadableStream) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
docker.modem.followProgress(stream, (e) => (e ? reject(e) : resolve()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDir(p: string) {
|
||||||
|
await mkdir(p, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function userDataDir(userId: string) {
|
||||||
|
return path.resolve(config.userDataRoot, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startGitea(userId: string): Promise<ContainerInfo> {
|
||||||
|
await ensureImage(config.giteaImage);
|
||||||
|
const httpPort = pickPort();
|
||||||
|
const sshPort = pickPort();
|
||||||
|
const name = `growqr-gitea-${userId}`;
|
||||||
|
const dataDir = path.join(userDataDir(userId), "gitea");
|
||||||
|
await ensureDir(dataDir);
|
||||||
|
|
||||||
|
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__security__INSTALL_LOCK=true",
|
||||||
|
],
|
||||||
|
HostConfig: {
|
||||||
|
Binds: [`${dataDir}:/data`],
|
||||||
|
PortBindings: {
|
||||||
|
"3000/tcp": [{ HostPort: String(httpPort) }],
|
||||||
|
"22/tcp": [{ HostPort: String(sshPort) }],
|
||||||
|
},
|
||||||
|
RestartPolicy: { Name: "unless-stopped" },
|
||||||
|
Memory: 1 * 1024 * 1024 * 1024, // 1 GB cap
|
||||||
|
NanoCpus: 1_000_000_000, // 1 CPU
|
||||||
|
},
|
||||||
|
ExposedPorts: { "3000/tcp": {}, "22/tcp": {} },
|
||||||
|
Labels: {
|
||||||
|
"growqr.userId": 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startOpenCode(userId: string): Promise<ContainerInfo> {
|
||||||
|
await ensureImage(config.opencodeImage);
|
||||||
|
const httpPort = pickPort();
|
||||||
|
const name = `growqr-opencode-${userId}`;
|
||||||
|
const workspaceDir = path.join(userDataDir(userId), "workspace");
|
||||||
|
await ensureDir(workspaceDir);
|
||||||
|
|
||||||
|
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.
|
||||||
|
Env: [
|
||||||
|
`OPENCODE_WORKSPACE=/workspace`,
|
||||||
|
`OPENCODE_PORT=4096`,
|
||||||
|
],
|
||||||
|
HostConfig: {
|
||||||
|
Binds: [`${workspaceDir}:/workspace`],
|
||||||
|
PortBindings: {
|
||||||
|
"4096/tcp": [{ HostPort: String(httpPort) }],
|
||||||
|
},
|
||||||
|
RestartPolicy: { Name: "unless-stopped" },
|
||||||
|
Memory: 2 * 1024 * 1024 * 1024, // 2 GB cap
|
||||||
|
NanoCpus: 2_000_000_000, // 2 CPUs
|
||||||
|
},
|
||||||
|
ExposedPorts: { "4096/tcp": {} },
|
||||||
|
Labels: {
|
||||||
|
"growqr.userId": 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function provisionUserStack(userId: string): Promise<UserStack> {
|
||||||
|
if (stacks.has(userId)) return stacks.get(userId)!;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserStack(userId: string): UserStack | undefined {
|
||||||
|
return stacks.get(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopUserStack(userId: string): Promise<void> {
|
||||||
|
const stack = stacks.get(userId);
|
||||||
|
if (!stack) return;
|
||||||
|
for (const c of [stack.gitea, stack.opencode]) {
|
||||||
|
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);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ err, id: c.id }, "failed to stop container");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stacks.delete(userId);
|
||||||
|
log.info({ userId }, "stopped user stack");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listStacks(): UserStack[] {
|
||||||
|
return Array.from(stacks.values());
|
||||||
|
}
|
||||||
34
src/index.ts
Normal file
34
src/index.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { serve } from "@hono/node-server";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
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";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const app = new Hono();
|
||||||
|
app.get("/", (c) => c.json({ name: "growqr-backend", status: "ok" }));
|
||||||
|
app.get("/healthz", (c) => c.json({ ok: true }));
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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 },
|
||||||
|
"growqr-backend listening",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
log.error({ err }, "fatal startup error");
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
10
src/log.ts
Normal file
10
src/log.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import pino from "pino";
|
||||||
|
import { config } from "./config.js";
|
||||||
|
|
||||||
|
export const log = pino({
|
||||||
|
level: config.logLevel,
|
||||||
|
transport:
|
||||||
|
process.env.NODE_ENV === "production"
|
||||||
|
? undefined
|
||||||
|
: { target: "pino-pretty", options: { colorize: true } },
|
||||||
|
});
|
||||||
40
src/routes/actors.ts
Normal file
40
src/routes/actors.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
provisionUserStack,
|
||||||
|
getUserStack,
|
||||||
|
stopUserStack,
|
||||||
|
listStacks,
|
||||||
|
} from "../docker/manager.js";
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
export function actorRoutes() {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
const ProvisionSchema = z.object({ userId: z.string().min(1) });
|
||||||
|
|
||||||
|
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);
|
||||||
|
return c.json({ userId, stack });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/:userId/stop", async (c) => {
|
||||||
|
await stopUserStack(c.req.param("userId"));
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
47
src/routes/git.ts
Normal file
47
src/routes/git.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getUserStack, provisionUserStack } from "../docker/manager.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();
|
||||||
|
|
||||||
|
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"));
|
||||||
|
if (!stack) return c.json({ error: "not provisioned" }, 404);
|
||||||
|
return c.json({ gitea: stack.gitea });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/users/:userId/repos/:repoName/commit", async (c) => {
|
||||||
|
const userId = c.req.param("userId");
|
||||||
|
const repoName = c.req.param("repoName");
|
||||||
|
const body = z
|
||||||
|
.object({
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
52
src/routes/opencode.ts
Normal file
52
src/routes/opencode.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getUserStack, provisionUserStack } from "../docker/manager.js";
|
||||||
|
|
||||||
|
// PRD §5.3 — OpenCode Docker management API.
|
||||||
|
// These endpoints control the per-user OpenCode container that sub-agent
|
||||||
|
// workflows run through.
|
||||||
|
export function opencodeRoutes() {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
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);
|
||||||
|
return c.json({
|
||||||
|
sessionId: `sess-${Date.now()}`,
|
||||||
|
target: `${stack.opencode.host}:${stack.opencode.ports.http}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/sessions/:sessionId/messages", async (c) => {
|
||||||
|
const body = z
|
||||||
|
.object({ userId: z.string(), prompt: z.string() })
|
||||||
|
.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}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "growqr-frontend"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user