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:
sai karthik
2026-05-18 15:12:34 +05:30
commit 5eaf52b8a5
19 changed files with 6106 additions and 0 deletions

20
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}