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.
This commit is contained in:
26
.env.example
26
.env.example
@@ -3,9 +3,9 @@ LOG_LEVEL=info
|
|||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
# Postgres (started by docker-compose; defaults match the compose service)
|
# 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_USER=growqr
|
||||||
POSTGRES_PASSWORD=growqr
|
POSTGRES_PASSWORD=******
|
||||||
POSTGRES_DB=growqr
|
POSTGRES_DB=growqr
|
||||||
|
|
||||||
# Clerk auth — get from dashboard.clerk.com → API Keys
|
# 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_BASE_URL=https://opencode.ai/zen/v1
|
||||||
LLM_MODEL=kimi-k2.6
|
LLM_MODEL=kimi-k2.6
|
||||||
GROW_AGENT_MODEL=kimi-k2.6
|
GROW_AGENT_MODEL=kimi-k2.6
|
||||||
SUB_AGENT_MODEL=kimi-k2.6
|
|
||||||
MAX_AGENT_TOKENS=4096
|
MAX_AGENT_TOKENS=4096
|
||||||
|
|
||||||
# Shared secret for actor → backend service calls (rotate in prod)
|
# Shared secret for actor → backend service calls (rotate in prod)
|
||||||
SERVICE_TOKEN=dev-service-token-REPLACE_ME
|
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 Kit engine (self-hosted in docker-compose)
|
||||||
RIVET_ENDPOINT=http://localhost:6420
|
RIVET_ENDPOINT=http://localhost:6420
|
||||||
@@ -34,9 +45,8 @@ INTERVIEW_SERVICE_URL=http://localhost:8007
|
|||||||
ROLEPLAY_SERVICE_URL=http://localhost:8008
|
ROLEPLAY_SERVICE_URL=http://localhost:8008
|
||||||
QSCORE_SERVICE_URL=http://localhost:8000
|
QSCORE_SERVICE_URL=http://localhost:8000
|
||||||
|
|
||||||
# Per-user container images
|
# Per-user OpenCode container image (shared, changes.md §3)
|
||||||
GITEA_IMAGE=gitea/gitea:1.22
|
OPENCODE_IMAGE=ghcr.io/anomalyco/opencode:latest
|
||||||
OPENCODE_IMAGE=ghcr.io/sst/opencode:latest
|
|
||||||
|
|
||||||
# Host where spawned containers expose their ports.
|
# Host where spawned containers expose their ports.
|
||||||
# - localhost in dev
|
# - localhost in dev
|
||||||
@@ -46,7 +56,7 @@ USER_CONTAINER_HOST=127.0.0.1
|
|||||||
# Workspace root on the host. Each user gets a subdir.
|
# Workspace root on the host. Each user gets a subdir.
|
||||||
USER_DATA_ROOT=./.data/users
|
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_START=20000
|
||||||
USER_PORT_RANGE_END=29999
|
USER_PORT_RANGE_END=29999
|
||||||
|
|
||||||
|
|||||||
@@ -16,5 +16,12 @@ ENV NODE_ENV=production
|
|||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY --from=build /app/dist ./dist
|
COPY --from=build /app/dist ./dist
|
||||||
COPY package.json ./
|
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
|
EXPOSE 4000
|
||||||
CMD ["node", "dist/index.js"]
|
CMD ["node", "dist/index.js"]
|
||||||
|
|||||||
10
agents/emily.md
Normal file
10
agents/emily.md
Normal file
@@ -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.
|
||||||
11
agents/job-apply.md
Normal file
11
agents/job-apply.md
Normal file
@@ -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.
|
||||||
11
agents/job-search.md
Normal file
11
agents/job-search.md
Normal file
@@ -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.
|
||||||
11
agents/qscore.md
Normal file
11
agents/qscore.md
Normal file
@@ -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.
|
||||||
11
agents/resume.md
Normal file
11
agents/resume.md
Normal file
@@ -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.
|
||||||
10
agents/sara.md
Normal file
10
agents/sara.md
Normal file
@@ -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.
|
||||||
@@ -19,8 +19,33 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
restart: unless-stopped
|
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.
|
# 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:
|
rivet-engine:
|
||||||
image: rivetgg/engine:latest
|
image: rivetgg/engine:latest
|
||||||
container_name: growqr-rivet
|
container_name: growqr-rivet
|
||||||
@@ -34,7 +59,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# The HTTP backend (Hono + Rivet Kit client + Docker manager).
|
# 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:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -43,6 +68,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
gitea:
|
||||||
|
condition: service_healthy
|
||||||
rivet-engine:
|
rivet-engine:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
ports:
|
ports:
|
||||||
@@ -51,30 +78,44 @@ services:
|
|||||||
PORT: 4000
|
PORT: 4000
|
||||||
NODE_ENV: ${NODE_ENV:-production}
|
NODE_ENV: ${NODE_ENV:-production}
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER:-growqr}:${POSTGRES_PASSWORD:-growqr}@postgres:5432/${POSTGRES_DB:-growqr}
|
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_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_SECRET_KEY: ${CLERK_SECRET_KEY}
|
||||||
CLERK_PUBLISHABLE_KEY: ${CLERK_PUBLISHABLE_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}
|
OPENCODE_API_KEY: ${OPENCODE_API_KEY}
|
||||||
LLM_PROVIDER: ${LLM_PROVIDER:-opencode}
|
LLM_PROVIDER: ${LLM_PROVIDER:-opencode}
|
||||||
LLM_BASE_URL: ${LLM_BASE_URL:-https://opencode.ai/zen/v1}
|
LLM_BASE_URL: ${LLM_BASE_URL:-https://opencode.ai/zen/v1}
|
||||||
LLM_MODEL: ${LLM_MODEL:-kimi-k2.6}
|
LLM_MODEL: ${LLM_MODEL:-kimi-k2.6}
|
||||||
GROW_AGENT_MODEL: ${GROW_AGENT_MODEL:-kimi-k2.6}
|
GROW_AGENT_MODEL: ${GROW_AGENT_MODEL:-kimi-k2.6}
|
||||||
SUB_AGENT_MODEL: ${SUB_AGENT_MODEL:-kimi-k2.6}
|
# Per-user OpenCode containers
|
||||||
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}
|
|
||||||
OPENCODE_IMAGE: ${OPENCODE_IMAGE:-ghcr.io/anomalyco/opencode:latest}
|
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_CONTAINER_HOST: ${USER_CONTAINER_HOST:-host.docker.internal}
|
||||||
USER_DATA_ROOT: /data/users
|
USER_DATA_ROOT: /data/users
|
||||||
USER_PORT_RANGE_START: 20000
|
USER_PORT_RANGE_START: 20000
|
||||||
USER_PORT_RANGE_END: 29999
|
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}
|
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
|
||||||
volumes:
|
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
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
# Shared host dir that per-user containers will also bind-mount their
|
# Shared host dir that per-user containers will also bind-mount their
|
||||||
# workspace from (so backend and spawned containers see the same files).
|
# workspace from (so backend and spawned containers see the same files).
|
||||||
@@ -86,10 +127,11 @@ services:
|
|||||||
retries: 6
|
retries: 6
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Note: per-user OpenCode + Gitea containers are NOT defined here.
|
# Only per-user OpenCode containers are spawned dynamically now.
|
||||||
# The backend spawns them dynamically via dockerode on /actors/provision.
|
# Gitea is a central shared service defined above.
|
||||||
# See src/docker/manager.ts.
|
# See src/docker/manager.ts for the per-user OpenCode lifecycle.
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
rivet-data:
|
rivet-data:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
|
gitea-data:
|
||||||
|
|||||||
32
drizzle/0001_central_gitea_unified_actor.sql
Normal file
32
drizzle/0001_central_gitea_unified_actor.sql
Normal file
@@ -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).
|
||||||
19
prompts/system.txt
Normal file
19
prompts/system.txt
Normal file
@@ -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.
|
||||||
@@ -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<string> {
|
|
||||||
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<unknown> {
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
import { setup } from "rivetkit";
|
import { setup } from "rivetkit";
|
||||||
import { growAgent } from "./grow-agent.js";
|
import { userActor } from "./user-actor.js";
|
||||||
import { subAgent } from "./sub-agent.js";
|
|
||||||
import { workflowJob } from "./workflow-job.js";
|
|
||||||
|
|
||||||
|
// Per changes.md §5: ONE unified actor per user.
|
||||||
|
// No separate growAgent, subAgent, or workflowJob actors.
|
||||||
export const registry = setup({
|
export const registry = setup({
|
||||||
use: {
|
use: {
|
||||||
growAgent,
|
userActor,
|
||||||
subAgent,
|
|
||||||
workflowJob,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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<void> {
|
|
||||||
const { userId, subAgentId, type, prompt, channelId, onEvent } = input;
|
|
||||||
try {
|
|
||||||
const target = await opencodeUrlFor(userId);
|
|
||||||
if (!target) {
|
|
||||||
throw new Error("OpenCode container not provisioned for user");
|
|
||||||
}
|
|
||||||
const client = new OpencodeClient(target.baseUrl, target.password);
|
|
||||||
|
|
||||||
const session = await client.createSession({
|
|
||||||
title: `${type} :: ${subAgentId}`,
|
|
||||||
});
|
|
||||||
await db.insert(opencodeSessions).values({
|
|
||||||
id: session.id,
|
|
||||||
userId,
|
|
||||||
actorId: subAgentId,
|
|
||||||
title: session.title ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
onEvent("sub-agent-event", {
|
|
||||||
subAgentId,
|
|
||||||
type: "started",
|
|
||||||
channelId,
|
|
||||||
sessionId: session.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Open SSE stream for live progress.
|
|
||||||
const aborter = client.streamEvents((ev) => {
|
|
||||||
onEvent("sub-agent-event", {
|
|
||||||
subAgentId,
|
|
||||||
type: "progress",
|
|
||||||
channelId,
|
|
||||||
event: ev.event,
|
|
||||||
data: ev.data,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send the prompt synchronously and capture the final response text.
|
|
||||||
const result = await client.sendMessage({
|
|
||||||
sessionId: session.id,
|
|
||||||
text: prompt,
|
|
||||||
});
|
|
||||||
aborter.abort();
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(actorsTable)
|
|
||||||
.set({ status: "done", lastActivityAt: new Date() })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(actorsTable.userId, userId),
|
|
||||||
eq(actorsTable.actorId, subAgentId),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
onEvent("sub-agent-event", {
|
|
||||||
subAgentId,
|
|
||||||
type: "done",
|
|
||||||
channelId,
|
|
||||||
result,
|
|
||||||
});
|
|
||||||
log.info({ subAgentId, sessionId: session.id }, "sub-agent done");
|
|
||||||
} catch (err) {
|
|
||||||
log.error({ err, subAgentId }, "sub-agent failed");
|
|
||||||
await db
|
|
||||||
.update(actorsTable)
|
|
||||||
.set({ status: "error", lastActivityAt: new Date() })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(actorsTable.userId, userId),
|
|
||||||
eq(actorsTable.actorId, subAgentId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.catch(() => undefined);
|
|
||||||
onEvent("sub-agent-event", {
|
|
||||||
subAgentId,
|
|
||||||
type: "error",
|
|
||||||
channelId,
|
|
||||||
message: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,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;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
748
src/actors/user-actor.ts
Normal file
748
src/actors/user-actor.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
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<WorkflowModuleState, "id" | "name">,
|
||||||
|
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<unknown> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<WorkflowAgentState, "id" | "name">,
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,94 +1,51 @@
|
|||||||
export type AgentKind =
|
// ── Sub-agent prompt module catalog (changes.md §2D + §3) ──
|
||||||
| "master"
|
// Sub-agents are NOT separate actors. They are prompt modules loaded into
|
||||||
| "workflow"
|
// the unified user agent's system prompt.
|
||||||
| "microservice"
|
//
|
||||||
| "score";
|
// 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 = {
|
import {
|
||||||
id: string;
|
getSubAgentModules,
|
||||||
name: string;
|
getUnifiedSystemPrompt,
|
||||||
role: string;
|
getSubAgentModule as loaderGetSubAgentModule,
|
||||||
kind: AgentKind;
|
jobApplicationModuleIds as loaderJobApplicationModuleIds,
|
||||||
description: string;
|
type SubAgentModule,
|
||||||
service?: "interview-service" | "roleplay-service" | "qscore-service";
|
} from "../lib/prompt-loader.js";
|
||||||
};
|
|
||||||
|
|
||||||
export const agentCatalog = [
|
export type { SubAgentModule };
|
||||||
{
|
export type SubAgentId = string;
|
||||||
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 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 {
|
// Initialize from disk. Called once at startup by index.ts.
|
||||||
return agentCatalog.find((agent) => agent.id === id);
|
// After this call, subAgentModules is populated and all functions work.
|
||||||
|
export async function initCatalog(): Promise<void> {
|
||||||
|
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[] {
|
export function getSubAgentModule(id: string): SubAgentModule | undefined {
|
||||||
return [
|
return loaderGetSubAgentModule(id);
|
||||||
"grow",
|
}
|
||||||
"resume",
|
|
||||||
"job-search",
|
export function jobApplicationModuleIds(): string[] {
|
||||||
"job-apply",
|
return loaderJobApplicationModuleIds();
|
||||||
"sara",
|
}
|
||||||
"emily",
|
|
||||||
"qscore",
|
// Build the unified Grow Agent system prompt from disk (changes.md §3).
|
||||||
];
|
export function buildUnifiedSystemPrompt(): string {
|
||||||
|
return getUnifiedSystemPrompt();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const config = {
|
|||||||
// Postgres metadata DB (users, registry, container mappings).
|
// Postgres metadata DB (users, registry, container mappings).
|
||||||
databaseUrl:
|
databaseUrl:
|
||||||
process.env.DATABASE_URL ??
|
process.env.DATABASE_URL ??
|
||||||
"postgres://growqr:growqr@localhost:5432/growqr",
|
"***************************************/growqr",
|
||||||
|
|
||||||
// Clerk auth.
|
// Clerk auth.
|
||||||
clerkSecretKey: process.env.CLERK_SECRET_KEY ?? "",
|
clerkSecretKey: process.env.CLERK_SECRET_KEY ?? "",
|
||||||
@@ -25,7 +25,7 @@ export const config = {
|
|||||||
serviceToken: process.env.SERVICE_TOKEN ?? "",
|
serviceToken: process.env.SERVICE_TOKEN ?? "",
|
||||||
a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "dev-a2a-key",
|
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",
|
llmProvider: process.env.LLM_PROVIDER ?? "opencode",
|
||||||
llmApiKey:
|
llmApiKey:
|
||||||
process.env.LLM_API_KEY ??
|
process.env.LLM_API_KEY ??
|
||||||
@@ -36,14 +36,10 @@ export const config = {
|
|||||||
process.env.OPENCODE_BASE_URL ??
|
process.env.OPENCODE_BASE_URL ??
|
||||||
"https://opencode.ai/zen/v1",
|
"https://opencode.ai/zen/v1",
|
||||||
opencodeApiKey: process.env.OPENCODE_API_KEY ?? "",
|
opencodeApiKey: process.env.OPENCODE_API_KEY ?? "",
|
||||||
growAgentModel:
|
agentModel:
|
||||||
process.env.GROW_AGENT_MODEL ??
|
process.env.GROW_AGENT_MODEL ??
|
||||||
process.env.LLM_MODEL ??
|
process.env.LLM_MODEL ??
|
||||||
"kimi-k2.6",
|
"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).
|
// Rivet Kit engine endpoint (self-hosted in docker-compose).
|
||||||
rivetEndpoint: process.env.RIVET_ENDPOINT ?? "http://localhost:6420",
|
rivetEndpoint: process.env.RIVET_ENDPOINT ?? "http://localhost:6420",
|
||||||
@@ -59,14 +55,25 @@ export const config = {
|
|||||||
qscoreServiceUrl:
|
qscoreServiceUrl:
|
||||||
process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000",
|
process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000",
|
||||||
|
|
||||||
// Per-user container images.
|
// ── Central Gitea (one org-wide instance, changes.md §2A) ──
|
||||||
giteaImage: process.env.GITEA_IMAGE ?? "gitea/gitea:1.22",
|
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:
|
opencodeImage:
|
||||||
process.env.OPENCODE_IMAGE ?? "ghcr.io/anomalyco/opencode:latest",
|
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).
|
// Host that user containers expose ports on (the host running Docker).
|
||||||
userContainerHost: process.env.USER_CONTAINER_HOST ?? "127.0.0.1",
|
userContainerHost: process.env.USER_CONTAINER_HOST ?? "127.0.0.1",
|
||||||
userDataRoot: process.env.USER_DATA_ROOT ?? "./.data/users",
|
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),
|
userPortRangeStart: Number(process.env.USER_PORT_RANGE_START ?? 20000),
|
||||||
userPortRangeEnd: Number(process.env.USER_PORT_RANGE_END ?? 29999),
|
userPortRangeEnd: Number(process.env.USER_PORT_RANGE_END ?? 29999),
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ export const users = pgTable(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// One per user. Tracks the user's Grow Agent's container stack + Gitea creds.
|
// One per user. Tracks the user's unified agent's container stack + Git repo.
|
||||||
// PRD §3.2 + §5.2.
|
// 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(
|
export const userStacks = pgTable(
|
||||||
"user_stacks",
|
"user_stacks",
|
||||||
{
|
{
|
||||||
@@ -44,15 +45,11 @@ export const userStacks = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default("provisioning"),
|
.default("provisioning"),
|
||||||
|
|
||||||
giteaContainerId: text("gitea_container_id"),
|
// Central Gitea (shared org-wide, changes.md §2A).
|
||||||
giteaContainerName: text("gitea_container_name"),
|
giteaRepoName: text("gitea_repo_name"),
|
||||||
giteaHost: text("gitea_host"),
|
giteaRepoOwner: text("gitea_repo_owner"),
|
||||||
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"),
|
|
||||||
|
|
||||||
|
// Per-user OpenCode container (from shared image, changes.md §3).
|
||||||
opencodeContainerId: text("opencode_container_id"),
|
opencodeContainerId: text("opencode_container_id"),
|
||||||
opencodeContainerName: text("opencode_container_name"),
|
opencodeContainerName: text("opencode_container_name"),
|
||||||
opencodeHost: text("opencode_host"),
|
opencodeHost: text("opencode_host"),
|
||||||
@@ -60,6 +57,12 @@ export const userStacks = pgTable(
|
|||||||
opencodePassword: text("opencode_password"),
|
opencodePassword: text("opencode_password"),
|
||||||
|
|
||||||
workspacePath: text("workspace_path"),
|
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"),
|
lastError: text("last_error"),
|
||||||
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
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
|
// Per changes.md §5: ONE unified actor per user (no separate grow/sub actors).
|
||||||
// child rows keyed by (userId, actorId).
|
// The actor manages: infra state, git state, runtime comms, migrations, API orchestration.
|
||||||
export const actors = pgTable(
|
export const actors = pgTable(
|
||||||
"actors",
|
"actors",
|
||||||
{
|
{
|
||||||
@@ -83,15 +86,12 @@ export const actors = pgTable(
|
|||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
kind: text("kind", { enum: ["grow", "sub"] }).notNull(),
|
kind: text("kind", { enum: ["user"] }).notNull().default("user"),
|
||||||
subType: text("sub_type"), // for sub-agents: "coding", "repo", "quest", ...
|
|
||||||
status: text("status", {
|
status: text("status", {
|
||||||
enum: ["idle", "running", "done", "error"],
|
enum: ["idle", "running", "done", "error"],
|
||||||
})
|
})
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("idle"),
|
.default("idle"),
|
||||||
channelId: text("channel_id"),
|
|
||||||
parentActorId: text("parent_actor_id"),
|
|
||||||
lastActivityAt: timestamp("last_activity_at", { withTimezone: true }),
|
lastActivityAt: timestamp("last_activity_at", { withTimezone: true }),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
|
|||||||
@@ -14,24 +14,17 @@ export type { UserStack };
|
|||||||
|
|
||||||
const docker = new Docker();
|
const docker = new Docker();
|
||||||
|
|
||||||
// Allocated host ports kept in-memory; rehydrated from the DB on boot so
|
// ── Port allocator (OpenCode containers only; Gitea is central) ──
|
||||||
// we don't double-allocate across restarts.
|
|
||||||
const allocatedPorts = new Set<number>();
|
const allocatedPorts = new Set<number>();
|
||||||
|
|
||||||
export async function hydratePortAllocator(): Promise<void> {
|
export async function hydratePortAllocator(): Promise<void> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({ opencode: userStacks.opencodePort })
|
||||||
giteaHttp: userStacks.giteaHttpPort,
|
|
||||||
giteaSsh: userStacks.giteaSshPort,
|
|
||||||
opencode: userStacks.opencodePort,
|
|
||||||
})
|
|
||||||
.from(userStacks);
|
.from(userStacks);
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
for (const p of [r.giteaHttp, r.giteaSsh, r.opencode]) {
|
if (r.opencode) allocatedPorts.add(r.opencode);
|
||||||
if (p) allocatedPorts.add(p);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
log.info({ count: allocatedPorts.size }, "hydrated port allocator");
|
log.info({ count: allocatedPorts.size }, "hydrated port allocator (OpenCode only)");
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickPort(): number {
|
function pickPort(): number {
|
||||||
@@ -48,6 +41,8 @@ function releasePort(port: number | null | undefined) {
|
|||||||
if (port != null) allocatedPorts.delete(port);
|
if (port != null) allocatedPorts.delete(port);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Image helpers ──
|
||||||
|
|
||||||
async function ensureImage(image: string) {
|
async function ensureImage(image: string) {
|
||||||
try {
|
try {
|
||||||
await docker.getImage(image).inspect();
|
await docker.getImage(image).inspect();
|
||||||
@@ -71,7 +66,6 @@ function userDataDir(userId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function safeContainerName(prefix: string, 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, "_")}`;
|
return `${prefix}-${userId.replace(/[^a-zA-Z0-9_.-]/g, "_")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,77 +77,97 @@ async function findExistingContainer(name: string) {
|
|||||||
return list[0];
|
return list[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startGiteaContainer(opts: {
|
// ── Central Gitea bootstrap (changes.md §2A) ──
|
||||||
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);
|
|
||||||
|
|
||||||
const existing = await findExistingContainer(name);
|
let centralGiteaClient: GiteaClient | null = null;
|
||||||
if (existing) {
|
let centralGiteaReady = false;
|
||||||
if (existing.State !== "running") {
|
|
||||||
await docker.getContainer(existing.Id).start().catch(() => undefined);
|
async function getCentralGiteaClient(): Promise<GiteaClient> {
|
||||||
|
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<void> {
|
||||||
|
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({
|
centralGiteaReady = true;
|
||||||
name,
|
log.info({ url: config.giteaUrl, org: config.giteaOrgName }, "central Gitea ready");
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function shellQuote(value: string): string {
|
// ── Git clone into OpenCode workspace (changes.md §4 step 3) ──
|
||||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
// 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<void> {
|
||||||
|
const container = docker.getContainer(opts.containerId);
|
||||||
|
|
||||||
async function execGiteaCli(containerId: string, args: string[]): Promise<string> {
|
// Build authenticated clone URL.
|
||||||
const container = docker.getContainer(containerId);
|
let authUrl = opts.repoUrl;
|
||||||
const command = [
|
if (opts.giteaToken) {
|
||||||
"gitea",
|
// Embed token in URL: https://token@host/org/repo.git
|
||||||
"--work-path",
|
authUrl = opts.repoUrl.replace("://", `://${encodeURIComponent(opts.giteaToken)}@`);
|
||||||
"/data/gitea",
|
} else if (opts.giteaUser && opts.giteaPassword) {
|
||||||
"--config",
|
authUrl = opts.repoUrl.replace("://", `://${encodeURIComponent(opts.giteaUser)}:${encodeURIComponent(opts.giteaPassword)}@`);
|
||||||
"/data/gitea/conf/app.ini",
|
}
|
||||||
...args,
|
|
||||||
]
|
// Check if workspace is already a git repo; if so, pull instead of clone.
|
||||||
.map(shellQuote)
|
const checkExec = await container.exec({
|
||||||
.join(" ");
|
Cmd: ["sh", "-c", "test -d /workspace/.git && echo 'exists' || echo 'missing'"],
|
||||||
const exec = await container.exec({
|
|
||||||
Cmd: ["su", "git", "-c", command],
|
|
||||||
AttachStdout: true,
|
AttachStdout: true,
|
||||||
AttachStderr: 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<void>((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 stream = await exec.start({ Detach: false, Tty: false });
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
@@ -165,62 +179,64 @@ async function execGiteaCli(containerId: string, args: string[]): Promise<string
|
|||||||
const output = Buffer.concat(chunks).toString("utf8");
|
const output = Buffer.concat(chunks).toString("utf8");
|
||||||
const info = await exec.inspect();
|
const info = await exec.inspect();
|
||||||
if (info.ExitCode && info.ExitCode !== 0) {
|
if (info.ExitCode && info.ExitCode !== 0) {
|
||||||
throw new Error(`gitea cli failed (${info.ExitCode}): ${output}`);
|
// Clone failed but workspace still works via Gitea API directly.
|
||||||
|
log.warn({ output, exitCode: info.ExitCode }, "git clone/pull into workspace had non-zero exit");
|
||||||
}
|
}
|
||||||
return output;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runs `gitea admin user create --admin ...` inside the container.
|
// ── Git workspace sync (changes.md §2B: "Sync to Git") ──
|
||||||
// Idempotent: the CLI returns non-zero if the user already exists, which is fine.
|
// Commits and pushes changes from the container's /workspace back to the
|
||||||
async function ensureGiteaAdmin(opts: {
|
// central Gitea repo, ensuring work done inside OpenCode is persisted as
|
||||||
containerId: string;
|
// Git history. Called after significant events (workflows, code generation).
|
||||||
username: string;
|
export async function syncWorkspaceToGit(userId: string, message?: string): Promise<void> {
|
||||||
password: string;
|
const stack = await getUserStack(userId);
|
||||||
email: string;
|
if (!stack?.opencodeContainerId || !stack.giteaRepoOwner || !stack.giteaRepoName) {
|
||||||
}): Promise<void> {
|
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 {
|
try {
|
||||||
await execGiteaCli(opts.containerId, [
|
const exec = await container.exec({
|
||||||
"admin",
|
Cmd: cmd,
|
||||||
"user",
|
AttachStdout: true,
|
||||||
"create",
|
AttachStderr: true,
|
||||||
"--admin",
|
WorkingDir: "/workspace",
|
||||||
"--username",
|
});
|
||||||
opts.username,
|
const stream = await exec.start({ Detach: false, Tty: false });
|
||||||
"--password",
|
const chunks: Buffer[] = [];
|
||||||
opts.password,
|
stream.on("data", (chunk: Buffer) => chunks.push(Buffer.from(chunk)));
|
||||||
"--email",
|
await new Promise<void>((resolve) => {
|
||||||
opts.email,
|
stream.on("end", () => resolve());
|
||||||
"--must-change-password=false",
|
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) {
|
} catch (err) {
|
||||||
log.debug(
|
log.warn({ err, userId }, "workspace sync to Git failed (non-fatal)");
|
||||||
{ err },
|
|
||||||
"gitea admin user create returned non-zero (likely already exists)",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateGiteaToken(opts: {
|
// ── Per-user OpenCode container (changes.md §2B + §3) ──
|
||||||
containerId: string;
|
|
||||||
username: string;
|
|
||||||
scopes: string[];
|
|
||||||
}): Promise<string> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startOpencodeContainer(opts: {
|
async function startOpencodeContainer(opts: {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -240,16 +256,17 @@ async function startOpencodeContainer(opts: {
|
|||||||
return { id: existing.Id, name };
|
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({
|
const container = await docker.createContainer({
|
||||||
name,
|
name,
|
||||||
Image: config.opencodeImage,
|
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"],
|
Cmd: ["serve", "--port", "4096", "--hostname", "0.0.0.0"],
|
||||||
Env: [
|
Env: [
|
||||||
`OPENCODE_SERVER_PASSWORD=${opts.password}`,
|
`OPENCODE_SERVER_PASSWORD=${opts.password}`,
|
||||||
`OPENCODE_WORKSPACE=/workspace`,
|
`OPENCODE_WORKSPACE=/workspace`,
|
||||||
|
`GROWQR_IMAGE_VERSION=${config.opencodeImageVersion}`,
|
||||||
|
`GROWQR_PROMPT_VERSION=${config.promptVersion}`,
|
||||||
],
|
],
|
||||||
WorkingDir: "/workspace",
|
WorkingDir: "/workspace",
|
||||||
HostConfig: {
|
HostConfig: {
|
||||||
@@ -265,6 +282,8 @@ async function startOpencodeContainer(opts: {
|
|||||||
Labels: {
|
Labels: {
|
||||||
"growqr.userId": opts.userId,
|
"growqr.userId": opts.userId,
|
||||||
"growqr.role": "opencode",
|
"growqr.role": "opencode",
|
||||||
|
"growqr.imageVersion": config.opencodeImageVersion,
|
||||||
|
"growqr.promptVersion": config.promptVersion,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await container.start();
|
await container.start();
|
||||||
@@ -272,18 +291,27 @@ async function startOpencodeContainer(opts: {
|
|||||||
return { id: container.id, name };
|
return { id: container.id, name };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provisions the per-user stack. Idempotent: returns the existing stack if
|
// ── User provisioning (changes.md §4) ──
|
||||||
// the user already has one in the DB and the containers are running.
|
|
||||||
|
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:
|
// Steps (changes.md §4):
|
||||||
// 1. Pick ports + allocate.
|
// 1. Ensure central Gitea is reachable + org exists.
|
||||||
// 2. Start Gitea + OpenCode containers (or reuse).
|
// 2. Pick port for per-user OpenCode container.
|
||||||
// 3. Wait for Gitea HTTP to come up.
|
// 3. Start OpenCode container (from shared image, changes.md §3).
|
||||||
// 4. Create the per-user Gitea admin via `gitea admin user create`.
|
// 4. Create the user's repo in the central Gitea org (changes.md §2A).
|
||||||
// 5. Mint a long-lived access token for the admin.
|
// 5. Initialize repo structure (memory/, conversations/, state/, etc. — changes.md §11).
|
||||||
// 6. Create the user's memory repo with auto_init.
|
// 6. Wait for OpenCode readiness.
|
||||||
// 7. Wait for OpenCode to come up.
|
// 7. Persist everything to user_stacks with version tracking (changes.md §9).
|
||||||
// 8. Persist everything to user_stacks.
|
|
||||||
export async function provisionUserStack(userId: string): Promise<UserStack> {
|
export async function provisionUserStack(userId: string): Promise<UserStack> {
|
||||||
const existing = await db.query.userStacks.findFirst({
|
const existing = await db.query.userStacks.findFirst({
|
||||||
where: eq(userStacks.userId, userId),
|
where: eq(userStacks.userId, userId),
|
||||||
@@ -293,16 +321,13 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await ensureDir(userDataDir(userId));
|
await ensureDir(userDataDir(userId));
|
||||||
|
await ensureCentralGiteaReady();
|
||||||
|
|
||||||
const giteaHttpPort = existing?.giteaHttpPort ?? pickPort();
|
|
||||||
const giteaSshPort = existing?.giteaSshPort ?? pickPort();
|
|
||||||
const opencodePort = existing?.opencodePort ?? pickPort();
|
const opencodePort = existing?.opencodePort ?? pickPort();
|
||||||
const opencodePassword =
|
const opencodePassword =
|
||||||
existing?.opencodePassword ?? randomBytes(24).toString("hex");
|
existing?.opencodePassword ?? randomBytes(24).toString("hex");
|
||||||
const adminUsername =
|
const repoName = existing?.giteaRepoName ?? repoNameFor(userId);
|
||||||
existing?.giteaAdminUser ?? `gq_${userId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 24).toLowerCase() || "user"}`;
|
const repoOwner = config.giteaOrgName;
|
||||||
const adminPassword = randomBytes(24).toString("hex");
|
|
||||||
const adminEmail = `${adminUsername}@growqr.local`;
|
|
||||||
|
|
||||||
// Upsert "provisioning" row first so a crash mid-way leaves a recoverable record.
|
// Upsert "provisioning" row first so a crash mid-way leaves a recoverable record.
|
||||||
await db
|
await db
|
||||||
@@ -310,14 +335,15 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
|
|||||||
.values({
|
.values({
|
||||||
userId,
|
userId,
|
||||||
status: "provisioning",
|
status: "provisioning",
|
||||||
giteaHttpPort,
|
|
||||||
giteaSshPort,
|
|
||||||
opencodePort,
|
opencodePort,
|
||||||
opencodePassword,
|
opencodePassword,
|
||||||
giteaAdminUser: adminUsername,
|
giteaRepoName: repoName,
|
||||||
giteaHost: config.userContainerHost,
|
giteaRepoOwner: repoOwner,
|
||||||
opencodeHost: config.userContainerHost,
|
opencodeHost: config.userContainerHost,
|
||||||
workspacePath: userDataDir(userId),
|
workspacePath: userDataDir(userId),
|
||||||
|
imageVersion: config.opencodeImageVersion,
|
||||||
|
migrationVersion: config.migrationVersion,
|
||||||
|
promptVersion: config.promptVersion,
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: userStacks.userId,
|
target: userStacks.userId,
|
||||||
@@ -329,59 +355,86 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const gitea = await startGiteaContainer({
|
// Start per-user OpenCode container (shared image, changes.md §3).
|
||||||
userId,
|
|
||||||
httpPort: giteaHttpPort,
|
|
||||||
sshPort: giteaSshPort,
|
|
||||||
});
|
|
||||||
const opencode = await startOpencodeContainer({
|
const opencode = await startOpencodeContainer({
|
||||||
userId,
|
userId,
|
||||||
httpPort: opencodePort,
|
httpPort: opencodePort,
|
||||||
password: opencodePassword,
|
password: opencodePassword,
|
||||||
});
|
});
|
||||||
|
|
||||||
const giteaBase = `http://${config.userContainerHost}:${giteaHttpPort}`;
|
// Create the user's repo in the central Gitea org (changes.md §2A + §4 step 2).
|
||||||
await waitForGitea(giteaBase, 90_000);
|
const giteaClient = await getCentralGiteaClient();
|
||||||
|
const repo = await giteaClient.ensureOrgRepo({
|
||||||
// Bootstrap admin user (idempotent — the CLI returns non-zero if exists).
|
org: repoOwner,
|
||||||
await ensureGiteaAdmin({
|
name: repoName,
|
||||||
containerId: gitea.id,
|
description: `GrowQR memory + workspace for user ${userId}`,
|
||||||
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)",
|
|
||||||
autoInit: true,
|
autoInit: true,
|
||||||
private: 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.
|
// OpenCode readiness.
|
||||||
const opencodeBase = `http://${config.userContainerHost}:${opencodePort}`;
|
const opencodeBase = `http://${config.userContainerHost}:${opencodePort}`;
|
||||||
await waitForOpencode(opencodeBase, opencodePassword, 90_000);
|
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
|
const updated = await db
|
||||||
.update(userStacks)
|
.update(userStacks)
|
||||||
.set({
|
.set({
|
||||||
status: "running",
|
status: "running",
|
||||||
giteaContainerId: gitea.id,
|
giteaRepoName: repo.name,
|
||||||
giteaContainerName: gitea.name,
|
giteaRepoOwner: repo.owner,
|
||||||
giteaAdminToken: token,
|
|
||||||
giteaMemoryRepo: `${memoryRepo.owner}/${memoryRepo.name}`,
|
|
||||||
opencodeContainerId: opencode.id,
|
opencodeContainerId: opencode.id,
|
||||||
opencodeContainerName: opencode.name,
|
opencodeContainerName: opencode.name,
|
||||||
|
imageVersion: config.opencodeImageVersion,
|
||||||
|
migrationVersion: config.migrationVersion,
|
||||||
|
promptVersion: config.promptVersion,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
@@ -390,7 +443,7 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
|
|||||||
|
|
||||||
const row = updated[0];
|
const row = updated[0];
|
||||||
if (!row) throw new Error("user stack row vanished mid-provision");
|
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;
|
return row;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error({ err, userId }, "provisionUserStack failed");
|
log.error({ err, userId }, "provisionUserStack failed");
|
||||||
@@ -416,24 +469,23 @@ export async function getUserStack(userId: string): Promise<UserStack | null> {
|
|||||||
export async function stopUserStack(userId: string): Promise<void> {
|
export async function stopUserStack(userId: string): Promise<void> {
|
||||||
const stack = await getUserStack(userId);
|
const stack = await getUserStack(userId);
|
||||||
if (!stack) return;
|
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 {
|
try {
|
||||||
const c = docker.getContainer(id);
|
const c = docker.getContainer(stack.opencodeContainerId);
|
||||||
await c.stop({ t: 5 }).catch(() => undefined);
|
await c.stop({ t: 5 }).catch(() => undefined);
|
||||||
await c.remove({ force: true }).catch(() => undefined);
|
await c.remove({ force: true }).catch(() => undefined);
|
||||||
} catch (err) {
|
} 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);
|
releasePort(stack.opencodePort);
|
||||||
await db
|
await db
|
||||||
.update(userStacks)
|
.update(userStacks)
|
||||||
.set({
|
.set({
|
||||||
status: "stopped",
|
status: "stopped",
|
||||||
giteaContainerId: null,
|
|
||||||
opencodeContainerId: null,
|
opencodeContainerId: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
@@ -445,19 +497,19 @@ export async function listStacks(): Promise<UserStack[]> {
|
|||||||
return db.query.userStacks.findMany();
|
return db.query.userStacks.findMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience: build a Gitea client for a user's stack.
|
// ── Client helpers ──
|
||||||
export async function giteaClientFor(userId: string): Promise<GiteaClient | null> {
|
|
||||||
const stack = await getUserStack(userId);
|
// Build a Gitea client pointed at the CENTRAL Gitea instance (changes.md §2A).
|
||||||
if (!stack?.giteaAdminToken || !stack.giteaHost || !stack.giteaHttpPort) {
|
// Uses the admin token for repo operations on behalf of any user.
|
||||||
|
export async function giteaClientFor(_userId: string): Promise<GiteaClient | null> {
|
||||||
|
try {
|
||||||
|
return await getCentralGiteaClient();
|
||||||
|
} catch {
|
||||||
return null;
|
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(
|
export async function opencodeUrlFor(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<{ baseUrl: string; password: string | undefined } | null> {
|
): 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.
|
// ── Boot reconciliation (changes.md §9) ──
|
||||||
// If a container is gone, flip the row to "stopped" so the next provision
|
|
||||||
// recreates it cleanly.
|
// 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<void> {
|
export async function reconcileOnBoot(): Promise<void> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(userStacks)
|
.from(userStacks)
|
||||||
.where(
|
.where(
|
||||||
and(eq(userStacks.status, "running"), isNotNull(userStacks.giteaContainerId)),
|
and(eq(userStacks.status, "running"), isNotNull(userStacks.opencodeContainerId)),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
|
if (!row.opencodeContainerId) continue;
|
||||||
|
|
||||||
let healthy = true;
|
let healthy = true;
|
||||||
for (const id of [row.giteaContainerId, row.opencodeContainerId]) {
|
try {
|
||||||
if (!id) {
|
const info = await docker.getContainer(row.opencodeContainerId).inspect();
|
||||||
healthy = false;
|
if (!info.State.Running) healthy = false;
|
||||||
break;
|
} catch {
|
||||||
}
|
healthy = false;
|
||||||
try {
|
|
||||||
const info = await docker.getContainer(id).inspect();
|
|
||||||
if (!info.State.Running) healthy = false;
|
|
||||||
} catch {
|
|
||||||
healthy = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!healthy) {
|
if (!healthy) {
|
||||||
await db
|
await db
|
||||||
.update(userStacks)
|
.update(userStacks)
|
||||||
.set({ status: "stopped", updatedAt: new Date() })
|
.set({ status: "stopped", updatedAt: new Date() })
|
||||||
.where(eq(userStacks.userId, row.userId));
|
.where(eq(userStacks.userId, row.userId));
|
||||||
log.info({ userId: row.userId }, "stack marked stopped during reconcile");
|
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",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/index.ts
20
src/index.ts
@@ -12,12 +12,25 @@ import { userRoutes } from "./routes/users.js";
|
|||||||
import { agentRoutes } from "./routes/agents.js";
|
import { agentRoutes } from "./routes/agents.js";
|
||||||
import { workflowRoutes } from "./routes/workflows.js";
|
import { workflowRoutes } from "./routes/workflows.js";
|
||||||
import { db } from "./db/client.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() {
|
async function main() {
|
||||||
// Boot-time DB sanity + reconcile.
|
// Boot-time DB sanity + reconcile + central Gitea readiness.
|
||||||
await db.execute("select 1");
|
await db.execute("select 1");
|
||||||
await hydratePortAllocator();
|
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();
|
await reconcileOnBoot();
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -58,7 +71,7 @@ async function main() {
|
|||||||
// Rivet Kit actor traffic (frontend uses @rivetkit/react against this prefix).
|
// Rivet Kit actor traffic (frontend uses @rivetkit/react against this prefix).
|
||||||
app.all("/api/rivet/*", (c) => registry.handler(c.req.raw));
|
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("/users", userRoutes());
|
||||||
app.route("/agents", agentRoutes());
|
app.route("/agents", agentRoutes());
|
||||||
app.route("/workflows", workflowRoutes());
|
app.route("/workflows", workflowRoutes());
|
||||||
@@ -76,6 +89,7 @@ async function main() {
|
|||||||
{
|
{
|
||||||
port: info.port,
|
port: info.port,
|
||||||
rivet: config.rivetEndpoint,
|
rivet: config.rivetEndpoint,
|
||||||
|
gitea: config.giteaUrl,
|
||||||
env: config.nodeEnv,
|
env: config.nodeEnv,
|
||||||
},
|
},
|
||||||
"growqr-backend listening",
|
"growqr-backend listening",
|
||||||
|
|||||||
@@ -132,6 +132,64 @@ export class GiteaClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Central Gitea org methods (changes.md §2A) ──
|
||||||
|
|
||||||
|
// Ensure an organization exists. Idempotent.
|
||||||
|
async ensureOrg(orgName: string): Promise<void> {
|
||||||
|
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.
|
// Creates or updates a file in a repo. Used for memory commits.
|
||||||
async putFile(opts: {
|
async putFile(opts: {
|
||||||
owner: string;
|
owner: string;
|
||||||
|
|||||||
115
src/lib/llm.ts
115
src/lib/llm.ts
@@ -1,28 +1,14 @@
|
|||||||
import { config } from "../config.js";
|
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.
|
// ── LLM type definitions ──
|
||||||
|
// The system prompt and agent tools are loaded from disk at startup
|
||||||
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.
|
// (prompts/system.txt + agents/*.md) via prompt-loader.ts.
|
||||||
|
// The unified tools are assembled in user-actor.ts using the catalog.
|
||||||
Operating principles:
|
|
||||||
- Be concise and direct. The user sees your messages in a Slack-like chat.
|
|
||||||
- Maintain durable memory: commit important decisions, goals, and progress to the user's memory repo using \`commit_memory\`. Read existing context with \`read_memory\` before making suggestions that depend on history.
|
|
||||||
- For anything that requires code, shell, file edits, or generated artifacts, spawn a sub-agent via \`spawn_sub_agent\`. The sub-agent runs through the user's OpenCode container.
|
|
||||||
- Track active goals and quests. Surface progress proactively when the user returns.
|
|
||||||
- Prefer one small commit per meaningful state change over batching.
|
|
||||||
- Never invent tool names. Only use the tools provided.
|
|
||||||
`;
|
|
||||||
|
|
||||||
export type GrowAgentTool =
|
|
||||||
| "spawn_sub_agent"
|
|
||||||
| "commit_memory"
|
|
||||||
| "read_memory"
|
|
||||||
| "list_memory";
|
|
||||||
|
|
||||||
export type LlmTool = {
|
export type LlmTool = {
|
||||||
type: "function";
|
type: "function";
|
||||||
function: {
|
function: {
|
||||||
name: GrowAgentTool;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
parameters: Record<string, unknown>;
|
parameters: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
@@ -48,96 +34,7 @@ export type LlmMessage = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const growAgentTools: LlmTool[] = [
|
// ── LLM API client ──
|
||||||
{
|
|
||||||
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"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
type ChatCompletionsResponse = {
|
type ChatCompletionsResponse = {
|
||||||
choices?: Array<{
|
choices?: Array<{
|
||||||
|
|||||||
168
src/lib/prompt-loader.ts
Normal file
168
src/lib/prompt-loader.ts
Normal file
@@ -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<void> {
|
||||||
|
// ── 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,8 @@ import { db } from "../db/client.js";
|
|||||||
import { actors as actorsTable } from "../db/schema.js";
|
import { actors as actorsTable } from "../db/schema.js";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
// PRD §5.2 — Actor registry HTTP surface.
|
// Per changes.md §5: ONE unified actor per user.
|
||||||
// All routes are user-scoped via Clerk auth; userId is derived from the
|
// Routes are user-scoped via Clerk auth; userId derived from session token.
|
||||||
// session token, never trusted from the body.
|
|
||||||
export function actorRoutes() {
|
export function actorRoutes() {
|
||||||
const app = new Hono<AuthContext>();
|
const app = new Hono<AuthContext>();
|
||||||
app.use("*", requireUser);
|
app.use("*", requireUser);
|
||||||
@@ -34,7 +33,6 @@ export function actorRoutes() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get("/", async (c) => {
|
app.get("/", async (c) => {
|
||||||
// Admin/debug — returns the caller's stacks only. Tighten further if needed.
|
|
||||||
const userId = c.get("userId");
|
const userId = c.get("userId");
|
||||||
const all = await listStacks();
|
const all = await listStacks();
|
||||||
return c.json({ stacks: all.filter((s) => s.userId === userId) });
|
return c.json({ stacks: all.filter((s) => s.userId === userId) });
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { agentCatalog } from "../agents/catalog.js";
|
import { subAgentModules } from "../agents/catalog.js";
|
||||||
import { requireUser, type AuthContext } from "../auth/clerk.js";
|
import { requireUser, type AuthContext } from "../auth/clerk.js";
|
||||||
|
|
||||||
export function agentRoutes() {
|
export function agentRoutes() {
|
||||||
const app = new Hono<AuthContext>();
|
const app = new Hono<AuthContext>();
|
||||||
app.use("*", requireUser);
|
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;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { requireUser, type AuthContext } from "../auth/clerk.js";
|
|||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { repos } from "../db/schema.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() {
|
export function gitRoutes() {
|
||||||
const app = new Hono<AuthContext>();
|
const app = new Hono<AuthContext>();
|
||||||
app.use("*", requireUser);
|
app.use("*", requireUser);
|
||||||
@@ -16,10 +17,8 @@ export function gitRoutes() {
|
|||||||
if (!stack) return c.json({ error: "not provisioned" }, 404);
|
if (!stack) return c.json({ error: "not provisioned" }, 404);
|
||||||
return c.json({
|
return c.json({
|
||||||
gitea: {
|
gitea: {
|
||||||
host: stack.giteaHost,
|
repoOwner: stack.giteaRepoOwner,
|
||||||
port: stack.giteaHttpPort,
|
repoName: stack.giteaRepoName,
|
||||||
sshPort: stack.giteaSshPort,
|
|
||||||
memoryRepo: stack.giteaMemoryRepo,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -31,10 +30,14 @@ export function gitRoutes() {
|
|||||||
.parse(await c.req.json());
|
.parse(await c.req.json());
|
||||||
const client = await giteaClientFor(userId);
|
const client = await giteaClientFor(userId);
|
||||||
const stack = await getUserStack(userId);
|
const stack = await getUserStack(userId);
|
||||||
if (!client || !stack) {
|
if (!client || !stack?.giteaRepoOwner) {
|
||||||
return c.json({ error: "not provisioned" }, 404);
|
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
|
await db
|
||||||
.insert(repos)
|
.insert(repos)
|
||||||
.values({
|
.values({
|
||||||
@@ -61,15 +64,12 @@ export function gitRoutes() {
|
|||||||
})
|
})
|
||||||
.parse(await c.req.json());
|
.parse(await c.req.json());
|
||||||
const client = await giteaClientFor(userId);
|
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 stack = await getUserStack(userId);
|
||||||
const owner = stack?.giteaAdminUser ?? "";
|
if (!client || !stack?.giteaRepoOwner) {
|
||||||
if (!owner) return c.json({ error: "no gitea owner" }, 500);
|
return c.json({ error: "not provisioned" }, 404);
|
||||||
|
}
|
||||||
const result = await client.putFile({
|
const result = await client.putFile({
|
||||||
owner,
|
owner: stack.giteaRepoOwner,
|
||||||
repo: repoName,
|
repo: repoName,
|
||||||
path: body.path,
|
path: body.path,
|
||||||
contentUtf8: body.content,
|
contentUtf8: body.content,
|
||||||
@@ -82,19 +82,19 @@ export function gitRoutes() {
|
|||||||
app.get("/repos/:name/contents/*", async (c) => {
|
app.get("/repos/:name/contents/*", async (c) => {
|
||||||
const userId = c.get("userId");
|
const userId = c.get("userId");
|
||||||
const repoName = c.req.param("name");
|
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 client = await giteaClientFor(userId);
|
||||||
const stack = await getUserStack(userId);
|
const stack = await getUserStack(userId);
|
||||||
if (!client || !stack?.giteaAdminUser) {
|
if (!client || !stack?.giteaRepoOwner) {
|
||||||
return c.json({ error: "not provisioned" }, 404);
|
return c.json({ error: "not provisioned" }, 404);
|
||||||
}
|
}
|
||||||
const content = await client.readFile({
|
const content = await client.readFile({
|
||||||
owner: stack.giteaAdminUser,
|
owner: stack.giteaRepoOwner,
|
||||||
repo: repoName,
|
repo: repoName,
|
||||||
path,
|
path: filePath,
|
||||||
});
|
});
|
||||||
if (content == null) return c.json({ error: "not found" }, 404);
|
if (content == null) return c.json({ error: "not found" }, 404);
|
||||||
return c.json({ path, content });
|
return c.json({ path: filePath, content });
|
||||||
});
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import type { Registry } from "../actors/registry.js";
|
|||||||
|
|
||||||
const client = createClient<Registry>(config.rivetEndpoint);
|
const client = createClient<Registry>(config.rivetEndpoint);
|
||||||
|
|
||||||
function jobWorkflowFor(userId: string) {
|
// Per changes.md §5: one unified userActor per user.
|
||||||
return client.workflowJob.getOrCreate([userId, "job-application"]);
|
function userActorFor(userId: string) {
|
||||||
|
return client.userActor.getOrCreate([userId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function workflowRoutes() {
|
export function workflowRoutes() {
|
||||||
@@ -20,41 +21,42 @@ export function workflowRoutes() {
|
|||||||
const body = z
|
const body = z
|
||||||
.object({ goal: z.string().min(1).optional() })
|
.object({ goal: z.string().min(1).optional() })
|
||||||
.parse(await c.req.json().catch(() => ({})));
|
.parse(await c.req.json().catch(() => ({})));
|
||||||
const handle = jobWorkflowFor(userId);
|
const handle = userActorFor(userId);
|
||||||
const state = await handle.init({ userId, goal: body.goal });
|
await handle.init({ userId });
|
||||||
const started = await handle.start();
|
const state = await handle.startWorkflow({ goal: body.goal });
|
||||||
return c.json({ workflow: started ?? state });
|
return c.json({ workflow: state });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/job-application", async (c) => {
|
app.get("/job-application", async (c) => {
|
||||||
const userId = c.get("userId");
|
const userId = c.get("userId");
|
||||||
const handle = jobWorkflowFor(userId);
|
const handle = userActorFor(userId);
|
||||||
const state = await handle.init({ userId });
|
await handle.init({ userId });
|
||||||
|
const state = await handle.getWorkflowStatus();
|
||||||
return c.json({ workflow: state });
|
return c.json({ workflow: state });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/job-application/pause", async (c) => {
|
app.post("/job-application/pause", async (c) => {
|
||||||
const userId = c.get("userId");
|
const userId = c.get("userId");
|
||||||
const workflow = await jobWorkflowFor(userId).pause();
|
const workflow = await userActorFor(userId).pauseWorkflow();
|
||||||
return c.json({ workflow });
|
return c.json({ workflow });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/job-application/resume", async (c) => {
|
app.post("/job-application/resume", async (c) => {
|
||||||
const userId = c.get("userId");
|
const userId = c.get("userId");
|
||||||
const workflow = await jobWorkflowFor(userId).resume();
|
const workflow = await userActorFor(userId).resumeWorkflow();
|
||||||
return c.json({ workflow });
|
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 userId = c.get("userId");
|
||||||
const agentId = c.req.param("agentId");
|
const moduleId = c.req.param("moduleId");
|
||||||
const workflow = await jobWorkflowFor(userId).runAgent({ agentId });
|
const workflow = await userActorFor(userId).runWorkflowModule({ moduleId });
|
||||||
return c.json({ workflow });
|
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 userId = c.get("userId");
|
||||||
const agentId = c.req.param("agentId");
|
const moduleId = c.req.param("moduleId");
|
||||||
const body = z
|
const body = z
|
||||||
.object({
|
.object({
|
||||||
question: z.string().min(1),
|
question: z.string().min(1),
|
||||||
@@ -63,8 +65,8 @@ export function workflowRoutes() {
|
|||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
})
|
})
|
||||||
.parse(await c.req.json());
|
.parse(await c.req.json());
|
||||||
const workflow = await jobWorkflowFor(userId).recordQaScore({
|
const workflow = await userActorFor(userId).recordQaScore({
|
||||||
agentId,
|
moduleId,
|
||||||
...body,
|
...body,
|
||||||
});
|
});
|
||||||
return c.json({ workflow });
|
return c.json({ workflow });
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import type { AgentProfile } from "../agents/catalog.js";
|
|
||||||
import { createHash } from "node:crypto";
|
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 = {
|
export type ServiceAgentResult = {
|
||||||
status: "ok" | "unavailable" | "local";
|
status: "ok" | "unavailable" | "local";
|
||||||
summary: string;
|
summary: string;
|
||||||
@@ -209,7 +218,7 @@ async function runQuinnQScore(ctx: ServiceAgentContext): Promise<ServiceAgentRes
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runServiceAgentProbe(
|
export async function runServiceAgentProbe(
|
||||||
agent: AgentProfile,
|
agent: ServiceAgentRef,
|
||||||
ctx?: ServiceAgentContext,
|
ctx?: ServiceAgentContext,
|
||||||
): Promise<ServiceAgentResult> {
|
): Promise<ServiceAgentResult> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user