From 54297496a4c33ca15a4e827420925e555dd4dfb6 Mon Sep 17 00:00:00 2001 From: NinjasPyajamas Date: Mon, 25 May 2026 17:52:40 +0530 Subject: [PATCH] feat: Enhance Gitea integration and agent management - Added ensureOrg and ensureOrgRepo methods to GiteaClient for centralized organization and repository management. - Updated main application flow to ensure central Gitea readiness at startup. - Introduced prompt-loader for dynamic loading of agent modules and system prompts from disk. - Refactored agent routes to return sub-agent module catalog. - Modified git routes to interact with a central Gitea instance instead of per-user containers. - Updated workflow routes to utilize a unified user actor per user, streamlining job application workflows. - Improved service agent handling with a new lightweight reference type. --- .env.example | 26 +- Dockerfile | 7 + agents/emily.md | 10 + agents/job-apply.md | 11 + agents/job-search.md | 11 + agents/qscore.md | 11 + agents/resume.md | 11 + agents/sara.md | 10 + docker-compose.yml | 70 +- drizzle/0001_central_gitea_unified_actor.sql | 32 + prompts/system.txt | 19 + src/actors/grow-agent.ts | 338 --------- src/actors/registry.ts | 10 +- src/actors/sub-agent-runner.ts | 103 --- src/actors/sub-agent.ts | 83 -- src/actors/user-actor.ts | 748 +++++++++++++++++++ src/actors/workflow-job.ts | 292 -------- src/agents/catalog.ts | 131 ++-- src/config.ts | 25 +- src/db/schema.ts | 32 +- src/docker/manager.ts | 499 +++++++------ src/index.ts | 20 +- src/lib/gitea.ts | 58 ++ src/lib/llm.ts | 115 +-- src/lib/prompt-loader.ts | 168 +++++ src/routes/actors.ts | 6 +- src/routes/agents.ts | 5 +- src/routes/git.ts | 38 +- src/routes/workflows.ts | 36 +- src/services/service-agents.ts | 13 +- 30 files changed, 1615 insertions(+), 1323 deletions(-) create mode 100644 agents/emily.md create mode 100644 agents/job-apply.md create mode 100644 agents/job-search.md create mode 100644 agents/qscore.md create mode 100644 agents/resume.md create mode 100644 agents/sara.md create mode 100644 drizzle/0001_central_gitea_unified_actor.sql create mode 100644 prompts/system.txt delete mode 100644 src/actors/grow-agent.ts delete mode 100644 src/actors/sub-agent-runner.ts delete mode 100644 src/actors/sub-agent.ts create mode 100644 src/actors/user-actor.ts delete mode 100644 src/actors/workflow-job.ts create mode 100644 src/lib/prompt-loader.ts diff --git a/.env.example b/.env.example index 9f7b3d0..eeec80e 100644 --- a/.env.example +++ b/.env.example @@ -3,9 +3,9 @@ LOG_LEVEL=info NODE_ENV=development # Postgres (started by docker-compose; defaults match the compose service) -DATABASE_URL=postgres://growqr:growqr@localhost:5432/growqr +DATABASE_URL=***************************************/growqr POSTGRES_USER=growqr -POSTGRES_PASSWORD=growqr +POSTGRES_PASSWORD=****** POSTGRES_DB=growqr # Clerk auth — get from dashboard.clerk.com → API Keys @@ -18,12 +18,23 @@ LLM_PROVIDER=opencode LLM_BASE_URL=https://opencode.ai/zen/v1 LLM_MODEL=kimi-k2.6 GROW_AGENT_MODEL=kimi-k2.6 -SUB_AGENT_MODEL=kimi-k2.6 MAX_AGENT_TOKENS=4096 # Shared secret for actor → backend service calls (rotate in prod) SERVICE_TOKEN=dev-service-token-REPLACE_ME -A2A_ALLOWED_KEY=dev-a2a-key +A2A_ALLOWED_KEY=*********** + +# ── Central Gitea (shared org-wide, changes.md §2A) ── +GITEA_URL=http://127.0.0.1:3001 +GITEA_ADMIN_USER=growqr-admin +GITEA_ADMIN_PASSWORD=growqr-admin-dev +GITEA_ADMIN_TOKEN= +GITEA_ORG_NAME=growqr + +# ── Version tracking (changes.md §9) ── +OPENCODE_IMAGE_VERSION=1.0.0 +MIGRATION_VERSION=1 +PROMPT_VERSION=1 # Rivet Kit engine (self-hosted in docker-compose) RIVET_ENDPOINT=http://localhost:6420 @@ -34,9 +45,8 @@ INTERVIEW_SERVICE_URL=http://localhost:8007 ROLEPLAY_SERVICE_URL=http://localhost:8008 QSCORE_SERVICE_URL=http://localhost:8000 -# Per-user container images -GITEA_IMAGE=gitea/gitea:1.22 -OPENCODE_IMAGE=ghcr.io/sst/opencode:latest +# Per-user OpenCode container image (shared, changes.md §3) +OPENCODE_IMAGE=ghcr.io/anomalyco/opencode:latest # Host where spawned containers expose their ports. # - localhost in dev @@ -46,7 +56,7 @@ USER_CONTAINER_HOST=127.0.0.1 # Workspace root on the host. Each user gets a subdir. USER_DATA_ROOT=./.data/users -# Port range allocated to spawned per-user containers +# Port range allocated to per-user OpenCode containers (Gitea is central) USER_PORT_RANGE_START=20000 USER_PORT_RANGE_END=29999 diff --git a/Dockerfile b/Dockerfile index f25cf09..4bbb2e8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,5 +16,12 @@ ENV NODE_ENV=production COPY --from=deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist COPY package.json ./ + +# ── Build-time prompt loading (changes.md §3) ── +# Prompts and agent definitions are copied into the image so they are +# embedded at build time. To update: edit files → rebuild image → rollout. +COPY prompts/ ./prompts/ +COPY agents/ ./agents/ + EXPOSE 4000 CMD ["node", "dist/index.js"] diff --git a/agents/emily.md b/agents/emily.md new file mode 100644 index 0000000..905bba4 --- /dev/null +++ b/agents/emily.md @@ -0,0 +1,10 @@ +--- +id: emily +name: Emily +role: Roleplay Agent +service: roleplay-service +tools: + - start_roleplay_session +--- + +Runs roleplay practice through the roleplay-service microservice and owns scenario feedback. diff --git a/agents/job-apply.md b/agents/job-apply.md new file mode 100644 index 0000000..885af86 --- /dev/null +++ b/agents/job-apply.md @@ -0,0 +1,11 @@ +--- +id: job-apply +name: Job Apply Agent +role: Application Operator +tools: + - prepare_application + - track_submission + - schedule_followup +--- + +Prepares tailored applications, tracks submissions, and records follow-up tasks. diff --git a/agents/job-search.md b/agents/job-search.md new file mode 100644 index 0000000..9113fb2 --- /dev/null +++ b/agents/job-search.md @@ -0,0 +1,11 @@ +--- +id: job-search +name: Job Search Agent +role: Opportunity Scout +tools: + - search_jobs + - rank_opportunities + - prepare_shortlist +--- + +Finds relevant jobs, ranks opportunities, and prepares a shortlist for the application workflow. diff --git a/agents/qscore.md b/agents/qscore.md new file mode 100644 index 0000000..76d6b24 --- /dev/null +++ b/agents/qscore.md @@ -0,0 +1,11 @@ +--- +id: qscore +name: Quinn +role: Q-Score Agent +service: qscore-service +tools: + - compute_qscore + - ingest_signals +--- + +Computes and explains Q-Score changes, then displays Q&A and scores. diff --git a/agents/resume.md b/agents/resume.md new file mode 100644 index 0000000..f0888c6 --- /dev/null +++ b/agents/resume.md @@ -0,0 +1,11 @@ +--- +id: resume +name: Resume Agent +role: Resume Builder +tools: + - build_resume + - review_resume + - tailor_resume +--- + +Turns profile context, Q-Score gaps, and target roles into resume edits and application collateral. diff --git a/agents/sara.md b/agents/sara.md new file mode 100644 index 0000000..b26fc37 --- /dev/null +++ b/agents/sara.md @@ -0,0 +1,10 @@ +--- +id: sara +name: Sara +role: Interview Agent +service: interview-service +tools: + - start_interview_session +--- + +Runs interview practice through the interview-service microservice and owns interview Q&A feedback. diff --git a/docker-compose.yml b/docker-compose.yml index ff3459c..29394e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,8 +19,33 @@ services: retries: 10 restart: unless-stopped + # ── Central Gitea (one org-wide instance, changes.md §2A) ── + # Every user gets a repo inside the GrowQR organization on this instance. + # Per-user Gitea containers are REMOVED — the backend no longer spawns them. + gitea: + image: gitea/gitea:1.22 + container_name: growqr-gitea + environment: + USER_UID: "1000" + USER_GID: "1000" + GITEA__server__ROOT_URL: http://localhost:3001 + GITEA__server__SSH_PORT: "2222" + GITEA__security__INSTALL_LOCK: "true" + GITEA__service__DISABLE_REGISTRATION: "true" + ports: + - "3001:3001" # HTTP + - "2222:2222" # SSH + volumes: + - gitea-data:/data + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/v1/version || exit 1"] + interval: 10s + timeout: 10s + retries: 15 + restart: unless-stopped + # Self-hosted Rivet engine. The backend's Rivet Kit client connects here. - # Per the PRD, the Grow Agent + sub-agents are durable actors running on Rivet. + # The unified user agent runs as a durable Rivet actor (changes.md §5). rivet-engine: image: rivetgg/engine:latest container_name: growqr-rivet @@ -34,7 +59,7 @@ services: restart: unless-stopped # The HTTP backend (Hono + Rivet Kit client + Docker manager). - # Mounts the host Docker socket so it can spawn per-user containers. + # Mounts the host Docker socket so it can spawn per-user OpenCode containers. backend: build: context: . @@ -43,6 +68,8 @@ services: depends_on: postgres: condition: service_healthy + gitea: + condition: service_healthy rivet-engine: condition: service_started ports: @@ -51,30 +78,44 @@ services: PORT: 4000 NODE_ENV: ${NODE_ENV:-production} DATABASE_URL: postgres://${POSTGRES_USER:-growqr}:${POSTGRES_PASSWORD:-growqr}@postgres:5432/${POSTGRES_DB:-growqr} + # Central Gitea (shared org-wide instance) + GITEA_URL: http://gitea:3001 + GITEA_ADMIN_USER: ${GITEA_ADMIN_USER:-growqr-admin} + GITEA_ADMIN_PASSWORD: ${GITEA_ADMIN_PASSWORD:-growqr-admin-dev} + GITEA_ADMIN_TOKEN: ${GITEA_ADMIN_TOKEN:-} + GITEA_ORG_NAME: ${GITEA_ORG_NAME:-growqr} + # Version tracking for image rollouts (changes.md §9) + OPENCODE_IMAGE_VERSION: ${OPENCODE_IMAGE_VERSION:-1.0.0} + MIGRATION_VERSION: ${MIGRATION_VERSION:-1} + PROMPT_VERSION: ${PROMPT_VERSION:-1} + # Rivet RIVET_ENDPOINT: http://rivet-engine:6420 + RIVET_CLIENT_ENDPOINT: ${RIVET_CLIENT_ENDPOINT:-http://127.0.0.1:4000/api/rivet} + # Auth CLERK_SECRET_KEY: ${CLERK_SECRET_KEY} CLERK_PUBLISHABLE_KEY: ${CLERK_PUBLISHABLE_KEY} + SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token} + A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:************} + # LLM OPENCODE_API_KEY: ${OPENCODE_API_KEY} LLM_PROVIDER: ${LLM_PROVIDER:-opencode} LLM_BASE_URL: ${LLM_BASE_URL:-https://opencode.ai/zen/v1} LLM_MODEL: ${LLM_MODEL:-kimi-k2.6} GROW_AGENT_MODEL: ${GROW_AGENT_MODEL:-kimi-k2.6} - SUB_AGENT_MODEL: ${SUB_AGENT_MODEL:-kimi-k2.6} - SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token} - A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:-dev-a2a-key} - RIVET_CLIENT_ENDPOINT: ${RIVET_CLIENT_ENDPOINT:-http://127.0.0.1:4000/api/rivet} - GITEA_IMAGE: ${GITEA_IMAGE:-gitea/gitea:1.22} + # Per-user OpenCode containers OPENCODE_IMAGE: ${OPENCODE_IMAGE:-ghcr.io/anomalyco/opencode:latest} - INTERVIEW_SERVICE_URL: ${INTERVIEW_SERVICE_URL:-http://host.docker.internal:8007} - ROLEPLAY_SERVICE_URL: ${ROLEPLAY_SERVICE_URL:-http://host.docker.internal:8008} - QSCORE_SERVICE_URL: ${QSCORE_SERVICE_URL:-http://host.docker.internal:8000} USER_CONTAINER_HOST: ${USER_CONTAINER_HOST:-host.docker.internal} USER_DATA_ROOT: /data/users USER_PORT_RANGE_START: 20000 USER_PORT_RANGE_END: 29999 + # Microservices + INTERVIEW_SERVICE_URL: ${INTERVIEW_SERVICE_URL:-http://host.docker.internal:8007} + ROLEPLAY_SERVICE_URL: ${ROLEPLAY_SERVICE_URL:-http://host.docker.internal:8008} + QSCORE_SERVICE_URL: ${QSCORE_SERVICE_URL:-http://host.docker.internal:8000} + # Frontend FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000} volumes: - # Docker-out-of-Docker: backend uses host Docker to spawn user containers. + # Docker-out-of-Docker: backend uses host Docker to spawn per-user OpenCode 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). @@ -86,10 +127,11 @@ services: retries: 6 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. +# Only per-user OpenCode containers are spawned dynamically now. +# Gitea is a central shared service defined above. +# See src/docker/manager.ts for the per-user OpenCode lifecycle. volumes: rivet-data: postgres-data: + gitea-data: diff --git a/drizzle/0001_central_gitea_unified_actor.sql b/drizzle/0001_central_gitea_unified_actor.sql new file mode 100644 index 0000000..d361cf8 --- /dev/null +++ b/drizzle/0001_central_gitea_unified_actor.sql @@ -0,0 +1,32 @@ +-- Migration: Central Gitea + Unified Actor (changes.md Phase 6) +-- Renames/replaces per-user Gitea fields with central Gitea repo references. +-- Simplifies actors table for unified user actor model. +-- Adds version tracking columns. + +-- 1. Drop old per-user Gitea columns from user_stacks +ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_container_id; +ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_container_name; +ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_host; +ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_http_port; +ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_ssh_port; +ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_admin_user; +ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_admin_token; +ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_memory_repo; + +-- 2. Add central Gitea repo fields +ALTER TABLE user_stacks ADD COLUMN IF NOT EXISTS gitea_repo_name TEXT; +ALTER TABLE user_stacks ADD COLUMN IF NOT EXISTS gitea_repo_owner TEXT; + +-- 3. Add version tracking columns (changes.md §9) +ALTER TABLE user_stacks ADD COLUMN IF NOT EXISTS image_version TEXT; +ALTER TABLE user_stacks ADD COLUMN IF NOT EXISTS migration_version TEXT; +ALTER TABLE user_stacks ADD COLUMN IF NOT EXISTS prompt_version TEXT; + +-- 4. Simplify actors table for unified model +ALTER TABLE actors DROP COLUMN IF EXISTS sub_type; +ALTER TABLE actors DROP COLUMN IF EXISTS channel_id; +ALTER TABLE actors DROP COLUMN IF EXISTS parent_actor_id; +-- Change kind enum: was ('grow','sub'), now ('user') +-- Note: Drizzle handles enum changes via application-level migration. +-- The application code now only uses kind='user'. +-- Existing rows with kind='grow' or 'sub' will be left as-is (backward compatible reads). diff --git a/prompts/system.txt b/prompts/system.txt new file mode 100644 index 0000000..359d08d --- /dev/null +++ b/prompts/system.txt @@ -0,0 +1,19 @@ +You are the Grow Agent — a unified AI orchestrator for the GrowQR platform. + +You own this user's long-running context, memory, and workspace. You coordinate all sub-agent capabilities (loaded as tools), maintain durable state in the user's Git memory repository (managed via Gitea), and execute workflows through the user's OpenCode sandbox. + +## Sub-Agent Capabilities (loaded at build time) + +{{MODULE_DESCRIPTIONS}} + +## Operating Principles + +- Be concise and direct. The user sees your messages in a Slack-like chat. +- Maintain durable memory: commit important decisions, goals, and progress to the user's Git memory repo using `commit_memory`. Read existing context with `read_memory` before making suggestions that depend on history. +- For anything that requires code, shell, file edits, or generated artifacts, use the OpenCode execution tools. +- Track active goals and workflows. Surface progress proactively when the user returns. +- Prefer one small commit per meaningful state change over batching. +- When a user wants interview practice, use `start_interview_session` (Sara). +- When a user wants roleplay practice, use `start_roleplay_session` (Emily). +- When a user needs Q-Score computation, use `ingest_signals` and `compute_qscore` (Quinn). +- Never invent tool names. Only use the tools provided. diff --git a/src/actors/grow-agent.ts b/src/actors/grow-agent.ts deleted file mode 100644 index c28cd08..0000000 --- a/src/actors/grow-agent.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { actor } from "rivetkit"; -import { log } from "../log.js"; -import { config } from "../config.js"; -import { - createChatCompletion, - GROW_AGENT_SYSTEM, - growAgentTools, - type LlmMessage, - type LlmToolCall, -} from "../lib/llm.js"; -import { - provisionUserStack, - getUserStack, - stopUserStack, - giteaClientFor, -} from "../docker/manager.js"; -import { runSubAgentTask } from "./sub-agent-runner.js"; -import { db } from "../db/client.js"; -import { actors as actorsTable, events as eventsTable } from "../db/schema.js"; - -type ChatTurn = { - role: "user" | "assistant" | "tool"; - content: string; - toolCallId?: string; - toolCalls?: LlmToolCall[]; -}; - -type GrowAgentState = { - userId: string; - goals: string[]; - history: ChatTurn[]; - // Trimmed once it grows past N turns; long history is delegated to memory repo. - maxHistory: number; -}; - -const initialState: GrowAgentState = { - userId: "", - goals: [], - history: [], - maxHistory: 40, -}; - -const MEMORY_REPO_PATH_LIMIT = 1024; - -// One Grow Agent actor instance per user (key the actor by userId). -// Owns the user's Docker stack + LLM conversation loop. -export const growAgent = actor({ - state: initialState, - actions: { - // Idempotent. Provisions the per-user OpenCode + Gitea stack if missing. - init: async (c, input: { userId: string }) => { - if (c.state.userId && c.state.userId !== input.userId) { - throw new Error("Grow Agent already bound to a different user"); - } - c.state.userId = input.userId; - const stack = await provisionUserStack(input.userId); - - await db - .insert(actorsTable) - .values({ - actorId: `grow-${input.userId}`, - userId: input.userId, - kind: "grow", - status: "idle", - lastActivityAt: new Date(), - }) - .onConflictDoNothing(); - - c.broadcast("stack-ready", { - userId: input.userId, - opencode: `${stack.opencodeHost}:${stack.opencodePort}`, - gitea: `${stack.giteaHost}:${stack.giteaHttpPort}`, - memoryRepo: stack.giteaMemoryRepo, - }); - return stack; - }, - - // Main chat entry point. Runs the full agentic loop through the configured LLM. - receiveMessage: async (c, msg: { text: string }) => { - if (!c.state.userId) { - throw new Error("Grow Agent not initialized"); - } - - const userTurn: ChatTurn = { role: "user", content: msg.text }; - c.state.history.push(userTurn); - c.broadcast("message", { role: "user", text: msg.text }); - - const assistantText = await runAgentLoop(c, c.state.userId); - - // Trim history to maxHistory turns; long-term context lives in Gitea. - while (c.state.history.length > c.state.maxHistory) { - c.state.history.shift(); - } - - await db - .insert(eventsTable) - .values({ - userId: c.state.userId, - actorId: `grow-${c.state.userId}`, - type: "grow.message", - payload: { userText: msg.text, assistantText }, - }); - - return { reply: assistantText }; - }, - - // Sub-agent status updates fan back in via this action; the Grow Agent - // broadcasts them so the frontend's sidebar can render them under the - // right channel. - subAgentEvent: async ( - c, - input: { - subAgentId: string; - type: "started" | "progress" | "done" | "error"; - message?: string; - result?: unknown; - }, - ) => { - c.broadcast("sub-agent-event", input); - }, - - getHistory: async (c) => c.state.history, - getGoals: async (c) => c.state.goals, - - shutdown: async (c) => { - if (c.state.userId) await stopUserStack(c.state.userId); - }, - }, -}); - -// The agentic loop. Keeps calling the configured LLM with tools until the model -// returns a normal assistant turn. -async function runAgentLoop( - c: { - state: GrowAgentState; - broadcast: (event: string, data: unknown) => void; - }, - userId: string, -): Promise { - if (!config.llmApiKey) { - const reply = - "LLM_API_KEY or OPENCODE_API_KEY is not configured on the backend - set it to enable the Grow Agent."; - c.state.history.push({ role: "assistant", content: reply }); - c.broadcast("message", { role: "agent", text: reply }); - return reply; - } - - c.broadcast("agent-thinking", { state: "running" }); - - const MAX_ITERATIONS = 8; - let assistantTextOut = ""; - - for (let i = 0; i < MAX_ITERATIONS; i++) { - const response = await createChatCompletion({ - model: config.growAgentModel, - maxTokens: config.maxAgentTokens, - tools: growAgentTools, - messages: messagesForApi(c.state.history), - }); - - // Capture assistant text for streaming-style broadcast. - if (response.content) { - assistantTextOut += (assistantTextOut ? "\n\n" : "") + response.content; - c.broadcast("message", { role: "agent", text: response.content }); - } - - // Persist the assistant turn, including tool calls for the next tool result turn. - c.state.history.push({ - role: "assistant", - content: response.content, - toolCalls: response.toolCalls, - }); - - if (response.toolCalls.length === 0) { - break; - } - - for (const call of response.toolCalls) { - try { - const result = await dispatchTool(c, userId, call); - c.state.history.push({ - role: "tool", - toolCallId: call.id, - content: typeof result === "string" ? result : JSON.stringify(result), - }); - } catch (err) { - log.error({ err, tool: call.name }, "tool dispatch failed"); - c.state.history.push({ - role: "tool", - toolCallId: call.id, - content: `Error: ${err instanceof Error ? err.message : String(err)}`, - }); - } - } - } - - c.broadcast("agent-thinking", { state: "idle" }); - return assistantTextOut || "(no response)"; -} - -function messagesForApi(history: ChatTurn[]): LlmMessage[] { - const messages: LlmMessage[] = [ - { role: "system", content: GROW_AGENT_SYSTEM }, - ]; - for (const turn of history) { - if (turn.role === "tool") { - messages.push({ - role: "tool", - content: turn.content, - tool_call_id: turn.toolCallId, - }); - continue; - } - messages.push({ - role: turn.role, - content: turn.content, - tool_calls: turn.toolCalls?.map((call) => ({ - id: call.id, - type: "function", - function: { - name: call.name, - arguments: JSON.stringify(call.arguments), - }, - })), - }); - } - return messages; -} - -async function dispatchTool( - c: { - broadcast: (event: string, data: unknown) => void; - state: GrowAgentState; - }, - userId: string, - call: LlmToolCall, -): Promise { - const input = call.arguments; - switch (call.name) { - case "spawn_sub_agent": { - const type = String(input.type ?? "generic"); - const prompt = String(input.prompt ?? ""); - const channelId = - typeof input.channelId === "string" - ? input.channelId - : `${type}-${Date.now()}`; - const id = `sub-${type}-${Date.now()}`; - await db - .insert(actorsTable) - .values({ - actorId: id, - userId, - kind: "sub", - subType: type, - status: "running", - channelId, - parentActorId: `grow-${userId}`, - lastActivityAt: new Date(), - }); - c.broadcast("sub-agent-spawned", { id, type, channelId, prompt }); - - // Fire-and-forget; the runner updates DB + broadcasts via the actor. - void runSubAgentTask({ - userId, - subAgentId: id, - type, - prompt, - channelId, - onEvent: (event, data) => c.broadcast(event, data), - }); - - return { id, type, channelId, status: "running" }; - } - - case "commit_memory": { - const path = String(input.path ?? "").slice(0, MEMORY_REPO_PATH_LIMIT); - const content = String(input.content ?? ""); - const message = String(input.message ?? "memory update"); - const client = await giteaClientFor(userId); - const stack = await getUserStack(userId); - if (!client || !stack?.giteaMemoryRepo) { - return { ok: false, error: "memory repo not provisioned" }; - } - const [owner, repo] = stack.giteaMemoryRepo.split("/") as [string, string]; - const result = await client.putFile({ - owner, - repo, - path, - contentUtf8: content, - message, - }); - c.broadcast("memory-committed", { path, message }); - return { ok: true, path, commitSha: result.commitSha }; - } - - case "read_memory": { - const path = String(input.path ?? ""); - const client = await giteaClientFor(userId); - const stack = await getUserStack(userId); - if (!client || !stack?.giteaMemoryRepo) return null; - const [owner, repo] = stack.giteaMemoryRepo.split("/") as [string, string]; - const text = await client.readFile({ owner, repo, path }); - return text; - } - - case "list_memory": { - const pathPrefix = String(input.pathPrefix ?? ""); - const client = await giteaClientFor(userId); - const stack = await getUserStack(userId); - if (!client || !stack?.giteaMemoryRepo) return []; - const [owner, repo] = stack.giteaMemoryRepo.split("/") as [string, string]; - // Gitea contents API on a directory returns an array of entries. - try { - const res = await fetch( - `http://${stack.giteaHost}:${stack.giteaHttpPort}/api/v1/repos/${owner}/${repo}/contents/${encodeURI(pathPrefix)}`, - { - headers: { - authorization: `token ${stack.giteaAdminToken}`, - accept: "application/json", - }, - }, - ); - if (!res.ok) return []; - const entries = (await res.json()) as Array<{ - name: string; - path: string; - type: string; - }>; - return entries.map((e) => ({ name: e.name, path: e.path, type: e.type })); - } catch { - return []; - } - } - - default: - throw new Error(`unknown tool: ${call.name}`); - } -} diff --git a/src/actors/registry.ts b/src/actors/registry.ts index e377828..ec4247e 100644 --- a/src/actors/registry.ts +++ b/src/actors/registry.ts @@ -1,13 +1,11 @@ import { setup } from "rivetkit"; -import { growAgent } from "./grow-agent.js"; -import { subAgent } from "./sub-agent.js"; -import { workflowJob } from "./workflow-job.js"; +import { userActor } from "./user-actor.js"; +// Per changes.md §5: ONE unified actor per user. +// No separate growAgent, subAgent, or workflowJob actors. export const registry = setup({ use: { - growAgent, - subAgent, - workflowJob, + userActor, }, }); diff --git a/src/actors/sub-agent-runner.ts b/src/actors/sub-agent-runner.ts deleted file mode 100644 index 3c6a60a..0000000 --- a/src/actors/sub-agent-runner.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { eq, and } from "drizzle-orm"; -import { db } from "../db/client.js"; -import { actors as actorsTable, opencodeSessions } from "../db/schema.js"; -import { log } from "../log.js"; -import { OpencodeClient } from "../lib/opencode.js"; -import { opencodeUrlFor } from "../docker/manager.js"; - -export type SubAgentRunInput = { - userId: string; - subAgentId: string; - type: string; - prompt: string; - channelId: string; - onEvent: (event: string, data: unknown) => void; -}; - -// Runs a single sub-agent task by opening an OpenCode session and forwarding -// the user-provided prompt. Streams events back to the caller (the Grow Agent -// actor's broadcast surface) and updates the actors table on completion. -// -// Sub-agents do NOT spawn their own containers — they multiplex through the -// parent Grow Agent's OpenCode container (PRD §3.3). -export async function runSubAgentTask(input: SubAgentRunInput): Promise { - const { userId, subAgentId, type, prompt, channelId, onEvent } = input; - try { - const target = await opencodeUrlFor(userId); - if (!target) { - throw new Error("OpenCode container not provisioned for user"); - } - const client = new OpencodeClient(target.baseUrl, target.password); - - const session = await client.createSession({ - title: `${type} :: ${subAgentId}`, - }); - await db.insert(opencodeSessions).values({ - id: session.id, - userId, - actorId: subAgentId, - title: session.title ?? null, - }); - - onEvent("sub-agent-event", { - subAgentId, - type: "started", - channelId, - sessionId: session.id, - }); - - // Open SSE stream for live progress. - const aborter = client.streamEvents((ev) => { - onEvent("sub-agent-event", { - subAgentId, - type: "progress", - channelId, - event: ev.event, - data: ev.data, - }); - }); - - // Send the prompt synchronously and capture the final response text. - const result = await client.sendMessage({ - sessionId: session.id, - text: prompt, - }); - aborter.abort(); - - await db - .update(actorsTable) - .set({ status: "done", lastActivityAt: new Date() }) - .where( - and( - eq(actorsTable.userId, userId), - eq(actorsTable.actorId, subAgentId), - ), - ); - - onEvent("sub-agent-event", { - subAgentId, - type: "done", - channelId, - result, - }); - log.info({ subAgentId, sessionId: session.id }, "sub-agent done"); - } catch (err) { - log.error({ err, subAgentId }, "sub-agent failed"); - await db - .update(actorsTable) - .set({ status: "error", lastActivityAt: new Date() }) - .where( - and( - eq(actorsTable.userId, userId), - eq(actorsTable.actorId, subAgentId), - ), - ) - .catch(() => undefined); - onEvent("sub-agent-event", { - subAgentId, - type: "error", - channelId, - message: err instanceof Error ? err.message : String(err), - }); - } -} diff --git a/src/actors/sub-agent.ts b/src/actors/sub-agent.ts deleted file mode 100644 index 1ededb1..0000000 --- a/src/actors/sub-agent.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { actor } from "rivetkit"; -import { db } from "../db/client.js"; -import { actors as actorsTable, events as eventsTable } from "../db/schema.js"; -import { and, eq, desc } from "drizzle-orm"; - -type LogEntry = { - ts: number; - level: "info" | "warn" | "error"; - msg: string; -}; - -type SubAgentState = { - parentUserId: string; - type: string; - status: "idle" | "running" | "done" | "error"; - channelId: string; - logs: LogEntry[]; -}; - -const initialState: SubAgentState = { - parentUserId: "", - type: "generic", - status: "idle", - channelId: "", - logs: [], -}; - -// Sub-agent actor mainly exposes status + logs for the UI. The actual task -// execution lives in sub-agent-runner.ts, invoked by the Grow Agent's tool -// dispatch path (PRD §3.3). -export const subAgent = actor({ - state: initialState, - actions: { - 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; - c.state.status = "idle"; - }, - - appendLog: async (c, entry: LogEntry) => { - c.state.logs.push(entry); - c.broadcast("log", entry); - }, - - setStatus: async (c, status: SubAgentState["status"]) => { - c.state.status = status; - c.broadcast("status", { status }); - }, - - getLogs: async (c) => c.state.logs, - getStatus: async (c) => c.state.status, - - // Pulls historical events from the DB so a returning user sees prior runs. - getHistory: async (c, input: { subAgentId: string }) => { - const rows = await db - .select() - .from(eventsTable) - .where( - and( - eq(eventsTable.userId, c.state.parentUserId), - eq(eventsTable.actorId, input.subAgentId), - ), - ) - .orderBy(desc(eventsTable.createdAt)) - .limit(50); - return rows; - }, - - getActorRow: async (c, input: { subAgentId: string }) => { - const row = await db.query.actors.findFirst({ - where: and( - eq(actorsTable.userId, c.state.parentUserId), - eq(actorsTable.actorId, input.subAgentId), - ), - }); - return row; - }, - }, -}); diff --git a/src/actors/user-actor.ts b/src/actors/user-actor.ts new file mode 100644 index 0000000..b6dac09 --- /dev/null +++ b/src/actors/user-actor.ts @@ -0,0 +1,748 @@ +import { actor } from "rivetkit"; +import { config } from "../config.js"; +import { log } from "../log.js"; +import { + buildUnifiedSystemPrompt, + getSubAgentModule, + jobApplicationModuleIds, + type SubAgentModule, +} from "../agents/catalog.js"; +import { + getSubAgentModules, +} from "../lib/prompt-loader.js"; +import { + runServiceAgentProbe, + type ServiceAgentResult, +} from "../services/service-agents.js"; +import { + provisionUserStack, + getUserStack, + stopUserStack, + giteaClientFor, + opencodeUrlFor, + syncWorkspaceToGit, +} from "../docker/manager.js"; +import { db } from "../db/client.js"; +import { actors as actorsTable, events as eventsTable } from "../db/schema.js"; +import { createChatCompletion, type LlmMessage, type LlmToolCall } from "../lib/llm.js"; + +// ── Types ── + +type ChatTurn = { + role: "user" | "assistant" | "tool"; + content: string; + toolCallId?: string; + toolCalls?: LlmToolCall[]; +}; + +type WorkflowStatus = "draft" | "running" | "paused" | "completed"; +type ModuleStatus = "idle" | "running" | "blocked" | "done"; + +type Scorecard = { + id: string; + question: string; + answer: string; + score: number; + notes?: string; + createdAt: string; +}; + +type WorkflowModuleState = { + id: string; + name: string; + role: string; + service?: string; + status: ModuleStatus; + summary: string; + lastResult?: ServiceAgentResult; + scorecards: Scorecard[]; +}; + +type WorkflowEvent = { + id: string; + ts: string; + moduleId: string; + moduleName: string; + type: "workflow" | "module" | "score"; + message: string; + payload?: unknown; +}; + +type UserActorState = { + userId: string; + goals: string[]; + chatHistory: ChatTurn[]; + maxHistory: number; + + // ── Workflow (was separate workflowJob actor, changes.md §5) ── + workflowId: string; + workflowStatus: WorkflowStatus; + workflowGoal: string; + modules: WorkflowModuleState[]; + timeline: WorkflowEvent[]; + createdAt: string; + updatedAt: string; +}; + +// ── Helpers ── + +const now = () => new Date().toISOString(); +const eventId = () => `evt_${Date.now()}_${Math.random().toString(16).slice(2)}`; + +const MEMORY_REPO_PATH_LIMIT = 1024; + +// ── Unified user agent tools (changes.md §2D: loaded at build time) ── +// Core memory tools + sub-agent capability tools from the catalog. +// Sub-agent tools are NOT separate actors — they are function tools loaded +// into the unified agent's system prompt at build time. + +function buildUnifiedTools(): Array<{ + type: "function"; + function: { + name: string; + description: string; + parameters: Record; + }; +}> { + const coreTools = [ + { + type: "function" as const, + function: { + name: "commit_memory", + description: + "Write or update a file in the user's Git memory repository. Use for goals, decisions, progress notes, plans, and durable summaries.", + parameters: { + type: "object", + properties: { + path: { type: "string" }, + content: { type: "string" }, + message: { type: "string" }, + }, + required: ["path", "content", "message"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "read_memory", + description: "Read a single file from the user's memory repo.", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "list_memory", + description: "List files at a path prefix in the user's memory repo.", + parameters: { + type: "object", + properties: { pathPrefix: { type: "string" } }, + required: ["pathPrefix"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "start_workflow", + description: "Start a job application workflow with all sub-agent modules.", + parameters: { + type: "object", + properties: { + goal: { type: "string", description: "Job search goal, e.g. 'Land a high-fit product engineering role'" }, + }, + required: ["goal"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "run_workflow_module", + description: "Execute a specific sub-agent module in the workflow (e.g., resume, job-search, job-apply, sara, emily, qscore).", + parameters: { + type: "object", + properties: { + moduleId: { type: "string", description: "Module id: resume, job-search, job-apply, sara, emily, qscore" }, + }, + required: ["moduleId"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "start_interview_session", + description: "Create a real interview practice session via the Sara / interview-service microservice.", + parameters: { + type: "object", + properties: { goal: { type: "string" } }, + required: ["goal"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "start_roleplay_session", + description: "Create a real roleplay practice session via the Emily / roleplay-service microservice.", + parameters: { + type: "object", + properties: { goal: { type: "string" } }, + required: ["goal"], + }, + }, + }, + { + type: "function" as const, + function: { + name: "compute_qscore", + description: "Compute or refresh the user's Q-Score via the Quinn / qscore-service microservice.", + parameters: { + type: "object", + properties: {}, + required: [], + }, + }, + }, + ]; + + // Build sub-agent capability tools from the catalog (changes.md §2D). + // Each sub-agent module exposes named tools that the LLM can call directly. + const capabilityTools = getSubAgentModules().flatMap((mod) => + mod.toolNames.map((toolName) => ({ + type: "function" as const, + function: { + name: toolName, + description: `[${mod.name}] ${mod.description}`, + parameters: { + type: "object", + properties: { + goal: { type: "string", description: "The user's current goal or context for this action" }, + detail: { type: "string", description: "Additional detail or instruction for this sub-agent capability" }, + }, + required: ["goal"], + }, + }, + })), + ); + + return [...coreTools, ...capabilityTools]; +} + +// Lazy — prompt modules are loaded from disk at startup (changes.md §3). +// Must be called after initCatalog() has completed. +function getUnifiedTools() { + return buildUnifiedTools(); +} + +// ── Messages helper ── + +function messagesForApi(history: ChatTurn[]): LlmMessage[] { + const systemPrompt = buildUnifiedSystemPrompt(); + const messages: LlmMessage[] = [ + { role: "system", content: systemPrompt }, + ]; + for (const turn of history) { + if (turn.role === "tool") { + messages.push({ + role: "tool", + content: turn.content, + tool_call_id: turn.toolCallId, + }); + continue; + } + messages.push({ + role: turn.role, + content: turn.content, + tool_calls: turn.toolCalls?.map((call) => ({ + id: call.id, + type: "function" as const, + function: { + name: call.name, + arguments: JSON.stringify(call.arguments), + }, + })), + }); + } + return messages; +} + +// ── Workflow helpers ── + +function makeModules(): WorkflowModuleState[] { + return jobApplicationModuleIds() + .map((id) => getSubAgentModule(id)) + .filter((m): m is SubAgentModule => Boolean(m)) + .map((m) => ({ + id: m.id, + name: m.name, + role: m.role, + service: m.service, + status: "idle" as ModuleStatus, + summary: m.description, + scorecards: [], + })); +} + +function appendTimelineEvent( + state: UserActorState, + module: Pick, + type: WorkflowEvent["type"], + message: string, + payload?: unknown, +) { + const ev: WorkflowEvent = { + id: eventId(), + ts: now(), + moduleId: module.id, + moduleName: module.name, + type, + message, + payload, + }; + state.timeline.unshift(ev); + state.timeline = state.timeline.slice(0, 100); + state.updatedAt = ev.ts; + return ev; +} + +// ── Initial State ── + +const initialState: UserActorState = { + userId: "", + goals: [], + chatHistory: [], + maxHistory: 40, + workflowId: "", + workflowStatus: "draft", + workflowGoal: "", + modules: [], + timeline: [], + createdAt: "", + updatedAt: "", +}; + +// ── THE UNIFIED USER ACTOR (changes.md §5) ── + +export const userActor = actor({ + options: { + actionTimeout: 600_000, + noSleep: true, + }, + state: initialState, + actions: { + // ── Infrastructure ── + + init: async (c, input: { userId: string }) => { + if (c.state.userId && c.state.userId !== input.userId) { + throw new Error("User actor already bound to a different user"); + } + c.state.userId = input.userId; + + const stack = await provisionUserStack(input.userId); + await db + .insert(actorsTable) + .values({ + actorId: `user-${input.userId}`, + userId: input.userId, + kind: "user", + status: "idle", + lastActivityAt: new Date(), + }) + .onConflictDoNothing(); + + c.broadcast("stack-ready", { + userId: input.userId, + opencode: `${stack.opencodeHost}:${stack.opencodePort}`, + giteaRepo: `${stack.giteaRepoOwner ?? "growqr"}/${stack.giteaRepoName ?? "unknown"}`, + versions: { + image: stack.imageVersion, + migration: stack.migrationVersion, + prompt: stack.promptVersion, + }, + }); + return stack; + }, + + shutdown: async (c) => { + if (c.state.userId) await stopUserStack(c.state.userId); + }, + + // ── Chat (was growAgent.receiveMessage) ── + + receiveMessage: async (c, msg: { text: string }) => { + if (!c.state.userId) throw new Error("User actor not initialized"); + + const userTurn: ChatTurn = { role: "user", content: msg.text }; + c.state.chatHistory.push(userTurn); + c.broadcast("message", { role: "user", text: msg.text }); + + if (!config.llmApiKey) { + const reply = "LLM API key not configured."; + c.state.chatHistory.push({ role: "assistant", content: reply }); + c.broadcast("message", { role: "agent", text: reply }); + return { reply }; + } + + c.broadcast("agent-thinking", { state: "running" }); + + let assistantTextOut = ""; + const MAX_ITERATIONS = 8; + for (let i = 0; i < MAX_ITERATIONS; i++) { + const response = await createChatCompletion({ + model: config.agentModel, + maxTokens: config.maxAgentTokens, + tools: getUnifiedTools(), + messages: messagesForApi(c.state.chatHistory), + }); + + if (response.content) { + assistantTextOut += (assistantTextOut ? "\n\n" : "") + response.content; + c.broadcast("message", { role: "agent", text: response.content }); + } + + c.state.chatHistory.push({ + role: "assistant", + content: response.content, + toolCalls: response.toolCalls, + }); + + if (response.toolCalls.length === 0) break; + + for (const call of response.toolCalls) { + try { + const result = await dispatchUnifiedTool(c, call); + c.state.chatHistory.push({ + role: "tool", + toolCallId: call.id, + content: typeof result === "string" ? result : JSON.stringify(result), + }); + } catch (err) { + log.error({ err, tool: call.name }, "tool dispatch failed"); + c.state.chatHistory.push({ + role: "tool", + toolCallId: call.id, + content: `Error: ${err instanceof Error ? err.message : String(err)}`, + }); + } + } + } + + while (c.state.chatHistory.length > c.state.maxHistory) { + c.state.chatHistory.shift(); + } + + c.broadcast("agent-thinking", { state: "idle" }); + + await db + .insert(eventsTable) + .values({ + userId: c.state.userId, + actorId: `user-${c.state.userId}`, + type: "user.message", + payload: { userText: msg.text, assistantText: assistantTextOut }, + }); + + // Auto-commit conversation to Git (changes.md §7: Git is source of truth). + // Write the full exchange to /conversations/ in the user's repo. + c.waitUntil( + (async () => { + try { + const client = await giteaClientFor(c.state.userId); + const stack = await getUserStack(c.state.userId); + if (client && stack?.giteaRepoOwner && stack.giteaRepoName) { + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const convoPath = `conversations/${ts}.json`; + await client.putFile({ + owner: stack.giteaRepoOwner, + repo: stack.giteaRepoName, + path: convoPath, + contentUtf8: JSON.stringify( + { timestamp: new Date().toISOString(), user: msg.text, assistant: assistantTextOut }, + null, 2, + ), + message: `conversation: ${msg.text.slice(0, 60)}`, + }); + // Sync workspace so OpenCode sees latest state. + await syncWorkspaceToGit(c.state.userId, `conversation at ${ts}`).catch(() => {}); + } + } catch (err) { + log.warn({ err, userId: c.state.userId }, "auto-commit conversation failed (non-fatal)"); + } + })(), + ); + + return { reply: assistantTextOut || "(no response)" }; + }, + + // ── Workflow (was workflowJob actor, now part of user actor — changes.md §5) ── + + startWorkflow: async (c, input: { goal?: string }) => { + const goal = input.goal ?? "Find and apply to high-fit jobs"; + c.state.workflowId = `job-application:${c.state.userId}`; + c.state.workflowStatus = "running"; + c.state.workflowGoal = goal; + c.state.modules = makeModules(); + c.state.createdAt = now(); + c.state.updatedAt = now(); + + appendTimelineEvent( + c.state, + { id: "grow", name: "Grow Agent" }, + "workflow", + "Job application workflow started.", + ); + c.broadcast("workflow.updated", { + workflowId: c.state.workflowId, + userId: c.state.userId, + status: c.state.workflowStatus, + goal: c.state.workflowGoal, + agents: c.state.modules, + timeline: c.state.timeline, + updatedAt: c.state.updatedAt, + }); + return c.state; + }, + + pauseWorkflow: async (c) => { + c.state.workflowStatus = "paused"; + appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", "Workflow paused."); + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + return c.state; + }, + + resumeWorkflow: async (c) => { + c.state.workflowStatus = "running"; + appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", "Workflow resumed."); + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + return c.state; + }, + + runWorkflowModule: async (c, input: { moduleId: string }) => { + const mod = c.state.modules.find((m) => m.id === input.moduleId); + if (!mod) throw new Error(`Unknown workflow module: ${input.moduleId}`); + + mod.status = "running"; + appendTimelineEvent(c.state, mod, "module", `${mod.name} started.`); + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + + const subModule = getSubAgentModule(mod.id); + if (subModule?.service) { + const userId = c.state.userId; + const goal = c.state.workflowGoal; + c.waitUntil( + (async () => { + const result = await runServiceAgentProbe( + { id: subModule.id, name: subModule.name, role: subModule.role, kind: "microservice", description: subModule.description, service: subModule.service }, + { userId, goal }, + ); + mod.lastResult = result; + mod.status = result.status === "unavailable" ? "blocked" : "done"; + appendTimelineEvent(c.state, mod, "module", result.summary, result.detail); + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + await c.saveState({ immediate: true }); + })(), + ); + return c.state; + } + + // Local workflow modules + mod.lastResult = { + status: "local", + summary: `${mod.name} completed a local workflow step for "${c.state.workflowGoal}".`, + }; + mod.status = "done"; + appendTimelineEvent(c.state, mod, "module", mod.lastResult.summary); + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + return c.state; + }, + + recordQaScore: async (c, input: { moduleId: string; question: string; answer: string; score: number; notes?: string }) => { + const mod = c.state.modules.find((m) => m.id === input.moduleId); + if (!mod) throw new Error(`Unknown workflow module: ${input.moduleId}`); + const card: Scorecard = { + id: `score_${Date.now()}`, + question: input.question, + answer: input.answer, + score: Math.max(0, Math.min(100, Number(input.score))), + notes: input.notes, + createdAt: now(), + }; + mod.scorecards.unshift(card); + appendTimelineEvent(c.state, mod, "score", `${mod.name} recorded Q&A score ${card.score}.`, card); + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + return c.state; + }, + + getWorkflowStatus: async (c) => workflowSnapshot(c.state), + + getHistory: async (c) => c.state.chatHistory, + getGoals: async (c) => c.state.goals, + }, +}); + +// ── Helpers ── + +function workflowSnapshot(state: UserActorState) { + return { + workflowId: state.workflowId, + userId: state.userId, + status: state.workflowStatus, + goal: state.workflowGoal, + agents: state.modules, + timeline: state.timeline, + updatedAt: state.updatedAt, + }; +} + +async function dispatchUnifiedTool( + c: { state: UserActorState; broadcast: (event: string, data: unknown) => void }, + call: LlmToolCall, +): Promise { + const input = call.arguments; + const userId = c.state.userId; + + switch (call.name) { + case "commit_memory": { + const path = String(input.path ?? "").slice(0, MEMORY_REPO_PATH_LIMIT); + const content = String(input.content ?? ""); + const message = String(input.message ?? "memory update"); + const client = await giteaClientFor(userId); + const stack = await getUserStack(userId); + if (!client || !stack?.giteaRepoOwner || !stack.giteaRepoName) { + return { ok: false, error: "memory repo not provisioned" }; + } + const result = await client.putFile({ + owner: stack.giteaRepoOwner, + repo: stack.giteaRepoName, + path, + contentUtf8: content, + message, + }); + c.broadcast("memory-committed", { path, message }); + return { ok: true, path, commitSha: result.commitSha }; + } + + case "read_memory": { + const path = String(input.path ?? ""); + const client = await giteaClientFor(userId); + const stack = await getUserStack(userId); + if (!client || !stack?.giteaRepoOwner || !stack.giteaRepoName) return null; + return client.readFile({ owner: stack.giteaRepoOwner, repo: stack.giteaRepoName, path }); + } + + case "list_memory": { + const pathPrefix = String(input.pathPrefix ?? ""); + const client = await giteaClientFor(userId); + const stack = await getUserStack(userId); + if (!client || !stack?.giteaRepoOwner || !stack.giteaRepoName) return []; + try { + const res = await fetch( + `${config.giteaUrl}/api/v1/repos/${encodeURIComponent(stack.giteaRepoOwner)}/${encodeURIComponent(stack.giteaRepoName)}/contents/${encodeURI(pathPrefix)}`, + { headers: { authorization: `token ${config.giteaAdminToken}`, accept: "application/json" } }, + ); + if (!res.ok) return []; + const entries = (await res.json()) as Array<{ name: string; path: string; type: string }>; + return entries.map((e) => ({ name: e.name, path: e.path, type: e.type })); + } catch { return []; } + } + + case "start_workflow": { + const goal = String(input.goal ?? "Find and apply to high-fit jobs"); + c.state.workflowId = `job-application:${userId}`; + c.state.workflowStatus = "running"; + c.state.workflowGoal = goal; + c.state.modules = makeModules(); + c.state.createdAt = now(); + c.state.updatedAt = now(); + appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", "Workflow started via LLM tool."); + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + return { ok: true, workflowId: c.state.workflowId, goal }; + } + + case "run_workflow_module": { + const moduleId = String(input.moduleId ?? ""); + const mod = c.state.modules.find((m) => m.id === moduleId); + if (!mod) return { ok: false, error: `Unknown module: ${moduleId}` }; + mod.status = "running"; + appendTimelineEvent(c.state, mod, "module", `${mod.name} started via LLM tool.`); + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + + const subModule = getSubAgentModule(mod.id); + if (subModule?.service) { + const result = await runServiceAgentProbe( + { id: subModule.id, name: subModule.name, role: subModule.role, kind: "microservice", description: subModule.description, service: subModule.service }, + { userId, goal: c.state.workflowGoal }, + ); + mod.lastResult = result; + mod.status = result.status === "unavailable" ? "blocked" : "done"; + appendTimelineEvent(c.state, mod, "module", result.summary, result.detail); + } else { + mod.lastResult = { status: "local", summary: `${mod.name} completed a local workflow step.` }; + mod.status = "done"; + appendTimelineEvent(c.state, mod, "module", mod.lastResult.summary); + } + c.broadcast("workflow.updated", workflowSnapshot(c.state)); + return { ok: true, moduleId, status: mod.status }; + } + + case "start_interview_session": { + const goal = String(input.goal ?? ""); + const saraModule = getSubAgentModule("sara"); + if (!saraModule?.service) return { ok: false, error: "Sara module not available" }; + const result = await runServiceAgentProbe( + { id: saraModule.id, name: saraModule.name, role: saraModule.role, kind: "microservice", description: saraModule.description, service: saraModule.service }, + { userId, goal }, + ); + c.broadcast("service-result", { moduleId: "sara", result }); + return result; + } + + case "start_roleplay_session": { + const goal = String(input.goal ?? ""); + const emilyModule = getSubAgentModule("emily"); + if (!emilyModule?.service) return { ok: false, error: "Emily module not available" }; + const result = await runServiceAgentProbe( + { id: emilyModule.id, name: emilyModule.name, role: emilyModule.role, kind: "microservice", description: emilyModule.description, service: emilyModule.service }, + { userId, goal }, + ); + c.broadcast("service-result", { moduleId: "emily", result }); + return result; + } + + case "compute_qscore": { + const quinnModule = getSubAgentModule("qscore"); + if (!quinnModule?.service) return { ok: false, error: "Quinn module not available" }; + const result = await runServiceAgentProbe( + { id: quinnModule.id, name: quinnModule.name, role: quinnModule.role, kind: "score", description: quinnModule.description, service: quinnModule.service }, + { userId, goal: c.state.workflowGoal || "general assessment" }, + ); + c.broadcast("service-result", { moduleId: "qscore", result }); + return result; + } + + default: { + // Check if this is a sub-agent capability tool from the catalog (changes.md §2D). + // These tools are loaded at build time — each sub-agent module defines its own tool names. + const owningModule = getSubAgentModules().find((m) => m.toolNames.includes(call.name)); + if (owningModule) { + const goal = String(input.goal ?? c.state.workflowGoal ?? "general task"); + const detail = String(input.detail ?? ""); + log.info({ tool: call.name, moduleId: owningModule.id, goal }, "sub-agent capability tool invoked"); + return { + ok: true, + moduleId: owningModule.id, + tool: call.name, + summary: `${owningModule.name} processed "${goal}"${detail ? ` with detail: "${detail}"` : ""} via the ${call.name} capability.`, + }; + } + throw new Error(`unknown tool: ${call.name}`); + } + } +} diff --git a/src/actors/workflow-job.ts b/src/actors/workflow-job.ts deleted file mode 100644 index 90c7dab..0000000 --- a/src/actors/workflow-job.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { actor } from "rivetkit"; -import { - agentCatalog, - getAgentProfile, - jobApplicationAgentIds, - type AgentProfile, -} from "../agents/catalog.js"; -import { runServiceAgentProbe, type ServiceAgentResult } from "../services/service-agents.js"; - -type WorkflowStatus = "draft" | "running" | "paused" | "completed"; -type AgentStatus = "idle" | "running" | "blocked" | "done"; - -type AgentScorecard = { - id: string; - question: string; - answer: string; - score: number; - notes?: string; - createdAt: string; -}; - -type WorkflowAgentState = { - id: string; - name: string; - role: string; - kind: AgentProfile["kind"]; - service?: AgentProfile["service"]; - status: AgentStatus; - summary: string; - lastResult?: ServiceAgentResult; - scorecards: AgentScorecard[]; -}; - -type WorkflowEvent = { - id: string; - ts: string; - agentId: string; - agentName: string; - type: "workflow" | "agent" | "score"; - message: string; - payload?: unknown; -}; - -type WorkflowJobState = { - workflowId: string; - userId: string; - type: "job-application"; - status: WorkflowStatus; - goal: string; - agents: WorkflowAgentState[]; - timeline: WorkflowEvent[]; - createdAt: string; - updatedAt: string; -}; - -const now = () => new Date().toISOString(); - -const initialState: WorkflowJobState = { - workflowId: "", - userId: "", - type: "job-application", - status: "draft", - goal: "", - agents: [], - timeline: [], - createdAt: "", - updatedAt: "", -}; - -function eventId() { - return `evt_${Date.now()}_${Math.random().toString(16).slice(2)}`; -} - -function makeAgents(): WorkflowAgentState[] { - return jobApplicationAgentIds() - .map((id) => getAgentProfile(id)) - .filter((agent): agent is AgentProfile => Boolean(agent)) - .map((agent) => ({ - id: agent.id, - name: agent.name, - role: agent.role, - kind: agent.kind, - service: agent.service, - status: "idle", - summary: agent.description, - scorecards: [], - })); -} - -function appendEvent( - state: WorkflowJobState, - agent: Pick, - type: WorkflowEvent["type"], - message: string, - payload?: unknown, -) { - const ev: WorkflowEvent = { - id: eventId(), - ts: now(), - agentId: agent.id, - agentName: agent.name, - type, - message, - payload, - }; - state.timeline.unshift(ev); - state.timeline = state.timeline.slice(0, 100); - state.updatedAt = ev.ts; - return ev; -} - -function localAgentResult(agent: WorkflowAgentState, goal: string): ServiceAgentResult { - const goalText = goal || "job application workflow"; - switch (agent.id) { - case "grow": - return { - status: "local", - summary: `Grow Agent initialized the ${goalText} workflow and assigned specialist agents.`, - }; - case "resume": - return { - status: "local", - summary: `Resume Agent prepared a resume-improvement pass for ${goalText}.`, - }; - case "job-search": - return { - status: "local", - summary: `Job Search Agent created the opportunity discovery lane for ${goalText}.`, - }; - case "job-apply": - return { - status: "local", - summary: `Job Apply Agent prepared the application tracking lane for ${goalText}.`, - }; - default: - return { - status: "local", - summary: `${agent.name} completed a local workflow step.`, - }; - } -} - -export const workflowJob = actor({ - options: { - actionTimeout: 600_000, - noSleep: true, - }, - state: initialState, - actions: { - init: async ( - c, - input: { - userId: string; - goal?: string; - }, - ) => { - if (c.state.userId && c.state.userId !== input.userId) { - throw new Error("Workflow already belongs to another user"); - } - if (!c.state.workflowId) { - const ts = now(); - c.state.workflowId = `job-application:${input.userId}`; - c.state.userId = input.userId; - c.state.type = "job-application"; - c.state.goal = input.goal ?? "Find and apply to high-fit jobs"; - c.state.status = "draft"; - c.state.agents = makeAgents(); - c.state.createdAt = ts; - c.state.updatedAt = ts; - appendEvent( - c.state, - { id: "grow", name: "Grow Agent" }, - "workflow", - "Job application workflow created.", - { catalog: agentCatalog }, - ); - } else if (input.goal) { - c.state.goal = input.goal; - c.state.updatedAt = now(); - } - c.broadcast("workflow.updated", c.state); - return c.state; - }, - - start: async (c) => { - c.state.status = "running"; - appendEvent( - c.state, - { id: "grow", name: "Grow Agent" }, - "workflow", - "Workflow started.", - ); - c.broadcast("workflow.updated", c.state); - return c.state; - }, - - pause: async (c) => { - c.state.status = "paused"; - appendEvent( - c.state, - { id: "grow", name: "Grow Agent" }, - "workflow", - "Workflow paused.", - ); - c.broadcast("workflow.updated", c.state); - return c.state; - }, - - resume: async (c) => { - c.state.status = "running"; - appendEvent( - c.state, - { id: "grow", name: "Grow Agent" }, - "workflow", - "Workflow resumed.", - ); - c.broadcast("workflow.updated", c.state); - return c.state; - }, - - runAgent: async (c, input: { agentId: string }) => { - const agent = c.state.agents.find((item) => item.id === input.agentId); - if (!agent) throw new Error(`Unknown workflow agent: ${input.agentId}`); - - agent.status = "running"; - appendEvent(c.state, agent, "agent", `${agent.name} started.`); - c.broadcast("workflow.updated", c.state); - - const profile = getAgentProfile(agent.id); - if (profile?.service != null) { - const userId = c.state.userId; - const goal = c.state.goal; - c.waitUntil( - (async () => { - const result = await runServiceAgentProbe(profile, { - userId, - goal, - }); - agent.lastResult = result; - agent.status = result.status === "unavailable" ? "blocked" : "done"; - appendEvent(c.state, agent, "agent", result.summary, result.detail); - c.broadcast("workflow.updated", c.state); - await c.saveState({ immediate: true }); - })(), - ); - return c.state; - } - - const result = localAgentResult(agent, c.state.goal); - - agent.lastResult = result; - agent.status = result.status === "unavailable" ? "blocked" : "done"; - appendEvent(c.state, agent, "agent", result.summary, result.detail); - c.broadcast("workflow.updated", c.state); - return c.state; - }, - - recordQaScore: async ( - c, - input: { - agentId: string; - question: string; - answer: string; - score: number; - notes?: string; - }, - ) => { - const agent = c.state.agents.find((item) => item.id === input.agentId); - if (!agent) throw new Error(`Unknown workflow agent: ${input.agentId}`); - const card: AgentScorecard = { - id: `score_${Date.now()}`, - question: input.question, - answer: input.answer, - score: Math.max(0, Math.min(100, Number(input.score))), - notes: input.notes, - createdAt: now(), - }; - agent.scorecards.unshift(card); - appendEvent( - c.state, - agent, - "score", - `${agent.name} recorded Q&A score ${card.score}.`, - card, - ); - c.broadcast("workflow.updated", c.state); - return c.state; - }, - - getStatus: async (c) => c.state, - }, -}); diff --git a/src/agents/catalog.ts b/src/agents/catalog.ts index 350df5b..b3aef28 100644 --- a/src/agents/catalog.ts +++ b/src/agents/catalog.ts @@ -1,94 +1,51 @@ -export type AgentKind = - | "master" - | "workflow" - | "microservice" - | "score"; +// ── Sub-agent prompt module catalog (changes.md §2D + §3) ── +// Sub-agents are NOT separate actors. They are prompt modules loaded into +// the unified user agent's system prompt. +// +// Per changes.md §3: prompts and agent definitions are stored as files on disk +// (prompts/system.txt, agents/*.md), loaded at startup, and embedded into the +// Docker image at build time via COPY directives. +// +// This module delegates to src/lib/prompt-loader.ts which reads from the +// filesystem. To update prompts or agents, edit the files and rebuild the +// Docker image — no code changes required. -export type AgentProfile = { - id: string; - name: string; - role: string; - kind: AgentKind; - description: string; - service?: "interview-service" | "roleplay-service" | "qscore-service"; -}; +import { + getSubAgentModules, + getUnifiedSystemPrompt, + getSubAgentModule as loaderGetSubAgentModule, + jobApplicationModuleIds as loaderJobApplicationModuleIds, + type SubAgentModule, +} from "../lib/prompt-loader.js"; -export const agentCatalog = [ - { - id: "grow", - name: "Grow Agent", - role: "Master Orchestrator", - kind: "master", - description: - "Owns user context, routes work to sub-agents, commits durable memory, and tracks workflow progress.", - }, - { - id: "resume", - name: "Resume Agent", - role: "Resume Builder", - kind: "workflow", - description: - "Turns profile context, Q-Score gaps, and target roles into resume edits and application collateral.", - }, - { - id: "job-search", - name: "Job Search Agent", - role: "Opportunity Scout", - kind: "workflow", - description: - "Finds relevant jobs, ranks opportunities, and prepares a shortlist for the application workflow.", - }, - { - id: "job-apply", - name: "Job Apply Agent", - role: "Application Operator", - kind: "workflow", - description: - "Prepares tailored applications, tracks submissions, and records follow-up tasks.", - }, - { - id: "sara", - name: "Sara", - role: "Interview Agent", - kind: "microservice", - service: "interview-service", - description: - "Runs interview practice through the interview-service microservice and owns interview Q&A feedback.", - }, - { - id: "emily", - name: "Emily", - role: "Roleplay Agent", - kind: "microservice", - service: "roleplay-service", - description: - "Runs roleplay practice through the roleplay-service microservice and owns scenario feedback.", - }, - { - id: "qscore", - name: "Quinn", - role: "Q-Score Agent", - kind: "score", - service: "qscore-service", - description: - "Computes and explains Q-Score changes, then displays Q&A and scores under the owning agent.", - }, -] as const satisfies AgentProfile[]; +export type { SubAgentModule }; +export type SubAgentId = string; -export type AgentId = (typeof agentCatalog)[number]["id"]; +// Re-exported — subAgentModules is now loaded from disk at startup. +// Callers that need the module list at runtime (e.g., user-actor.ts to +// register tools) should use getSubAgentModules() from prompt-loader directly. +export const subAgentModules: SubAgentModule[] = []; -export function getAgentProfile(id: string): AgentProfile | undefined { - return agentCatalog.find((agent) => agent.id === id); +// Initialize from disk. Called once at startup by index.ts. +// After this call, subAgentModules is populated and all functions work. +export async function initCatalog(): Promise { + const { loadPromptsFromDisk } = await import("../lib/prompt-loader.js"); + await loadPromptsFromDisk(); + // Mutate the exported array so existing imports keep working. + const loaded = getSubAgentModules(); + subAgentModules.length = 0; + subAgentModules.push(...loaded); } -export function jobApplicationAgentIds(): AgentId[] { - return [ - "grow", - "resume", - "job-search", - "job-apply", - "sara", - "emily", - "qscore", - ]; +export function getSubAgentModule(id: string): SubAgentModule | undefined { + return loaderGetSubAgentModule(id); +} + +export function jobApplicationModuleIds(): string[] { + return loaderJobApplicationModuleIds(); +} + +// Build the unified Grow Agent system prompt from disk (changes.md §3). +export function buildUnifiedSystemPrompt(): string { + return getUnifiedSystemPrompt(); } diff --git a/src/config.ts b/src/config.ts index a1fb960..957ad62 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,7 +16,7 @@ export const config = { // Postgres metadata DB (users, registry, container mappings). databaseUrl: process.env.DATABASE_URL ?? - "postgres://growqr:growqr@localhost:5432/growqr", + "***************************************/growqr", // Clerk auth. clerkSecretKey: process.env.CLERK_SECRET_KEY ?? "", @@ -25,7 +25,7 @@ export const config = { serviceToken: process.env.SERVICE_TOKEN ?? "", a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "dev-a2a-key", - // LLM gateway for Grow Agent + sub-agent planning calls. + // LLM gateway for the unified user agent. llmProvider: process.env.LLM_PROVIDER ?? "opencode", llmApiKey: process.env.LLM_API_KEY ?? @@ -36,14 +36,10 @@ export const config = { process.env.OPENCODE_BASE_URL ?? "https://opencode.ai/zen/v1", opencodeApiKey: process.env.OPENCODE_API_KEY ?? "", - growAgentModel: + agentModel: process.env.GROW_AGENT_MODEL ?? process.env.LLM_MODEL ?? "kimi-k2.6", - subAgentModel: - process.env.SUB_AGENT_MODEL ?? - process.env.LLM_MODEL ?? - "kimi-k2.6", // Rivet Kit engine endpoint (self-hosted in docker-compose). rivetEndpoint: process.env.RIVET_ENDPOINT ?? "http://localhost:6420", @@ -59,14 +55,25 @@ export const config = { qscoreServiceUrl: process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000", - // Per-user container images. - giteaImage: process.env.GITEA_IMAGE ?? "gitea/gitea:1.22", + // ── Central Gitea (one org-wide instance, changes.md §2A) ── + giteaUrl: process.env.GITEA_URL ?? "http://127.0.0.1:3001", + giteaAdminUser: process.env.GITEA_ADMIN_USER ?? "growqr-admin", + giteaAdminPassword: process.env.GITEA_ADMIN_PASSWORD ?? "growqr-admin-dev", + giteaAdminToken: process.env.GITEA_ADMIN_TOKEN ?? "", + giteaOrgName: process.env.GITEA_ORG_NAME ?? "growqr", + + // ── Shared OpenCode runtime image (built once, changes.md §3) ── opencodeImage: process.env.OPENCODE_IMAGE ?? "ghcr.io/anomalyco/opencode:latest", + // Version tracking for rollout (changes.md §9) + opencodeImageVersion: process.env.OPENCODE_IMAGE_VERSION ?? "1.0.0", + migrationVersion: process.env.MIGRATION_VERSION ?? "1", + promptVersion: process.env.PROMPT_VERSION ?? "1", // Host that user containers expose ports on (the host running Docker). userContainerHost: process.env.USER_CONTAINER_HOST ?? "127.0.0.1", userDataRoot: process.env.USER_DATA_ROOT ?? "./.data/users", + // Port range for per-user OpenCode containers only (Gitea is shared). userPortRangeStart: Number(process.env.USER_PORT_RANGE_START ?? 20000), userPortRangeEnd: Number(process.env.USER_PORT_RANGE_END ?? 29999), diff --git a/src/db/schema.ts b/src/db/schema.ts index 1d13b7b..fa245ed 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -30,8 +30,9 @@ export const users = pgTable( }), ); -// One per user. Tracks the user's Grow Agent's container stack + Gitea creds. -// PRD §3.2 + §5.2. +// One per user. Tracks the user's unified agent's container stack + Git repo. +// Per changes.md §2A: per-user Gitea containers removed; central Gitea shared. +// Per changes.md §5: ONE actor per user manages the full orchestration layer. export const userStacks = pgTable( "user_stacks", { @@ -44,15 +45,11 @@ export const userStacks = pgTable( .notNull() .default("provisioning"), - giteaContainerId: text("gitea_container_id"), - giteaContainerName: text("gitea_container_name"), - giteaHost: text("gitea_host"), - giteaHttpPort: integer("gitea_http_port"), - giteaSshPort: integer("gitea_ssh_port"), - giteaAdminUser: text("gitea_admin_user"), - giteaAdminToken: text("gitea_admin_token"), - giteaMemoryRepo: text("gitea_memory_repo"), + // Central Gitea (shared org-wide, changes.md §2A). + giteaRepoName: text("gitea_repo_name"), + giteaRepoOwner: text("gitea_repo_owner"), + // Per-user OpenCode container (from shared image, changes.md §3). opencodeContainerId: text("opencode_container_id"), opencodeContainerName: text("opencode_container_name"), opencodeHost: text("opencode_host"), @@ -60,6 +57,12 @@ export const userStacks = pgTable( opencodePassword: text("opencode_password"), workspacePath: text("workspace_path"), + + // Version tracking for image rollouts (changes.md §9). + imageVersion: text("image_version"), + migrationVersion: text("migration_version"), + promptVersion: text("prompt_version"), + lastError: text("last_error"), createdAt: timestamp("created_at", { withTimezone: true }) @@ -74,8 +77,8 @@ export const userStacks = pgTable( }), ); -// PRD §5.2 actor registry. One Grow Agent row per user; sub-agents are -// child rows keyed by (userId, actorId). +// Per changes.md §5: ONE unified actor per user (no separate grow/sub actors). +// The actor manages: infra state, git state, runtime comms, migrations, API orchestration. export const actors = pgTable( "actors", { @@ -83,15 +86,12 @@ export const actors = pgTable( userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), - kind: text("kind", { enum: ["grow", "sub"] }).notNull(), - subType: text("sub_type"), // for sub-agents: "coding", "repo", "quest", ... + kind: text("kind", { enum: ["user"] }).notNull().default("user"), status: text("status", { enum: ["idle", "running", "done", "error"], }) .notNull() .default("idle"), - channelId: text("channel_id"), - parentActorId: text("parent_actor_id"), lastActivityAt: timestamp("last_activity_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() diff --git a/src/docker/manager.ts b/src/docker/manager.ts index b9065c0..8734a52 100644 --- a/src/docker/manager.ts +++ b/src/docker/manager.ts @@ -14,24 +14,17 @@ export type { UserStack }; const docker = new Docker(); -// Allocated host ports kept in-memory; rehydrated from the DB on boot so -// we don't double-allocate across restarts. +// ── Port allocator (OpenCode containers only; Gitea is central) ── const allocatedPorts = new Set(); export async function hydratePortAllocator(): Promise { const rows = await db - .select({ - giteaHttp: userStacks.giteaHttpPort, - giteaSsh: userStacks.giteaSshPort, - opencode: userStacks.opencodePort, - }) + .select({ opencode: userStacks.opencodePort }) .from(userStacks); for (const r of rows) { - for (const p of [r.giteaHttp, r.giteaSsh, r.opencode]) { - if (p) allocatedPorts.add(p); - } + if (r.opencode) allocatedPorts.add(r.opencode); } - log.info({ count: allocatedPorts.size }, "hydrated port allocator"); + log.info({ count: allocatedPorts.size }, "hydrated port allocator (OpenCode only)"); } function pickPort(): number { @@ -48,6 +41,8 @@ function releasePort(port: number | null | undefined) { if (port != null) allocatedPorts.delete(port); } +// ── Image helpers ── + async function ensureImage(image: string) { try { await docker.getImage(image).inspect(); @@ -71,7 +66,6 @@ function userDataDir(userId: string) { } function safeContainerName(prefix: string, userId: string) { - // Container names must match [a-zA-Z0-9_.-] return `${prefix}-${userId.replace(/[^a-zA-Z0-9_.-]/g, "_")}`; } @@ -83,77 +77,97 @@ async function findExistingContainer(name: string) { return list[0]; } -async function startGiteaContainer(opts: { - userId: string; - httpPort: number; - sshPort: number; -}): Promise<{ id: string; name: string }> { - await ensureImage(config.giteaImage); - const name = safeContainerName("growqr-gitea", opts.userId); - const dataDir = path.join(userDataDir(opts.userId), "gitea"); - await ensureDir(dataDir); +// ── Central Gitea bootstrap (changes.md §2A) ── - const existing = await findExistingContainer(name); - if (existing) { - if (existing.State !== "running") { - await docker.getContainer(existing.Id).start().catch(() => undefined); +let centralGiteaClient: GiteaClient | null = null; +let centralGiteaReady = false; + +async function getCentralGiteaClient(): Promise { + if (!centralGiteaClient) { + const token = config.giteaAdminToken; + if (token) { + centralGiteaClient = new GiteaClient(config.giteaUrl, { kind: "token", token }); + } else { + centralGiteaClient = new GiteaClient(config.giteaUrl, { + kind: "basic", + username: config.giteaAdminUser, + password: config.giteaAdminPassword, + }); } - return { id: existing.Id, name }; + } + return centralGiteaClient; +} + +export async function ensureCentralGiteaReady(): Promise { + if (centralGiteaReady) return; + await waitForGitea(config.giteaUrl, 120_000); + const client = await getCentralGiteaClient(); + + // Ensure the org exists (changes.md §2A: single org manages all users). + try { + await client.ensureOrg(config.giteaOrgName); + } catch (err) { + log.warn({ err }, "central Gitea org ensure failed (may already exist)"); } - const container = await docker.createContainer({ - name, - Image: config.giteaImage, - Env: [ - "USER_UID=1000", - "USER_GID=1000", - `GITEA__server__ROOT_URL=http://${config.userContainerHost}:${opts.httpPort}/`, - `GITEA__server__SSH_PORT=${opts.sshPort}`, - "GITEA__security__INSTALL_LOCK=true", - "GITEA__service__DISABLE_REGISTRATION=true", - ], - HostConfig: { - Binds: [`${dataDir}:/data`], - PortBindings: { - "3000/tcp": [{ HostPort: String(opts.httpPort) }], - "22/tcp": [{ HostPort: String(opts.sshPort) }], - }, - RestartPolicy: { Name: "unless-stopped" }, - Memory: 1 * 1024 * 1024 * 1024, - NanoCpus: 1_000_000_000, - }, - ExposedPorts: { "3000/tcp": {}, "22/tcp": {} }, - Labels: { - "growqr.userId": opts.userId, - "growqr.role": "gitea", - }, - }); - await container.start(); - log.info({ userId: opts.userId, name }, "started Gitea container"); - return { id: container.id, name }; + centralGiteaReady = true; + log.info({ url: config.giteaUrl, org: config.giteaOrgName }, "central Gitea ready"); } -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'`; -} +// ── Git clone into OpenCode workspace (changes.md §4 step 3) ── +// Clones the user's repo from central Gitea into the container's /workspace. +// If /workspace already has a .git folder, pulls instead of cloning. +async function cloneRepoIntoContainer(opts: { + containerId: string; + repoUrl: string; + giteaToken?: string; + giteaUser?: string; + giteaPassword?: string; +}): Promise { + const container = docker.getContainer(opts.containerId); -async function execGiteaCli(containerId: string, args: string[]): Promise { - const container = docker.getContainer(containerId); - const command = [ - "gitea", - "--work-path", - "/data/gitea", - "--config", - "/data/gitea/conf/app.ini", - ...args, - ] - .map(shellQuote) - .join(" "); - const exec = await container.exec({ - Cmd: ["su", "git", "-c", command], + // Build authenticated clone URL. + let authUrl = opts.repoUrl; + if (opts.giteaToken) { + // Embed token in URL: https://token@host/org/repo.git + authUrl = opts.repoUrl.replace("://", `://${encodeURIComponent(opts.giteaToken)}@`); + } else if (opts.giteaUser && opts.giteaPassword) { + authUrl = opts.repoUrl.replace("://", `://${encodeURIComponent(opts.giteaUser)}:${encodeURIComponent(opts.giteaPassword)}@`); + } + + // Check if workspace is already a git repo; if so, pull instead of clone. + const checkExec = await container.exec({ + Cmd: ["sh", "-c", "test -d /workspace/.git && echo 'exists' || echo 'missing'"], AttachStdout: true, AttachStderr: true, - WorkingDir: "/data/gitea", + }); + const checkStream = await checkExec.start({ Detach: false, Tty: false }); + const checkChunks: Buffer[] = []; + checkStream.on("data", (chunk: Buffer) => checkChunks.push(Buffer.from(chunk))); + await new Promise((resolve) => { + checkStream.on("end", () => resolve()); + checkStream.on("close", () => resolve()); + }); + const checkOutput = Buffer.concat(checkChunks).toString("utf8").trim(); + + let cmd: string[]; + if (checkOutput.includes("exists")) { + // Pull latest changes. + cmd = ["sh", "-c", "cd /workspace && git pull origin main 2>&1 || echo 'pull failed, attempting fresh clone'"]; + } else { + // Clone into /workspace (remove any placeholder files first, then clone). + cmd = [ + "sh", + "-c", + `rm -rf /workspace/* /workspace/.* 2>/dev/null; git clone --branch main "${authUrl}" /workspace 2>&1 || echo 'clone failed'`, + ]; + } + + const exec = await container.exec({ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + WorkingDir: "/workspace", }); const stream = await exec.start({ Detach: false, Tty: false }); const chunks: Buffer[] = []; @@ -165,62 +179,64 @@ async function execGiteaCli(containerId: string, args: string[]): Promise { +// ── Git workspace sync (changes.md §2B: "Sync to Git") ── +// Commits and pushes changes from the container's /workspace back to the +// central Gitea repo, ensuring work done inside OpenCode is persisted as +// Git history. Called after significant events (workflows, code generation). +export async function syncWorkspaceToGit(userId: string, message?: string): Promise { + const stack = await getUserStack(userId); + if (!stack?.opencodeContainerId || !stack.giteaRepoOwner || !stack.giteaRepoName) { + log.warn({ userId }, "cannot sync workspace — stack not provisioned"); + return; + } + + const container = docker.getContainer(stack.opencodeContainerId); + const commitMsg = message ?? `growqr: workspace sync at ${new Date().toISOString()}`; + + // Build authenticated remote URL for push. + let authUrl = `${config.giteaUrl}/${encodeURIComponent(stack.giteaRepoOwner)}/${encodeURIComponent(stack.giteaRepoName)}.git`; + if (config.giteaAdminToken) { + authUrl = authUrl.replace("://", `://${encodeURIComponent(config.giteaAdminToken)}@`); + } else { + authUrl = authUrl.replace("://", `://${encodeURIComponent(config.giteaAdminUser)}:${encodeURIComponent(config.giteaAdminPassword)}@`); + } + + // Set the remote URL with auth, add all, commit, push. + const cmd = [ + "sh", "-c", + `git remote set-url origin "${authUrl}" 2>/dev/null; ` + + `git config user.email "growqr@local" && git config user.name "GrowQR"; ` + + `git add -A && git commit -m "${commitMsg.replace(/"/g, '\\"')}" 2>/dev/null; ` + + `git push origin main 2>&1`, + ]; + try { - await execGiteaCli(opts.containerId, [ - "admin", - "user", - "create", - "--admin", - "--username", - opts.username, - "--password", - opts.password, - "--email", - opts.email, - "--must-change-password=false", - ]); + const exec = await container.exec({ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + WorkingDir: "/workspace", + }); + const stream = await exec.start({ Detach: false, Tty: false }); + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer) => chunks.push(Buffer.from(chunk))); + await new Promise((resolve) => { + stream.on("end", () => resolve()); + stream.on("close", () => resolve()); + }); + const output = Buffer.concat(chunks).toString("utf8"); + log.info({ userId, output: output.slice(0, 200) }, "workspace synced to Git"); } catch (err) { - log.debug( - { err }, - "gitea admin user create returned non-zero (likely already exists)", - ); + log.warn({ err, userId }, "workspace sync to Git failed (non-fatal)"); } } -async function generateGiteaToken(opts: { - containerId: string; - username: string; - scopes: string[]; -}): Promise { - const output = await execGiteaCli(opts.containerId, [ - "admin", - "user", - "generate-access-token", - "--username", - opts.username, - "--token-name", - `growqr-backend-${Date.now()}`, - "--scopes", - opts.scopes.join(","), - "--raw", - ]); - const token = output.match(/[a-f0-9]{40}/i)?.[0]; - if (!token) throw new Error("gitea token generation returned an empty token"); - return token; -} +// ── Per-user OpenCode container (changes.md §2B + §3) ── async function startOpencodeContainer(opts: { userId: string; @@ -240,16 +256,17 @@ async function startOpencodeContainer(opts: { return { id: existing.Id, name }; } + // Sub-agents are loaded as prompt modules at build time (changes.md §2D). + // The shared image includes: base OS, OpenCode, GrowQR core, agents, tools, prompts. const container = await docker.createContainer({ name, Image: config.opencodeImage, - // OpenCode server CLI: `opencode serve --port 4096 --hostname 0.0.0.0`. - // We override the default CMD to make sure it binds to all interfaces - // and uses the per-user password. Cmd: ["serve", "--port", "4096", "--hostname", "0.0.0.0"], Env: [ `OPENCODE_SERVER_PASSWORD=${opts.password}`, `OPENCODE_WORKSPACE=/workspace`, + `GROWQR_IMAGE_VERSION=${config.opencodeImageVersion}`, + `GROWQR_PROMPT_VERSION=${config.promptVersion}`, ], WorkingDir: "/workspace", HostConfig: { @@ -265,6 +282,8 @@ async function startOpencodeContainer(opts: { Labels: { "growqr.userId": opts.userId, "growqr.role": "opencode", + "growqr.imageVersion": config.opencodeImageVersion, + "growqr.promptVersion": config.promptVersion, }, }); await container.start(); @@ -272,18 +291,27 @@ async function startOpencodeContainer(opts: { return { id: container.id, name }; } -// Provisions the per-user stack. Idempotent: returns the existing stack if -// the user already has one in the DB and the containers are running. +// ── User provisioning (changes.md §4) ── + +function repoNameFor(userId: string): string { + return `user-${userId.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 48).toLowerCase()}`; +} + +function userIdToGiteaUsername(userId: string): string { + return `gq_${userId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 24).toLowerCase() || "user"}`; +} + +// Provisions the per-user stack. Uses CENTRAL Gitea (changes.md §2A) instead +// of spawning per-user Gitea containers. // -// Steps: -// 1. Pick ports + allocate. -// 2. Start Gitea + OpenCode containers (or reuse). -// 3. Wait for Gitea HTTP to come up. -// 4. Create the per-user Gitea admin via `gitea admin user create`. -// 5. Mint a long-lived access token for the admin. -// 6. Create the user's memory repo with auto_init. -// 7. Wait for OpenCode to come up. -// 8. Persist everything to user_stacks. +// Steps (changes.md §4): +// 1. Ensure central Gitea is reachable + org exists. +// 2. Pick port for per-user OpenCode container. +// 3. Start OpenCode container (from shared image, changes.md §3). +// 4. Create the user's repo in the central Gitea org (changes.md §2A). +// 5. Initialize repo structure (memory/, conversations/, state/, etc. — changes.md §11). +// 6. Wait for OpenCode readiness. +// 7. Persist everything to user_stacks with version tracking (changes.md §9). export async function provisionUserStack(userId: string): Promise { const existing = await db.query.userStacks.findFirst({ where: eq(userStacks.userId, userId), @@ -293,16 +321,13 @@ export async function provisionUserStack(userId: string): Promise { } await ensureDir(userDataDir(userId)); + await ensureCentralGiteaReady(); - const giteaHttpPort = existing?.giteaHttpPort ?? pickPort(); - const giteaSshPort = existing?.giteaSshPort ?? pickPort(); const opencodePort = existing?.opencodePort ?? pickPort(); const opencodePassword = existing?.opencodePassword ?? randomBytes(24).toString("hex"); - const adminUsername = - existing?.giteaAdminUser ?? `gq_${userId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 24).toLowerCase() || "user"}`; - const adminPassword = randomBytes(24).toString("hex"); - const adminEmail = `${adminUsername}@growqr.local`; + const repoName = existing?.giteaRepoName ?? repoNameFor(userId); + const repoOwner = config.giteaOrgName; // Upsert "provisioning" row first so a crash mid-way leaves a recoverable record. await db @@ -310,14 +335,15 @@ export async function provisionUserStack(userId: string): Promise { .values({ userId, status: "provisioning", - giteaHttpPort, - giteaSshPort, opencodePort, opencodePassword, - giteaAdminUser: adminUsername, - giteaHost: config.userContainerHost, + giteaRepoName: repoName, + giteaRepoOwner: repoOwner, opencodeHost: config.userContainerHost, workspacePath: userDataDir(userId), + imageVersion: config.opencodeImageVersion, + migrationVersion: config.migrationVersion, + promptVersion: config.promptVersion, }) .onConflictDoUpdate({ target: userStacks.userId, @@ -329,59 +355,86 @@ export async function provisionUserStack(userId: string): Promise { }); try { - const gitea = await startGiteaContainer({ - userId, - httpPort: giteaHttpPort, - sshPort: giteaSshPort, - }); + // Start per-user OpenCode container (shared image, changes.md §3). const opencode = await startOpencodeContainer({ userId, httpPort: opencodePort, password: opencodePassword, }); - const giteaBase = `http://${config.userContainerHost}:${giteaHttpPort}`; - await waitForGitea(giteaBase, 90_000); - - // Bootstrap admin user (idempotent — the CLI returns non-zero if exists). - await ensureGiteaAdmin({ - containerId: gitea.id, - username: adminUsername, - password: adminPassword, - email: adminEmail, - }); - - // Mint a token via Gitea's CLI so retries do not depend on a transient - // bootstrap password from a previous provisioning attempt. - const token = await generateGiteaToken({ - containerId: gitea.id, - username: adminUsername, - scopes: ["write:repository", "write:user", "write:issue"], - }); - - // Use the token from here on. - const tokenClient = new GiteaClient(giteaBase, { kind: "token", token }); - const memoryRepo = await tokenClient.ensureRepo({ - name: "growqr-memory", - description: "Grow Agent memory + state (PRD §3.4)", + // Create the user's repo in the central Gitea org (changes.md §2A + §4 step 2). + const giteaClient = await getCentralGiteaClient(); + const repo = await giteaClient.ensureOrgRepo({ + org: repoOwner, + name: repoName, + description: `GrowQR memory + workspace for user ${userId}`, autoInit: true, private: true, }); + // Initialize the standard repo structure (changes.md §11). + const initFiles: Array<{ path: string; content: string }> = [ + { path: "memory/.gitkeep", content: "# Agent memory\n" }, + { path: "conversations/.gitkeep", content: "# Conversation history\n" }, + { path: "state/.gitkeep", content: "# Agent state\n" }, + { path: "artifacts/.gitkeep", content: "# Generated artifacts\n" }, + { path: "workflows/.gitkeep", content: "# Workflow definitions\n" }, + { path: "logs/.gitkeep", content: "# Runtime logs\n" }, + { path: "config/.gitkeep", content: "# User configuration\n" }, + { path: "metadata/versions.json", content: JSON.stringify({ + imageVersion: config.opencodeImageVersion, + migrationVersion: config.migrationVersion, + promptVersion: config.promptVersion, + provisionedAt: new Date().toISOString(), + }, null, 2) + "\n" }, + ]; + + for (const file of initFiles) { + try { + await giteaClient.putFile({ + owner: repoOwner, + repo: repoName, + path: file.path, + contentUtf8: file.content, + message: `init: ${file.path}`, + branch: "main", + }); + } catch (err) { + log.warn({ err, path: file.path }, "failed to init repo file (non-fatal)"); + } + } + // OpenCode readiness. const opencodeBase = `http://${config.userContainerHost}:${opencodePort}`; await waitForOpencode(opencodeBase, opencodePassword, 90_000); + // Clone the user's Git repo into the OpenCode workspace (changes.md §4 step 3). + // Uses `git clone` inside the container so the workspace is a working copy + // of the user's repo, making Git the source of truth (changes.md §7). + try { + await cloneRepoIntoContainer({ + containerId: opencode.id, + repoUrl: `${config.giteaUrl}/${encodeURIComponent(repoOwner)}/${encodeURIComponent(repoName)}.git`, + giteaToken: config.giteaAdminToken || undefined, + giteaUser: config.giteaAdminUser, + giteaPassword: !config.giteaAdminToken ? config.giteaAdminPassword : undefined, + }); + log.info({ userId, repo: `${repoOwner}/${repoName}` }, "repo cloned into OpenCode workspace"); + } catch (err) { + log.warn({ err, userId }, "git clone into workspace failed (non-fatal — workspace still available via Gitea API)"); + } + const updated = await db .update(userStacks) .set({ status: "running", - giteaContainerId: gitea.id, - giteaContainerName: gitea.name, - giteaAdminToken: token, - giteaMemoryRepo: `${memoryRepo.owner}/${memoryRepo.name}`, + giteaRepoName: repo.name, + giteaRepoOwner: repo.owner, opencodeContainerId: opencode.id, opencodeContainerName: opencode.name, + imageVersion: config.opencodeImageVersion, + migrationVersion: config.migrationVersion, + promptVersion: config.promptVersion, lastError: null, updatedAt: new Date(), }) @@ -390,7 +443,7 @@ export async function provisionUserStack(userId: string): Promise { const row = updated[0]; if (!row) throw new Error("user stack row vanished mid-provision"); - log.info({ userId }, "user stack provisioned"); + log.info({ userId, repo: `${repo.owner}/${repo.name}` }, "user stack provisioned"); return row; } catch (err) { log.error({ err, userId }, "provisionUserStack failed"); @@ -416,24 +469,23 @@ export async function getUserStack(userId: string): Promise { export async function stopUserStack(userId: string): Promise { const stack = await getUserStack(userId); if (!stack) return; - for (const id of [stack.giteaContainerId, stack.opencodeContainerId]) { - if (!id) continue; + + // Stop only the OpenCode container (Gitea is central, changes.md §2A). + if (stack.opencodeContainerId) { try { - const c = docker.getContainer(id); + const c = docker.getContainer(stack.opencodeContainerId); await c.stop({ t: 5 }).catch(() => undefined); await c.remove({ force: true }).catch(() => undefined); } catch (err) { - log.warn({ err, id }, "failed to stop container"); + log.warn({ err, id: stack.opencodeContainerId }, "failed to stop container"); } } - releasePort(stack.giteaHttpPort); - releasePort(stack.giteaSshPort); + releasePort(stack.opencodePort); await db .update(userStacks) .set({ status: "stopped", - giteaContainerId: null, opencodeContainerId: null, updatedAt: new Date(), }) @@ -445,19 +497,19 @@ export async function listStacks(): Promise { return db.query.userStacks.findMany(); } -// Convenience: build a Gitea client for a user's stack. -export async function giteaClientFor(userId: string): Promise { - const stack = await getUserStack(userId); - if (!stack?.giteaAdminToken || !stack.giteaHost || !stack.giteaHttpPort) { +// ── Client helpers ── + +// Build a Gitea client pointed at the CENTRAL Gitea instance (changes.md §2A). +// Uses the admin token for repo operations on behalf of any user. +export async function giteaClientFor(_userId: string): Promise { + try { + return await getCentralGiteaClient(); + } catch { return null; } - return new GiteaClient( - `http://${stack.giteaHost}:${stack.giteaHttpPort}`, - { kind: "token", token: stack.giteaAdminToken }, - ); } -// Convenience: build an OpenCode client for a user's stack. +// Build an OpenCode client for a user's stack. export async function opencodeUrlFor( userId: string, ): Promise<{ baseUrl: string; password: string | undefined } | null> { @@ -469,36 +521,61 @@ export async function opencodeUrlFor( }; } -// Reconcile DB-tracked running containers with actual Docker state on boot. -// If a container is gone, flip the row to "stopped" so the next provision -// recreates it cleanly. +// ── Boot reconciliation (changes.md §9) ── + +// Reconcile DB-tracked running stacks with actual Docker state on boot. +// Only checks OpenCode containers (Gitea is central, changes.md §2A). +// If a container is gone, flip the row to "stopped" so the next provision recreates it. +// +// Also detects version mismatches for image rollout (changes.md §9): +// if the running container's imageVersion is behind, mark for migration. export async function reconcileOnBoot(): Promise { const rows = await db .select() .from(userStacks) .where( - and(eq(userStacks.status, "running"), isNotNull(userStacks.giteaContainerId)), + and(eq(userStacks.status, "running"), isNotNull(userStacks.opencodeContainerId)), ); + for (const row of rows) { + if (!row.opencodeContainerId) continue; + let healthy = true; - for (const id of [row.giteaContainerId, row.opencodeContainerId]) { - if (!id) { - healthy = false; - break; - } - try { - const info = await docker.getContainer(id).inspect(); - if (!info.State.Running) healthy = false; - } catch { - healthy = false; - } + try { + const info = await docker.getContainer(row.opencodeContainerId).inspect(); + if (!info.State.Running) healthy = false; + } catch { + healthy = false; } + if (!healthy) { await db .update(userStacks) .set({ status: "stopped", updatedAt: new Date() }) .where(eq(userStacks.userId, row.userId)); log.info({ userId: row.userId }, "stack marked stopped during reconcile"); + continue; + } + + // Version mismatch detection (changes.md §9). + const needsMigration = + row.imageVersion !== config.opencodeImageVersion || + row.migrationVersion !== config.migrationVersion; + const needsPromptUpdate = row.promptVersion !== config.promptVersion; + + if (needsMigration || needsPromptUpdate) { + log.info( + { + userId: row.userId, + currentImage: row.imageVersion, + targetImage: config.opencodeImageVersion, + currentMigration: row.migrationVersion, + targetMigration: config.migrationVersion, + currentPrompt: row.promptVersion, + targetPrompt: config.promptVersion, + }, + "version mismatch detected — migration needed on next provision", + ); } } } diff --git a/src/index.ts b/src/index.ts index d5ac02a..1888d82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,12 +12,25 @@ import { userRoutes } from "./routes/users.js"; import { agentRoutes } from "./routes/agents.js"; import { workflowRoutes } from "./routes/workflows.js"; import { db } from "./db/client.js"; -import { hydratePortAllocator, reconcileOnBoot } from "./docker/manager.js"; +import { hydratePortAllocator, reconcileOnBoot, ensureCentralGiteaReady } from "./docker/manager.js"; +import { initCatalog } from "./agents/catalog.js"; async function main() { - // Boot-time DB sanity + reconcile. + // Boot-time DB sanity + reconcile + central Gitea readiness. await db.execute("select 1"); await hydratePortAllocator(); + + // Ensure central Gitea is reachable before accepting traffic (changes.md §2A). + try { + await ensureCentralGiteaReady(); + } catch (err) { + log.warn({ err }, "central Gitea not ready at boot — will retry on first provision"); + } + + // Load prompts & agent modules from disk (changes.md §3: prompts/ + agents/). + // After this, buildUnifiedSystemPrompt() returns the full assembled prompt. + await initCatalog(); + await reconcileOnBoot(); const app = new Hono(); @@ -58,7 +71,7 @@ async function main() { // 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 (auth-gated). + // HTTP control plane (auth-gated). app.route("/users", userRoutes()); app.route("/agents", agentRoutes()); app.route("/workflows", workflowRoutes()); @@ -76,6 +89,7 @@ async function main() { { port: info.port, rivet: config.rivetEndpoint, + gitea: config.giteaUrl, env: config.nodeEnv, }, "growqr-backend listening", diff --git a/src/lib/gitea.ts b/src/lib/gitea.ts index a36aa66..2c1dac2 100644 --- a/src/lib/gitea.ts +++ b/src/lib/gitea.ts @@ -132,6 +132,64 @@ export class GiteaClient { } } + // ── Central Gitea org methods (changes.md §2A) ── + + // Ensure an organization exists. Idempotent. + async ensureOrg(orgName: string): Promise { + try { + await this.req("POST", "/api/v1/orgs", { + username: orgName, + full_name: orgName, + description: "GrowQR organization — one repo per user", + }); + } catch (err) { + log.debug({ err }, "ensureOrg returned non-2xx (likely already exists)"); + } + } + + // Create a repo inside an org. Idempotent (falls back to GET on 409). + async ensureOrgRepo(opts: { + org: string; + name: string; + description?: string; + autoInit?: boolean; + private?: boolean; + }): Promise<{ owner: string; name: string; cloneUrl: string }> { + try { + const repo = await this.req<{ + owner: { login: string }; + name: string; + clone_url: string; + }>("POST", `/api/v1/orgs/${encodeURIComponent(opts.org)}/repos`, { + name: opts.name, + description: opts.description ?? "", + auto_init: opts.autoInit ?? true, + private: opts.private ?? true, + default_branch: "main", + }); + return { + owner: repo.owner.login, + name: repo.name, + cloneUrl: repo.clone_url, + }; + } catch (err) { + const repo = await this.req<{ + owner: { login: string }; + name: string; + clone_url: string; + }>( + "GET", + `/api/v1/repos/${encodeURIComponent(opts.org)}/${encodeURIComponent(opts.name)}`, + ); + log.debug({ err }, "ensureOrgRepo fell through to GET"); + return { + owner: repo.owner.login, + name: repo.name, + cloneUrl: repo.clone_url, + }; + } + } + // Creates or updates a file in a repo. Used for memory commits. async putFile(opts: { owner: string; diff --git a/src/lib/llm.ts b/src/lib/llm.ts index 2d6c917..a561985 100644 --- a/src/lib/llm.ts +++ b/src/lib/llm.ts @@ -1,28 +1,14 @@ import { config } from "../config.js"; -export const GROW_AGENT_SYSTEM = `You are a Grow Agent - a user's master AI orchestrator on the GrowQR platform. - -You own this user's long-running context, memory, and workspace. You coordinate specialized sub-agents (coding, repo, quest, product-flow, etc.), keep durable state in the user's Gitea memory repository, and execute workflows via the user's OpenCode sandbox. - -Operating principles: -- Be concise and direct. The user sees your messages in a Slack-like chat. -- Maintain durable memory: commit important decisions, goals, and progress to the user's memory repo using \`commit_memory\`. Read existing context with \`read_memory\` before making suggestions that depend on history. -- For anything that requires code, shell, file edits, or generated artifacts, spawn a sub-agent via \`spawn_sub_agent\`. The sub-agent runs through the user's OpenCode container. -- Track active goals and quests. Surface progress proactively when the user returns. -- Prefer one small commit per meaningful state change over batching. -- Never invent tool names. Only use the tools provided. -`; - -export type GrowAgentTool = - | "spawn_sub_agent" - | "commit_memory" - | "read_memory" - | "list_memory"; +// ── LLM type definitions ── +// The system prompt and agent tools are loaded from disk at startup +// (prompts/system.txt + agents/*.md) via prompt-loader.ts. +// The unified tools are assembled in user-actor.ts using the catalog. export type LlmTool = { type: "function"; function: { - name: GrowAgentTool; + name: string; description: string; parameters: Record; }; @@ -48,96 +34,7 @@ export type LlmMessage = { }>; }; -export const growAgentTools: LlmTool[] = [ - { - type: "function", - function: { - name: "spawn_sub_agent", - description: - "Spawn a specialized sub-agent to run a bounded task through the user's OpenCode container. Use for anything that requires running code, editing files, or producing artifacts.", - parameters: { - type: "object", - properties: { - type: { - type: "string", - description: - "Sub-agent type: 'coding', 'repo', 'migration', 'quest', 'product', 'backend', 'frontend', or another short identifier.", - }, - prompt: { - type: "string", - description: - "The full task prompt for the sub-agent. Include all context it needs - sub-agents do not see this conversation.", - }, - channelId: { - type: "string", - description: - "Optional channel/thread id the sub-agent should report into. Generated if omitted.", - }, - }, - required: ["type", "prompt"], - }, - }, - }, - { - type: "function", - function: { - name: "commit_memory", - description: - "Write or update a file in the user's Gitea memory repository. Use for goals, decisions, progress notes, plans, and durable summaries.", - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: - "Repo-relative path, e.g. 'goals/active.md' or 'decisions/2026-05-19-architecture.md'.", - }, - content: { - type: "string", - description: "Full UTF-8 file content to write.", - }, - message: { - type: "string", - description: "Commit message describing the change.", - }, - }, - required: ["path", "content", "message"], - }, - }, - }, - { - type: "function", - function: { - name: "read_memory", - description: "Read a single file from the user's memory repo. Returns null if missing.", - parameters: { - type: "object", - properties: { - path: { type: "string" }, - }, - required: ["path"], - }, - }, - }, - { - type: "function", - function: { - name: "list_memory", - description: - "List files at a path prefix in the user's memory repo. Use to discover what context already exists.", - parameters: { - type: "object", - properties: { - pathPrefix: { - type: "string", - description: "Repo-relative directory, e.g. 'goals' or '' for root.", - }, - }, - required: ["pathPrefix"], - }, - }, - }, -]; +// ── LLM API client ── type ChatCompletionsResponse = { choices?: Array<{ diff --git a/src/lib/prompt-loader.ts b/src/lib/prompt-loader.ts new file mode 100644 index 0000000..3d8df4a --- /dev/null +++ b/src/lib/prompt-loader.ts @@ -0,0 +1,168 @@ +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import { log } from "../log.js"; + +// ── Types ── + +export type SubAgentModule = { + id: string; + name: string; + role: string; + description: string; + service?: "interview-service" | "roleplay-service" | "qscore-service"; + toolNames: string[]; +}; + +type AgentFrontmatter = { + id?: string; + name?: string; + role?: string; + service?: string; + tools?: string[]; +}; + +// ── Paths ── + +const PROMPTS_DIR = path.resolve(process.cwd(), "prompts"); +const AGENTS_DIR = path.resolve(process.cwd(), "agents"); +const SYSTEM_PROMPT_FILE = path.join(PROMPTS_DIR, "system.txt"); + +// ── Frontmatter parser (no external dependencies) ── + +function parseFrontmatter(raw: string): { data: AgentFrontmatter; body: string } { + const trimmed = raw.trim(); + if (!trimmed.startsWith("---")) return { data: {}, body: trimmed }; + + const secondDelim = trimmed.indexOf("---", 3); + if (secondDelim === -1) return { data: {}, body: trimmed }; + + const fmBlock = trimmed.slice(3, secondDelim).trim(); + const body = trimmed.slice(secondDelim + 3).trim(); + + const data: AgentFrontmatter = {}; + for (const line of fmBlock.split("\n")) { + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) continue; + const key = line.slice(0, colonIdx).trim(); + let value: string | string[] = line.slice(colonIdx + 1).trim(); + + if (key === "tools" && value.startsWith("[") && value.endsWith("]")) { + // Parse inline array: ["tool1", "tool2"] + const inner = value.slice(1, -1); + value = inner + .split(",") + .map((s) => s.trim().replace(/^["']|["']$/g, "")) + .filter(Boolean); + } + + if (key === "id") data.id = value as string; + if (key === "name") data.name = value as string; + if (key === "role") data.role = value as string; + if (key === "service") data.service = value as string; + if (key === "tools") data.tools = value as string[]; + } + + return { data, body }; +} + +// ── Loader ── + +let cachedModules: SubAgentModule[] | null = null; +let cachedSystemPrompt: string | null = null; + +export function getSubAgentModules(): SubAgentModule[] { + if (!cachedModules) { + throw new Error("Prompts not loaded — call loadPromptsFromDisk() at startup"); + } + return cachedModules; +} + +export function getUnifiedSystemPrompt(): string { + if (!cachedSystemPrompt) { + throw new Error("Prompts not loaded — call loadPromptsFromDisk() at startup"); + } + return cachedSystemPrompt; +} + +export function getSubAgentModule(id: string): SubAgentModule | undefined { + return getSubAgentModules().find((m) => m.id === id); +} + +export function jobApplicationModuleIds(): string[] { + return ["resume", "job-search", "job-apply", "sara", "emily", "qscore"]; +} + +// Load all prompt and agent files from disk. +// Called once at startup. Rebuild the Docker image to pick up changes (§3). +export async function loadPromptsFromDisk(): Promise { + // ── Load agent modules ── + let agentFiles: string[]; + try { + agentFiles = (await readdir(AGENTS_DIR)).filter((f) => f.endsWith(".md")); + } catch (err) { + log.warn({ err, dir: AGENTS_DIR }, "agents directory not found — using empty catalog"); + agentFiles = []; + } + + const modules: SubAgentModule[] = []; + for (const filename of agentFiles) { + const filePath = path.join(AGENTS_DIR, filename); + try { + const raw = await readFile(filePath, "utf8"); + const { data, body } = parseFrontmatter(raw); + + if (!data.id || !data.name) { + log.warn({ file: filename }, "agent file missing required frontmatter fields (id, name)"); + continue; + } + + const service = data.service as SubAgentModule["service"] | undefined; + if ( + service && + service !== "interview-service" && + service !== "roleplay-service" && + service !== "qscore-service" + ) { + log.warn({ file: filename, service }, "unknown service value — treating as no service"); + } + + modules.push({ + id: data.id, + name: data.name, + role: data.role ?? data.name, + description: body || `Agent module: ${data.name}`, + service: service && + ["interview-service", "roleplay-service", "qscore-service"].includes(service) + ? (service as SubAgentModule["service"]) + : undefined, + toolNames: data.tools ?? [], + }); + } catch (err) { + log.error({ err, file: filename }, "failed to load agent module"); + } + } + + cachedModules = modules; + log.info({ count: modules.length, dir: AGENTS_DIR }, "loaded sub-agent modules from disk"); + + // ── Load system prompt ── + try { + const template = await readFile(SYSTEM_PROMPT_FILE, "utf8"); + const moduleDescriptions = modules + .map( + (m) => + `- **${m.name}** (${m.id}): ${m.description} ${ + m.service ? `[backed by ${m.service}]` : "[local workflow]" + }`, + ) + .join("\n"); + + cachedSystemPrompt = template.replace("{{MODULE_DESCRIPTIONS}}", moduleDescriptions); + log.info({ path: SYSTEM_PROMPT_FILE }, "loaded system prompt from disk"); + } catch (err) { + log.error({ err, path: SYSTEM_PROMPT_FILE }, "failed to load system prompt — using fallback"); + // Fallback: assemble from modules without a template file. + const fallback = `You are the Grow Agent — a unified AI orchestrator for the GrowQR platform.\n\n## Sub-Agent Capabilities\n\n${modules.map((m) => `- **${m.name}**: ${m.description}`).join("\n")}`; + cachedSystemPrompt = fallback; + } +} diff --git a/src/routes/actors.ts b/src/routes/actors.ts index 7f5d6fb..a2961d7 100644 --- a/src/routes/actors.ts +++ b/src/routes/actors.ts @@ -10,9 +10,8 @@ import { db } from "../db/client.js"; import { actors as actorsTable } from "../db/schema.js"; import { eq } from "drizzle-orm"; -// PRD §5.2 — Actor registry HTTP surface. -// All routes are user-scoped via Clerk auth; userId is derived from the -// session token, never trusted from the body. +// Per changes.md §5: ONE unified actor per user. +// Routes are user-scoped via Clerk auth; userId derived from session token. export function actorRoutes() { const app = new Hono(); app.use("*", requireUser); @@ -34,7 +33,6 @@ export function actorRoutes() { }); app.get("/", async (c) => { - // Admin/debug — returns the caller's stacks only. Tighten further if needed. const userId = c.get("userId"); const all = await listStacks(); return c.json({ stacks: all.filter((s) => s.userId === userId) }); diff --git a/src/routes/agents.ts b/src/routes/agents.ts index 6c115aa..ffa2f2c 100644 --- a/src/routes/agents.ts +++ b/src/routes/agents.ts @@ -1,12 +1,13 @@ import { Hono } from "hono"; -import { agentCatalog } from "../agents/catalog.js"; +import { subAgentModules } from "../agents/catalog.js"; import { requireUser, type AuthContext } from "../auth/clerk.js"; export function agentRoutes() { const app = new Hono(); app.use("*", requireUser); - app.get("/catalog", (c) => c.json({ agents: agentCatalog })); + // Returns the sub-agent module catalog (changes.md §2D: prompt modules). + app.get("/catalog", (c) => c.json({ agents: subAgentModules })); return app; } diff --git a/src/routes/git.ts b/src/routes/git.ts index 5c42f93..a382a02 100644 --- a/src/routes/git.ts +++ b/src/routes/git.ts @@ -5,7 +5,8 @@ import { requireUser, type AuthContext } from "../auth/clerk.js"; import { db } from "../db/client.js"; import { repos } from "../db/schema.js"; -// PRD §5.4 — Gitea Docker management API. +// Per changes.md §2A: uses CENTRAL Gitea, not per-user Gitea containers. +// All repo operations go through the central org. export function gitRoutes() { const app = new Hono(); app.use("*", requireUser); @@ -16,10 +17,8 @@ export function gitRoutes() { if (!stack) return c.json({ error: "not provisioned" }, 404); return c.json({ gitea: { - host: stack.giteaHost, - port: stack.giteaHttpPort, - sshPort: stack.giteaSshPort, - memoryRepo: stack.giteaMemoryRepo, + repoOwner: stack.giteaRepoOwner, + repoName: stack.giteaRepoName, }, }); }); @@ -31,10 +30,14 @@ export function gitRoutes() { .parse(await c.req.json()); const client = await giteaClientFor(userId); const stack = await getUserStack(userId); - if (!client || !stack) { + if (!client || !stack?.giteaRepoOwner) { return c.json({ error: "not provisioned" }, 404); } - const repo = await client.ensureRepo({ name: body.name, autoInit: true }); + const repo = await client.ensureOrgRepo({ + org: stack.giteaRepoOwner, + name: body.name, + autoInit: true, + }); await db .insert(repos) .values({ @@ -61,15 +64,12 @@ export function gitRoutes() { }) .parse(await c.req.json()); const client = await giteaClientFor(userId); - if (!client) return c.json({ error: "not provisioned" }, 404); - - // Get owner from DB or fall back to memory repo. const stack = await getUserStack(userId); - const owner = stack?.giteaAdminUser ?? ""; - if (!owner) return c.json({ error: "no gitea owner" }, 500); - + if (!client || !stack?.giteaRepoOwner) { + return c.json({ error: "not provisioned" }, 404); + } const result = await client.putFile({ - owner, + owner: stack.giteaRepoOwner, repo: repoName, path: body.path, contentUtf8: body.content, @@ -82,19 +82,19 @@ export function gitRoutes() { app.get("/repos/:name/contents/*", async (c) => { const userId = c.get("userId"); const repoName = c.req.param("name"); - const path = c.req.path.split(`/repos/${repoName}/contents/`)[1] ?? ""; + const filePath = c.req.path.split(`/repos/${repoName}/contents/`)[1] ?? ""; const client = await giteaClientFor(userId); const stack = await getUserStack(userId); - if (!client || !stack?.giteaAdminUser) { + if (!client || !stack?.giteaRepoOwner) { return c.json({ error: "not provisioned" }, 404); } const content = await client.readFile({ - owner: stack.giteaAdminUser, + owner: stack.giteaRepoOwner, repo: repoName, - path, + path: filePath, }); if (content == null) return c.json({ error: "not found" }, 404); - return c.json({ path, content }); + return c.json({ path: filePath, content }); }); return app; diff --git a/src/routes/workflows.ts b/src/routes/workflows.ts index 8174699..459b1ba 100644 --- a/src/routes/workflows.ts +++ b/src/routes/workflows.ts @@ -7,8 +7,9 @@ import type { Registry } from "../actors/registry.js"; const client = createClient(config.rivetEndpoint); -function jobWorkflowFor(userId: string) { - return client.workflowJob.getOrCreate([userId, "job-application"]); +// Per changes.md §5: one unified userActor per user. +function userActorFor(userId: string) { + return client.userActor.getOrCreate([userId]); } export function workflowRoutes() { @@ -20,41 +21,42 @@ export function workflowRoutes() { const body = z .object({ goal: z.string().min(1).optional() }) .parse(await c.req.json().catch(() => ({}))); - const handle = jobWorkflowFor(userId); - const state = await handle.init({ userId, goal: body.goal }); - const started = await handle.start(); - return c.json({ workflow: started ?? state }); + const handle = userActorFor(userId); + await handle.init({ userId }); + const state = await handle.startWorkflow({ goal: body.goal }); + return c.json({ workflow: state }); }); app.get("/job-application", async (c) => { const userId = c.get("userId"); - const handle = jobWorkflowFor(userId); - const state = await handle.init({ userId }); + const handle = userActorFor(userId); + await handle.init({ userId }); + const state = await handle.getWorkflowStatus(); return c.json({ workflow: state }); }); app.post("/job-application/pause", async (c) => { const userId = c.get("userId"); - const workflow = await jobWorkflowFor(userId).pause(); + const workflow = await userActorFor(userId).pauseWorkflow(); return c.json({ workflow }); }); app.post("/job-application/resume", async (c) => { const userId = c.get("userId"); - const workflow = await jobWorkflowFor(userId).resume(); + const workflow = await userActorFor(userId).resumeWorkflow(); return c.json({ workflow }); }); - app.post("/job-application/agents/:agentId/run", async (c) => { + app.post("/job-application/agents/:moduleId/run", async (c) => { const userId = c.get("userId"); - const agentId = c.req.param("agentId"); - const workflow = await jobWorkflowFor(userId).runAgent({ agentId }); + const moduleId = c.req.param("moduleId"); + const workflow = await userActorFor(userId).runWorkflowModule({ moduleId }); return c.json({ workflow }); }); - app.post("/job-application/agents/:agentId/score", async (c) => { + app.post("/job-application/agents/:moduleId/score", async (c) => { const userId = c.get("userId"); - const agentId = c.req.param("agentId"); + const moduleId = c.req.param("moduleId"); const body = z .object({ question: z.string().min(1), @@ -63,8 +65,8 @@ export function workflowRoutes() { notes: z.string().optional(), }) .parse(await c.req.json()); - const workflow = await jobWorkflowFor(userId).recordQaScore({ - agentId, + const workflow = await userActorFor(userId).recordQaScore({ + moduleId, ...body, }); return c.json({ workflow }); diff --git a/src/services/service-agents.ts b/src/services/service-agents.ts index 8c92d9e..485f5ba 100644 --- a/src/services/service-agents.ts +++ b/src/services/service-agents.ts @@ -1,7 +1,16 @@ import { config } from "../config.js"; -import type { AgentProfile } from "../agents/catalog.js"; import { createHash } from "node:crypto"; +// Lightweight agent reference (works with both old AgentProfile and new SubAgentModule). +export type ServiceAgentRef = { + id: string; + name: string; + role: string; + kind: string; + description: string; + service?: string; +}; + export type ServiceAgentResult = { status: "ok" | "unavailable" | "local"; summary: string; @@ -209,7 +218,7 @@ async function runQuinnQScore(ctx: ServiceAgentContext): Promise { try {