Compare commits
4 Commits
2d471c61b4
...
0a9bbe6b6f
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a9bbe6b6f | |||
| 4d284b58d7 | |||
| e48c19b840 | |||
| 54297496a4 |
26
.env.example
26
.env.example
@@ -3,9 +3,9 @@ LOG_LEVEL=info
|
||||
NODE_ENV=development
|
||||
|
||||
# Postgres (started by docker-compose; defaults match the compose service)
|
||||
DATABASE_URL=postgres://growqr:growqr@localhost:5432/growqr
|
||||
DATABASE_URL=***************************************/growqr
|
||||
POSTGRES_USER=growqr
|
||||
POSTGRES_PASSWORD=growqr
|
||||
POSTGRES_PASSWORD=******
|
||||
POSTGRES_DB=growqr
|
||||
|
||||
# Clerk auth — get from dashboard.clerk.com → API Keys
|
||||
@@ -18,12 +18,23 @@ LLM_PROVIDER=opencode
|
||||
LLM_BASE_URL=https://opencode.ai/zen/v1
|
||||
LLM_MODEL=kimi-k2.6
|
||||
GROW_AGENT_MODEL=kimi-k2.6
|
||||
SUB_AGENT_MODEL=kimi-k2.6
|
||||
MAX_AGENT_TOKENS=4096
|
||||
|
||||
# Shared secret for actor → backend service calls (rotate in prod)
|
||||
SERVICE_TOKEN=dev-service-token-REPLACE_ME
|
||||
A2A_ALLOWED_KEY=dev-a2a-key
|
||||
A2A_ALLOWED_KEY=***********
|
||||
|
||||
# ── Central Gitea (shared org-wide, changes.md §2A) ──
|
||||
GITEA_URL=http://127.0.0.1:3001
|
||||
GITEA_ADMIN_USER=growqr-admin
|
||||
GITEA_ADMIN_PASSWORD=growqr-admin-dev
|
||||
GITEA_ADMIN_TOKEN=
|
||||
GITEA_ORG_NAME=growqr
|
||||
|
||||
# ── Version tracking (changes.md §9) ──
|
||||
OPENCODE_IMAGE_VERSION=1.0.0
|
||||
MIGRATION_VERSION=1
|
||||
PROMPT_VERSION=1
|
||||
|
||||
# Rivet Kit engine (self-hosted in docker-compose)
|
||||
RIVET_ENDPOINT=http://localhost:6420
|
||||
@@ -34,9 +45,8 @@ INTERVIEW_SERVICE_URL=http://localhost:8007
|
||||
ROLEPLAY_SERVICE_URL=http://localhost:8008
|
||||
QSCORE_SERVICE_URL=http://localhost:8000
|
||||
|
||||
# Per-user container images
|
||||
GITEA_IMAGE=gitea/gitea:1.22
|
||||
OPENCODE_IMAGE=ghcr.io/sst/opencode:latest
|
||||
# Per-user OpenCode container image (shared, changes.md §3)
|
||||
OPENCODE_IMAGE=ghcr.io/anomalyco/opencode:latest
|
||||
|
||||
# Host where spawned containers expose their ports.
|
||||
# - localhost in dev
|
||||
@@ -46,7 +56,7 @@ USER_CONTAINER_HOST=127.0.0.1
|
||||
# Workspace root on the host. Each user gets a subdir.
|
||||
USER_DATA_ROOT=./.data/users
|
||||
|
||||
# Port range allocated to spawned per-user containers
|
||||
# Port range allocated to per-user OpenCode containers (Gitea is central)
|
||||
USER_PORT_RANGE_START=20000
|
||||
USER_PORT_RANGE_END=29999
|
||||
|
||||
|
||||
@@ -16,5 +16,12 @@ ENV NODE_ENV=production
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY package.json ./
|
||||
|
||||
# ── Build-time prompt loading (changes.md §3) ──
|
||||
# Prompts and agent definitions are copied into the image so they are
|
||||
# embedded at build time. To update: edit files → rebuild image → rollout.
|
||||
COPY prompts/ ./prompts/
|
||||
COPY agents/ ./agents/
|
||||
|
||||
EXPOSE 4000
|
||||
CMD ["node", "dist/index.js"]
|
||||
|
||||
33
agents/emily.md
Normal file
33
agents/emily.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
id: emily
|
||||
name: Emily
|
||||
role: Roleplay Agent
|
||||
service: roleplay-service
|
||||
tools:
|
||||
- start_roleplay_session
|
||||
---
|
||||
|
||||
## Domain
|
||||
Emily is the **Roleplay Agent**. She runs realistic workplace scenarios to help users practice conversations, negotiations, and difficult situations. She plays different personas convincingly and provides feedback.
|
||||
|
||||
## When to use this agent (trigger phrases)
|
||||
Use `start_roleplay_session` when the user:
|
||||
- Wants to negotiate: "salary negotiation", "negotiate offer", "counter offer", "compensation", "equity discussion", "signing bonus", "benefits negotiation"
|
||||
- Has a difficult conversation: "asking for a raise", "promotion conversation", "talk to my manager", "difficult conversation with boss"
|
||||
- Is leaving a job: "resignation", "quit my job", "put in notice", "two weeks notice", "leaving my company"
|
||||
- Wants to practice soft skills: "roleplay", "practice conversation", "rehearse", "act out"
|
||||
- Has networking needs: "coffee chat", "informational interview", "networking event", "cold outreach"
|
||||
- Has stakeholder scenarios: "client meeting", "stakeholder presentation", "pitch to executives", "cross-functional"
|
||||
- Has conflict situations: "conflict with coworker", "team disagreement", "difficult colleague", "managing up"
|
||||
- Has performance situations: "performance review", "self-review", "annual review", "how to present my work"
|
||||
- Needs general conversation practice: "how to say", "what should I tell", "how do I bring up", "need to tell my"
|
||||
|
||||
## What Emily NEVER does
|
||||
- Interview practice or technical questions → Sara
|
||||
- Resume writing → Resume Agent
|
||||
- Job searching → Job Search Agent
|
||||
- Q-Score computation → Quinn
|
||||
- Career coaching beyond roleplay → general chat
|
||||
|
||||
## How it works
|
||||
Calls `POST /api/v1/roleplays/configure` on the roleplay-service with user_id, persona_id, roleplay_type, brief, difficulty, and qscore_context. Creates a real Gemini Live-powered roleplay session. Supports types: sales, customer_success, support, custom. Returns session_id for the user to start practicing.
|
||||
35
agents/job-apply.md
Normal file
35
agents/job-apply.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
id: job-apply
|
||||
name: Job Apply Agent
|
||||
role: Application Operator
|
||||
tools:
|
||||
- prepare_application
|
||||
- track_submission
|
||||
- schedule_followup
|
||||
---
|
||||
|
||||
## Domain
|
||||
The **Job Apply Agent** manages the user's job application process end-to-end. It prepares tailored applications, tracks submissions and statuses, schedules follow-ups, manages deadlines, and helps with offer evaluation.
|
||||
|
||||
## When to use this agent (trigger phrases)
|
||||
Use `prepare_application`, `track_submission`, or `schedule_followup` when the user:
|
||||
- Is applying: "apply to jobs", "submit application", "send my application", "apply for [role]", "application for"
|
||||
- Wants cover letters: "cover letter", "write cover letter", "application letter", "customize cover letter for"
|
||||
- Needs tracking: "track my applications", "application status", "where did I apply", "application pipeline"
|
||||
- Has follow-ups: "follow up on application", "check application status", "after applying", "no response from"
|
||||
- Has multiple offers: "multiple offers", "offer comparison", "which offer should I take", "evaluate offers"
|
||||
- Needs offer evaluation: "offer letter review", "total compensation", "TC comparison", "offer negotiation prep"
|
||||
- Has deadline pressure: "application deadline", "apply before", "closing date", "expiring offer"
|
||||
- Wants organization: "organize my job search", "application tracker", "job hunt organization"
|
||||
- Needs references: "reference list", "who should I use as reference", "reference check prep"
|
||||
- Has portfolio needs: "portfolio for jobs", "work samples", "GitHub for applications", "project showcase"
|
||||
|
||||
## What this agent NEVER does
|
||||
- Resume content optimization → Resume Agent
|
||||
- Job discovery → Job Search Agent
|
||||
- Interview practice → Sara
|
||||
- Roleplay → Emily
|
||||
- Q-Score → Quinn
|
||||
|
||||
## How it works
|
||||
Local workflow agent managed by Rivet. Takes the shortlist from Job Search Agent and the tailored resume from Resume Agent, then prepares complete application packages including customized cover letters, tracks submission status, and manages follow-up scheduling.
|
||||
36
agents/job-search.md
Normal file
36
agents/job-search.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
id: job-search
|
||||
name: Job Search Agent
|
||||
role: Opportunity Scout
|
||||
service: matchmaking-service
|
||||
tools:
|
||||
- search_jobs
|
||||
- rank_opportunities
|
||||
- prepare_shortlist
|
||||
---
|
||||
|
||||
## Domain
|
||||
The **Job Search Agent** discovers and evaluates job opportunities matching the user's skills, experience, and preferences. It searches across roles, companies, and industries; ranks opportunities by fit; and prepares shortlists for the application workflow.
|
||||
|
||||
## When to use this agent (trigger phrases)
|
||||
Use `search_jobs`, `rank_opportunities`, or `prepare_shortlist` when the user:
|
||||
- Is actively looking: "find jobs", "job search", "looking for work", "job hunting", "on the market", "searching for roles"
|
||||
- Wants matching: "what jobs match my skills", "roles that fit me", "jobs for my background", "positions for"
|
||||
- Has role preferences: "[role] jobs", "backend engineer positions", "product manager roles", "data scientist openings"
|
||||
- Has company interests: "who's hiring", "companies hiring", "startups hiring", "FAANG jobs", "tech companies"
|
||||
- Has location preferences: "remote jobs", "work from home", "hybrid jobs", "jobs in [city]", "relocation"
|
||||
- Has experience level: "entry level jobs", "senior positions", "junior roles", "[N] years experience jobs"
|
||||
- Wants market context: "job market trends", "in-demand skills", "hot jobs", "salary ranges for", "industry outlook"
|
||||
- Is unemployed/transitioning: "I need a job", "help me find work", "laid off", "between jobs", "looking after graduation"
|
||||
- Wants company research: "should I apply to [company]", "company culture", "best companies for"
|
||||
- Needs networking: "recruiter outreach", "referral strategy", "networking for jobs", "headhunter"
|
||||
|
||||
## What this agent NEVER does
|
||||
- Resume optimization → Resume Agent
|
||||
- Interview practice → Sara
|
||||
- Roleplay → Emily
|
||||
- Q-Score → Quinn
|
||||
- Application tracking → Job Apply Agent
|
||||
|
||||
## How it works
|
||||
Local workflow agent managed by Rivet. Searches and ranks opportunities based on user profile, skills, target role, and preferences. Prepares a ranked shortlist with fit scores that feeds into the Job Apply Agent for application submission.
|
||||
31
agents/qscore.md
Normal file
31
agents/qscore.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
id: qscore
|
||||
name: Quinn
|
||||
role: Q-Score Agent
|
||||
service: qscore-service
|
||||
tools:
|
||||
- compute_qscore
|
||||
- ingest_signals
|
||||
---
|
||||
|
||||
## Domain
|
||||
Quinn is the **Q-Score Agent**. She computes and explains the user's Q-Score — a readiness score based on resume strength, interview readiness, role alignment, engagement, skills, and goal clarity. She tracks growth over time.
|
||||
|
||||
## When to use this agent (trigger phrases)
|
||||
Use `ingest_signals` + `compute_qscore` when the user:
|
||||
- Wants their readiness score: "what's my q-score", "how ready am I", "readiness score", "calculate my score", "check my progress"
|
||||
- Completed a resume update and wants to see impact: "I updated my resume, check my score", "after optimizing resume"
|
||||
- Completed interview practice and wants assessment: "after interview practice", "how did practice affect my score"
|
||||
- Completed roleplay and wants evaluation: "after roleplay", "roleplay feedback score"
|
||||
- Wants overall career health check: "career readiness", "job readiness", "how prepared am I", "am I ready to apply"
|
||||
- Wants to track growth: "score trend", "progress tracking", "improvement over time", "how much have I improved"
|
||||
- Mentions metrics: "quantify my readiness", "measure my growth", "score me", "rate my profile"
|
||||
|
||||
## What Quinn NEVER does
|
||||
- Interview practice → Sara
|
||||
- Roleplay scenarios → Emily
|
||||
- Resume editing → Resume Agent
|
||||
- Job searching → Job Search Agent
|
||||
|
||||
## How it works
|
||||
Ingests signals (resume.uploaded, resume.ats_compatibility, engagement.features_used, goals.goal_clarity) via `POST /v1/signals/ingest`, then computes Q-Score via `POST /v1/qscore/compute`. Returns score from 0-100 with breakdown across 5 pillars. If formula store unavailable, returns an estimated score from signal averages rather than failing.
|
||||
13
agents/resume.md
Normal file
13
agents/resume.md
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
id: resume
|
||||
name: Resume Agent
|
||||
role: Resume Builder
|
||||
service: resume-service
|
||||
tools:
|
||||
- build_resume
|
||||
- review_resume
|
||||
- tailor_resume
|
||||
- analyze_resume
|
||||
---
|
||||
|
||||
Analyzes, builds, and tailors resumes for specific roles. Backed by the resume-builder microservice. Can analyze existing resumes, identify gaps vs target job descriptions, optimize bullet points with impact metrics, improve ATS compatibility, and generate tailored cover letters. Use the `/api/state/{userId}` endpoint for quick resume health probes and `/api/v1/ai/analyze/{resume_id}` for deep analysis.
|
||||
31
agents/sara.md
Normal file
31
agents/sara.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
id: sara
|
||||
name: Sara
|
||||
role: Interview Agent
|
||||
service: interview-service
|
||||
tools:
|
||||
- start_interview_session
|
||||
---
|
||||
|
||||
## Domain
|
||||
Sara is the **Interview Agent**. She only handles job interview preparation and practice. Her focus is behavioral interviews, technical interviews, mock sessions, and interview feedback.
|
||||
|
||||
## When to use this agent (trigger phrases)
|
||||
Use `start_interview_session` when the user:
|
||||
- Wants to practice interviews: "mock interview", "interview prep", "practice interview", "rehearse interview"
|
||||
- Has behavioral questions: "STAR method", "tell me about yourself", "behavioral questions", "common interview questions"
|
||||
- Has technical interview needs: "coding interview", "system design", "technical screen", "whiteboard"
|
||||
- Has an upcoming interview: "interview tomorrow", "interview next week", "upcoming interview", "phone screen", "onsite", "final round", "panel interview"
|
||||
- Wants interview feedback: "how did I do", "improve my answers", "interview confidence", "nervous about interview"
|
||||
- Asks about specific question types: "case interview", "product sense", "estimation questions", "leadership questions"
|
||||
- Mentions any FAANG/tech company in interview context: Google, Meta, Amazon, Apple, Netflix, Microsoft, Stripe, Airbnb, Uber, etc.
|
||||
|
||||
## What Sara NEVER does
|
||||
- Resume writing or optimization → Resume Agent
|
||||
- Roleplay scenarios, negotiation, salary talk → Emily
|
||||
- Job searching or matching → Job Search Agent
|
||||
- Q-Score analysis → Quinn
|
||||
- Career switching advice → general chat
|
||||
|
||||
## How it works
|
||||
Calls `POST /api/v1/configure` on the interview-service with user_id, interview_type, duration, and target role. Creates a real Gemini Live-powered interview session with audio streaming. Returns a session_id that the user can open to start practicing.
|
||||
@@ -19,8 +19,33 @@ services:
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
# ── Central Gitea (one org-wide instance, changes.md §2A) ──
|
||||
# Every user gets a repo inside the GrowQR organization on this instance.
|
||||
# Per-user Gitea containers are REMOVED — the backend no longer spawns them.
|
||||
gitea:
|
||||
image: gitea/gitea:1.22
|
||||
container_name: growqr-gitea
|
||||
environment:
|
||||
USER_UID: "1000"
|
||||
USER_GID: "1000"
|
||||
GITEA__server__ROOT_URL: http://localhost:3001
|
||||
GITEA__server__SSH_PORT: "2222"
|
||||
GITEA__security__INSTALL_LOCK: "true"
|
||||
GITEA__service__DISABLE_REGISTRATION: "true"
|
||||
ports:
|
||||
- "3001:3000" # HTTP (Gitea listens on 3000 internally)
|
||||
- "2222:2222" # SSH
|
||||
volumes:
|
||||
- gitea-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/api/v1/version || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 15
|
||||
restart: unless-stopped
|
||||
|
||||
# Self-hosted Rivet engine. The backend's Rivet Kit client connects here.
|
||||
# Per the PRD, the Grow Agent + sub-agents are durable actors running on Rivet.
|
||||
# The unified user agent runs as a durable Rivet actor (changes.md §5).
|
||||
rivet-engine:
|
||||
image: rivetgg/engine:latest
|
||||
container_name: growqr-rivet
|
||||
@@ -34,7 +59,7 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
# The HTTP backend (Hono + Rivet Kit client + Docker manager).
|
||||
# Mounts the host Docker socket so it can spawn per-user containers.
|
||||
# Mounts the host Docker socket so it can spawn per-user OpenCode containers.
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
@@ -43,6 +68,8 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
gitea:
|
||||
condition: service_healthy
|
||||
rivet-engine:
|
||||
condition: service_started
|
||||
ports:
|
||||
@@ -51,30 +78,45 @@ services:
|
||||
PORT: 4000
|
||||
NODE_ENV: ${NODE_ENV:-production}
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-growqr}:${POSTGRES_PASSWORD:-growqr}@postgres:5432/${POSTGRES_DB:-growqr}
|
||||
# Central Gitea (shared org-wide instance)
|
||||
GITEA_URL: http://gitea:3001
|
||||
GITEA_ADMIN_USER: ${GITEA_ADMIN_USER:-growqr-admin}
|
||||
GITEA_ADMIN_PASSWORD: ${GITEA_ADMIN_PASSWORD:-growqr-admin-dev}
|
||||
GITEA_ADMIN_TOKEN: ${GITEA_ADMIN_TOKEN:-}
|
||||
GITEA_ORG_NAME: ${GITEA_ORG_NAME:-growqr}
|
||||
# Version tracking for image rollouts (changes.md §9)
|
||||
OPENCODE_IMAGE_VERSION: ${OPENCODE_IMAGE_VERSION:-1.0.0}
|
||||
MIGRATION_VERSION: ${MIGRATION_VERSION:-1}
|
||||
PROMPT_VERSION: ${PROMPT_VERSION:-1}
|
||||
# Rivet
|
||||
RIVET_ENDPOINT: http://rivet-engine:6420
|
||||
RIVET_CLIENT_ENDPOINT: ${RIVET_CLIENT_ENDPOINT:-http://127.0.0.1:4000/api/rivet}
|
||||
# Auth
|
||||
CLERK_SECRET_KEY: ${CLERK_SECRET_KEY}
|
||||
CLERK_PUBLISHABLE_KEY: ${CLERK_PUBLISHABLE_KEY}
|
||||
SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token}
|
||||
A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:-dev-a2a-key}
|
||||
# LLM
|
||||
OPENCODE_API_KEY: ${OPENCODE_API_KEY}
|
||||
LLM_PROVIDER: ${LLM_PROVIDER:-opencode}
|
||||
LLM_BASE_URL: ${LLM_BASE_URL:-https://opencode.ai/zen/v1}
|
||||
LLM_MODEL: ${LLM_MODEL:-kimi-k2.6}
|
||||
GROW_AGENT_MODEL: ${GROW_AGENT_MODEL:-kimi-k2.6}
|
||||
SUB_AGENT_MODEL: ${SUB_AGENT_MODEL:-kimi-k2.6}
|
||||
SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token}
|
||||
A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:-dev-a2a-key}
|
||||
RIVET_CLIENT_ENDPOINT: ${RIVET_CLIENT_ENDPOINT:-http://127.0.0.1:4000/api/rivet}
|
||||
GITEA_IMAGE: ${GITEA_IMAGE:-gitea/gitea:1.22}
|
||||
# Per-user OpenCode containers
|
||||
OPENCODE_IMAGE: ${OPENCODE_IMAGE:-ghcr.io/anomalyco/opencode:latest}
|
||||
INTERVIEW_SERVICE_URL: ${INTERVIEW_SERVICE_URL:-http://host.docker.internal:8007}
|
||||
ROLEPLAY_SERVICE_URL: ${ROLEPLAY_SERVICE_URL:-http://host.docker.internal:8008}
|
||||
QSCORE_SERVICE_URL: ${QSCORE_SERVICE_URL:-http://host.docker.internal:8000}
|
||||
USER_CONTAINER_HOST: ${USER_CONTAINER_HOST:-host.docker.internal}
|
||||
USER_DATA_ROOT: /data/users
|
||||
USER_PORT_RANGE_START: 20000
|
||||
USER_PORT_RANGE_END: 29999
|
||||
# Microservices
|
||||
INTERVIEW_SERVICE_URL: ${INTERVIEW_SERVICE_URL:-http://host.docker.internal:8007}
|
||||
ROLEPLAY_SERVICE_URL: ${ROLEPLAY_SERVICE_URL:-http://host.docker.internal:8008}
|
||||
QSCORE_SERVICE_URL: ${QSCORE_SERVICE_URL:-http://host.docker.internal:8000}
|
||||
RESUME_SERVICE_URL: ${RESUME_SERVICE_URL:-http://host.docker.internal:8002}
|
||||
# Frontend
|
||||
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
|
||||
volumes:
|
||||
# Docker-out-of-Docker: backend uses host Docker to spawn user containers.
|
||||
# Docker-out-of-Docker: backend uses host Docker to spawn per-user OpenCode containers.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# Shared host dir that per-user containers will also bind-mount their
|
||||
# workspace from (so backend and spawned containers see the same files).
|
||||
@@ -86,10 +128,11 @@ services:
|
||||
retries: 6
|
||||
restart: unless-stopped
|
||||
|
||||
# Note: per-user OpenCode + Gitea containers are NOT defined here.
|
||||
# The backend spawns them dynamically via dockerode on /actors/provision.
|
||||
# See src/docker/manager.ts.
|
||||
# Only per-user OpenCode containers are spawned dynamically now.
|
||||
# Gitea is a central shared service defined above.
|
||||
# See src/docker/manager.ts for the per-user OpenCode lifecycle.
|
||||
|
||||
volumes:
|
||||
rivet-data:
|
||||
postgres-data:
|
||||
gitea-data:
|
||||
|
||||
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).
|
||||
90
prompts/system.txt
Normal file
90
prompts/system.txt
Normal file
@@ -0,0 +1,90 @@
|
||||
You are the Grow Agent — a unified AI orchestrator for the GrowQR platform.
|
||||
|
||||
You coordinate sub-agent capabilities (loaded as tools), maintain durable state, and execute workflows through microservices.
|
||||
|
||||
## CRITICAL RULES
|
||||
|
||||
1. **When the user asks you to DO something (launch/start/run/create/begin/tailor/analyze) — CALL THE TOOL IMMEDIATELY.** Do not say "starting now" without actually calling the tool. Do not roleplay. The user expects real results.
|
||||
|
||||
2. **When the user provides information (resume, JD, preferences), respond conversationally first, then guide them to the next step.**
|
||||
|
||||
3. **Never show tool call syntax, XML tags, or function call blocks in your visible text.** Tool execution happens silently behind the scenes.
|
||||
|
||||
4. **Be concise** — 1-3 short paragraphs max per response. This is a chat, not a document.
|
||||
|
||||
5. **Use the [WORKFLOW: id] tag at the end of responses** when a workflow context is established.
|
||||
|
||||
## TOOLS YOU MUST USE (not describe, actually call):
|
||||
|
||||
- `start_interview_session` — call when user says "start interview", "launch interview", "practice interview", "mock interview", "set me an interview", "interview me"
|
||||
- `start_roleplay_session` — call when user says "start roleplay", "launch roleplay", "roleplay", "negotiation practice"
|
||||
- `analyze_resume` — call when user says "analyze my resume", "check my resume", "review my resume"
|
||||
- `tailor_resume` — call when user says "tailor my resume", "optimize my resume", "fix my resume"
|
||||
- `compute_qscore` — call when user says "compute score", "what's my score", "check readiness"
|
||||
- `start_interview_to_offer` — call when user says "prepare me for [company] interview", "full interview prep"
|
||||
|
||||
## When User Asks For An Interview:
|
||||
1. If they specified type (behavioral/technical/system design) AND company/role → call `start_interview_session` with the goal
|
||||
2. If they only said "interview" without type → ask "Behavioral, technical, or system design?"
|
||||
3. After calling the tool, report what happened: include the session link or any result
|
||||
4. End with [WORKFLOW: interview-practice]
|
||||
|
||||
## When User Pastes Their Resume:
|
||||
- Acknowledge what you see (role, key skills, strengths/weaknesses)
|
||||
- NEVER call analyze_resume automatically — ask "Would you like me to run a full analysis?"
|
||||
- When they say yes → call analyze_resume → report results
|
||||
- End with [WORKFLOW: resume-boost]
|
||||
|
||||
## When User Says "Prepare for [Role] at [Company]":
|
||||
- This is a multi-step workflow. FIRST, ask for the job description.
|
||||
- Do NOT call start_interview_to_offer yet — wait for the JD.
|
||||
- After JD: ask for resume.
|
||||
- After resume: ask if they want you to analyze/tailor it.
|
||||
- After resume optimization: ask what type of interview to prepare.
|
||||
- When they choose type → call start_interview_session.
|
||||
- Then offer roleplay → call start_roleplay_session when they confirm.
|
||||
- Then offer Q-Score → call compute_qscore.
|
||||
- Use [WORKFLOW: interview-to-offer] tag throughout.
|
||||
|
||||
## IMPORTANT: Tool Calling Anti-Patterns
|
||||
|
||||
❌ BAD:
|
||||
User: "launch my interview"
|
||||
Assistant: "Launching your interview session now!"
|
||||
// (no tool called — this is lying to the user)
|
||||
|
||||
✅ GOOD:
|
||||
User: "launch my interview"
|
||||
Assistant calls start_interview_session → receives result → "Your interview session is ready! [session URL]. You can click Open to begin."
|
||||
|
||||
❌ BAD:
|
||||
User: "analyze my resume"
|
||||
Assistant: "I'll analyze your resume right away."
|
||||
// (no tool called)
|
||||
|
||||
✅ GOOD:
|
||||
User: "analyze my resume"
|
||||
Assistant calls analyze_resume → "Here's your analysis: [results]. Your strengths are..."
|
||||
|
||||
## Sub-Agent Capabilities
|
||||
|
||||
{{MODULE_DESCRIPTIONS}}
|
||||
|
||||
## Workflow Tags (put at the VERY END, on their own line)
|
||||
|
||||
- [WORKFLOW: interview-to-offer] — full interview prep pipeline
|
||||
- [WORKFLOW: interview-practice] — interview sessions with Sara
|
||||
- [WORKFLOW: resume-boost] — resume analysis and optimization
|
||||
- [WORKFLOW: roleplay-practice] — roleplay sessions with Emily
|
||||
- [WORKFLOW: career-switch] — career change navigation
|
||||
- [WORKFLOW: job-search] — job discovery
|
||||
- [WORKFLOW: job-preparation] — broad company preparation
|
||||
|
||||
NEVER mention these tags in your visible text. They are system-internal.
|
||||
|
||||
## Tone
|
||||
|
||||
- Friendly, warm, conversational — like a career coach
|
||||
- Direct and actionable — skip the fluff
|
||||
- Acknowledge the user's situation ("That's exciting!", "Great goal!")
|
||||
- Use markdown for structure (bold, bullets)
|
||||
@@ -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 { growAgent } from "./grow-agent.js";
|
||||
import { subAgent } from "./sub-agent.js";
|
||||
import { workflowJob } from "./workflow-job.js";
|
||||
import { userActor } from "./user-actor.js";
|
||||
|
||||
// Per changes.md §5: ONE unified actor per user.
|
||||
// No separate growAgent, subAgent, or workflowJob actors.
|
||||
export const registry = setup({
|
||||
use: {
|
||||
growAgent,
|
||||
subAgent,
|
||||
workflowJob,
|
||||
userActor,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
965
src/actors/user-actor.ts
Normal file
965
src/actors/user-actor.ts
Normal file
@@ -0,0 +1,965 @@
|
||||
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 {
|
||||
buildServiceSessionUrl,
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "analyze_resume",
|
||||
description: "Analyze the user's resume using the Resume Agent microservice. Returns completeness score, skill gaps, and optimization recommendations.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
goal: { type: "string", description: "Target role or job description for context" },
|
||||
},
|
||||
required: ["goal"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "tailor_resume",
|
||||
description: "Tailor the user's resume for a specific job description or role. Optimizes bullet points, adds keywords, and improves ATS compatibility.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
goal: { type: "string", description: "Target role and company for resume tailoring" },
|
||||
},
|
||||
required: ["goal"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "start_interview_to_offer",
|
||||
description: "Start the Interview-to-Offer Accelerator workflow. This is a guided end-to-end pipeline: (1) Analyze & tailor resume for the role, (2) Create interview practice session with Sara, (3) Create roleplay session with Emily, (4) Compute Q-Score readiness. Use this when the user has a specific interview scheduled and wants comprehensive preparation.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
goal: { type: "string", description: "The target role and company, e.g. 'Software Engineer at Google' or 'Product Manager at Stripe'" },
|
||||
job_description: { type: "string", description: "Optional: the job description or key requirements" },
|
||||
},
|
||||
required: ["goal"],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 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)",
|
||||
sessions: c.state.modules
|
||||
.filter(m => m.service && m.lastResult?.detail)
|
||||
.map(m => {
|
||||
const detail = m.lastResult!.detail as Record<string, unknown> | undefined;
|
||||
return {
|
||||
moduleId: m.id,
|
||||
moduleName: m.name,
|
||||
status: m.status,
|
||||
sessionId: detail?.session_id as string | undefined,
|
||||
sessionUrl: typeof detail?.ui_session_url === "string"
|
||||
? detail.ui_session_url
|
||||
: buildServiceSessionUrl(m.service, detail, c.state.workflowGoal),
|
||||
summary: m.lastResult?.summary,
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
case "analyze_resume": {
|
||||
const goal = String(input.goal ?? c.state.workflowGoal ?? "general");
|
||||
const resumeModule = getSubAgentModule("resume");
|
||||
if (!resumeModule) return { ok: false, error: "Resume module not available" };
|
||||
const result = await runServiceAgentProbe(
|
||||
{ id: resumeModule.id, name: resumeModule.name, role: resumeModule.role, kind: "microservice", description: resumeModule.description, service: resumeModule.service },
|
||||
{ userId, goal },
|
||||
);
|
||||
c.broadcast("service-result", { moduleId: "resume", result });
|
||||
return result;
|
||||
}
|
||||
|
||||
case "tailor_resume": {
|
||||
const goal = String(input.goal ?? c.state.workflowGoal ?? "general");
|
||||
const resumeModule = getSubAgentModule("resume");
|
||||
if (!resumeModule) return { ok: false, error: "Resume module not available" };
|
||||
const result = await runServiceAgentProbe(
|
||||
{ id: resumeModule.id, name: resumeModule.name, role: resumeModule.role, kind: "microservice", description: resumeModule.description, service: resumeModule.service },
|
||||
{ userId, goal },
|
||||
);
|
||||
c.broadcast("service-result", { moduleId: "resume", result });
|
||||
return result;
|
||||
}
|
||||
|
||||
case "start_interview_to_offer": {
|
||||
const goal = String(input.goal ?? "");
|
||||
const jobDesc = String(input.job_description ?? "");
|
||||
|
||||
// Start the workflow
|
||||
c.state.workflowId = `interview-to-offer:${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", `Interview-to-Offer workflow started for: ${goal}`);
|
||||
|
||||
// Step 1: Resume Agent — analyze and tailor
|
||||
const resumeModule = getSubAgentModule("resume");
|
||||
const resumeMod = c.state.modules.find(m => m.id === "resume");
|
||||
if (resumeMod && resumeModule) {
|
||||
resumeMod.status = "running";
|
||||
appendTimelineEvent(c.state, resumeMod, "module", "Resume Agent analyzing your profile...");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
try {
|
||||
const resumeResult = await runServiceAgentProbe(
|
||||
{ id: resumeModule.id, name: resumeModule.name, role: resumeModule.role, kind: "microservice", description: resumeModule.description, service: resumeModule.service },
|
||||
{ userId, goal },
|
||||
);
|
||||
resumeMod.lastResult = resumeResult;
|
||||
resumeMod.status = resumeResult.status === "unavailable" ? "blocked" : "done";
|
||||
appendTimelineEvent(c.state, resumeMod, "module", resumeResult.summary);
|
||||
} catch (err) {
|
||||
resumeMod.status = "blocked";
|
||||
appendTimelineEvent(c.state, resumeMod, "module", `Resume Agent failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
// Step 2: Sara — create interview session
|
||||
const saraModule = getSubAgentModule("sara");
|
||||
const saraMod = c.state.modules.find(m => m.id === "sara");
|
||||
if (saraMod && saraModule?.service) {
|
||||
saraMod.status = "running";
|
||||
appendTimelineEvent(c.state, saraMod, "module", "Sara creating interview practice session...");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
try {
|
||||
const saraResult = await runServiceAgentProbe(
|
||||
{ id: saraModule.id, name: saraModule.name, role: saraModule.role, kind: "microservice", description: saraModule.description, service: saraModule.service },
|
||||
{ userId, goal: goal + (jobDesc ? `\nJob Description: ${jobDesc}` : "") },
|
||||
);
|
||||
saraMod.lastResult = saraResult;
|
||||
saraMod.status = saraResult.status === "unavailable" ? "blocked" : "done";
|
||||
appendTimelineEvent(c.state, saraMod, "module", saraResult.summary);
|
||||
} catch (err) {
|
||||
saraMod.status = "blocked";
|
||||
appendTimelineEvent(c.state, saraMod, "module", `Sara session failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
// Step 3: Emily — create roleplay session
|
||||
const emilyModule = getSubAgentModule("emily");
|
||||
const emilyMod = c.state.modules.find(m => m.id === "emily");
|
||||
if (emilyMod && emilyModule?.service) {
|
||||
emilyMod.status = "running";
|
||||
appendTimelineEvent(c.state, emilyMod, "module", "Emily creating roleplay scenario...");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
try {
|
||||
const emilyResult = await runServiceAgentProbe(
|
||||
{ id: emilyModule.id, name: emilyModule.name, role: emilyModule.role, kind: "microservice", description: emilyModule.description, service: emilyModule.service },
|
||||
{ userId, goal: `Interview negotiation and communication practice for: ${goal}` },
|
||||
);
|
||||
emilyMod.lastResult = emilyResult;
|
||||
emilyMod.status = emilyResult.status === "unavailable" ? "blocked" : "done";
|
||||
appendTimelineEvent(c.state, emilyMod, "module", emilyResult.summary);
|
||||
} catch (err) {
|
||||
emilyMod.status = "blocked";
|
||||
appendTimelineEvent(c.state, emilyMod, "module", `Emily session failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
// Step 4: Quinn — compute Q-Score
|
||||
const quinnModule = getSubAgentModule("qscore");
|
||||
const quinnMod = c.state.modules.find(m => m.id === "qscore");
|
||||
if (quinnMod && quinnModule?.service) {
|
||||
quinnMod.status = "running";
|
||||
appendTimelineEvent(c.state, quinnMod, "module", "Quinn computing your readiness Q-Score...");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
try {
|
||||
const quinnResult = await runServiceAgentProbe(
|
||||
{ id: quinnModule.id, name: quinnModule.name, role: quinnModule.role, kind: "score", description: quinnModule.description, service: quinnModule.service },
|
||||
{ userId, goal },
|
||||
);
|
||||
quinnMod.lastResult = quinnResult;
|
||||
quinnMod.status = quinnResult.status === "unavailable" ? "blocked" : "done";
|
||||
appendTimelineEvent(c.state, quinnMod, "module", quinnResult.summary);
|
||||
} catch (err) {
|
||||
quinnMod.status = "blocked";
|
||||
appendTimelineEvent(c.state, quinnMod, "module", `Q-Score computation failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
const doneCount = c.state.modules.filter(m => m.status === "done").length;
|
||||
const totalModules = c.state.modules.filter(m => m.service).length;
|
||||
const blockedCount = c.state.modules.filter(m => m.status === "blocked").length;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
workflowId: c.state.workflowId,
|
||||
goal,
|
||||
modulesCompleted: doneCount,
|
||||
totalServiceModules: totalModules,
|
||||
blocked: blockedCount,
|
||||
summary: `Interview-to-Offer workflow completed ${doneCount}/${totalModules} service modules${blockedCount > 0 ? ` (${blockedCount} unavailable)` : ""}.`,
|
||||
timeline: c.state.timeline,
|
||||
modules: c.state.modules.filter(m => m.service).map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
status: m.status,
|
||||
summary: m.lastResult?.summary ?? m.summary,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
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 =
|
||||
| "master"
|
||||
| "workflow"
|
||||
| "microservice"
|
||||
| "score";
|
||||
// ── Sub-agent prompt module catalog (changes.md §2D + §3) ──
|
||||
// Sub-agents are NOT separate actors. They are prompt modules loaded into
|
||||
// the unified user agent's system prompt.
|
||||
//
|
||||
// Per changes.md §3: prompts and agent definitions are stored as files on disk
|
||||
// (prompts/system.txt, agents/*.md), loaded at startup, and embedded into the
|
||||
// Docker image at build time via COPY directives.
|
||||
//
|
||||
// This module delegates to src/lib/prompt-loader.ts which reads from the
|
||||
// filesystem. To update prompts or agents, edit the files and rebuild the
|
||||
// Docker image — no code changes required.
|
||||
|
||||
export type AgentProfile = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
kind: AgentKind;
|
||||
description: string;
|
||||
service?: "interview-service" | "roleplay-service" | "qscore-service";
|
||||
};
|
||||
import {
|
||||
getSubAgentModules,
|
||||
getUnifiedSystemPrompt,
|
||||
getSubAgentModule as loaderGetSubAgentModule,
|
||||
jobApplicationModuleIds as loaderJobApplicationModuleIds,
|
||||
type SubAgentModule,
|
||||
} from "../lib/prompt-loader.js";
|
||||
|
||||
export const agentCatalog = [
|
||||
{
|
||||
id: "grow",
|
||||
name: "Grow Agent",
|
||||
role: "Master Orchestrator",
|
||||
kind: "master",
|
||||
description:
|
||||
"Owns user context, routes work to sub-agents, commits durable memory, and tracks workflow progress.",
|
||||
},
|
||||
{
|
||||
id: "resume",
|
||||
name: "Resume Agent",
|
||||
role: "Resume Builder",
|
||||
kind: "workflow",
|
||||
description:
|
||||
"Turns profile context, Q-Score gaps, and target roles into resume edits and application collateral.",
|
||||
},
|
||||
{
|
||||
id: "job-search",
|
||||
name: "Job Search Agent",
|
||||
role: "Opportunity Scout",
|
||||
kind: "workflow",
|
||||
description:
|
||||
"Finds relevant jobs, ranks opportunities, and prepares a shortlist for the application workflow.",
|
||||
},
|
||||
{
|
||||
id: "job-apply",
|
||||
name: "Job Apply Agent",
|
||||
role: "Application Operator",
|
||||
kind: "workflow",
|
||||
description:
|
||||
"Prepares tailored applications, tracks submissions, and records follow-up tasks.",
|
||||
},
|
||||
{
|
||||
id: "sara",
|
||||
name: "Sara",
|
||||
role: "Interview Agent",
|
||||
kind: "microservice",
|
||||
service: "interview-service",
|
||||
description:
|
||||
"Runs interview practice through the interview-service microservice and owns interview Q&A feedback.",
|
||||
},
|
||||
{
|
||||
id: "emily",
|
||||
name: "Emily",
|
||||
role: "Roleplay Agent",
|
||||
kind: "microservice",
|
||||
service: "roleplay-service",
|
||||
description:
|
||||
"Runs roleplay practice through the roleplay-service microservice and owns scenario feedback.",
|
||||
},
|
||||
{
|
||||
id: "qscore",
|
||||
name: "Quinn",
|
||||
role: "Q-Score Agent",
|
||||
kind: "score",
|
||||
service: "qscore-service",
|
||||
description:
|
||||
"Computes and explains Q-Score changes, then displays Q&A and scores under the owning agent.",
|
||||
},
|
||||
] as const satisfies AgentProfile[];
|
||||
export type { SubAgentModule };
|
||||
export type SubAgentId = string;
|
||||
|
||||
export type AgentId = (typeof agentCatalog)[number]["id"];
|
||||
// Re-exported — subAgentModules is now loaded from disk at startup.
|
||||
// Callers that need the module list at runtime (e.g., user-actor.ts to
|
||||
// register tools) should use getSubAgentModules() from prompt-loader directly.
|
||||
export const subAgentModules: SubAgentModule[] = [];
|
||||
|
||||
export function getAgentProfile(id: string): AgentProfile | undefined {
|
||||
return agentCatalog.find((agent) => agent.id === id);
|
||||
// Initialize from disk. Called once at startup by index.ts.
|
||||
// After this call, subAgentModules is populated and all functions work.
|
||||
export async function initCatalog(): Promise<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[] {
|
||||
return [
|
||||
"grow",
|
||||
"resume",
|
||||
"job-search",
|
||||
"job-apply",
|
||||
"sara",
|
||||
"emily",
|
||||
"qscore",
|
||||
];
|
||||
export function getSubAgentModule(id: string): SubAgentModule | undefined {
|
||||
return loaderGetSubAgentModule(id);
|
||||
}
|
||||
|
||||
export function jobApplicationModuleIds(): string[] {
|
||||
return loaderJobApplicationModuleIds();
|
||||
}
|
||||
|
||||
// Build the unified Grow Agent system prompt from disk (changes.md §3).
|
||||
export function buildUnifiedSystemPrompt(): string {
|
||||
return getUnifiedSystemPrompt();
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export const config = {
|
||||
// Postgres metadata DB (users, registry, container mappings).
|
||||
databaseUrl:
|
||||
process.env.DATABASE_URL ??
|
||||
"postgres://growqr:growqr@localhost:5432/growqr",
|
||||
"***************************************/growqr",
|
||||
|
||||
// Clerk auth.
|
||||
clerkSecretKey: process.env.CLERK_SECRET_KEY ?? "",
|
||||
@@ -25,7 +25,7 @@ export const config = {
|
||||
serviceToken: process.env.SERVICE_TOKEN ?? "",
|
||||
a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "dev-a2a-key",
|
||||
|
||||
// LLM gateway for Grow Agent + sub-agent planning calls.
|
||||
// LLM gateway for the unified user agent.
|
||||
llmProvider: process.env.LLM_PROVIDER ?? "opencode",
|
||||
llmApiKey:
|
||||
process.env.LLM_API_KEY ??
|
||||
@@ -36,14 +36,10 @@ export const config = {
|
||||
process.env.OPENCODE_BASE_URL ??
|
||||
"https://opencode.ai/zen/v1",
|
||||
opencodeApiKey: process.env.OPENCODE_API_KEY ?? "",
|
||||
growAgentModel:
|
||||
agentModel:
|
||||
process.env.GROW_AGENT_MODEL ??
|
||||
process.env.LLM_MODEL ??
|
||||
"kimi-k2.6",
|
||||
subAgentModel:
|
||||
process.env.SUB_AGENT_MODEL ??
|
||||
process.env.LLM_MODEL ??
|
||||
"kimi-k2.6",
|
||||
|
||||
// Rivet Kit engine endpoint (self-hosted in docker-compose).
|
||||
rivetEndpoint: process.env.RIVET_ENDPOINT ?? "http://localhost:6420",
|
||||
@@ -58,15 +54,32 @@ export const config = {
|
||||
process.env.ROLEPLAY_SERVICE_URL ?? "http://localhost:8008",
|
||||
qscoreServiceUrl:
|
||||
process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000",
|
||||
resumeServiceUrl:
|
||||
process.env.RESUME_SERVICE_URL ?? "http://localhost:8002",
|
||||
matchmakingServiceUrl:
|
||||
process.env.MATCHMAKING_SERVICE_URL ?? "http://localhost:8006",
|
||||
growqrAppFrontendUrl:
|
||||
process.env.GROWQR_APP_FRONTEND_URL ?? "http://localhost:3002",
|
||||
|
||||
// Per-user container images.
|
||||
giteaImage: process.env.GITEA_IMAGE ?? "gitea/gitea:1.22",
|
||||
// ── Central Gitea (one org-wide instance, changes.md §2A) ──
|
||||
giteaUrl: process.env.GITEA_URL ?? "http://127.0.0.1:3001",
|
||||
giteaAdminUser: process.env.GITEA_ADMIN_USER ?? "growqr-admin",
|
||||
giteaAdminPassword: process.env.GITEA_ADMIN_PASSWORD ?? "growqr-admin-dev",
|
||||
giteaAdminToken: process.env.GITEA_ADMIN_TOKEN ?? "",
|
||||
giteaOrgName: process.env.GITEA_ORG_NAME ?? "growqr",
|
||||
|
||||
// ── Shared OpenCode runtime image (built once, changes.md §3) ──
|
||||
opencodeImage:
|
||||
process.env.OPENCODE_IMAGE ?? "ghcr.io/anomalyco/opencode:latest",
|
||||
// Version tracking for rollout (changes.md §9)
|
||||
opencodeImageVersion: process.env.OPENCODE_IMAGE_VERSION ?? "1.0.0",
|
||||
migrationVersion: process.env.MIGRATION_VERSION ?? "1",
|
||||
promptVersion: process.env.PROMPT_VERSION ?? "1",
|
||||
|
||||
// Host that user containers expose ports on (the host running Docker).
|
||||
userContainerHost: process.env.USER_CONTAINER_HOST ?? "127.0.0.1",
|
||||
userDataRoot: process.env.USER_DATA_ROOT ?? "./.data/users",
|
||||
// Port range for per-user OpenCode containers only (Gitea is shared).
|
||||
userPortRangeStart: Number(process.env.USER_PORT_RANGE_START ?? 20000),
|
||||
userPortRangeEnd: Number(process.env.USER_PORT_RANGE_END ?? 29999),
|
||||
|
||||
|
||||
@@ -30,8 +30,9 @@ export const users = pgTable(
|
||||
}),
|
||||
);
|
||||
|
||||
// One per user. Tracks the user's Grow Agent's container stack + Gitea creds.
|
||||
// PRD §3.2 + §5.2.
|
||||
// One per user. Tracks the user's unified agent's container stack + Git repo.
|
||||
// Per changes.md §2A: per-user Gitea containers removed; central Gitea shared.
|
||||
// Per changes.md §5: ONE actor per user manages the full orchestration layer.
|
||||
export const userStacks = pgTable(
|
||||
"user_stacks",
|
||||
{
|
||||
@@ -44,15 +45,11 @@ export const userStacks = pgTable(
|
||||
.notNull()
|
||||
.default("provisioning"),
|
||||
|
||||
giteaContainerId: text("gitea_container_id"),
|
||||
giteaContainerName: text("gitea_container_name"),
|
||||
giteaHost: text("gitea_host"),
|
||||
giteaHttpPort: integer("gitea_http_port"),
|
||||
giteaSshPort: integer("gitea_ssh_port"),
|
||||
giteaAdminUser: text("gitea_admin_user"),
|
||||
giteaAdminToken: text("gitea_admin_token"),
|
||||
giteaMemoryRepo: text("gitea_memory_repo"),
|
||||
// Central Gitea (shared org-wide, changes.md §2A).
|
||||
giteaRepoName: text("gitea_repo_name"),
|
||||
giteaRepoOwner: text("gitea_repo_owner"),
|
||||
|
||||
// Per-user OpenCode container (from shared image, changes.md §3).
|
||||
opencodeContainerId: text("opencode_container_id"),
|
||||
opencodeContainerName: text("opencode_container_name"),
|
||||
opencodeHost: text("opencode_host"),
|
||||
@@ -60,6 +57,12 @@ export const userStacks = pgTable(
|
||||
opencodePassword: text("opencode_password"),
|
||||
|
||||
workspacePath: text("workspace_path"),
|
||||
|
||||
// Version tracking for image rollouts (changes.md §9).
|
||||
imageVersion: text("image_version"),
|
||||
migrationVersion: text("migration_version"),
|
||||
promptVersion: text("prompt_version"),
|
||||
|
||||
lastError: text("last_error"),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
@@ -74,8 +77,8 @@ export const userStacks = pgTable(
|
||||
}),
|
||||
);
|
||||
|
||||
// PRD §5.2 actor registry. One Grow Agent row per user; sub-agents are
|
||||
// child rows keyed by (userId, actorId).
|
||||
// Per changes.md §5: ONE unified actor per user (no separate grow/sub actors).
|
||||
// The actor manages: infra state, git state, runtime comms, migrations, API orchestration.
|
||||
export const actors = pgTable(
|
||||
"actors",
|
||||
{
|
||||
@@ -83,15 +86,12 @@ export const actors = pgTable(
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
kind: text("kind", { enum: ["grow", "sub"] }).notNull(),
|
||||
subType: text("sub_type"), // for sub-agents: "coding", "repo", "quest", ...
|
||||
kind: text("kind", { enum: ["user"] }).notNull().default("user"),
|
||||
status: text("status", {
|
||||
enum: ["idle", "running", "done", "error"],
|
||||
})
|
||||
.notNull()
|
||||
.default("idle"),
|
||||
channelId: text("channel_id"),
|
||||
parentActorId: text("parent_actor_id"),
|
||||
lastActivityAt: timestamp("last_activity_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
|
||||
@@ -14,24 +14,17 @@ export type { UserStack };
|
||||
|
||||
const docker = new Docker();
|
||||
|
||||
// Allocated host ports kept in-memory; rehydrated from the DB on boot so
|
||||
// we don't double-allocate across restarts.
|
||||
// ── Port allocator (OpenCode containers only; Gitea is central) ──
|
||||
const allocatedPorts = new Set<number>();
|
||||
|
||||
export async function hydratePortAllocator(): Promise<void> {
|
||||
const rows = await db
|
||||
.select({
|
||||
giteaHttp: userStacks.giteaHttpPort,
|
||||
giteaSsh: userStacks.giteaSshPort,
|
||||
opencode: userStacks.opencodePort,
|
||||
})
|
||||
.select({ opencode: userStacks.opencodePort })
|
||||
.from(userStacks);
|
||||
for (const r of rows) {
|
||||
for (const p of [r.giteaHttp, r.giteaSsh, r.opencode]) {
|
||||
if (p) allocatedPorts.add(p);
|
||||
}
|
||||
if (r.opencode) allocatedPorts.add(r.opencode);
|
||||
}
|
||||
log.info({ count: allocatedPorts.size }, "hydrated port allocator");
|
||||
log.info({ count: allocatedPorts.size }, "hydrated port allocator (OpenCode only)");
|
||||
}
|
||||
|
||||
function pickPort(): number {
|
||||
@@ -48,6 +41,8 @@ function releasePort(port: number | null | undefined) {
|
||||
if (port != null) allocatedPorts.delete(port);
|
||||
}
|
||||
|
||||
// ── Image helpers ──
|
||||
|
||||
async function ensureImage(image: string) {
|
||||
try {
|
||||
await docker.getImage(image).inspect();
|
||||
@@ -71,7 +66,6 @@ function userDataDir(userId: string) {
|
||||
}
|
||||
|
||||
function safeContainerName(prefix: string, userId: string) {
|
||||
// Container names must match [a-zA-Z0-9_.-]
|
||||
return `${prefix}-${userId.replace(/[^a-zA-Z0-9_.-]/g, "_")}`;
|
||||
}
|
||||
|
||||
@@ -83,77 +77,97 @@ async function findExistingContainer(name: string) {
|
||||
return list[0];
|
||||
}
|
||||
|
||||
async function startGiteaContainer(opts: {
|
||||
userId: string;
|
||||
httpPort: number;
|
||||
sshPort: number;
|
||||
}): Promise<{ id: string; name: string }> {
|
||||
await ensureImage(config.giteaImage);
|
||||
const name = safeContainerName("growqr-gitea", opts.userId);
|
||||
const dataDir = path.join(userDataDir(opts.userId), "gitea");
|
||||
await ensureDir(dataDir);
|
||||
// ── Central Gitea bootstrap (changes.md §2A) ──
|
||||
|
||||
const existing = await findExistingContainer(name);
|
||||
if (existing) {
|
||||
if (existing.State !== "running") {
|
||||
await docker.getContainer(existing.Id).start().catch(() => undefined);
|
||||
let centralGiteaClient: GiteaClient | null = null;
|
||||
let centralGiteaReady = false;
|
||||
|
||||
async function getCentralGiteaClient(): Promise<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({
|
||||
name,
|
||||
Image: config.giteaImage,
|
||||
Env: [
|
||||
"USER_UID=1000",
|
||||
"USER_GID=1000",
|
||||
`GITEA__server__ROOT_URL=http://${config.userContainerHost}:${opts.httpPort}/`,
|
||||
`GITEA__server__SSH_PORT=${opts.sshPort}`,
|
||||
"GITEA__security__INSTALL_LOCK=true",
|
||||
"GITEA__service__DISABLE_REGISTRATION=true",
|
||||
],
|
||||
HostConfig: {
|
||||
Binds: [`${dataDir}:/data`],
|
||||
PortBindings: {
|
||||
"3000/tcp": [{ HostPort: String(opts.httpPort) }],
|
||||
"22/tcp": [{ HostPort: String(opts.sshPort) }],
|
||||
},
|
||||
RestartPolicy: { Name: "unless-stopped" },
|
||||
Memory: 1 * 1024 * 1024 * 1024,
|
||||
NanoCpus: 1_000_000_000,
|
||||
},
|
||||
ExposedPorts: { "3000/tcp": {}, "22/tcp": {} },
|
||||
Labels: {
|
||||
"growqr.userId": opts.userId,
|
||||
"growqr.role": "gitea",
|
||||
},
|
||||
});
|
||||
await container.start();
|
||||
log.info({ userId: opts.userId, name }, "started Gitea container");
|
||||
return { id: container.id, name };
|
||||
centralGiteaReady = true;
|
||||
log.info({ url: config.giteaUrl, org: config.giteaOrgName }, "central Gitea ready");
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
// ── Git clone into OpenCode workspace (changes.md §4 step 3) ──
|
||||
// Clones the user's repo from central Gitea into the container's /workspace.
|
||||
// If /workspace already has a .git folder, pulls instead of cloning.
|
||||
async function cloneRepoIntoContainer(opts: {
|
||||
containerId: string;
|
||||
repoUrl: string;
|
||||
giteaToken?: string;
|
||||
giteaUser?: string;
|
||||
giteaPassword?: string;
|
||||
}): Promise<void> {
|
||||
const container = docker.getContainer(opts.containerId);
|
||||
|
||||
async function execGiteaCli(containerId: string, args: string[]): Promise<string> {
|
||||
const container = docker.getContainer(containerId);
|
||||
const command = [
|
||||
"gitea",
|
||||
"--work-path",
|
||||
"/data/gitea",
|
||||
"--config",
|
||||
"/data/gitea/conf/app.ini",
|
||||
...args,
|
||||
]
|
||||
.map(shellQuote)
|
||||
.join(" ");
|
||||
const exec = await container.exec({
|
||||
Cmd: ["su", "git", "-c", command],
|
||||
// Build authenticated clone URL.
|
||||
let authUrl = opts.repoUrl;
|
||||
if (opts.giteaToken) {
|
||||
// Embed token in URL: https://token@host/org/repo.git
|
||||
authUrl = opts.repoUrl.replace("://", `://${encodeURIComponent(opts.giteaToken)}@`);
|
||||
} else if (opts.giteaUser && opts.giteaPassword) {
|
||||
authUrl = opts.repoUrl.replace("://", `://${encodeURIComponent(opts.giteaUser)}:${encodeURIComponent(opts.giteaPassword)}@`);
|
||||
}
|
||||
|
||||
// Check if workspace is already a git repo; if so, pull instead of clone.
|
||||
const checkExec = await container.exec({
|
||||
Cmd: ["sh", "-c", "test -d /workspace/.git && echo 'exists' || echo 'missing'"],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
WorkingDir: "/data/gitea",
|
||||
});
|
||||
const checkStream = await checkExec.start({ Detach: false, Tty: false });
|
||||
const checkChunks: Buffer[] = [];
|
||||
checkStream.on("data", (chunk: Buffer) => checkChunks.push(Buffer.from(chunk)));
|
||||
await new Promise<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 chunks: Buffer[] = [];
|
||||
@@ -165,62 +179,64 @@ async function execGiteaCli(containerId: string, args: string[]): Promise<string
|
||||
const output = Buffer.concat(chunks).toString("utf8");
|
||||
const info = await exec.inspect();
|
||||
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.
|
||||
// Idempotent: the CLI returns non-zero if the user already exists, which is fine.
|
||||
async function ensureGiteaAdmin(opts: {
|
||||
containerId: string;
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
}): Promise<void> {
|
||||
// ── Git workspace sync (changes.md §2B: "Sync to Git") ──
|
||||
// Commits and pushes changes from the container's /workspace back to the
|
||||
// central Gitea repo, ensuring work done inside OpenCode is persisted as
|
||||
// Git history. Called after significant events (workflows, code generation).
|
||||
export async function syncWorkspaceToGit(userId: string, message?: string): Promise<void> {
|
||||
const stack = await getUserStack(userId);
|
||||
if (!stack?.opencodeContainerId || !stack.giteaRepoOwner || !stack.giteaRepoName) {
|
||||
log.warn({ userId }, "cannot sync workspace — stack not provisioned");
|
||||
return;
|
||||
}
|
||||
|
||||
const container = docker.getContainer(stack.opencodeContainerId);
|
||||
const commitMsg = message ?? `growqr: workspace sync at ${new Date().toISOString()}`;
|
||||
|
||||
// Build authenticated remote URL for push.
|
||||
let authUrl = `${config.giteaUrl}/${encodeURIComponent(stack.giteaRepoOwner)}/${encodeURIComponent(stack.giteaRepoName)}.git`;
|
||||
if (config.giteaAdminToken) {
|
||||
authUrl = authUrl.replace("://", `://${encodeURIComponent(config.giteaAdminToken)}@`);
|
||||
} else {
|
||||
authUrl = authUrl.replace("://", `://${encodeURIComponent(config.giteaAdminUser)}:${encodeURIComponent(config.giteaAdminPassword)}@`);
|
||||
}
|
||||
|
||||
// Set the remote URL with auth, add all, commit, push.
|
||||
const cmd = [
|
||||
"sh", "-c",
|
||||
`git remote set-url origin "${authUrl}" 2>/dev/null; ` +
|
||||
`git config user.email "growqr@local" && git config user.name "GrowQR"; ` +
|
||||
`git add -A && git commit -m "${commitMsg.replace(/"/g, '\\"')}" 2>/dev/null; ` +
|
||||
`git push origin main 2>&1`,
|
||||
];
|
||||
|
||||
try {
|
||||
await execGiteaCli(opts.containerId, [
|
||||
"admin",
|
||||
"user",
|
||||
"create",
|
||||
"--admin",
|
||||
"--username",
|
||||
opts.username,
|
||||
"--password",
|
||||
opts.password,
|
||||
"--email",
|
||||
opts.email,
|
||||
"--must-change-password=false",
|
||||
]);
|
||||
const exec = await container.exec({
|
||||
Cmd: cmd,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
WorkingDir: "/workspace",
|
||||
});
|
||||
const stream = await exec.start({ Detach: false, Tty: false });
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(Buffer.from(chunk)));
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.on("end", () => resolve());
|
||||
stream.on("close", () => resolve());
|
||||
});
|
||||
const output = Buffer.concat(chunks).toString("utf8");
|
||||
log.info({ userId, output: output.slice(0, 200) }, "workspace synced to Git");
|
||||
} catch (err) {
|
||||
log.debug(
|
||||
{ err },
|
||||
"gitea admin user create returned non-zero (likely already exists)",
|
||||
);
|
||||
log.warn({ err, userId }, "workspace sync to Git failed (non-fatal)");
|
||||
}
|
||||
}
|
||||
|
||||
async function generateGiteaToken(opts: {
|
||||
containerId: string;
|
||||
username: string;
|
||||
scopes: string[];
|
||||
}): Promise<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;
|
||||
}
|
||||
// ── Per-user OpenCode container (changes.md §2B + §3) ──
|
||||
|
||||
async function startOpencodeContainer(opts: {
|
||||
userId: string;
|
||||
@@ -240,16 +256,17 @@ async function startOpencodeContainer(opts: {
|
||||
return { id: existing.Id, name };
|
||||
}
|
||||
|
||||
// Sub-agents are loaded as prompt modules at build time (changes.md §2D).
|
||||
// The shared image includes: base OS, OpenCode, GrowQR core, agents, tools, prompts.
|
||||
const container = await docker.createContainer({
|
||||
name,
|
||||
Image: config.opencodeImage,
|
||||
// OpenCode server CLI: `opencode serve --port 4096 --hostname 0.0.0.0`.
|
||||
// We override the default CMD to make sure it binds to all interfaces
|
||||
// and uses the per-user password.
|
||||
Cmd: ["serve", "--port", "4096", "--hostname", "0.0.0.0"],
|
||||
Env: [
|
||||
`OPENCODE_SERVER_PASSWORD=${opts.password}`,
|
||||
`OPENCODE_WORKSPACE=/workspace`,
|
||||
`GROWQR_IMAGE_VERSION=${config.opencodeImageVersion}`,
|
||||
`GROWQR_PROMPT_VERSION=${config.promptVersion}`,
|
||||
],
|
||||
WorkingDir: "/workspace",
|
||||
HostConfig: {
|
||||
@@ -265,6 +282,8 @@ async function startOpencodeContainer(opts: {
|
||||
Labels: {
|
||||
"growqr.userId": opts.userId,
|
||||
"growqr.role": "opencode",
|
||||
"growqr.imageVersion": config.opencodeImageVersion,
|
||||
"growqr.promptVersion": config.promptVersion,
|
||||
},
|
||||
});
|
||||
await container.start();
|
||||
@@ -272,18 +291,27 @@ async function startOpencodeContainer(opts: {
|
||||
return { id: container.id, name };
|
||||
}
|
||||
|
||||
// Provisions the per-user stack. Idempotent: returns the existing stack if
|
||||
// the user already has one in the DB and the containers are running.
|
||||
// ── User provisioning (changes.md §4) ──
|
||||
|
||||
function repoNameFor(userId: string): string {
|
||||
return `user-${userId.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 48).toLowerCase()}`;
|
||||
}
|
||||
|
||||
function userIdToGiteaUsername(userId: string): string {
|
||||
return `gq_${userId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 24).toLowerCase() || "user"}`;
|
||||
}
|
||||
|
||||
// Provisions the per-user stack. Uses CENTRAL Gitea (changes.md §2A) instead
|
||||
// of spawning per-user Gitea containers.
|
||||
//
|
||||
// Steps:
|
||||
// 1. Pick ports + allocate.
|
||||
// 2. Start Gitea + OpenCode containers (or reuse).
|
||||
// 3. Wait for Gitea HTTP to come up.
|
||||
// 4. Create the per-user Gitea admin via `gitea admin user create`.
|
||||
// 5. Mint a long-lived access token for the admin.
|
||||
// 6. Create the user's memory repo with auto_init.
|
||||
// 7. Wait for OpenCode to come up.
|
||||
// 8. Persist everything to user_stacks.
|
||||
// Steps (changes.md §4):
|
||||
// 1. Ensure central Gitea is reachable + org exists.
|
||||
// 2. Pick port for per-user OpenCode container.
|
||||
// 3. Start OpenCode container (from shared image, changes.md §3).
|
||||
// 4. Create the user's repo in the central Gitea org (changes.md §2A).
|
||||
// 5. Initialize repo structure (memory/, conversations/, state/, etc. — changes.md §11).
|
||||
// 6. Wait for OpenCode readiness.
|
||||
// 7. Persist everything to user_stacks with version tracking (changes.md §9).
|
||||
export async function provisionUserStack(userId: string): Promise<UserStack> {
|
||||
const existing = await db.query.userStacks.findFirst({
|
||||
where: eq(userStacks.userId, userId),
|
||||
@@ -293,16 +321,13 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
|
||||
}
|
||||
|
||||
await ensureDir(userDataDir(userId));
|
||||
await ensureCentralGiteaReady();
|
||||
|
||||
const giteaHttpPort = existing?.giteaHttpPort ?? pickPort();
|
||||
const giteaSshPort = existing?.giteaSshPort ?? pickPort();
|
||||
const opencodePort = existing?.opencodePort ?? pickPort();
|
||||
const opencodePassword =
|
||||
existing?.opencodePassword ?? randomBytes(24).toString("hex");
|
||||
const adminUsername =
|
||||
existing?.giteaAdminUser ?? `gq_${userId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 24).toLowerCase() || "user"}`;
|
||||
const adminPassword = randomBytes(24).toString("hex");
|
||||
const adminEmail = `${adminUsername}@growqr.local`;
|
||||
const repoName = existing?.giteaRepoName ?? repoNameFor(userId);
|
||||
const repoOwner = config.giteaOrgName;
|
||||
|
||||
// Upsert "provisioning" row first so a crash mid-way leaves a recoverable record.
|
||||
await db
|
||||
@@ -310,14 +335,15 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
|
||||
.values({
|
||||
userId,
|
||||
status: "provisioning",
|
||||
giteaHttpPort,
|
||||
giteaSshPort,
|
||||
opencodePort,
|
||||
opencodePassword,
|
||||
giteaAdminUser: adminUsername,
|
||||
giteaHost: config.userContainerHost,
|
||||
giteaRepoName: repoName,
|
||||
giteaRepoOwner: repoOwner,
|
||||
opencodeHost: config.userContainerHost,
|
||||
workspacePath: userDataDir(userId),
|
||||
imageVersion: config.opencodeImageVersion,
|
||||
migrationVersion: config.migrationVersion,
|
||||
promptVersion: config.promptVersion,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: userStacks.userId,
|
||||
@@ -329,59 +355,86 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
|
||||
});
|
||||
|
||||
try {
|
||||
const gitea = await startGiteaContainer({
|
||||
userId,
|
||||
httpPort: giteaHttpPort,
|
||||
sshPort: giteaSshPort,
|
||||
});
|
||||
// Start per-user OpenCode container (shared image, changes.md §3).
|
||||
const opencode = await startOpencodeContainer({
|
||||
userId,
|
||||
httpPort: opencodePort,
|
||||
password: opencodePassword,
|
||||
});
|
||||
|
||||
const giteaBase = `http://${config.userContainerHost}:${giteaHttpPort}`;
|
||||
await waitForGitea(giteaBase, 90_000);
|
||||
|
||||
// Bootstrap admin user (idempotent — the CLI returns non-zero if exists).
|
||||
await ensureGiteaAdmin({
|
||||
containerId: gitea.id,
|
||||
username: adminUsername,
|
||||
password: adminPassword,
|
||||
email: adminEmail,
|
||||
});
|
||||
|
||||
// Mint a token via Gitea's CLI so retries do not depend on a transient
|
||||
// bootstrap password from a previous provisioning attempt.
|
||||
const token = await generateGiteaToken({
|
||||
containerId: gitea.id,
|
||||
username: adminUsername,
|
||||
scopes: ["write:repository", "write:user", "write:issue"],
|
||||
});
|
||||
|
||||
// Use the token from here on.
|
||||
const tokenClient = new GiteaClient(giteaBase, { kind: "token", token });
|
||||
const memoryRepo = await tokenClient.ensureRepo({
|
||||
name: "growqr-memory",
|
||||
description: "Grow Agent memory + state (PRD §3.4)",
|
||||
// Create the user's repo in the central Gitea org (changes.md §2A + §4 step 2).
|
||||
const giteaClient = await getCentralGiteaClient();
|
||||
const repo = await giteaClient.ensureOrgRepo({
|
||||
org: repoOwner,
|
||||
name: repoName,
|
||||
description: `GrowQR memory + workspace for user ${userId}`,
|
||||
autoInit: true,
|
||||
private: true,
|
||||
});
|
||||
|
||||
// Initialize the standard repo structure (changes.md §11).
|
||||
const initFiles: Array<{ path: string; content: string }> = [
|
||||
{ path: "memory/.gitkeep", content: "# Agent memory\n" },
|
||||
{ path: "conversations/.gitkeep", content: "# Conversation history\n" },
|
||||
{ path: "state/.gitkeep", content: "# Agent state\n" },
|
||||
{ path: "artifacts/.gitkeep", content: "# Generated artifacts\n" },
|
||||
{ path: "workflows/.gitkeep", content: "# Workflow definitions\n" },
|
||||
{ path: "logs/.gitkeep", content: "# Runtime logs\n" },
|
||||
{ path: "config/.gitkeep", content: "# User configuration\n" },
|
||||
{ path: "metadata/versions.json", content: JSON.stringify({
|
||||
imageVersion: config.opencodeImageVersion,
|
||||
migrationVersion: config.migrationVersion,
|
||||
promptVersion: config.promptVersion,
|
||||
provisionedAt: new Date().toISOString(),
|
||||
}, null, 2) + "\n" },
|
||||
];
|
||||
|
||||
for (const file of initFiles) {
|
||||
try {
|
||||
await giteaClient.putFile({
|
||||
owner: repoOwner,
|
||||
repo: repoName,
|
||||
path: file.path,
|
||||
contentUtf8: file.content,
|
||||
message: `init: ${file.path}`,
|
||||
branch: "main",
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn({ err, path: file.path }, "failed to init repo file (non-fatal)");
|
||||
}
|
||||
}
|
||||
|
||||
// OpenCode readiness.
|
||||
const opencodeBase = `http://${config.userContainerHost}:${opencodePort}`;
|
||||
await waitForOpencode(opencodeBase, opencodePassword, 90_000);
|
||||
|
||||
// Clone the user's Git repo into the OpenCode workspace (changes.md §4 step 3).
|
||||
// Uses `git clone` inside the container so the workspace is a working copy
|
||||
// of the user's repo, making Git the source of truth (changes.md §7).
|
||||
try {
|
||||
await cloneRepoIntoContainer({
|
||||
containerId: opencode.id,
|
||||
repoUrl: `${config.giteaUrl}/${encodeURIComponent(repoOwner)}/${encodeURIComponent(repoName)}.git`,
|
||||
giteaToken: config.giteaAdminToken || undefined,
|
||||
giteaUser: config.giteaAdminUser,
|
||||
giteaPassword: !config.giteaAdminToken ? config.giteaAdminPassword : undefined,
|
||||
});
|
||||
log.info({ userId, repo: `${repoOwner}/${repoName}` }, "repo cloned into OpenCode workspace");
|
||||
} catch (err) {
|
||||
log.warn({ err, userId }, "git clone into workspace failed (non-fatal — workspace still available via Gitea API)");
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(userStacks)
|
||||
.set({
|
||||
status: "running",
|
||||
giteaContainerId: gitea.id,
|
||||
giteaContainerName: gitea.name,
|
||||
giteaAdminToken: token,
|
||||
giteaMemoryRepo: `${memoryRepo.owner}/${memoryRepo.name}`,
|
||||
giteaRepoName: repo.name,
|
||||
giteaRepoOwner: repo.owner,
|
||||
opencodeContainerId: opencode.id,
|
||||
opencodeContainerName: opencode.name,
|
||||
imageVersion: config.opencodeImageVersion,
|
||||
migrationVersion: config.migrationVersion,
|
||||
promptVersion: config.promptVersion,
|
||||
lastError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@@ -390,7 +443,7 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
|
||||
|
||||
const row = updated[0];
|
||||
if (!row) throw new Error("user stack row vanished mid-provision");
|
||||
log.info({ userId }, "user stack provisioned");
|
||||
log.info({ userId, repo: `${repo.owner}/${repo.name}` }, "user stack provisioned");
|
||||
return row;
|
||||
} catch (err) {
|
||||
log.error({ err, userId }, "provisionUserStack failed");
|
||||
@@ -416,24 +469,23 @@ export async function getUserStack(userId: string): Promise<UserStack | null> {
|
||||
export async function stopUserStack(userId: string): Promise<void> {
|
||||
const stack = await getUserStack(userId);
|
||||
if (!stack) return;
|
||||
for (const id of [stack.giteaContainerId, stack.opencodeContainerId]) {
|
||||
if (!id) continue;
|
||||
|
||||
// Stop only the OpenCode container (Gitea is central, changes.md §2A).
|
||||
if (stack.opencodeContainerId) {
|
||||
try {
|
||||
const c = docker.getContainer(id);
|
||||
const c = docker.getContainer(stack.opencodeContainerId);
|
||||
await c.stop({ t: 5 }).catch(() => undefined);
|
||||
await c.remove({ force: true }).catch(() => undefined);
|
||||
} catch (err) {
|
||||
log.warn({ err, id }, "failed to stop container");
|
||||
log.warn({ err, id: stack.opencodeContainerId }, "failed to stop container");
|
||||
}
|
||||
}
|
||||
releasePort(stack.giteaHttpPort);
|
||||
releasePort(stack.giteaSshPort);
|
||||
|
||||
releasePort(stack.opencodePort);
|
||||
await db
|
||||
.update(userStacks)
|
||||
.set({
|
||||
status: "stopped",
|
||||
giteaContainerId: null,
|
||||
opencodeContainerId: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@@ -445,19 +497,19 @@ export async function listStacks(): Promise<UserStack[]> {
|
||||
return db.query.userStacks.findMany();
|
||||
}
|
||||
|
||||
// Convenience: build a Gitea client for a user's stack.
|
||||
export async function giteaClientFor(userId: string): Promise<GiteaClient | null> {
|
||||
const stack = await getUserStack(userId);
|
||||
if (!stack?.giteaAdminToken || !stack.giteaHost || !stack.giteaHttpPort) {
|
||||
// ── Client helpers ──
|
||||
|
||||
// Build a Gitea client pointed at the CENTRAL Gitea instance (changes.md §2A).
|
||||
// Uses the admin token for repo operations on behalf of any user.
|
||||
export async function giteaClientFor(_userId: string): Promise<GiteaClient | null> {
|
||||
try {
|
||||
return await getCentralGiteaClient();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return new GiteaClient(
|
||||
`http://${stack.giteaHost}:${stack.giteaHttpPort}`,
|
||||
{ kind: "token", token: stack.giteaAdminToken },
|
||||
);
|
||||
}
|
||||
|
||||
// Convenience: build an OpenCode client for a user's stack.
|
||||
// Build an OpenCode client for a user's stack.
|
||||
export async function opencodeUrlFor(
|
||||
userId: string,
|
||||
): Promise<{ baseUrl: string; password: string | undefined } | null> {
|
||||
@@ -469,36 +521,61 @@ export async function opencodeUrlFor(
|
||||
};
|
||||
}
|
||||
|
||||
// Reconcile DB-tracked running containers with actual Docker state on boot.
|
||||
// If a container is gone, flip the row to "stopped" so the next provision
|
||||
// recreates it cleanly.
|
||||
// ── Boot reconciliation (changes.md §9) ──
|
||||
|
||||
// Reconcile DB-tracked running stacks with actual Docker state on boot.
|
||||
// Only checks OpenCode containers (Gitea is central, changes.md §2A).
|
||||
// If a container is gone, flip the row to "stopped" so the next provision recreates it.
|
||||
//
|
||||
// Also detects version mismatches for image rollout (changes.md §9):
|
||||
// if the running container's imageVersion is behind, mark for migration.
|
||||
export async function reconcileOnBoot(): Promise<void> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(userStacks)
|
||||
.where(
|
||||
and(eq(userStacks.status, "running"), isNotNull(userStacks.giteaContainerId)),
|
||||
and(eq(userStacks.status, "running"), isNotNull(userStacks.opencodeContainerId)),
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.opencodeContainerId) continue;
|
||||
|
||||
let healthy = true;
|
||||
for (const id of [row.giteaContainerId, row.opencodeContainerId]) {
|
||||
if (!id) {
|
||||
healthy = false;
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const info = await docker.getContainer(id).inspect();
|
||||
if (!info.State.Running) healthy = false;
|
||||
} catch {
|
||||
healthy = false;
|
||||
}
|
||||
try {
|
||||
const info = await docker.getContainer(row.opencodeContainerId).inspect();
|
||||
if (!info.State.Running) healthy = false;
|
||||
} catch {
|
||||
healthy = false;
|
||||
}
|
||||
|
||||
if (!healthy) {
|
||||
await db
|
||||
.update(userStacks)
|
||||
.set({ status: "stopped", updatedAt: new Date() })
|
||||
.where(eq(userStacks.userId, row.userId));
|
||||
log.info({ userId: row.userId }, "stack marked stopped during reconcile");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Version mismatch detection (changes.md §9).
|
||||
const needsMigration =
|
||||
row.imageVersion !== config.opencodeImageVersion ||
|
||||
row.migrationVersion !== config.migrationVersion;
|
||||
const needsPromptUpdate = row.promptVersion !== config.promptVersion;
|
||||
|
||||
if (needsMigration || needsPromptUpdate) {
|
||||
log.info(
|
||||
{
|
||||
userId: row.userId,
|
||||
currentImage: row.imageVersion,
|
||||
targetImage: config.opencodeImageVersion,
|
||||
currentMigration: row.migrationVersion,
|
||||
targetMigration: config.migrationVersion,
|
||||
currentPrompt: row.promptVersion,
|
||||
targetPrompt: config.promptVersion,
|
||||
},
|
||||
"version mismatch detected — migration needed on next provision",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
72
src/index.ts
72
src/index.ts
@@ -11,13 +11,27 @@ import { gitRoutes } from "./routes/git.js";
|
||||
import { userRoutes } from "./routes/users.js";
|
||||
import { agentRoutes } from "./routes/agents.js";
|
||||
import { workflowRoutes } from "./routes/workflows.js";
|
||||
import { chatRoutes } from "./routes/chat.js";
|
||||
import { db } from "./db/client.js";
|
||||
import { hydratePortAllocator, reconcileOnBoot } from "./docker/manager.js";
|
||||
import { hydratePortAllocator, reconcileOnBoot, ensureCentralGiteaReady } from "./docker/manager.js";
|
||||
import { initCatalog } from "./agents/catalog.js";
|
||||
|
||||
async function main() {
|
||||
// Boot-time DB sanity + reconcile.
|
||||
// Boot-time DB sanity + reconcile + central Gitea readiness.
|
||||
await db.execute("select 1");
|
||||
await hydratePortAllocator();
|
||||
|
||||
// Ensure central Gitea is reachable before accepting traffic (changes.md §2A).
|
||||
try {
|
||||
await ensureCentralGiteaReady();
|
||||
} catch (err) {
|
||||
log.warn({ err }, "central Gitea not ready at boot — will retry on first provision");
|
||||
}
|
||||
|
||||
// Load prompts & agent modules from disk (changes.md §3: prompts/ + agents/).
|
||||
// After this, buildUnifiedSystemPrompt() returns the full assembled prompt.
|
||||
await initCatalog();
|
||||
|
||||
await reconcileOnBoot();
|
||||
|
||||
const app = new Hono();
|
||||
@@ -55,27 +69,71 @@ async function main() {
|
||||
}
|
||||
});
|
||||
|
||||
// Rivet Kit actor traffic (frontend uses @rivetkit/react against this prefix).
|
||||
app.all("/api/rivet/*", (c) => registry.handler(c.req.raw));
|
||||
|
||||
// PRD HTTP control plane (auth-gated).
|
||||
// HTTP control plane (auth-gated).
|
||||
app.route("/users", userRoutes());
|
||||
app.route("/agents", agentRoutes());
|
||||
app.route("/workflows", workflowRoutes());
|
||||
app.route("/actors", actorRoutes());
|
||||
app.route("/opencode", opencodeRoutes());
|
||||
app.route("/git", gitRoutes());
|
||||
app.route("/api/chat", chatRoutes());
|
||||
|
||||
if (process.env.RIVET_RUN_ENGINE === "1") {
|
||||
// Self-hosted: embedded engine runs at localhost:6420.
|
||||
// Proxy frontend Rivet traffic to the engine instead of using registry.handler()
|
||||
// (handler conflicts with startRunner — they're mutually exclusive).
|
||||
delete process.env.RIVET_ENDPOINT;
|
||||
app.all("/api/rivet/*", async (c) => {
|
||||
const url = new URL(c.req.url);
|
||||
url.hostname = "127.0.0.1";
|
||||
url.port = "6420";
|
||||
url.pathname = url.pathname.replace("/api/rivet", "");
|
||||
|
||||
// Forward headers, stripping hop-by-hop ones
|
||||
const fwdHeaders = new Headers();
|
||||
for (const [k, v] of Object.entries(c.req.raw.headers)) {
|
||||
if (k.toLowerCase() === "host") continue;
|
||||
if (k.toLowerCase() === "transfer-encoding") continue;
|
||||
fwdHeaders.set(k, v);
|
||||
}
|
||||
fwdHeaders.set("Host", "127.0.0.1:6420");
|
||||
|
||||
// For POST/PUT/PATCH, clone the body stream (Hono may have consumed it)
|
||||
const method = c.req.method.toUpperCase();
|
||||
const bodyMethods = ["POST", "PUT", "PATCH", "DELETE"];
|
||||
|
||||
try {
|
||||
const rawBody = bodyMethods.includes(method)
|
||||
? await c.req.raw.clone().arrayBuffer()
|
||||
: undefined;
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method,
|
||||
headers: fwdHeaders,
|
||||
body: rawBody && rawBody.byteLength > 0 ? new Uint8Array(rawBody) : undefined,
|
||||
});
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
headers: res.headers,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error({ err, url: url.toString() }, "rivet proxy error");
|
||||
return c.json({ error: "proxy_error" }, 502);
|
||||
}
|
||||
});
|
||||
registry.startRunner();
|
||||
} else {
|
||||
// Serverless: use registry.handler() for incoming actor traffic.
|
||||
app.all("/api/rivet/*", (c) => registry.handler(c.req.raw));
|
||||
}
|
||||
registry.startRunner();
|
||||
|
||||
serve({ fetch: app.fetch, port: config.port }, (info) => {
|
||||
log.info(
|
||||
{
|
||||
port: info.port,
|
||||
rivet: config.rivetEndpoint,
|
||||
gitea: config.giteaUrl,
|
||||
env: config.nodeEnv,
|
||||
},
|
||||
"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.
|
||||
async putFile(opts: {
|
||||
owner: string;
|
||||
|
||||
115
src/lib/llm.ts
115
src/lib/llm.ts
@@ -1,28 +1,14 @@
|
||||
import { config } from "../config.js";
|
||||
|
||||
export const GROW_AGENT_SYSTEM = `You are a Grow Agent - a user's master AI orchestrator on the GrowQR platform.
|
||||
|
||||
You own this user's long-running context, memory, and workspace. You coordinate specialized sub-agents (coding, repo, quest, product-flow, etc.), keep durable state in the user's Gitea memory repository, and execute workflows via the user's OpenCode sandbox.
|
||||
|
||||
Operating principles:
|
||||
- Be concise and direct. The user sees your messages in a Slack-like chat.
|
||||
- Maintain durable memory: commit important decisions, goals, and progress to the user's memory repo using \`commit_memory\`. Read existing context with \`read_memory\` before making suggestions that depend on history.
|
||||
- For anything that requires code, shell, file edits, or generated artifacts, spawn a sub-agent via \`spawn_sub_agent\`. The sub-agent runs through the user's OpenCode container.
|
||||
- Track active goals and quests. Surface progress proactively when the user returns.
|
||||
- Prefer one small commit per meaningful state change over batching.
|
||||
- Never invent tool names. Only use the tools provided.
|
||||
`;
|
||||
|
||||
export type GrowAgentTool =
|
||||
| "spawn_sub_agent"
|
||||
| "commit_memory"
|
||||
| "read_memory"
|
||||
| "list_memory";
|
||||
// ── LLM type definitions ──
|
||||
// The system prompt and agent tools are loaded from disk at startup
|
||||
// (prompts/system.txt + agents/*.md) via prompt-loader.ts.
|
||||
// The unified tools are assembled in user-actor.ts using the catalog.
|
||||
|
||||
export type LlmTool = {
|
||||
type: "function";
|
||||
function: {
|
||||
name: GrowAgentTool;
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
@@ -48,96 +34,7 @@ export type LlmMessage = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export const growAgentTools: LlmTool[] = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "spawn_sub_agent",
|
||||
description:
|
||||
"Spawn a specialized sub-agent to run a bounded task through the user's OpenCode container. Use for anything that requires running code, editing files, or producing artifacts.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: {
|
||||
type: "string",
|
||||
description:
|
||||
"Sub-agent type: 'coding', 'repo', 'migration', 'quest', 'product', 'backend', 'frontend', or another short identifier.",
|
||||
},
|
||||
prompt: {
|
||||
type: "string",
|
||||
description:
|
||||
"The full task prompt for the sub-agent. Include all context it needs - sub-agents do not see this conversation.",
|
||||
},
|
||||
channelId: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional channel/thread id the sub-agent should report into. Generated if omitted.",
|
||||
},
|
||||
},
|
||||
required: ["type", "prompt"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "commit_memory",
|
||||
description:
|
||||
"Write or update a file in the user's Gitea memory repository. Use for goals, decisions, progress notes, plans, and durable summaries.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description:
|
||||
"Repo-relative path, e.g. 'goals/active.md' or 'decisions/2026-05-19-architecture.md'.",
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
description: "Full UTF-8 file content to write.",
|
||||
},
|
||||
message: {
|
||||
type: "string",
|
||||
description: "Commit message describing the change.",
|
||||
},
|
||||
},
|
||||
required: ["path", "content", "message"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_memory",
|
||||
description: "Read a single file from the user's memory repo. Returns null if missing.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_memory",
|
||||
description:
|
||||
"List files at a path prefix in the user's memory repo. Use to discover what context already exists.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
pathPrefix: {
|
||||
type: "string",
|
||||
description: "Repo-relative directory, e.g. 'goals' or '' for root.",
|
||||
},
|
||||
},
|
||||
required: ["pathPrefix"],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
// ── LLM API client ──
|
||||
|
||||
type ChatCompletionsResponse = {
|
||||
choices?: Array<{
|
||||
|
||||
170
src/lib/prompt-loader.ts
Normal file
170
src/lib/prompt-loader.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
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" | "resume-service" | "matchmaking-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" &&
|
||||
service !== "resume-service" &&
|
||||
service !== "matchmaking-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", "resume-service", "matchmaking-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 { eq } from "drizzle-orm";
|
||||
|
||||
// PRD §5.2 — Actor registry HTTP surface.
|
||||
// All routes are user-scoped via Clerk auth; userId is derived from the
|
||||
// session token, never trusted from the body.
|
||||
// Per changes.md §5: ONE unified actor per user.
|
||||
// Routes are user-scoped via Clerk auth; userId derived from session token.
|
||||
export function actorRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
@@ -34,7 +33,6 @@ export function actorRoutes() {
|
||||
});
|
||||
|
||||
app.get("/", async (c) => {
|
||||
// Admin/debug — returns the caller's stacks only. Tighten further if needed.
|
||||
const userId = c.get("userId");
|
||||
const all = await listStacks();
|
||||
return c.json({ stacks: all.filter((s) => s.userId === userId) });
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Hono } from "hono";
|
||||
import { agentCatalog } from "../agents/catalog.js";
|
||||
import { subAgentModules } from "../agents/catalog.js";
|
||||
import { requireUser, type AuthContext } from "../auth/clerk.js";
|
||||
|
||||
export function agentRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
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;
|
||||
}
|
||||
|
||||
299
src/routes/chat.ts
Normal file
299
src/routes/chat.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import { createClient } from "rivetkit/client";
|
||||
import { config } from "../config.js";
|
||||
import { requireUser, type AuthContext } from "../auth/clerk.js";
|
||||
import type { Registry } from "../actors/registry.js";
|
||||
import type { LlmMessage } from "../lib/llm.js";
|
||||
import { createChatCompletion } from "../lib/llm.js";
|
||||
import { buildUnifiedSystemPrompt } from "../agents/catalog.js";
|
||||
import {
|
||||
buildServiceSessionUrl,
|
||||
runServiceAgentProbe,
|
||||
type ServiceAgentResult,
|
||||
} from "../services/service-agents.js";
|
||||
import { getSubAgentModules } from "../lib/prompt-loader.js";
|
||||
|
||||
const chatSchema = z.object({
|
||||
messages: z.array(
|
||||
z.object({
|
||||
role: z.enum(["user", "assistant", "system"]),
|
||||
content: z.string(),
|
||||
}),
|
||||
),
|
||||
agentId: z.string().optional(),
|
||||
});
|
||||
|
||||
function extractWorkflowTag(reply: string): string | undefined {
|
||||
const match = reply.match(/\[WORKFLOW:\s*([a-z-]+)\]/i);
|
||||
if (!match || !match[1]) return undefined;
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
function cleanWorkflowTag(reply: string): string {
|
||||
return reply.replace(/\[WORKFLOW:\s*[a-z-]+\]/gi, "").trim();
|
||||
}
|
||||
|
||||
function buildTools() {
|
||||
return [
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "start_interview_session",
|
||||
description: "Create a real interview practice session via the Sara / interview-service microservice. Call this when the user asks to start or launch an interview.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
target_role: { type: "string", description: "The target role and company, e.g., 'Software Engineer at Google'" },
|
||||
},
|
||||
required: ["target_role"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "start_roleplay_session",
|
||||
description: "Create a real roleplay session via Emily / roleplay-service. Call when user asks for roleplay or negotiation practice.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
goal: { type: "string", description: "What scenario to practice" },
|
||||
},
|
||||
required: ["goal"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "analyze_resume",
|
||||
description: "Analyze user's resume using the Resume Agent. Returns completeness, skills, and gaps.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
goal: { type: "string", description: "Target role for context" },
|
||||
},
|
||||
required: ["goal"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "compute_qscore",
|
||||
description: "Compute user's readiness Q-Score via Quinn / qscore-service.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function chatRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
|
||||
// Infer workflow step from which agents have been run
|
||||
function inferWorkflowStep(sessions: Array<{ moduleId: string; status: string }>, messages: Array<{ role: string; content: string }>): { workflowActive: boolean; workflowStep: number; goal: string } {
|
||||
const doneModules = new Set(sessions.filter(s => s.status === "done").map(s => s.moduleId));
|
||||
let step = 0;
|
||||
let goal = "";
|
||||
|
||||
// Extract goal from conversation (look for "I have an interview at..." or "prepare for...")
|
||||
for (const m of messages) {
|
||||
if (m.role === "user") {
|
||||
const lower = m.content.toLowerCase();
|
||||
if (lower.includes("interview at") || lower.includes("prepare for") || lower.includes("role at") || lower.includes("apply to")) {
|
||||
goal = m.content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Infer step from completed modules
|
||||
// Step 1: Workflow started (user described goal)
|
||||
if (goal) step = 1;
|
||||
// Step 2: User shared JD/role info
|
||||
if (messages.filter(m => m.role === "user" && m.content.length > 30).length >= 2) step = 2;
|
||||
// Step 3: Resume agent done
|
||||
if (doneModules.has("resume")) step = 3;
|
||||
// Step 4: Interview session created
|
||||
if (doneModules.has("sara")) step = 4;
|
||||
// Step 5: Roleplay session created
|
||||
if (doneModules.has("emily")) step = 5;
|
||||
// Step 6: QScore computed
|
||||
if (doneModules.has("qscore")) step = 6;
|
||||
|
||||
return {
|
||||
workflowActive: step > 0,
|
||||
workflowStep: step,
|
||||
goal: goal || "Career preparation",
|
||||
};
|
||||
}
|
||||
|
||||
app.post("/", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = chatSchema.parse(await c.req.json());
|
||||
const userText = body.messages[body.messages.length - 1]?.content ?? "";
|
||||
|
||||
// 1. Try Rivet actor path (full tool suite + conversation history)
|
||||
try {
|
||||
const client = createClient<Registry>(config.rivetClientEndpoint);
|
||||
const handle = client.userActor.getOrCreate([userId]);
|
||||
await handle.init({ userId });
|
||||
const result = await handle.receiveMessage({ text: userText });
|
||||
if (result?.reply) {
|
||||
const reply = cleanWorkflowTag(String(result.reply));
|
||||
const workflow = extractWorkflowTag(String(result.reply));
|
||||
const sessions = (result as any).sessions ?? [];
|
||||
const stepInfo = inferWorkflowStep(sessions, body.messages);
|
||||
return c.json({ reply, workflow, sessions, ...stepInfo });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Rivet chat unavailable, using direct LLM:", err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
// 2. Fallback: direct LLM with tool dispatch
|
||||
const systemPrompt = buildUnifiedSystemPrompt();
|
||||
const conversation: LlmMessage[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...body.messages.filter((m) => m.role !== "system"),
|
||||
];
|
||||
|
||||
try {
|
||||
const response1 = await createChatCompletion({
|
||||
model: config.agentModel,
|
||||
maxTokens: config.maxAgentTokens,
|
||||
tools: buildTools(),
|
||||
messages: conversation,
|
||||
});
|
||||
|
||||
let reply = response1.content || "";
|
||||
const sessions: Array<{
|
||||
moduleId: string;
|
||||
moduleName: string;
|
||||
status: string;
|
||||
sessionId?: string;
|
||||
sessionUrl?: string;
|
||||
summary?: string;
|
||||
}> = [];
|
||||
|
||||
// If LLM called a tool, execute it
|
||||
if (response1.toolCalls.length > 0) {
|
||||
conversation.push({
|
||||
role: "assistant",
|
||||
content: response1.content,
|
||||
tool_calls: response1.toolCalls.map((tc) => ({
|
||||
id: tc.id,
|
||||
type: "function" as const,
|
||||
function: { name: tc.name, arguments: JSON.stringify(tc.arguments) },
|
||||
})),
|
||||
});
|
||||
|
||||
for (const toolCall of response1.toolCalls) {
|
||||
console.log("LLM called tool:", toolCall.name, toolCall.arguments);
|
||||
let toolResult: ServiceAgentResult;
|
||||
|
||||
switch (toolCall.name) {
|
||||
case "start_interview_session": {
|
||||
toolResult = await runServiceAgentProbe(
|
||||
{ id: "sara", name: "Sara", role: "Interview Agent", kind: "microservice", description: "Interview practice", service: "interview-service" },
|
||||
{ userId, goal: String(toolCall.arguments.target_role ?? "general preparation") },
|
||||
);
|
||||
if (toolResult.status === "ok" && toolResult.detail) {
|
||||
const detail = toolResult.detail as Record<string, unknown>;
|
||||
sessions.push({
|
||||
moduleId: "sara",
|
||||
moduleName: "Sara",
|
||||
status: "done",
|
||||
sessionId: detail.session_id as string,
|
||||
sessionUrl: typeof detail.ui_session_url === "string"
|
||||
? detail.ui_session_url
|
||||
: buildServiceSessionUrl("interview-service", detail, String(toolCall.arguments.target_role ?? "general preparation")),
|
||||
summary: toolResult.summary,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "start_roleplay_session": {
|
||||
toolResult = await runServiceAgentProbe(
|
||||
{ id: "emily", name: "Emily", role: "Roleplay Agent", kind: "microservice", description: "Roleplay practice", service: "roleplay-service" },
|
||||
{ userId, goal: String(toolCall.arguments.goal ?? "general practice") },
|
||||
);
|
||||
if (toolResult.status === "ok" && toolResult.detail) {
|
||||
const detail = toolResult.detail as Record<string, unknown>;
|
||||
sessions.push({
|
||||
moduleId: "emily",
|
||||
moduleName: "Emily",
|
||||
status: "done",
|
||||
sessionId: detail.session_id as string,
|
||||
sessionUrl: typeof detail.ui_session_url === "string"
|
||||
? detail.ui_session_url
|
||||
: buildServiceSessionUrl("roleplay-service", detail, String(toolCall.arguments.goal ?? "general practice")),
|
||||
summary: toolResult.summary,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "analyze_resume": {
|
||||
toolResult = await runServiceAgentProbe(
|
||||
{ id: "resume", name: "Resume Agent", role: "Resume Builder", kind: "microservice", description: "Resume analysis", service: "resume-service" },
|
||||
{ userId, goal: String(toolCall.arguments.goal ?? "general") },
|
||||
);
|
||||
if (toolResult.status === "ok") {
|
||||
sessions.push({ moduleId: "resume", moduleName: "Resume Agent", status: "done", summary: toolResult.summary });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "compute_qscore": {
|
||||
toolResult = await runServiceAgentProbe(
|
||||
{ id: "qscore", name: "Quinn", role: "Q-Score Agent", kind: "score", description: "Readiness scoring", service: "qscore-service" },
|
||||
{ userId, goal: "general assessment" },
|
||||
);
|
||||
if (toolResult.status === "ok") {
|
||||
sessions.push({ moduleId: "qscore", moduleName: "Quinn", status: "done", summary: toolResult.summary });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second LLM call: summarize tool results
|
||||
const toolResults = sessions.map((s) =>
|
||||
`Tool result: ${s.moduleName} - ${s.status} - ${s.summary || ""}${s.sessionUrl ? ` - Demo URL: ${s.sessionUrl}` : ""}`,
|
||||
);
|
||||
for (const tr of toolResults) {
|
||||
conversation.push({ role: "tool", content: tr, tool_call_id: "tool" });
|
||||
}
|
||||
|
||||
const response2 = await createChatCompletion({
|
||||
model: config.agentModel,
|
||||
maxTokens: 1024,
|
||||
tools: [],
|
||||
messages: conversation,
|
||||
});
|
||||
|
||||
reply = cleanWorkflowTag(response2.content || reply);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
reply: cleanWorkflowTag(reply),
|
||||
workflow: extractWorkflowTag(reply),
|
||||
sessions,
|
||||
...inferWorkflowStep(sessions, body.messages),
|
||||
});
|
||||
} catch (llmErr) {
|
||||
console.error("Direct LLM chat error:", llmErr);
|
||||
return c.json(
|
||||
{ error: llmErr instanceof Error ? llmErr.message : "LLM error" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import { requireUser, type AuthContext } from "../auth/clerk.js";
|
||||
import { db } from "../db/client.js";
|
||||
import { repos } from "../db/schema.js";
|
||||
|
||||
// PRD §5.4 — Gitea Docker management API.
|
||||
// Per changes.md §2A: uses CENTRAL Gitea, not per-user Gitea containers.
|
||||
// All repo operations go through the central org.
|
||||
export function gitRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
@@ -16,10 +17,8 @@ export function gitRoutes() {
|
||||
if (!stack) return c.json({ error: "not provisioned" }, 404);
|
||||
return c.json({
|
||||
gitea: {
|
||||
host: stack.giteaHost,
|
||||
port: stack.giteaHttpPort,
|
||||
sshPort: stack.giteaSshPort,
|
||||
memoryRepo: stack.giteaMemoryRepo,
|
||||
repoOwner: stack.giteaRepoOwner,
|
||||
repoName: stack.giteaRepoName,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -31,10 +30,14 @@ export function gitRoutes() {
|
||||
.parse(await c.req.json());
|
||||
const client = await giteaClientFor(userId);
|
||||
const stack = await getUserStack(userId);
|
||||
if (!client || !stack) {
|
||||
if (!client || !stack?.giteaRepoOwner) {
|
||||
return c.json({ error: "not provisioned" }, 404);
|
||||
}
|
||||
const repo = await client.ensureRepo({ name: body.name, autoInit: true });
|
||||
const repo = await client.ensureOrgRepo({
|
||||
org: stack.giteaRepoOwner,
|
||||
name: body.name,
|
||||
autoInit: true,
|
||||
});
|
||||
await db
|
||||
.insert(repos)
|
||||
.values({
|
||||
@@ -61,15 +64,12 @@ export function gitRoutes() {
|
||||
})
|
||||
.parse(await c.req.json());
|
||||
const client = await giteaClientFor(userId);
|
||||
if (!client) return c.json({ error: "not provisioned" }, 404);
|
||||
|
||||
// Get owner from DB or fall back to memory repo.
|
||||
const stack = await getUserStack(userId);
|
||||
const owner = stack?.giteaAdminUser ?? "";
|
||||
if (!owner) return c.json({ error: "no gitea owner" }, 500);
|
||||
|
||||
if (!client || !stack?.giteaRepoOwner) {
|
||||
return c.json({ error: "not provisioned" }, 404);
|
||||
}
|
||||
const result = await client.putFile({
|
||||
owner,
|
||||
owner: stack.giteaRepoOwner,
|
||||
repo: repoName,
|
||||
path: body.path,
|
||||
contentUtf8: body.content,
|
||||
@@ -82,19 +82,19 @@ export function gitRoutes() {
|
||||
app.get("/repos/:name/contents/*", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const repoName = c.req.param("name");
|
||||
const path = c.req.path.split(`/repos/${repoName}/contents/`)[1] ?? "";
|
||||
const filePath = c.req.path.split(`/repos/${repoName}/contents/`)[1] ?? "";
|
||||
const client = await giteaClientFor(userId);
|
||||
const stack = await getUserStack(userId);
|
||||
if (!client || !stack?.giteaAdminUser) {
|
||||
if (!client || !stack?.giteaRepoOwner) {
|
||||
return c.json({ error: "not provisioned" }, 404);
|
||||
}
|
||||
const content = await client.readFile({
|
||||
owner: stack.giteaAdminUser,
|
||||
owner: stack.giteaRepoOwner,
|
||||
repo: repoName,
|
||||
path,
|
||||
path: filePath,
|
||||
});
|
||||
if (content == null) return c.json({ error: "not found" }, 404);
|
||||
return c.json({ path, content });
|
||||
return c.json({ path: filePath, content });
|
||||
});
|
||||
|
||||
return app;
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import { createClient } from "rivetkit/client";
|
||||
import { createClient, type Client } from "rivetkit/client";
|
||||
import { config } from "../config.js";
|
||||
import { requireUser, type AuthContext } from "../auth/clerk.js";
|
||||
import type { Registry } from "../actors/registry.js";
|
||||
|
||||
const client = createClient<Registry>(config.rivetEndpoint);
|
||||
// Lazy-load the Rivet client to avoid connecting at import time when the engine
|
||||
// isn't running (avoids "failed to fetch metadata" spam on startup).
|
||||
let _client: Client<Registry> | null = null;
|
||||
function getClient(): Client<Registry> {
|
||||
if (!_client) {
|
||||
_client = createClient<Registry>(config.rivetEndpoint);
|
||||
}
|
||||
return _client;
|
||||
}
|
||||
|
||||
function jobWorkflowFor(userId: string) {
|
||||
return client.workflowJob.getOrCreate([userId, "job-application"]);
|
||||
// Per changes.md §5: one unified userActor per user.
|
||||
function userActorFor(userId: string) {
|
||||
return getClient().userActor.getOrCreate([userId]);
|
||||
}
|
||||
|
||||
export function workflowRoutes() {
|
||||
@@ -20,41 +29,42 @@ export function workflowRoutes() {
|
||||
const body = z
|
||||
.object({ goal: z.string().min(1).optional() })
|
||||
.parse(await c.req.json().catch(() => ({})));
|
||||
const handle = jobWorkflowFor(userId);
|
||||
const state = await handle.init({ userId, goal: body.goal });
|
||||
const started = await handle.start();
|
||||
return c.json({ workflow: started ?? state });
|
||||
const handle = userActorFor(userId);
|
||||
await handle.init({ userId });
|
||||
const state = await handle.startWorkflow({ goal: body.goal });
|
||||
return c.json({ workflow: state });
|
||||
});
|
||||
|
||||
app.get("/job-application", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const handle = jobWorkflowFor(userId);
|
||||
const state = await handle.init({ userId });
|
||||
const handle = userActorFor(userId);
|
||||
await handle.init({ userId });
|
||||
const state = await handle.getWorkflowStatus();
|
||||
return c.json({ workflow: state });
|
||||
});
|
||||
|
||||
app.post("/job-application/pause", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const workflow = await jobWorkflowFor(userId).pause();
|
||||
const workflow = await userActorFor(userId).pauseWorkflow();
|
||||
return c.json({ workflow });
|
||||
});
|
||||
|
||||
app.post("/job-application/resume", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const workflow = await jobWorkflowFor(userId).resume();
|
||||
const workflow = await userActorFor(userId).resumeWorkflow();
|
||||
return c.json({ workflow });
|
||||
});
|
||||
|
||||
app.post("/job-application/agents/:agentId/run", async (c) => {
|
||||
app.post("/job-application/agents/:moduleId/run", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const agentId = c.req.param("agentId");
|
||||
const workflow = await jobWorkflowFor(userId).runAgent({ agentId });
|
||||
const moduleId = c.req.param("moduleId");
|
||||
const workflow = await userActorFor(userId).runWorkflowModule({ moduleId });
|
||||
return c.json({ workflow });
|
||||
});
|
||||
|
||||
app.post("/job-application/agents/:agentId/score", async (c) => {
|
||||
app.post("/job-application/agents/:moduleId/score", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const agentId = c.req.param("agentId");
|
||||
const moduleId = c.req.param("moduleId");
|
||||
const body = z
|
||||
.object({
|
||||
question: z.string().min(1),
|
||||
@@ -63,8 +73,8 @@ export function workflowRoutes() {
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
.parse(await c.req.json());
|
||||
const workflow = await jobWorkflowFor(userId).recordQaScore({
|
||||
agentId,
|
||||
const workflow = await userActorFor(userId).recordQaScore({
|
||||
moduleId,
|
||||
...body,
|
||||
});
|
||||
return c.json({ workflow });
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { config } from "../config.js";
|
||||
import type { AgentProfile } from "../agents/catalog.js";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
// Lightweight agent reference (works with both old AgentProfile and new SubAgentModule).
|
||||
export type ServiceAgentRef = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
kind: string;
|
||||
description: string;
|
||||
service?: string;
|
||||
};
|
||||
|
||||
export type ServiceAgentResult = {
|
||||
status: "ok" | "unavailable" | "local";
|
||||
summary: string;
|
||||
@@ -14,6 +23,33 @@ export type ServiceAgentContext = {
|
||||
goal: string;
|
||||
};
|
||||
|
||||
export function buildServiceSessionUrl(
|
||||
service: string | undefined,
|
||||
detail: Record<string, unknown> | undefined,
|
||||
goal?: string,
|
||||
): string | undefined {
|
||||
const sessionId = detail?.session_id ?? detail?.sessionId;
|
||||
if (!sessionId || typeof sessionId !== "string") return undefined;
|
||||
|
||||
const base = config.growqrAppFrontendUrl.replace(/\/$/, "");
|
||||
const params = new URLSearchParams({ session_id: sessionId });
|
||||
if (goal) params.set("goal", goal);
|
||||
|
||||
if (service === "interview-service") {
|
||||
params.set("role", String(detail?.target_role ?? goal ?? "Interview practice"));
|
||||
params.set("type", String(detail?.interview_type ?? "behavioral"));
|
||||
return `${base}/service-sessions/interview?${params.toString()}`;
|
||||
}
|
||||
|
||||
if (service === "roleplay-service") {
|
||||
params.set("role", String(detail?.target_role ?? goal ?? "Roleplay practice"));
|
||||
params.set("type", String(detail?.roleplay_type ?? "custom"));
|
||||
return `${base}/service-sessions/roleplay?${params.toString()}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stableUuid(input: string): string {
|
||||
const hex = createHash("sha256").update(input).digest("hex").slice(0, 32);
|
||||
return [
|
||||
@@ -88,7 +124,12 @@ async function runSaraInterview(ctx: ServiceAgentContext): Promise<ServiceAgentR
|
||||
return {
|
||||
status: "ok",
|
||||
summary: `Sara created interview session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
|
||||
detail,
|
||||
detail: {
|
||||
...detail,
|
||||
target_role: payload.context.target_role,
|
||||
interview_type: payload.interview_type,
|
||||
ui_session_url: buildServiceSessionUrl("interview-service", detail, ctx.goal),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,7 +168,12 @@ async function runEmilyRoleplay(ctx: ServiceAgentContext): Promise<ServiceAgentR
|
||||
return {
|
||||
status: "ok",
|
||||
summary: `Emily created roleplay session ${detail.session_id ?? "(pending id)"} for ${ctx.goal}.`,
|
||||
detail,
|
||||
detail: {
|
||||
...detail,
|
||||
target_role: payload.metadata.target_role,
|
||||
roleplay_type: payload.roleplay_type,
|
||||
ui_session_url: buildServiceSessionUrl("roleplay-service", detail, ctx.goal),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -161,21 +207,29 @@ async function runQuinnQScore(ctx: ServiceAgentContext): Promise<ServiceAgentRes
|
||||
},
|
||||
];
|
||||
|
||||
const ingest = await serviceJson<Record<string, unknown>>(
|
||||
config.qscoreServiceUrl,
|
||||
"/v1/signals/ingest",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
org_id: orgId,
|
||||
user_id: qscoreUserId,
|
||||
profession: "student",
|
||||
source: "growqr-workflow",
|
||||
signals,
|
||||
}),
|
||||
},
|
||||
);
|
||||
// Try to ingest signals (non-critical — may fail if QScore worker is down)
|
||||
let ingest: Record<string, unknown> | undefined;
|
||||
try {
|
||||
ingest = await serviceJson<Record<string, unknown>>(
|
||||
config.qscoreServiceUrl,
|
||||
"/v1/signals/ingest",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
org_id: orgId,
|
||||
user_id: qscoreUserId,
|
||||
profession: "student",
|
||||
source: "growqr-workflow",
|
||||
signals,
|
||||
}),
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
// Signal ingestion is optional — compute may still work with cached signals
|
||||
ingest = { status: "skipped", reason: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
|
||||
// Try to compute Q-Score
|
||||
let compute: Record<string, unknown> | undefined;
|
||||
try {
|
||||
compute = await serviceJson<Record<string, unknown>>(
|
||||
@@ -190,12 +244,18 @@ async function runQuinnQScore(ctx: ServiceAgentContext): Promise<ServiceAgentRes
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
// Graceful fallback: formula store unavailable → use static estimate
|
||||
const avgSignalScore = Math.round(
|
||||
signals.reduce((sum, s) => sum + s.score, 0) / signals.length,
|
||||
);
|
||||
return {
|
||||
status: "unavailable",
|
||||
summary:
|
||||
"Quinn ingested Q-Score signals, but computation is waiting for the QScore worker or formula store.",
|
||||
status: "ok",
|
||||
summary: `Quinn estimated Q-Score ~${avgSignalScore} (service compute unavailable: formula store may not be seeded). Based on ${signals.length} signals.`,
|
||||
detail: {
|
||||
ingest,
|
||||
estimated_q_score: avgSignalScore,
|
||||
signal_scores: signals.map(s => ({ id: s.signal_id, score: s.score })),
|
||||
compute_fallback: true,
|
||||
compute_error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
};
|
||||
@@ -208,8 +268,106 @@ async function runQuinnQScore(ctx: ServiceAgentContext): Promise<ServiceAgentRes
|
||||
};
|
||||
}
|
||||
|
||||
// ── Resume Agent (resume-builder service from growqr-app) ──
|
||||
|
||||
async function runResumeAnalyze(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
|
||||
// Probe resume state for the user
|
||||
try {
|
||||
const detail = await serviceJson<Record<string, unknown>>(
|
||||
config.resumeServiceUrl,
|
||||
`/api/state/${encodeURIComponent(ctx.userId)}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
const completeness = detail.resume_completeness ?? 0;
|
||||
const hasResume = (detail.resume_count as number) > 0;
|
||||
return {
|
||||
status: "ok",
|
||||
summary: hasResume
|
||||
? `Resume Agent found ${detail.resume_count} resume(s) at ${completeness}% completeness. Current role: ${detail.current_role ?? "unknown"}.`
|
||||
: "No existing resume found. Resume Agent is ready to build one from scratch.",
|
||||
detail: {
|
||||
resume_count: detail.resume_count,
|
||||
completeness,
|
||||
current_role: detail.current_role,
|
||||
current_company: detail.current_company,
|
||||
skills: detail.technical_skills ?? detail.skills ?? [],
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: "unavailable",
|
||||
summary: `Resume Agent unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function runResumeTailor(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
|
||||
// For now, return analysis-based tailoring
|
||||
// The resume-builder's AI capabilities will handle actual tailoring
|
||||
try {
|
||||
const stateResult = await runResumeAnalyze(ctx);
|
||||
if (stateResult.status !== "ok") return stateResult;
|
||||
|
||||
// Return summary with optimization guidance
|
||||
return {
|
||||
status: "ok",
|
||||
summary: `Resume Agent analyzed your profile for the role "${ctx.goal}". Skills detected: ${(stateResult.detail as any)?.skills?.slice(0, 5).join(", ") ?? "none"}. Resume ready for optimization.`,
|
||||
detail: {
|
||||
...(stateResult.detail as Record<string, unknown> ?? {}),
|
||||
goal: ctx.goal,
|
||||
recommendation: "Use the AI analysis and copilot tools to tailor bullet points, add missing keywords, and optimize for ATS.",
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: "unavailable",
|
||||
summary: `Resume tailoring failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function runMatchmaking(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
|
||||
const matchmakingUserId = stableUuid(ctx.userId);
|
||||
const query = new URLSearchParams({
|
||||
user_id: matchmakingUserId,
|
||||
top_n: "6",
|
||||
threshold: "60",
|
||||
recompute: "true",
|
||||
});
|
||||
|
||||
try {
|
||||
const detail = await serviceJson<Record<string, unknown>>(
|
||||
config.matchmakingServiceUrl,
|
||||
`/api/v1/feed?${query.toString()}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
const items = Array.isArray(detail.items)
|
||||
? detail.items
|
||||
: Array.isArray(detail.feed_items)
|
||||
? detail.feed_items
|
||||
: [];
|
||||
|
||||
return {
|
||||
status: "ok",
|
||||
summary: items.length > 0
|
||||
? `Scout pulled ${items.length} ranked opportunities from matchmaking for ${ctx.goal}.`
|
||||
: `Scout asked matchmaking to refresh opportunities for ${ctx.goal}. Add preferences if the feed is empty.`,
|
||||
detail: {
|
||||
...detail,
|
||||
matchmaking_user_id: matchmakingUserId,
|
||||
goal: ctx.goal,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: "unavailable",
|
||||
summary: `Matchmaking service unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function runServiceAgentProbe(
|
||||
agent: AgentProfile,
|
||||
agent: ServiceAgentRef,
|
||||
ctx?: ServiceAgentContext,
|
||||
): Promise<ServiceAgentResult> {
|
||||
try {
|
||||
@@ -226,6 +384,14 @@ export async function runServiceAgentProbe(
|
||||
return ctx
|
||||
? await runQuinnQScore(ctx)
|
||||
: healthCheck(config.qscoreServiceUrl, "Quinn / qscore-service");
|
||||
case "resume-service":
|
||||
return ctx
|
||||
? await runResumeTailor(ctx)
|
||||
: healthCheck(config.resumeServiceUrl, "Resume Agent / resume-service");
|
||||
case "matchmaking-service":
|
||||
return ctx
|
||||
? await runMatchmaking(ctx)
|
||||
: healthCheck(config.matchmakingServiceUrl, "Scout / matchmaking-service");
|
||||
default:
|
||||
return {
|
||||
status: "local",
|
||||
|
||||
Reference in New Issue
Block a user