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