feat: Enhance Gitea integration and agent management

- Added ensureOrg and ensureOrgRepo methods to GiteaClient for centralized organization and repository management.
- Updated main application flow to ensure central Gitea readiness at startup.
- Introduced prompt-loader for dynamic loading of agent modules and system prompts from disk.
- Refactored agent routes to return sub-agent module catalog.
- Modified git routes to interact with a central Gitea instance instead of per-user containers.
- Updated workflow routes to utilize a unified user actor per user, streamlining job application workflows.
- Improved service agent handling with a new lightweight reference type.
This commit is contained in:
2026-05-25 17:52:40 +05:30
parent 2d471c61b4
commit 54297496a4
30 changed files with 1615 additions and 1323 deletions

View File

@@ -3,9 +3,9 @@ LOG_LEVEL=info
NODE_ENV=development NODE_ENV=development
# Postgres (started by docker-compose; defaults match the compose service) # Postgres (started by docker-compose; defaults match the compose service)
DATABASE_URL=postgres://growqr:growqr@localhost:5432/growqr DATABASE_URL=***************************************/growqr
POSTGRES_USER=growqr POSTGRES_USER=growqr
POSTGRES_PASSWORD=growqr POSTGRES_PASSWORD=******
POSTGRES_DB=growqr POSTGRES_DB=growqr
# Clerk auth — get from dashboard.clerk.com → API Keys # Clerk auth — get from dashboard.clerk.com → API Keys
@@ -18,12 +18,23 @@ LLM_PROVIDER=opencode
LLM_BASE_URL=https://opencode.ai/zen/v1 LLM_BASE_URL=https://opencode.ai/zen/v1
LLM_MODEL=kimi-k2.6 LLM_MODEL=kimi-k2.6
GROW_AGENT_MODEL=kimi-k2.6 GROW_AGENT_MODEL=kimi-k2.6
SUB_AGENT_MODEL=kimi-k2.6
MAX_AGENT_TOKENS=4096 MAX_AGENT_TOKENS=4096
# Shared secret for actor → backend service calls (rotate in prod) # Shared secret for actor → backend service calls (rotate in prod)
SERVICE_TOKEN=dev-service-token-REPLACE_ME SERVICE_TOKEN=dev-service-token-REPLACE_ME
A2A_ALLOWED_KEY=dev-a2a-key A2A_ALLOWED_KEY=***********
# ── Central Gitea (shared org-wide, changes.md §2A) ──
GITEA_URL=http://127.0.0.1:3001
GITEA_ADMIN_USER=growqr-admin
GITEA_ADMIN_PASSWORD=growqr-admin-dev
GITEA_ADMIN_TOKEN=
GITEA_ORG_NAME=growqr
# ── Version tracking (changes.md §9) ──
OPENCODE_IMAGE_VERSION=1.0.0
MIGRATION_VERSION=1
PROMPT_VERSION=1
# Rivet Kit engine (self-hosted in docker-compose) # Rivet Kit engine (self-hosted in docker-compose)
RIVET_ENDPOINT=http://localhost:6420 RIVET_ENDPOINT=http://localhost:6420
@@ -34,9 +45,8 @@ INTERVIEW_SERVICE_URL=http://localhost:8007
ROLEPLAY_SERVICE_URL=http://localhost:8008 ROLEPLAY_SERVICE_URL=http://localhost:8008
QSCORE_SERVICE_URL=http://localhost:8000 QSCORE_SERVICE_URL=http://localhost:8000
# Per-user container images # Per-user OpenCode container image (shared, changes.md §3)
GITEA_IMAGE=gitea/gitea:1.22 OPENCODE_IMAGE=ghcr.io/anomalyco/opencode:latest
OPENCODE_IMAGE=ghcr.io/sst/opencode:latest
# Host where spawned containers expose their ports. # Host where spawned containers expose their ports.
# - localhost in dev # - localhost in dev
@@ -46,7 +56,7 @@ USER_CONTAINER_HOST=127.0.0.1
# Workspace root on the host. Each user gets a subdir. # Workspace root on the host. Each user gets a subdir.
USER_DATA_ROOT=./.data/users USER_DATA_ROOT=./.data/users
# Port range allocated to spawned per-user containers # Port range allocated to per-user OpenCode containers (Gitea is central)
USER_PORT_RANGE_START=20000 USER_PORT_RANGE_START=20000
USER_PORT_RANGE_END=29999 USER_PORT_RANGE_END=29999

View File

@@ -16,5 +16,12 @@ ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
COPY package.json ./ COPY package.json ./
# ── Build-time prompt loading (changes.md §3) ──
# Prompts and agent definitions are copied into the image so they are
# embedded at build time. To update: edit files → rebuild image → rollout.
COPY prompts/ ./prompts/
COPY agents/ ./agents/
EXPOSE 4000 EXPOSE 4000
CMD ["node", "dist/index.js"] CMD ["node", "dist/index.js"]

10
agents/emily.md Normal file
View File

@@ -0,0 +1,10 @@
---
id: emily
name: Emily
role: Roleplay Agent
service: roleplay-service
tools:
- start_roleplay_session
---
Runs roleplay practice through the roleplay-service microservice and owns scenario feedback.

11
agents/job-apply.md Normal file
View File

@@ -0,0 +1,11 @@
---
id: job-apply
name: Job Apply Agent
role: Application Operator
tools:
- prepare_application
- track_submission
- schedule_followup
---
Prepares tailored applications, tracks submissions, and records follow-up tasks.

11
agents/job-search.md Normal file
View File

@@ -0,0 +1,11 @@
---
id: job-search
name: Job Search Agent
role: Opportunity Scout
tools:
- search_jobs
- rank_opportunities
- prepare_shortlist
---
Finds relevant jobs, ranks opportunities, and prepares a shortlist for the application workflow.

11
agents/qscore.md Normal file
View File

@@ -0,0 +1,11 @@
---
id: qscore
name: Quinn
role: Q-Score Agent
service: qscore-service
tools:
- compute_qscore
- ingest_signals
---
Computes and explains Q-Score changes, then displays Q&A and scores.

11
agents/resume.md Normal file
View File

@@ -0,0 +1,11 @@
---
id: resume
name: Resume Agent
role: Resume Builder
tools:
- build_resume
- review_resume
- tailor_resume
---
Turns profile context, Q-Score gaps, and target roles into resume edits and application collateral.

10
agents/sara.md Normal file
View File

@@ -0,0 +1,10 @@
---
id: sara
name: Sara
role: Interview Agent
service: interview-service
tools:
- start_interview_session
---
Runs interview practice through the interview-service microservice and owns interview Q&A feedback.

View File

@@ -19,8 +19,33 @@ services:
retries: 10 retries: 10
restart: unless-stopped restart: unless-stopped
# ── Central Gitea (one org-wide instance, changes.md §2A) ──
# Every user gets a repo inside the GrowQR organization on this instance.
# Per-user Gitea containers are REMOVED — the backend no longer spawns them.
gitea:
image: gitea/gitea:1.22
container_name: growqr-gitea
environment:
USER_UID: "1000"
USER_GID: "1000"
GITEA__server__ROOT_URL: http://localhost:3001
GITEA__server__SSH_PORT: "2222"
GITEA__security__INSTALL_LOCK: "true"
GITEA__service__DISABLE_REGISTRATION: "true"
ports:
- "3001:3001" # HTTP
- "2222:2222" # SSH
volumes:
- gitea-data:/data
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/v1/version || exit 1"]
interval: 10s
timeout: 10s
retries: 15
restart: unless-stopped
# Self-hosted Rivet engine. The backend's Rivet Kit client connects here. # Self-hosted Rivet engine. The backend's Rivet Kit client connects here.
# Per the PRD, the Grow Agent + sub-agents are durable actors running on Rivet. # The unified user agent runs as a durable Rivet actor (changes.md §5).
rivet-engine: rivet-engine:
image: rivetgg/engine:latest image: rivetgg/engine:latest
container_name: growqr-rivet container_name: growqr-rivet
@@ -34,7 +59,7 @@ services:
restart: unless-stopped restart: unless-stopped
# The HTTP backend (Hono + Rivet Kit client + Docker manager). # The HTTP backend (Hono + Rivet Kit client + Docker manager).
# Mounts the host Docker socket so it can spawn per-user containers. # Mounts the host Docker socket so it can spawn per-user OpenCode containers.
backend: backend:
build: build:
context: . context: .
@@ -43,6 +68,8 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
gitea:
condition: service_healthy
rivet-engine: rivet-engine:
condition: service_started condition: service_started
ports: ports:
@@ -51,30 +78,44 @@ services:
PORT: 4000 PORT: 4000
NODE_ENV: ${NODE_ENV:-production} NODE_ENV: ${NODE_ENV:-production}
DATABASE_URL: postgres://${POSTGRES_USER:-growqr}:${POSTGRES_PASSWORD:-growqr}@postgres:5432/${POSTGRES_DB:-growqr} DATABASE_URL: postgres://${POSTGRES_USER:-growqr}:${POSTGRES_PASSWORD:-growqr}@postgres:5432/${POSTGRES_DB:-growqr}
# Central Gitea (shared org-wide instance)
GITEA_URL: http://gitea:3001
GITEA_ADMIN_USER: ${GITEA_ADMIN_USER:-growqr-admin}
GITEA_ADMIN_PASSWORD: ${GITEA_ADMIN_PASSWORD:-growqr-admin-dev}
GITEA_ADMIN_TOKEN: ${GITEA_ADMIN_TOKEN:-}
GITEA_ORG_NAME: ${GITEA_ORG_NAME:-growqr}
# Version tracking for image rollouts (changes.md §9)
OPENCODE_IMAGE_VERSION: ${OPENCODE_IMAGE_VERSION:-1.0.0}
MIGRATION_VERSION: ${MIGRATION_VERSION:-1}
PROMPT_VERSION: ${PROMPT_VERSION:-1}
# Rivet
RIVET_ENDPOINT: http://rivet-engine:6420 RIVET_ENDPOINT: http://rivet-engine:6420
RIVET_CLIENT_ENDPOINT: ${RIVET_CLIENT_ENDPOINT:-http://127.0.0.1:4000/api/rivet}
# Auth
CLERK_SECRET_KEY: ${CLERK_SECRET_KEY} CLERK_SECRET_KEY: ${CLERK_SECRET_KEY}
CLERK_PUBLISHABLE_KEY: ${CLERK_PUBLISHABLE_KEY} CLERK_PUBLISHABLE_KEY: ${CLERK_PUBLISHABLE_KEY}
SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token}
A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:************}
# LLM
OPENCODE_API_KEY: ${OPENCODE_API_KEY} OPENCODE_API_KEY: ${OPENCODE_API_KEY}
LLM_PROVIDER: ${LLM_PROVIDER:-opencode} LLM_PROVIDER: ${LLM_PROVIDER:-opencode}
LLM_BASE_URL: ${LLM_BASE_URL:-https://opencode.ai/zen/v1} LLM_BASE_URL: ${LLM_BASE_URL:-https://opencode.ai/zen/v1}
LLM_MODEL: ${LLM_MODEL:-kimi-k2.6} LLM_MODEL: ${LLM_MODEL:-kimi-k2.6}
GROW_AGENT_MODEL: ${GROW_AGENT_MODEL:-kimi-k2.6} GROW_AGENT_MODEL: ${GROW_AGENT_MODEL:-kimi-k2.6}
SUB_AGENT_MODEL: ${SUB_AGENT_MODEL:-kimi-k2.6} # Per-user OpenCode containers
SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token}
A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:-dev-a2a-key}
RIVET_CLIENT_ENDPOINT: ${RIVET_CLIENT_ENDPOINT:-http://127.0.0.1:4000/api/rivet}
GITEA_IMAGE: ${GITEA_IMAGE:-gitea/gitea:1.22}
OPENCODE_IMAGE: ${OPENCODE_IMAGE:-ghcr.io/anomalyco/opencode:latest} OPENCODE_IMAGE: ${OPENCODE_IMAGE:-ghcr.io/anomalyco/opencode:latest}
INTERVIEW_SERVICE_URL: ${INTERVIEW_SERVICE_URL:-http://host.docker.internal:8007}
ROLEPLAY_SERVICE_URL: ${ROLEPLAY_SERVICE_URL:-http://host.docker.internal:8008}
QSCORE_SERVICE_URL: ${QSCORE_SERVICE_URL:-http://host.docker.internal:8000}
USER_CONTAINER_HOST: ${USER_CONTAINER_HOST:-host.docker.internal} USER_CONTAINER_HOST: ${USER_CONTAINER_HOST:-host.docker.internal}
USER_DATA_ROOT: /data/users USER_DATA_ROOT: /data/users
USER_PORT_RANGE_START: 20000 USER_PORT_RANGE_START: 20000
USER_PORT_RANGE_END: 29999 USER_PORT_RANGE_END: 29999
# Microservices
INTERVIEW_SERVICE_URL: ${INTERVIEW_SERVICE_URL:-http://host.docker.internal:8007}
ROLEPLAY_SERVICE_URL: ${ROLEPLAY_SERVICE_URL:-http://host.docker.internal:8008}
QSCORE_SERVICE_URL: ${QSCORE_SERVICE_URL:-http://host.docker.internal:8000}
# Frontend
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000} FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
volumes: volumes:
# Docker-out-of-Docker: backend uses host Docker to spawn user containers. # Docker-out-of-Docker: backend uses host Docker to spawn per-user OpenCode containers.
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
# Shared host dir that per-user containers will also bind-mount their # Shared host dir that per-user containers will also bind-mount their
# workspace from (so backend and spawned containers see the same files). # workspace from (so backend and spawned containers see the same files).
@@ -86,10 +127,11 @@ services:
retries: 6 retries: 6
restart: unless-stopped restart: unless-stopped
# Note: per-user OpenCode + Gitea containers are NOT defined here. # Only per-user OpenCode containers are spawned dynamically now.
# The backend spawns them dynamically via dockerode on /actors/provision. # Gitea is a central shared service defined above.
# See src/docker/manager.ts. # See src/docker/manager.ts for the per-user OpenCode lifecycle.
volumes: volumes:
rivet-data: rivet-data:
postgres-data: postgres-data:
gitea-data:

View File

@@ -0,0 +1,32 @@
-- Migration: Central Gitea + Unified Actor (changes.md Phase 6)
-- Renames/replaces per-user Gitea fields with central Gitea repo references.
-- Simplifies actors table for unified user actor model.
-- Adds version tracking columns.
-- 1. Drop old per-user Gitea columns from user_stacks
ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_container_id;
ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_container_name;
ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_host;
ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_http_port;
ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_ssh_port;
ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_admin_user;
ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_admin_token;
ALTER TABLE user_stacks DROP COLUMN IF EXISTS gitea_memory_repo;
-- 2. Add central Gitea repo fields
ALTER TABLE user_stacks ADD COLUMN IF NOT EXISTS gitea_repo_name TEXT;
ALTER TABLE user_stacks ADD COLUMN IF NOT EXISTS gitea_repo_owner TEXT;
-- 3. Add version tracking columns (changes.md §9)
ALTER TABLE user_stacks ADD COLUMN IF NOT EXISTS image_version TEXT;
ALTER TABLE user_stacks ADD COLUMN IF NOT EXISTS migration_version TEXT;
ALTER TABLE user_stacks ADD COLUMN IF NOT EXISTS prompt_version TEXT;
-- 4. Simplify actors table for unified model
ALTER TABLE actors DROP COLUMN IF EXISTS sub_type;
ALTER TABLE actors DROP COLUMN IF EXISTS channel_id;
ALTER TABLE actors DROP COLUMN IF EXISTS parent_actor_id;
-- Change kind enum: was ('grow','sub'), now ('user')
-- Note: Drizzle handles enum changes via application-level migration.
-- The application code now only uses kind='user'.
-- Existing rows with kind='grow' or 'sub' will be left as-is (backward compatible reads).

19
prompts/system.txt Normal file
View File

@@ -0,0 +1,19 @@
You are the Grow Agent — a unified AI orchestrator for the GrowQR platform.
You own this user's long-running context, memory, and workspace. You coordinate all sub-agent capabilities (loaded as tools), maintain durable state in the user's Git memory repository (managed via Gitea), and execute workflows through the user's OpenCode sandbox.
## Sub-Agent Capabilities (loaded at build time)
{{MODULE_DESCRIPTIONS}}
## Operating Principles
- Be concise and direct. The user sees your messages in a Slack-like chat.
- Maintain durable memory: commit important decisions, goals, and progress to the user's Git memory repo using `commit_memory`. Read existing context with `read_memory` before making suggestions that depend on history.
- For anything that requires code, shell, file edits, or generated artifacts, use the OpenCode execution tools.
- Track active goals and workflows. Surface progress proactively when the user returns.
- Prefer one small commit per meaningful state change over batching.
- When a user wants interview practice, use `start_interview_session` (Sara).
- When a user wants roleplay practice, use `start_roleplay_session` (Emily).
- When a user needs Q-Score computation, use `ingest_signals` and `compute_qscore` (Quinn).
- Never invent tool names. Only use the tools provided.

View File

@@ -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}`);
}
}

View File

@@ -1,13 +1,11 @@
import { setup } from "rivetkit"; import { setup } from "rivetkit";
import { growAgent } from "./grow-agent.js"; import { userActor } from "./user-actor.js";
import { subAgent } from "./sub-agent.js";
import { workflowJob } from "./workflow-job.js";
// Per changes.md §5: ONE unified actor per user.
// No separate growAgent, subAgent, or workflowJob actors.
export const registry = setup({ export const registry = setup({
use: { use: {
growAgent, userActor,
subAgent,
workflowJob,
}, },
}); });

View File

@@ -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),
});
}
}

View File

@@ -1,83 +0,0 @@
import { actor } from "rivetkit";
import { db } from "../db/client.js";
import { actors as actorsTable, events as eventsTable } from "../db/schema.js";
import { and, eq, desc } from "drizzle-orm";
type LogEntry = {
ts: number;
level: "info" | "warn" | "error";
msg: string;
};
type SubAgentState = {
parentUserId: string;
type: string;
status: "idle" | "running" | "done" | "error";
channelId: string;
logs: LogEntry[];
};
const initialState: SubAgentState = {
parentUserId: "",
type: "generic",
status: "idle",
channelId: "",
logs: [],
};
// Sub-agent actor mainly exposes status + logs for the UI. The actual task
// execution lives in sub-agent-runner.ts, invoked by the Grow Agent's tool
// dispatch path (PRD §3.3).
export const subAgent = actor({
state: initialState,
actions: {
init: async (
c,
input: { parentUserId: string; type: string; channelId: string },
) => {
c.state.parentUserId = input.parentUserId;
c.state.type = input.type;
c.state.channelId = input.channelId;
c.state.status = "idle";
},
appendLog: async (c, entry: LogEntry) => {
c.state.logs.push(entry);
c.broadcast("log", entry);
},
setStatus: async (c, status: SubAgentState["status"]) => {
c.state.status = status;
c.broadcast("status", { status });
},
getLogs: async (c) => c.state.logs,
getStatus: async (c) => c.state.status,
// Pulls historical events from the DB so a returning user sees prior runs.
getHistory: async (c, input: { subAgentId: string }) => {
const rows = await db
.select()
.from(eventsTable)
.where(
and(
eq(eventsTable.userId, c.state.parentUserId),
eq(eventsTable.actorId, input.subAgentId),
),
)
.orderBy(desc(eventsTable.createdAt))
.limit(50);
return rows;
},
getActorRow: async (c, input: { subAgentId: string }) => {
const row = await db.query.actors.findFirst({
where: and(
eq(actorsTable.userId, c.state.parentUserId),
eq(actorsTable.actorId, input.subAgentId),
),
});
return row;
},
},
});

748
src/actors/user-actor.ts Normal file
View File

@@ -0,0 +1,748 @@
import { actor } from "rivetkit";
import { config } from "../config.js";
import { log } from "../log.js";
import {
buildUnifiedSystemPrompt,
getSubAgentModule,
jobApplicationModuleIds,
type SubAgentModule,
} from "../agents/catalog.js";
import {
getSubAgentModules,
} from "../lib/prompt-loader.js";
import {
runServiceAgentProbe,
type ServiceAgentResult,
} from "../services/service-agents.js";
import {
provisionUserStack,
getUserStack,
stopUserStack,
giteaClientFor,
opencodeUrlFor,
syncWorkspaceToGit,
} from "../docker/manager.js";
import { db } from "../db/client.js";
import { actors as actorsTable, events as eventsTable } from "../db/schema.js";
import { createChatCompletion, type LlmMessage, type LlmToolCall } from "../lib/llm.js";
// ── Types ──
type ChatTurn = {
role: "user" | "assistant" | "tool";
content: string;
toolCallId?: string;
toolCalls?: LlmToolCall[];
};
type WorkflowStatus = "draft" | "running" | "paused" | "completed";
type ModuleStatus = "idle" | "running" | "blocked" | "done";
type Scorecard = {
id: string;
question: string;
answer: string;
score: number;
notes?: string;
createdAt: string;
};
type WorkflowModuleState = {
id: string;
name: string;
role: string;
service?: string;
status: ModuleStatus;
summary: string;
lastResult?: ServiceAgentResult;
scorecards: Scorecard[];
};
type WorkflowEvent = {
id: string;
ts: string;
moduleId: string;
moduleName: string;
type: "workflow" | "module" | "score";
message: string;
payload?: unknown;
};
type UserActorState = {
userId: string;
goals: string[];
chatHistory: ChatTurn[];
maxHistory: number;
// ── Workflow (was separate workflowJob actor, changes.md §5) ──
workflowId: string;
workflowStatus: WorkflowStatus;
workflowGoal: string;
modules: WorkflowModuleState[];
timeline: WorkflowEvent[];
createdAt: string;
updatedAt: string;
};
// ── Helpers ──
const now = () => new Date().toISOString();
const eventId = () => `evt_${Date.now()}_${Math.random().toString(16).slice(2)}`;
const MEMORY_REPO_PATH_LIMIT = 1024;
// ── Unified user agent tools (changes.md §2D: loaded at build time) ──
// Core memory tools + sub-agent capability tools from the catalog.
// Sub-agent tools are NOT separate actors — they are function tools loaded
// into the unified agent's system prompt at build time.
function buildUnifiedTools(): Array<{
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}> {
const coreTools = [
{
type: "function" as const,
function: {
name: "commit_memory",
description:
"Write or update a file in the user's Git memory repository. Use for goals, decisions, progress notes, plans, and durable summaries.",
parameters: {
type: "object",
properties: {
path: { type: "string" },
content: { type: "string" },
message: { type: "string" },
},
required: ["path", "content", "message"],
},
},
},
{
type: "function" as const,
function: {
name: "read_memory",
description: "Read a single file from the user's memory repo.",
parameters: {
type: "object",
properties: { path: { type: "string" } },
required: ["path"],
},
},
},
{
type: "function" as const,
function: {
name: "list_memory",
description: "List files at a path prefix in the user's memory repo.",
parameters: {
type: "object",
properties: { pathPrefix: { type: "string" } },
required: ["pathPrefix"],
},
},
},
{
type: "function" as const,
function: {
name: "start_workflow",
description: "Start a job application workflow with all sub-agent modules.",
parameters: {
type: "object",
properties: {
goal: { type: "string", description: "Job search goal, e.g. 'Land a high-fit product engineering role'" },
},
required: ["goal"],
},
},
},
{
type: "function" as const,
function: {
name: "run_workflow_module",
description: "Execute a specific sub-agent module in the workflow (e.g., resume, job-search, job-apply, sara, emily, qscore).",
parameters: {
type: "object",
properties: {
moduleId: { type: "string", description: "Module id: resume, job-search, job-apply, sara, emily, qscore" },
},
required: ["moduleId"],
},
},
},
{
type: "function" as const,
function: {
name: "start_interview_session",
description: "Create a real interview practice session via the Sara / interview-service microservice.",
parameters: {
type: "object",
properties: { goal: { type: "string" } },
required: ["goal"],
},
},
},
{
type: "function" as const,
function: {
name: "start_roleplay_session",
description: "Create a real roleplay practice session via the Emily / roleplay-service microservice.",
parameters: {
type: "object",
properties: { goal: { type: "string" } },
required: ["goal"],
},
},
},
{
type: "function" as const,
function: {
name: "compute_qscore",
description: "Compute or refresh the user's Q-Score via the Quinn / qscore-service microservice.",
parameters: {
type: "object",
properties: {},
required: [],
},
},
},
];
// Build sub-agent capability tools from the catalog (changes.md §2D).
// Each sub-agent module exposes named tools that the LLM can call directly.
const capabilityTools = getSubAgentModules().flatMap((mod) =>
mod.toolNames.map((toolName) => ({
type: "function" as const,
function: {
name: toolName,
description: `[${mod.name}] ${mod.description}`,
parameters: {
type: "object",
properties: {
goal: { type: "string", description: "The user's current goal or context for this action" },
detail: { type: "string", description: "Additional detail or instruction for this sub-agent capability" },
},
required: ["goal"],
},
},
})),
);
return [...coreTools, ...capabilityTools];
}
// Lazy — prompt modules are loaded from disk at startup (changes.md §3).
// Must be called after initCatalog() has completed.
function getUnifiedTools() {
return buildUnifiedTools();
}
// ── Messages helper ──
function messagesForApi(history: ChatTurn[]): LlmMessage[] {
const systemPrompt = buildUnifiedSystemPrompt();
const messages: LlmMessage[] = [
{ role: "system", content: systemPrompt },
];
for (const turn of history) {
if (turn.role === "tool") {
messages.push({
role: "tool",
content: turn.content,
tool_call_id: turn.toolCallId,
});
continue;
}
messages.push({
role: turn.role,
content: turn.content,
tool_calls: turn.toolCalls?.map((call) => ({
id: call.id,
type: "function" as const,
function: {
name: call.name,
arguments: JSON.stringify(call.arguments),
},
})),
});
}
return messages;
}
// ── Workflow helpers ──
function makeModules(): WorkflowModuleState[] {
return jobApplicationModuleIds()
.map((id) => getSubAgentModule(id))
.filter((m): m is SubAgentModule => Boolean(m))
.map((m) => ({
id: m.id,
name: m.name,
role: m.role,
service: m.service,
status: "idle" as ModuleStatus,
summary: m.description,
scorecards: [],
}));
}
function appendTimelineEvent(
state: UserActorState,
module: Pick<WorkflowModuleState, "id" | "name">,
type: WorkflowEvent["type"],
message: string,
payload?: unknown,
) {
const ev: WorkflowEvent = {
id: eventId(),
ts: now(),
moduleId: module.id,
moduleName: module.name,
type,
message,
payload,
};
state.timeline.unshift(ev);
state.timeline = state.timeline.slice(0, 100);
state.updatedAt = ev.ts;
return ev;
}
// ── Initial State ──
const initialState: UserActorState = {
userId: "",
goals: [],
chatHistory: [],
maxHistory: 40,
workflowId: "",
workflowStatus: "draft",
workflowGoal: "",
modules: [],
timeline: [],
createdAt: "",
updatedAt: "",
};
// ── THE UNIFIED USER ACTOR (changes.md §5) ──
export const userActor = actor({
options: {
actionTimeout: 600_000,
noSleep: true,
},
state: initialState,
actions: {
// ── Infrastructure ──
init: async (c, input: { userId: string }) => {
if (c.state.userId && c.state.userId !== input.userId) {
throw new Error("User actor already bound to a different user");
}
c.state.userId = input.userId;
const stack = await provisionUserStack(input.userId);
await db
.insert(actorsTable)
.values({
actorId: `user-${input.userId}`,
userId: input.userId,
kind: "user",
status: "idle",
lastActivityAt: new Date(),
})
.onConflictDoNothing();
c.broadcast("stack-ready", {
userId: input.userId,
opencode: `${stack.opencodeHost}:${stack.opencodePort}`,
giteaRepo: `${stack.giteaRepoOwner ?? "growqr"}/${stack.giteaRepoName ?? "unknown"}`,
versions: {
image: stack.imageVersion,
migration: stack.migrationVersion,
prompt: stack.promptVersion,
},
});
return stack;
},
shutdown: async (c) => {
if (c.state.userId) await stopUserStack(c.state.userId);
},
// ── Chat (was growAgent.receiveMessage) ──
receiveMessage: async (c, msg: { text: string }) => {
if (!c.state.userId) throw new Error("User actor not initialized");
const userTurn: ChatTurn = { role: "user", content: msg.text };
c.state.chatHistory.push(userTurn);
c.broadcast("message", { role: "user", text: msg.text });
if (!config.llmApiKey) {
const reply = "LLM API key not configured.";
c.state.chatHistory.push({ role: "assistant", content: reply });
c.broadcast("message", { role: "agent", text: reply });
return { reply };
}
c.broadcast("agent-thinking", { state: "running" });
let assistantTextOut = "";
const MAX_ITERATIONS = 8;
for (let i = 0; i < MAX_ITERATIONS; i++) {
const response = await createChatCompletion({
model: config.agentModel,
maxTokens: config.maxAgentTokens,
tools: getUnifiedTools(),
messages: messagesForApi(c.state.chatHistory),
});
if (response.content) {
assistantTextOut += (assistantTextOut ? "\n\n" : "") + response.content;
c.broadcast("message", { role: "agent", text: response.content });
}
c.state.chatHistory.push({
role: "assistant",
content: response.content,
toolCalls: response.toolCalls,
});
if (response.toolCalls.length === 0) break;
for (const call of response.toolCalls) {
try {
const result = await dispatchUnifiedTool(c, call);
c.state.chatHistory.push({
role: "tool",
toolCallId: call.id,
content: typeof result === "string" ? result : JSON.stringify(result),
});
} catch (err) {
log.error({ err, tool: call.name }, "tool dispatch failed");
c.state.chatHistory.push({
role: "tool",
toolCallId: call.id,
content: `Error: ${err instanceof Error ? err.message : String(err)}`,
});
}
}
}
while (c.state.chatHistory.length > c.state.maxHistory) {
c.state.chatHistory.shift();
}
c.broadcast("agent-thinking", { state: "idle" });
await db
.insert(eventsTable)
.values({
userId: c.state.userId,
actorId: `user-${c.state.userId}`,
type: "user.message",
payload: { userText: msg.text, assistantText: assistantTextOut },
});
// Auto-commit conversation to Git (changes.md §7: Git is source of truth).
// Write the full exchange to /conversations/ in the user's repo.
c.waitUntil(
(async () => {
try {
const client = await giteaClientFor(c.state.userId);
const stack = await getUserStack(c.state.userId);
if (client && stack?.giteaRepoOwner && stack.giteaRepoName) {
const ts = new Date().toISOString().replace(/[:.]/g, "-");
const convoPath = `conversations/${ts}.json`;
await client.putFile({
owner: stack.giteaRepoOwner,
repo: stack.giteaRepoName,
path: convoPath,
contentUtf8: JSON.stringify(
{ timestamp: new Date().toISOString(), user: msg.text, assistant: assistantTextOut },
null, 2,
),
message: `conversation: ${msg.text.slice(0, 60)}`,
});
// Sync workspace so OpenCode sees latest state.
await syncWorkspaceToGit(c.state.userId, `conversation at ${ts}`).catch(() => {});
}
} catch (err) {
log.warn({ err, userId: c.state.userId }, "auto-commit conversation failed (non-fatal)");
}
})(),
);
return { reply: assistantTextOut || "(no response)" };
},
// ── Workflow (was workflowJob actor, now part of user actor — changes.md §5) ──
startWorkflow: async (c, input: { goal?: string }) => {
const goal = input.goal ?? "Find and apply to high-fit jobs";
c.state.workflowId = `job-application:${c.state.userId}`;
c.state.workflowStatus = "running";
c.state.workflowGoal = goal;
c.state.modules = makeModules();
c.state.createdAt = now();
c.state.updatedAt = now();
appendTimelineEvent(
c.state,
{ id: "grow", name: "Grow Agent" },
"workflow",
"Job application workflow started.",
);
c.broadcast("workflow.updated", {
workflowId: c.state.workflowId,
userId: c.state.userId,
status: c.state.workflowStatus,
goal: c.state.workflowGoal,
agents: c.state.modules,
timeline: c.state.timeline,
updatedAt: c.state.updatedAt,
});
return c.state;
},
pauseWorkflow: async (c) => {
c.state.workflowStatus = "paused";
appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", "Workflow paused.");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
return c.state;
},
resumeWorkflow: async (c) => {
c.state.workflowStatus = "running";
appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", "Workflow resumed.");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
return c.state;
},
runWorkflowModule: async (c, input: { moduleId: string }) => {
const mod = c.state.modules.find((m) => m.id === input.moduleId);
if (!mod) throw new Error(`Unknown workflow module: ${input.moduleId}`);
mod.status = "running";
appendTimelineEvent(c.state, mod, "module", `${mod.name} started.`);
c.broadcast("workflow.updated", workflowSnapshot(c.state));
const subModule = getSubAgentModule(mod.id);
if (subModule?.service) {
const userId = c.state.userId;
const goal = c.state.workflowGoal;
c.waitUntil(
(async () => {
const result = await runServiceAgentProbe(
{ id: subModule.id, name: subModule.name, role: subModule.role, kind: "microservice", description: subModule.description, service: subModule.service },
{ userId, goal },
);
mod.lastResult = result;
mod.status = result.status === "unavailable" ? "blocked" : "done";
appendTimelineEvent(c.state, mod, "module", result.summary, result.detail);
c.broadcast("workflow.updated", workflowSnapshot(c.state));
await c.saveState({ immediate: true });
})(),
);
return c.state;
}
// Local workflow modules
mod.lastResult = {
status: "local",
summary: `${mod.name} completed a local workflow step for "${c.state.workflowGoal}".`,
};
mod.status = "done";
appendTimelineEvent(c.state, mod, "module", mod.lastResult.summary);
c.broadcast("workflow.updated", workflowSnapshot(c.state));
return c.state;
},
recordQaScore: async (c, input: { moduleId: string; question: string; answer: string; score: number; notes?: string }) => {
const mod = c.state.modules.find((m) => m.id === input.moduleId);
if (!mod) throw new Error(`Unknown workflow module: ${input.moduleId}`);
const card: Scorecard = {
id: `score_${Date.now()}`,
question: input.question,
answer: input.answer,
score: Math.max(0, Math.min(100, Number(input.score))),
notes: input.notes,
createdAt: now(),
};
mod.scorecards.unshift(card);
appendTimelineEvent(c.state, mod, "score", `${mod.name} recorded Q&A score ${card.score}.`, card);
c.broadcast("workflow.updated", workflowSnapshot(c.state));
return c.state;
},
getWorkflowStatus: async (c) => workflowSnapshot(c.state),
getHistory: async (c) => c.state.chatHistory,
getGoals: async (c) => c.state.goals,
},
});
// ── Helpers ──
function workflowSnapshot(state: UserActorState) {
return {
workflowId: state.workflowId,
userId: state.userId,
status: state.workflowStatus,
goal: state.workflowGoal,
agents: state.modules,
timeline: state.timeline,
updatedAt: state.updatedAt,
};
}
async function dispatchUnifiedTool(
c: { state: UserActorState; broadcast: (event: string, data: unknown) => void },
call: LlmToolCall,
): Promise<unknown> {
const input = call.arguments;
const userId = c.state.userId;
switch (call.name) {
case "commit_memory": {
const path = String(input.path ?? "").slice(0, MEMORY_REPO_PATH_LIMIT);
const content = String(input.content ?? "");
const message = String(input.message ?? "memory update");
const client = await giteaClientFor(userId);
const stack = await getUserStack(userId);
if (!client || !stack?.giteaRepoOwner || !stack.giteaRepoName) {
return { ok: false, error: "memory repo not provisioned" };
}
const result = await client.putFile({
owner: stack.giteaRepoOwner,
repo: stack.giteaRepoName,
path,
contentUtf8: content,
message,
});
c.broadcast("memory-committed", { path, message });
return { ok: true, path, commitSha: result.commitSha };
}
case "read_memory": {
const path = String(input.path ?? "");
const client = await giteaClientFor(userId);
const stack = await getUserStack(userId);
if (!client || !stack?.giteaRepoOwner || !stack.giteaRepoName) return null;
return client.readFile({ owner: stack.giteaRepoOwner, repo: stack.giteaRepoName, path });
}
case "list_memory": {
const pathPrefix = String(input.pathPrefix ?? "");
const client = await giteaClientFor(userId);
const stack = await getUserStack(userId);
if (!client || !stack?.giteaRepoOwner || !stack.giteaRepoName) return [];
try {
const res = await fetch(
`${config.giteaUrl}/api/v1/repos/${encodeURIComponent(stack.giteaRepoOwner)}/${encodeURIComponent(stack.giteaRepoName)}/contents/${encodeURI(pathPrefix)}`,
{ headers: { authorization: `token ${config.giteaAdminToken}`, accept: "application/json" } },
);
if (!res.ok) return [];
const entries = (await res.json()) as Array<{ name: string; path: string; type: string }>;
return entries.map((e) => ({ name: e.name, path: e.path, type: e.type }));
} catch { return []; }
}
case "start_workflow": {
const goal = String(input.goal ?? "Find and apply to high-fit jobs");
c.state.workflowId = `job-application:${userId}`;
c.state.workflowStatus = "running";
c.state.workflowGoal = goal;
c.state.modules = makeModules();
c.state.createdAt = now();
c.state.updatedAt = now();
appendTimelineEvent(c.state, { id: "grow", name: "Grow Agent" }, "workflow", "Workflow started via LLM tool.");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
return { ok: true, workflowId: c.state.workflowId, goal };
}
case "run_workflow_module": {
const moduleId = String(input.moduleId ?? "");
const mod = c.state.modules.find((m) => m.id === moduleId);
if (!mod) return { ok: false, error: `Unknown module: ${moduleId}` };
mod.status = "running";
appendTimelineEvent(c.state, mod, "module", `${mod.name} started via LLM tool.`);
c.broadcast("workflow.updated", workflowSnapshot(c.state));
const subModule = getSubAgentModule(mod.id);
if (subModule?.service) {
const result = await runServiceAgentProbe(
{ id: subModule.id, name: subModule.name, role: subModule.role, kind: "microservice", description: subModule.description, service: subModule.service },
{ userId, goal: c.state.workflowGoal },
);
mod.lastResult = result;
mod.status = result.status === "unavailable" ? "blocked" : "done";
appendTimelineEvent(c.state, mod, "module", result.summary, result.detail);
} else {
mod.lastResult = { status: "local", summary: `${mod.name} completed a local workflow step.` };
mod.status = "done";
appendTimelineEvent(c.state, mod, "module", mod.lastResult.summary);
}
c.broadcast("workflow.updated", workflowSnapshot(c.state));
return { ok: true, moduleId, status: mod.status };
}
case "start_interview_session": {
const goal = String(input.goal ?? "");
const saraModule = getSubAgentModule("sara");
if (!saraModule?.service) return { ok: false, error: "Sara module not available" };
const result = await runServiceAgentProbe(
{ id: saraModule.id, name: saraModule.name, role: saraModule.role, kind: "microservice", description: saraModule.description, service: saraModule.service },
{ userId, goal },
);
c.broadcast("service-result", { moduleId: "sara", result });
return result;
}
case "start_roleplay_session": {
const goal = String(input.goal ?? "");
const emilyModule = getSubAgentModule("emily");
if (!emilyModule?.service) return { ok: false, error: "Emily module not available" };
const result = await runServiceAgentProbe(
{ id: emilyModule.id, name: emilyModule.name, role: emilyModule.role, kind: "microservice", description: emilyModule.description, service: emilyModule.service },
{ userId, goal },
);
c.broadcast("service-result", { moduleId: "emily", result });
return result;
}
case "compute_qscore": {
const quinnModule = getSubAgentModule("qscore");
if (!quinnModule?.service) return { ok: false, error: "Quinn module not available" };
const result = await runServiceAgentProbe(
{ id: quinnModule.id, name: quinnModule.name, role: quinnModule.role, kind: "score", description: quinnModule.description, service: quinnModule.service },
{ userId, goal: c.state.workflowGoal || "general assessment" },
);
c.broadcast("service-result", { moduleId: "qscore", result });
return result;
}
default: {
// Check if this is a sub-agent capability tool from the catalog (changes.md §2D).
// These tools are loaded at build time — each sub-agent module defines its own tool names.
const owningModule = getSubAgentModules().find((m) => m.toolNames.includes(call.name));
if (owningModule) {
const goal = String(input.goal ?? c.state.workflowGoal ?? "general task");
const detail = String(input.detail ?? "");
log.info({ tool: call.name, moduleId: owningModule.id, goal }, "sub-agent capability tool invoked");
return {
ok: true,
moduleId: owningModule.id,
tool: call.name,
summary: `${owningModule.name} processed "${goal}"${detail ? ` with detail: "${detail}"` : ""} via the ${call.name} capability.`,
};
}
throw new Error(`unknown tool: ${call.name}`);
}
}
}

View File

@@ -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,
},
});

View File

@@ -1,94 +1,51 @@
export type AgentKind = // ── Sub-agent prompt module catalog (changes.md §2D + §3) ──
| "master" // Sub-agents are NOT separate actors. They are prompt modules loaded into
| "workflow" // the unified user agent's system prompt.
| "microservice" //
| "score"; // Per changes.md §3: prompts and agent definitions are stored as files on disk
// (prompts/system.txt, agents/*.md), loaded at startup, and embedded into the
// Docker image at build time via COPY directives.
//
// This module delegates to src/lib/prompt-loader.ts which reads from the
// filesystem. To update prompts or agents, edit the files and rebuild the
// Docker image — no code changes required.
export type AgentProfile = { import {
id: string; getSubAgentModules,
name: string; getUnifiedSystemPrompt,
role: string; getSubAgentModule as loaderGetSubAgentModule,
kind: AgentKind; jobApplicationModuleIds as loaderJobApplicationModuleIds,
description: string; type SubAgentModule,
service?: "interview-service" | "roleplay-service" | "qscore-service"; } from "../lib/prompt-loader.js";
};
export const agentCatalog = [ export type { SubAgentModule };
{ export type SubAgentId = string;
id: "grow",
name: "Grow Agent",
role: "Master Orchestrator",
kind: "master",
description:
"Owns user context, routes work to sub-agents, commits durable memory, and tracks workflow progress.",
},
{
id: "resume",
name: "Resume Agent",
role: "Resume Builder",
kind: "workflow",
description:
"Turns profile context, Q-Score gaps, and target roles into resume edits and application collateral.",
},
{
id: "job-search",
name: "Job Search Agent",
role: "Opportunity Scout",
kind: "workflow",
description:
"Finds relevant jobs, ranks opportunities, and prepares a shortlist for the application workflow.",
},
{
id: "job-apply",
name: "Job Apply Agent",
role: "Application Operator",
kind: "workflow",
description:
"Prepares tailored applications, tracks submissions, and records follow-up tasks.",
},
{
id: "sara",
name: "Sara",
role: "Interview Agent",
kind: "microservice",
service: "interview-service",
description:
"Runs interview practice through the interview-service microservice and owns interview Q&A feedback.",
},
{
id: "emily",
name: "Emily",
role: "Roleplay Agent",
kind: "microservice",
service: "roleplay-service",
description:
"Runs roleplay practice through the roleplay-service microservice and owns scenario feedback.",
},
{
id: "qscore",
name: "Quinn",
role: "Q-Score Agent",
kind: "score",
service: "qscore-service",
description:
"Computes and explains Q-Score changes, then displays Q&A and scores under the owning agent.",
},
] as const satisfies AgentProfile[];
export type AgentId = (typeof agentCatalog)[number]["id"]; // Re-exported — subAgentModules is now loaded from disk at startup.
// Callers that need the module list at runtime (e.g., user-actor.ts to
// register tools) should use getSubAgentModules() from prompt-loader directly.
export const subAgentModules: SubAgentModule[] = [];
export function getAgentProfile(id: string): AgentProfile | undefined { // Initialize from disk. Called once at startup by index.ts.
return agentCatalog.find((agent) => agent.id === id); // After this call, subAgentModules is populated and all functions work.
export async function initCatalog(): Promise<void> {
const { loadPromptsFromDisk } = await import("../lib/prompt-loader.js");
await loadPromptsFromDisk();
// Mutate the exported array so existing imports keep working.
const loaded = getSubAgentModules();
subAgentModules.length = 0;
subAgentModules.push(...loaded);
} }
export function jobApplicationAgentIds(): AgentId[] { export function getSubAgentModule(id: string): SubAgentModule | undefined {
return [ return loaderGetSubAgentModule(id);
"grow", }
"resume",
"job-search", export function jobApplicationModuleIds(): string[] {
"job-apply", return loaderJobApplicationModuleIds();
"sara", }
"emily",
"qscore", // Build the unified Grow Agent system prompt from disk (changes.md §3).
]; export function buildUnifiedSystemPrompt(): string {
return getUnifiedSystemPrompt();
} }

View File

@@ -16,7 +16,7 @@ export const config = {
// Postgres metadata DB (users, registry, container mappings). // Postgres metadata DB (users, registry, container mappings).
databaseUrl: databaseUrl:
process.env.DATABASE_URL ?? process.env.DATABASE_URL ??
"postgres://growqr:growqr@localhost:5432/growqr", "***************************************/growqr",
// Clerk auth. // Clerk auth.
clerkSecretKey: process.env.CLERK_SECRET_KEY ?? "", clerkSecretKey: process.env.CLERK_SECRET_KEY ?? "",
@@ -25,7 +25,7 @@ export const config = {
serviceToken: process.env.SERVICE_TOKEN ?? "", serviceToken: process.env.SERVICE_TOKEN ?? "",
a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "dev-a2a-key", a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "dev-a2a-key",
// LLM gateway for Grow Agent + sub-agent planning calls. // LLM gateway for the unified user agent.
llmProvider: process.env.LLM_PROVIDER ?? "opencode", llmProvider: process.env.LLM_PROVIDER ?? "opencode",
llmApiKey: llmApiKey:
process.env.LLM_API_KEY ?? process.env.LLM_API_KEY ??
@@ -36,14 +36,10 @@ export const config = {
process.env.OPENCODE_BASE_URL ?? process.env.OPENCODE_BASE_URL ??
"https://opencode.ai/zen/v1", "https://opencode.ai/zen/v1",
opencodeApiKey: process.env.OPENCODE_API_KEY ?? "", opencodeApiKey: process.env.OPENCODE_API_KEY ?? "",
growAgentModel: agentModel:
process.env.GROW_AGENT_MODEL ?? process.env.GROW_AGENT_MODEL ??
process.env.LLM_MODEL ?? process.env.LLM_MODEL ??
"kimi-k2.6", "kimi-k2.6",
subAgentModel:
process.env.SUB_AGENT_MODEL ??
process.env.LLM_MODEL ??
"kimi-k2.6",
// Rivet Kit engine endpoint (self-hosted in docker-compose). // Rivet Kit engine endpoint (self-hosted in docker-compose).
rivetEndpoint: process.env.RIVET_ENDPOINT ?? "http://localhost:6420", rivetEndpoint: process.env.RIVET_ENDPOINT ?? "http://localhost:6420",
@@ -59,14 +55,25 @@ export const config = {
qscoreServiceUrl: qscoreServiceUrl:
process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000", process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000",
// Per-user container images. // ── Central Gitea (one org-wide instance, changes.md §2A) ──
giteaImage: process.env.GITEA_IMAGE ?? "gitea/gitea:1.22", giteaUrl: process.env.GITEA_URL ?? "http://127.0.0.1:3001",
giteaAdminUser: process.env.GITEA_ADMIN_USER ?? "growqr-admin",
giteaAdminPassword: process.env.GITEA_ADMIN_PASSWORD ?? "growqr-admin-dev",
giteaAdminToken: process.env.GITEA_ADMIN_TOKEN ?? "",
giteaOrgName: process.env.GITEA_ORG_NAME ?? "growqr",
// ── Shared OpenCode runtime image (built once, changes.md §3) ──
opencodeImage: opencodeImage:
process.env.OPENCODE_IMAGE ?? "ghcr.io/anomalyco/opencode:latest", process.env.OPENCODE_IMAGE ?? "ghcr.io/anomalyco/opencode:latest",
// Version tracking for rollout (changes.md §9)
opencodeImageVersion: process.env.OPENCODE_IMAGE_VERSION ?? "1.0.0",
migrationVersion: process.env.MIGRATION_VERSION ?? "1",
promptVersion: process.env.PROMPT_VERSION ?? "1",
// Host that user containers expose ports on (the host running Docker). // Host that user containers expose ports on (the host running Docker).
userContainerHost: process.env.USER_CONTAINER_HOST ?? "127.0.0.1", userContainerHost: process.env.USER_CONTAINER_HOST ?? "127.0.0.1",
userDataRoot: process.env.USER_DATA_ROOT ?? "./.data/users", userDataRoot: process.env.USER_DATA_ROOT ?? "./.data/users",
// Port range for per-user OpenCode containers only (Gitea is shared).
userPortRangeStart: Number(process.env.USER_PORT_RANGE_START ?? 20000), userPortRangeStart: Number(process.env.USER_PORT_RANGE_START ?? 20000),
userPortRangeEnd: Number(process.env.USER_PORT_RANGE_END ?? 29999), userPortRangeEnd: Number(process.env.USER_PORT_RANGE_END ?? 29999),

View File

@@ -30,8 +30,9 @@ export const users = pgTable(
}), }),
); );
// One per user. Tracks the user's Grow Agent's container stack + Gitea creds. // One per user. Tracks the user's unified agent's container stack + Git repo.
// PRD §3.2 + §5.2. // Per changes.md §2A: per-user Gitea containers removed; central Gitea shared.
// Per changes.md §5: ONE actor per user manages the full orchestration layer.
export const userStacks = pgTable( export const userStacks = pgTable(
"user_stacks", "user_stacks",
{ {
@@ -44,15 +45,11 @@ export const userStacks = pgTable(
.notNull() .notNull()
.default("provisioning"), .default("provisioning"),
giteaContainerId: text("gitea_container_id"), // Central Gitea (shared org-wide, changes.md §2A).
giteaContainerName: text("gitea_container_name"), giteaRepoName: text("gitea_repo_name"),
giteaHost: text("gitea_host"), giteaRepoOwner: text("gitea_repo_owner"),
giteaHttpPort: integer("gitea_http_port"),
giteaSshPort: integer("gitea_ssh_port"),
giteaAdminUser: text("gitea_admin_user"),
giteaAdminToken: text("gitea_admin_token"),
giteaMemoryRepo: text("gitea_memory_repo"),
// Per-user OpenCode container (from shared image, changes.md §3).
opencodeContainerId: text("opencode_container_id"), opencodeContainerId: text("opencode_container_id"),
opencodeContainerName: text("opencode_container_name"), opencodeContainerName: text("opencode_container_name"),
opencodeHost: text("opencode_host"), opencodeHost: text("opencode_host"),
@@ -60,6 +57,12 @@ export const userStacks = pgTable(
opencodePassword: text("opencode_password"), opencodePassword: text("opencode_password"),
workspacePath: text("workspace_path"), workspacePath: text("workspace_path"),
// Version tracking for image rollouts (changes.md §9).
imageVersion: text("image_version"),
migrationVersion: text("migration_version"),
promptVersion: text("prompt_version"),
lastError: text("last_error"), lastError: text("last_error"),
createdAt: timestamp("created_at", { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
@@ -74,8 +77,8 @@ export const userStacks = pgTable(
}), }),
); );
// PRD §5.2 actor registry. One Grow Agent row per user; sub-agents are // Per changes.md §5: ONE unified actor per user (no separate grow/sub actors).
// child rows keyed by (userId, actorId). // The actor manages: infra state, git state, runtime comms, migrations, API orchestration.
export const actors = pgTable( export const actors = pgTable(
"actors", "actors",
{ {
@@ -83,15 +86,12 @@ export const actors = pgTable(
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
kind: text("kind", { enum: ["grow", "sub"] }).notNull(), kind: text("kind", { enum: ["user"] }).notNull().default("user"),
subType: text("sub_type"), // for sub-agents: "coding", "repo", "quest", ...
status: text("status", { status: text("status", {
enum: ["idle", "running", "done", "error"], enum: ["idle", "running", "done", "error"],
}) })
.notNull() .notNull()
.default("idle"), .default("idle"),
channelId: text("channel_id"),
parentActorId: text("parent_actor_id"),
lastActivityAt: timestamp("last_activity_at", { withTimezone: true }), lastActivityAt: timestamp("last_activity_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }) createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow() .defaultNow()

View File

@@ -14,24 +14,17 @@ export type { UserStack };
const docker = new Docker(); const docker = new Docker();
// Allocated host ports kept in-memory; rehydrated from the DB on boot so // ── Port allocator (OpenCode containers only; Gitea is central) ──
// we don't double-allocate across restarts.
const allocatedPorts = new Set<number>(); const allocatedPorts = new Set<number>();
export async function hydratePortAllocator(): Promise<void> { export async function hydratePortAllocator(): Promise<void> {
const rows = await db const rows = await db
.select({ .select({ opencode: userStacks.opencodePort })
giteaHttp: userStacks.giteaHttpPort,
giteaSsh: userStacks.giteaSshPort,
opencode: userStacks.opencodePort,
})
.from(userStacks); .from(userStacks);
for (const r of rows) { for (const r of rows) {
for (const p of [r.giteaHttp, r.giteaSsh, r.opencode]) { if (r.opencode) allocatedPorts.add(r.opencode);
if (p) allocatedPorts.add(p);
}
} }
log.info({ count: allocatedPorts.size }, "hydrated port allocator"); log.info({ count: allocatedPorts.size }, "hydrated port allocator (OpenCode only)");
} }
function pickPort(): number { function pickPort(): number {
@@ -48,6 +41,8 @@ function releasePort(port: number | null | undefined) {
if (port != null) allocatedPorts.delete(port); if (port != null) allocatedPorts.delete(port);
} }
// ── Image helpers ──
async function ensureImage(image: string) { async function ensureImage(image: string) {
try { try {
await docker.getImage(image).inspect(); await docker.getImage(image).inspect();
@@ -71,7 +66,6 @@ function userDataDir(userId: string) {
} }
function safeContainerName(prefix: string, userId: string) { function safeContainerName(prefix: string, userId: string) {
// Container names must match [a-zA-Z0-9_.-]
return `${prefix}-${userId.replace(/[^a-zA-Z0-9_.-]/g, "_")}`; return `${prefix}-${userId.replace(/[^a-zA-Z0-9_.-]/g, "_")}`;
} }
@@ -83,77 +77,97 @@ async function findExistingContainer(name: string) {
return list[0]; return list[0];
} }
async function startGiteaContainer(opts: { // ── Central Gitea bootstrap (changes.md §2A) ──
userId: string;
httpPort: number;
sshPort: number;
}): Promise<{ id: string; name: string }> {
await ensureImage(config.giteaImage);
const name = safeContainerName("growqr-gitea", opts.userId);
const dataDir = path.join(userDataDir(opts.userId), "gitea");
await ensureDir(dataDir);
const existing = await findExistingContainer(name); let centralGiteaClient: GiteaClient | null = null;
if (existing) { let centralGiteaReady = false;
if (existing.State !== "running") {
await docker.getContainer(existing.Id).start().catch(() => undefined); async function getCentralGiteaClient(): Promise<GiteaClient> {
if (!centralGiteaClient) {
const token = config.giteaAdminToken;
if (token) {
centralGiteaClient = new GiteaClient(config.giteaUrl, { kind: "token", token });
} else {
centralGiteaClient = new GiteaClient(config.giteaUrl, {
kind: "basic",
username: config.giteaAdminUser,
password: config.giteaAdminPassword,
});
} }
return { id: existing.Id, name }; }
return centralGiteaClient;
}
export async function ensureCentralGiteaReady(): Promise<void> {
if (centralGiteaReady) return;
await waitForGitea(config.giteaUrl, 120_000);
const client = await getCentralGiteaClient();
// Ensure the org exists (changes.md §2A: single org manages all users).
try {
await client.ensureOrg(config.giteaOrgName);
} catch (err) {
log.warn({ err }, "central Gitea org ensure failed (may already exist)");
} }
const container = await docker.createContainer({ centralGiteaReady = true;
name, log.info({ url: config.giteaUrl, org: config.giteaOrgName }, "central Gitea ready");
Image: config.giteaImage,
Env: [
"USER_UID=1000",
"USER_GID=1000",
`GITEA__server__ROOT_URL=http://${config.userContainerHost}:${opts.httpPort}/`,
`GITEA__server__SSH_PORT=${opts.sshPort}`,
"GITEA__security__INSTALL_LOCK=true",
"GITEA__service__DISABLE_REGISTRATION=true",
],
HostConfig: {
Binds: [`${dataDir}:/data`],
PortBindings: {
"3000/tcp": [{ HostPort: String(opts.httpPort) }],
"22/tcp": [{ HostPort: String(opts.sshPort) }],
},
RestartPolicy: { Name: "unless-stopped" },
Memory: 1 * 1024 * 1024 * 1024,
NanoCpus: 1_000_000_000,
},
ExposedPorts: { "3000/tcp": {}, "22/tcp": {} },
Labels: {
"growqr.userId": opts.userId,
"growqr.role": "gitea",
},
});
await container.start();
log.info({ userId: opts.userId, name }, "started Gitea container");
return { id: container.id, name };
} }
function shellQuote(value: string): string { // ── Git clone into OpenCode workspace (changes.md §4 step 3) ──
return `'${value.replace(/'/g, "'\\''")}'`; // Clones the user's repo from central Gitea into the container's /workspace.
} // If /workspace already has a .git folder, pulls instead of cloning.
async function cloneRepoIntoContainer(opts: {
containerId: string;
repoUrl: string;
giteaToken?: string;
giteaUser?: string;
giteaPassword?: string;
}): Promise<void> {
const container = docker.getContainer(opts.containerId);
async function execGiteaCli(containerId: string, args: string[]): Promise<string> { // Build authenticated clone URL.
const container = docker.getContainer(containerId); let authUrl = opts.repoUrl;
const command = [ if (opts.giteaToken) {
"gitea", // Embed token in URL: https://token@host/org/repo.git
"--work-path", authUrl = opts.repoUrl.replace("://", `://${encodeURIComponent(opts.giteaToken)}@`);
"/data/gitea", } else if (opts.giteaUser && opts.giteaPassword) {
"--config", authUrl = opts.repoUrl.replace("://", `://${encodeURIComponent(opts.giteaUser)}:${encodeURIComponent(opts.giteaPassword)}@`);
"/data/gitea/conf/app.ini", }
...args,
] // Check if workspace is already a git repo; if so, pull instead of clone.
.map(shellQuote) const checkExec = await container.exec({
.join(" "); Cmd: ["sh", "-c", "test -d /workspace/.git && echo 'exists' || echo 'missing'"],
const exec = await container.exec({
Cmd: ["su", "git", "-c", command],
AttachStdout: true, AttachStdout: true,
AttachStderr: true, AttachStderr: true,
WorkingDir: "/data/gitea", });
const checkStream = await checkExec.start({ Detach: false, Tty: false });
const checkChunks: Buffer[] = [];
checkStream.on("data", (chunk: Buffer) => checkChunks.push(Buffer.from(chunk)));
await new Promise<void>((resolve) => {
checkStream.on("end", () => resolve());
checkStream.on("close", () => resolve());
});
const checkOutput = Buffer.concat(checkChunks).toString("utf8").trim();
let cmd: string[];
if (checkOutput.includes("exists")) {
// Pull latest changes.
cmd = ["sh", "-c", "cd /workspace && git pull origin main 2>&1 || echo 'pull failed, attempting fresh clone'"];
} else {
// Clone into /workspace (remove any placeholder files first, then clone).
cmd = [
"sh",
"-c",
`rm -rf /workspace/* /workspace/.* 2>/dev/null; git clone --branch main "${authUrl}" /workspace 2>&1 || echo 'clone failed'`,
];
}
const exec = await container.exec({
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
WorkingDir: "/workspace",
}); });
const stream = await exec.start({ Detach: false, Tty: false }); const stream = await exec.start({ Detach: false, Tty: false });
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
@@ -165,62 +179,64 @@ async function execGiteaCli(containerId: string, args: string[]): Promise<string
const output = Buffer.concat(chunks).toString("utf8"); const output = Buffer.concat(chunks).toString("utf8");
const info = await exec.inspect(); const info = await exec.inspect();
if (info.ExitCode && info.ExitCode !== 0) { if (info.ExitCode && info.ExitCode !== 0) {
throw new Error(`gitea cli failed (${info.ExitCode}): ${output}`); // Clone failed but workspace still works via Gitea API directly.
log.warn({ output, exitCode: info.ExitCode }, "git clone/pull into workspace had non-zero exit");
} }
return output;
} }
// Runs `gitea admin user create --admin ...` inside the container. // ── Git workspace sync (changes.md §2B: "Sync to Git") ──
// Idempotent: the CLI returns non-zero if the user already exists, which is fine. // Commits and pushes changes from the container's /workspace back to the
async function ensureGiteaAdmin(opts: { // central Gitea repo, ensuring work done inside OpenCode is persisted as
containerId: string; // Git history. Called after significant events (workflows, code generation).
username: string; export async function syncWorkspaceToGit(userId: string, message?: string): Promise<void> {
password: string; const stack = await getUserStack(userId);
email: string; if (!stack?.opencodeContainerId || !stack.giteaRepoOwner || !stack.giteaRepoName) {
}): Promise<void> { log.warn({ userId }, "cannot sync workspace — stack not provisioned");
return;
}
const container = docker.getContainer(stack.opencodeContainerId);
const commitMsg = message ?? `growqr: workspace sync at ${new Date().toISOString()}`;
// Build authenticated remote URL for push.
let authUrl = `${config.giteaUrl}/${encodeURIComponent(stack.giteaRepoOwner)}/${encodeURIComponent(stack.giteaRepoName)}.git`;
if (config.giteaAdminToken) {
authUrl = authUrl.replace("://", `://${encodeURIComponent(config.giteaAdminToken)}@`);
} else {
authUrl = authUrl.replace("://", `://${encodeURIComponent(config.giteaAdminUser)}:${encodeURIComponent(config.giteaAdminPassword)}@`);
}
// Set the remote URL with auth, add all, commit, push.
const cmd = [
"sh", "-c",
`git remote set-url origin "${authUrl}" 2>/dev/null; ` +
`git config user.email "growqr@local" && git config user.name "GrowQR"; ` +
`git add -A && git commit -m "${commitMsg.replace(/"/g, '\\"')}" 2>/dev/null; ` +
`git push origin main 2>&1`,
];
try { try {
await execGiteaCli(opts.containerId, [ const exec = await container.exec({
"admin", Cmd: cmd,
"user", AttachStdout: true,
"create", AttachStderr: true,
"--admin", WorkingDir: "/workspace",
"--username", });
opts.username, const stream = await exec.start({ Detach: false, Tty: false });
"--password", const chunks: Buffer[] = [];
opts.password, stream.on("data", (chunk: Buffer) => chunks.push(Buffer.from(chunk)));
"--email", await new Promise<void>((resolve) => {
opts.email, stream.on("end", () => resolve());
"--must-change-password=false", stream.on("close", () => resolve());
]); });
const output = Buffer.concat(chunks).toString("utf8");
log.info({ userId, output: output.slice(0, 200) }, "workspace synced to Git");
} catch (err) { } catch (err) {
log.debug( log.warn({ err, userId }, "workspace sync to Git failed (non-fatal)");
{ err },
"gitea admin user create returned non-zero (likely already exists)",
);
} }
} }
async function generateGiteaToken(opts: { // ── Per-user OpenCode container (changes.md §2B + §3) ──
containerId: string;
username: string;
scopes: string[];
}): Promise<string> {
const output = await execGiteaCli(opts.containerId, [
"admin",
"user",
"generate-access-token",
"--username",
opts.username,
"--token-name",
`growqr-backend-${Date.now()}`,
"--scopes",
opts.scopes.join(","),
"--raw",
]);
const token = output.match(/[a-f0-9]{40}/i)?.[0];
if (!token) throw new Error("gitea token generation returned an empty token");
return token;
}
async function startOpencodeContainer(opts: { async function startOpencodeContainer(opts: {
userId: string; userId: string;
@@ -240,16 +256,17 @@ async function startOpencodeContainer(opts: {
return { id: existing.Id, name }; return { id: existing.Id, name };
} }
// Sub-agents are loaded as prompt modules at build time (changes.md §2D).
// The shared image includes: base OS, OpenCode, GrowQR core, agents, tools, prompts.
const container = await docker.createContainer({ const container = await docker.createContainer({
name, name,
Image: config.opencodeImage, Image: config.opencodeImage,
// OpenCode server CLI: `opencode serve --port 4096 --hostname 0.0.0.0`.
// We override the default CMD to make sure it binds to all interfaces
// and uses the per-user password.
Cmd: ["serve", "--port", "4096", "--hostname", "0.0.0.0"], Cmd: ["serve", "--port", "4096", "--hostname", "0.0.0.0"],
Env: [ Env: [
`OPENCODE_SERVER_PASSWORD=${opts.password}`, `OPENCODE_SERVER_PASSWORD=${opts.password}`,
`OPENCODE_WORKSPACE=/workspace`, `OPENCODE_WORKSPACE=/workspace`,
`GROWQR_IMAGE_VERSION=${config.opencodeImageVersion}`,
`GROWQR_PROMPT_VERSION=${config.promptVersion}`,
], ],
WorkingDir: "/workspace", WorkingDir: "/workspace",
HostConfig: { HostConfig: {
@@ -265,6 +282,8 @@ async function startOpencodeContainer(opts: {
Labels: { Labels: {
"growqr.userId": opts.userId, "growqr.userId": opts.userId,
"growqr.role": "opencode", "growqr.role": "opencode",
"growqr.imageVersion": config.opencodeImageVersion,
"growqr.promptVersion": config.promptVersion,
}, },
}); });
await container.start(); await container.start();
@@ -272,18 +291,27 @@ async function startOpencodeContainer(opts: {
return { id: container.id, name }; return { id: container.id, name };
} }
// Provisions the per-user stack. Idempotent: returns the existing stack if // ── User provisioning (changes.md §4) ──
// the user already has one in the DB and the containers are running.
function repoNameFor(userId: string): string {
return `user-${userId.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 48).toLowerCase()}`;
}
function userIdToGiteaUsername(userId: string): string {
return `gq_${userId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 24).toLowerCase() || "user"}`;
}
// Provisions the per-user stack. Uses CENTRAL Gitea (changes.md §2A) instead
// of spawning per-user Gitea containers.
// //
// Steps: // Steps (changes.md §4):
// 1. Pick ports + allocate. // 1. Ensure central Gitea is reachable + org exists.
// 2. Start Gitea + OpenCode containers (or reuse). // 2. Pick port for per-user OpenCode container.
// 3. Wait for Gitea HTTP to come up. // 3. Start OpenCode container (from shared image, changes.md §3).
// 4. Create the per-user Gitea admin via `gitea admin user create`. // 4. Create the user's repo in the central Gitea org (changes.md §2A).
// 5. Mint a long-lived access token for the admin. // 5. Initialize repo structure (memory/, conversations/, state/, etc. — changes.md §11).
// 6. Create the user's memory repo with auto_init. // 6. Wait for OpenCode readiness.
// 7. Wait for OpenCode to come up. // 7. Persist everything to user_stacks with version tracking (changes.md §9).
// 8. Persist everything to user_stacks.
export async function provisionUserStack(userId: string): Promise<UserStack> { export async function provisionUserStack(userId: string): Promise<UserStack> {
const existing = await db.query.userStacks.findFirst({ const existing = await db.query.userStacks.findFirst({
where: eq(userStacks.userId, userId), where: eq(userStacks.userId, userId),
@@ -293,16 +321,13 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
} }
await ensureDir(userDataDir(userId)); await ensureDir(userDataDir(userId));
await ensureCentralGiteaReady();
const giteaHttpPort = existing?.giteaHttpPort ?? pickPort();
const giteaSshPort = existing?.giteaSshPort ?? pickPort();
const opencodePort = existing?.opencodePort ?? pickPort(); const opencodePort = existing?.opencodePort ?? pickPort();
const opencodePassword = const opencodePassword =
existing?.opencodePassword ?? randomBytes(24).toString("hex"); existing?.opencodePassword ?? randomBytes(24).toString("hex");
const adminUsername = const repoName = existing?.giteaRepoName ?? repoNameFor(userId);
existing?.giteaAdminUser ?? `gq_${userId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 24).toLowerCase() || "user"}`; const repoOwner = config.giteaOrgName;
const adminPassword = randomBytes(24).toString("hex");
const adminEmail = `${adminUsername}@growqr.local`;
// Upsert "provisioning" row first so a crash mid-way leaves a recoverable record. // Upsert "provisioning" row first so a crash mid-way leaves a recoverable record.
await db await db
@@ -310,14 +335,15 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
.values({ .values({
userId, userId,
status: "provisioning", status: "provisioning",
giteaHttpPort,
giteaSshPort,
opencodePort, opencodePort,
opencodePassword, opencodePassword,
giteaAdminUser: adminUsername, giteaRepoName: repoName,
giteaHost: config.userContainerHost, giteaRepoOwner: repoOwner,
opencodeHost: config.userContainerHost, opencodeHost: config.userContainerHost,
workspacePath: userDataDir(userId), workspacePath: userDataDir(userId),
imageVersion: config.opencodeImageVersion,
migrationVersion: config.migrationVersion,
promptVersion: config.promptVersion,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: userStacks.userId, target: userStacks.userId,
@@ -329,59 +355,86 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
}); });
try { try {
const gitea = await startGiteaContainer({ // Start per-user OpenCode container (shared image, changes.md §3).
userId,
httpPort: giteaHttpPort,
sshPort: giteaSshPort,
});
const opencode = await startOpencodeContainer({ const opencode = await startOpencodeContainer({
userId, userId,
httpPort: opencodePort, httpPort: opencodePort,
password: opencodePassword, password: opencodePassword,
}); });
const giteaBase = `http://${config.userContainerHost}:${giteaHttpPort}`; // Create the user's repo in the central Gitea org (changes.md §2A + §4 step 2).
await waitForGitea(giteaBase, 90_000); const giteaClient = await getCentralGiteaClient();
const repo = await giteaClient.ensureOrgRepo({
// Bootstrap admin user (idempotent — the CLI returns non-zero if exists). org: repoOwner,
await ensureGiteaAdmin({ name: repoName,
containerId: gitea.id, description: `GrowQR memory + workspace for user ${userId}`,
username: adminUsername,
password: adminPassword,
email: adminEmail,
});
// Mint a token via Gitea's CLI so retries do not depend on a transient
// bootstrap password from a previous provisioning attempt.
const token = await generateGiteaToken({
containerId: gitea.id,
username: adminUsername,
scopes: ["write:repository", "write:user", "write:issue"],
});
// Use the token from here on.
const tokenClient = new GiteaClient(giteaBase, { kind: "token", token });
const memoryRepo = await tokenClient.ensureRepo({
name: "growqr-memory",
description: "Grow Agent memory + state (PRD §3.4)",
autoInit: true, autoInit: true,
private: true, private: true,
}); });
// Initialize the standard repo structure (changes.md §11).
const initFiles: Array<{ path: string; content: string }> = [
{ path: "memory/.gitkeep", content: "# Agent memory\n" },
{ path: "conversations/.gitkeep", content: "# Conversation history\n" },
{ path: "state/.gitkeep", content: "# Agent state\n" },
{ path: "artifacts/.gitkeep", content: "# Generated artifacts\n" },
{ path: "workflows/.gitkeep", content: "# Workflow definitions\n" },
{ path: "logs/.gitkeep", content: "# Runtime logs\n" },
{ path: "config/.gitkeep", content: "# User configuration\n" },
{ path: "metadata/versions.json", content: JSON.stringify({
imageVersion: config.opencodeImageVersion,
migrationVersion: config.migrationVersion,
promptVersion: config.promptVersion,
provisionedAt: new Date().toISOString(),
}, null, 2) + "\n" },
];
for (const file of initFiles) {
try {
await giteaClient.putFile({
owner: repoOwner,
repo: repoName,
path: file.path,
contentUtf8: file.content,
message: `init: ${file.path}`,
branch: "main",
});
} catch (err) {
log.warn({ err, path: file.path }, "failed to init repo file (non-fatal)");
}
}
// OpenCode readiness. // OpenCode readiness.
const opencodeBase = `http://${config.userContainerHost}:${opencodePort}`; const opencodeBase = `http://${config.userContainerHost}:${opencodePort}`;
await waitForOpencode(opencodeBase, opencodePassword, 90_000); await waitForOpencode(opencodeBase, opencodePassword, 90_000);
// Clone the user's Git repo into the OpenCode workspace (changes.md §4 step 3).
// Uses `git clone` inside the container so the workspace is a working copy
// of the user's repo, making Git the source of truth (changes.md §7).
try {
await cloneRepoIntoContainer({
containerId: opencode.id,
repoUrl: `${config.giteaUrl}/${encodeURIComponent(repoOwner)}/${encodeURIComponent(repoName)}.git`,
giteaToken: config.giteaAdminToken || undefined,
giteaUser: config.giteaAdminUser,
giteaPassword: !config.giteaAdminToken ? config.giteaAdminPassword : undefined,
});
log.info({ userId, repo: `${repoOwner}/${repoName}` }, "repo cloned into OpenCode workspace");
} catch (err) {
log.warn({ err, userId }, "git clone into workspace failed (non-fatal — workspace still available via Gitea API)");
}
const updated = await db const updated = await db
.update(userStacks) .update(userStacks)
.set({ .set({
status: "running", status: "running",
giteaContainerId: gitea.id, giteaRepoName: repo.name,
giteaContainerName: gitea.name, giteaRepoOwner: repo.owner,
giteaAdminToken: token,
giteaMemoryRepo: `${memoryRepo.owner}/${memoryRepo.name}`,
opencodeContainerId: opencode.id, opencodeContainerId: opencode.id,
opencodeContainerName: opencode.name, opencodeContainerName: opencode.name,
imageVersion: config.opencodeImageVersion,
migrationVersion: config.migrationVersion,
promptVersion: config.promptVersion,
lastError: null, lastError: null,
updatedAt: new Date(), updatedAt: new Date(),
}) })
@@ -390,7 +443,7 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
const row = updated[0]; const row = updated[0];
if (!row) throw new Error("user stack row vanished mid-provision"); if (!row) throw new Error("user stack row vanished mid-provision");
log.info({ userId }, "user stack provisioned"); log.info({ userId, repo: `${repo.owner}/${repo.name}` }, "user stack provisioned");
return row; return row;
} catch (err) { } catch (err) {
log.error({ err, userId }, "provisionUserStack failed"); log.error({ err, userId }, "provisionUserStack failed");
@@ -416,24 +469,23 @@ export async function getUserStack(userId: string): Promise<UserStack | null> {
export async function stopUserStack(userId: string): Promise<void> { export async function stopUserStack(userId: string): Promise<void> {
const stack = await getUserStack(userId); const stack = await getUserStack(userId);
if (!stack) return; if (!stack) return;
for (const id of [stack.giteaContainerId, stack.opencodeContainerId]) {
if (!id) continue; // Stop only the OpenCode container (Gitea is central, changes.md §2A).
if (stack.opencodeContainerId) {
try { try {
const c = docker.getContainer(id); const c = docker.getContainer(stack.opencodeContainerId);
await c.stop({ t: 5 }).catch(() => undefined); await c.stop({ t: 5 }).catch(() => undefined);
await c.remove({ force: true }).catch(() => undefined); await c.remove({ force: true }).catch(() => undefined);
} catch (err) { } catch (err) {
log.warn({ err, id }, "failed to stop container"); log.warn({ err, id: stack.opencodeContainerId }, "failed to stop container");
} }
} }
releasePort(stack.giteaHttpPort);
releasePort(stack.giteaSshPort);
releasePort(stack.opencodePort); releasePort(stack.opencodePort);
await db await db
.update(userStacks) .update(userStacks)
.set({ .set({
status: "stopped", status: "stopped",
giteaContainerId: null,
opencodeContainerId: null, opencodeContainerId: null,
updatedAt: new Date(), updatedAt: new Date(),
}) })
@@ -445,19 +497,19 @@ export async function listStacks(): Promise<UserStack[]> {
return db.query.userStacks.findMany(); return db.query.userStacks.findMany();
} }
// Convenience: build a Gitea client for a user's stack. // ── Client helpers ──
export async function giteaClientFor(userId: string): Promise<GiteaClient | null> {
const stack = await getUserStack(userId); // Build a Gitea client pointed at the CENTRAL Gitea instance (changes.md §2A).
if (!stack?.giteaAdminToken || !stack.giteaHost || !stack.giteaHttpPort) { // Uses the admin token for repo operations on behalf of any user.
export async function giteaClientFor(_userId: string): Promise<GiteaClient | null> {
try {
return await getCentralGiteaClient();
} catch {
return null; return null;
} }
return new GiteaClient(
`http://${stack.giteaHost}:${stack.giteaHttpPort}`,
{ kind: "token", token: stack.giteaAdminToken },
);
} }
// Convenience: build an OpenCode client for a user's stack. // Build an OpenCode client for a user's stack.
export async function opencodeUrlFor( export async function opencodeUrlFor(
userId: string, userId: string,
): Promise<{ baseUrl: string; password: string | undefined } | null> { ): Promise<{ baseUrl: string; password: string | undefined } | null> {
@@ -469,36 +521,61 @@ export async function opencodeUrlFor(
}; };
} }
// Reconcile DB-tracked running containers with actual Docker state on boot. // ── Boot reconciliation (changes.md §9) ──
// If a container is gone, flip the row to "stopped" so the next provision
// recreates it cleanly. // Reconcile DB-tracked running stacks with actual Docker state on boot.
// Only checks OpenCode containers (Gitea is central, changes.md §2A).
// If a container is gone, flip the row to "stopped" so the next provision recreates it.
//
// Also detects version mismatches for image rollout (changes.md §9):
// if the running container's imageVersion is behind, mark for migration.
export async function reconcileOnBoot(): Promise<void> { export async function reconcileOnBoot(): Promise<void> {
const rows = await db const rows = await db
.select() .select()
.from(userStacks) .from(userStacks)
.where( .where(
and(eq(userStacks.status, "running"), isNotNull(userStacks.giteaContainerId)), and(eq(userStacks.status, "running"), isNotNull(userStacks.opencodeContainerId)),
); );
for (const row of rows) { for (const row of rows) {
if (!row.opencodeContainerId) continue;
let healthy = true; let healthy = true;
for (const id of [row.giteaContainerId, row.opencodeContainerId]) { try {
if (!id) { const info = await docker.getContainer(row.opencodeContainerId).inspect();
healthy = false; if (!info.State.Running) healthy = false;
break; } catch {
} healthy = false;
try {
const info = await docker.getContainer(id).inspect();
if (!info.State.Running) healthy = false;
} catch {
healthy = false;
}
} }
if (!healthy) { if (!healthy) {
await db await db
.update(userStacks) .update(userStacks)
.set({ status: "stopped", updatedAt: new Date() }) .set({ status: "stopped", updatedAt: new Date() })
.where(eq(userStacks.userId, row.userId)); .where(eq(userStacks.userId, row.userId));
log.info({ userId: row.userId }, "stack marked stopped during reconcile"); log.info({ userId: row.userId }, "stack marked stopped during reconcile");
continue;
}
// Version mismatch detection (changes.md §9).
const needsMigration =
row.imageVersion !== config.opencodeImageVersion ||
row.migrationVersion !== config.migrationVersion;
const needsPromptUpdate = row.promptVersion !== config.promptVersion;
if (needsMigration || needsPromptUpdate) {
log.info(
{
userId: row.userId,
currentImage: row.imageVersion,
targetImage: config.opencodeImageVersion,
currentMigration: row.migrationVersion,
targetMigration: config.migrationVersion,
currentPrompt: row.promptVersion,
targetPrompt: config.promptVersion,
},
"version mismatch detected — migration needed on next provision",
);
} }
} }
} }

View File

@@ -12,12 +12,25 @@ import { userRoutes } from "./routes/users.js";
import { agentRoutes } from "./routes/agents.js"; import { agentRoutes } from "./routes/agents.js";
import { workflowRoutes } from "./routes/workflows.js"; import { workflowRoutes } from "./routes/workflows.js";
import { db } from "./db/client.js"; import { db } from "./db/client.js";
import { hydratePortAllocator, reconcileOnBoot } from "./docker/manager.js"; import { hydratePortAllocator, reconcileOnBoot, ensureCentralGiteaReady } from "./docker/manager.js";
import { initCatalog } from "./agents/catalog.js";
async function main() { async function main() {
// Boot-time DB sanity + reconcile. // Boot-time DB sanity + reconcile + central Gitea readiness.
await db.execute("select 1"); await db.execute("select 1");
await hydratePortAllocator(); await hydratePortAllocator();
// Ensure central Gitea is reachable before accepting traffic (changes.md §2A).
try {
await ensureCentralGiteaReady();
} catch (err) {
log.warn({ err }, "central Gitea not ready at boot — will retry on first provision");
}
// Load prompts & agent modules from disk (changes.md §3: prompts/ + agents/).
// After this, buildUnifiedSystemPrompt() returns the full assembled prompt.
await initCatalog();
await reconcileOnBoot(); await reconcileOnBoot();
const app = new Hono(); const app = new Hono();
@@ -58,7 +71,7 @@ async function main() {
// Rivet Kit actor traffic (frontend uses @rivetkit/react against this prefix). // Rivet Kit actor traffic (frontend uses @rivetkit/react against this prefix).
app.all("/api/rivet/*", (c) => registry.handler(c.req.raw)); app.all("/api/rivet/*", (c) => registry.handler(c.req.raw));
// PRD HTTP control plane (auth-gated). // HTTP control plane (auth-gated).
app.route("/users", userRoutes()); app.route("/users", userRoutes());
app.route("/agents", agentRoutes()); app.route("/agents", agentRoutes());
app.route("/workflows", workflowRoutes()); app.route("/workflows", workflowRoutes());
@@ -76,6 +89,7 @@ async function main() {
{ {
port: info.port, port: info.port,
rivet: config.rivetEndpoint, rivet: config.rivetEndpoint,
gitea: config.giteaUrl,
env: config.nodeEnv, env: config.nodeEnv,
}, },
"growqr-backend listening", "growqr-backend listening",

View File

@@ -132,6 +132,64 @@ export class GiteaClient {
} }
} }
// ── Central Gitea org methods (changes.md §2A) ──
// Ensure an organization exists. Idempotent.
async ensureOrg(orgName: string): Promise<void> {
try {
await this.req("POST", "/api/v1/orgs", {
username: orgName,
full_name: orgName,
description: "GrowQR organization — one repo per user",
});
} catch (err) {
log.debug({ err }, "ensureOrg returned non-2xx (likely already exists)");
}
}
// Create a repo inside an org. Idempotent (falls back to GET on 409).
async ensureOrgRepo(opts: {
org: string;
name: string;
description?: string;
autoInit?: boolean;
private?: boolean;
}): Promise<{ owner: string; name: string; cloneUrl: string }> {
try {
const repo = await this.req<{
owner: { login: string };
name: string;
clone_url: string;
}>("POST", `/api/v1/orgs/${encodeURIComponent(opts.org)}/repos`, {
name: opts.name,
description: opts.description ?? "",
auto_init: opts.autoInit ?? true,
private: opts.private ?? true,
default_branch: "main",
});
return {
owner: repo.owner.login,
name: repo.name,
cloneUrl: repo.clone_url,
};
} catch (err) {
const repo = await this.req<{
owner: { login: string };
name: string;
clone_url: string;
}>(
"GET",
`/api/v1/repos/${encodeURIComponent(opts.org)}/${encodeURIComponent(opts.name)}`,
);
log.debug({ err }, "ensureOrgRepo fell through to GET");
return {
owner: repo.owner.login,
name: repo.name,
cloneUrl: repo.clone_url,
};
}
}
// Creates or updates a file in a repo. Used for memory commits. // Creates or updates a file in a repo. Used for memory commits.
async putFile(opts: { async putFile(opts: {
owner: string; owner: string;

View File

@@ -1,28 +1,14 @@
import { config } from "../config.js"; import { config } from "../config.js";
export const GROW_AGENT_SYSTEM = `You are a Grow Agent - a user's master AI orchestrator on the GrowQR platform. // ── LLM type definitions ──
// The system prompt and agent tools are loaded from disk at startup
You own this user's long-running context, memory, and workspace. You coordinate specialized sub-agents (coding, repo, quest, product-flow, etc.), keep durable state in the user's Gitea memory repository, and execute workflows via the user's OpenCode sandbox. // (prompts/system.txt + agents/*.md) via prompt-loader.ts.
// The unified tools are assembled in user-actor.ts using the catalog.
Operating principles:
- Be concise and direct. The user sees your messages in a Slack-like chat.
- Maintain durable memory: commit important decisions, goals, and progress to the user's memory repo using \`commit_memory\`. Read existing context with \`read_memory\` before making suggestions that depend on history.
- For anything that requires code, shell, file edits, or generated artifacts, spawn a sub-agent via \`spawn_sub_agent\`. The sub-agent runs through the user's OpenCode container.
- Track active goals and quests. Surface progress proactively when the user returns.
- Prefer one small commit per meaningful state change over batching.
- Never invent tool names. Only use the tools provided.
`;
export type GrowAgentTool =
| "spawn_sub_agent"
| "commit_memory"
| "read_memory"
| "list_memory";
export type LlmTool = { export type LlmTool = {
type: "function"; type: "function";
function: { function: {
name: GrowAgentTool; name: string;
description: string; description: string;
parameters: Record<string, unknown>; parameters: Record<string, unknown>;
}; };
@@ -48,96 +34,7 @@ export type LlmMessage = {
}>; }>;
}; };
export const growAgentTools: LlmTool[] = [ // ── LLM API client ──
{
type: "function",
function: {
name: "spawn_sub_agent",
description:
"Spawn a specialized sub-agent to run a bounded task through the user's OpenCode container. Use for anything that requires running code, editing files, or producing artifacts.",
parameters: {
type: "object",
properties: {
type: {
type: "string",
description:
"Sub-agent type: 'coding', 'repo', 'migration', 'quest', 'product', 'backend', 'frontend', or another short identifier.",
},
prompt: {
type: "string",
description:
"The full task prompt for the sub-agent. Include all context it needs - sub-agents do not see this conversation.",
},
channelId: {
type: "string",
description:
"Optional channel/thread id the sub-agent should report into. Generated if omitted.",
},
},
required: ["type", "prompt"],
},
},
},
{
type: "function",
function: {
name: "commit_memory",
description:
"Write or update a file in the user's Gitea memory repository. Use for goals, decisions, progress notes, plans, and durable summaries.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description:
"Repo-relative path, e.g. 'goals/active.md' or 'decisions/2026-05-19-architecture.md'.",
},
content: {
type: "string",
description: "Full UTF-8 file content to write.",
},
message: {
type: "string",
description: "Commit message describing the change.",
},
},
required: ["path", "content", "message"],
},
},
},
{
type: "function",
function: {
name: "read_memory",
description: "Read a single file from the user's memory repo. Returns null if missing.",
parameters: {
type: "object",
properties: {
path: { type: "string" },
},
required: ["path"],
},
},
},
{
type: "function",
function: {
name: "list_memory",
description:
"List files at a path prefix in the user's memory repo. Use to discover what context already exists.",
parameters: {
type: "object",
properties: {
pathPrefix: {
type: "string",
description: "Repo-relative directory, e.g. 'goals' or '' for root.",
},
},
required: ["pathPrefix"],
},
},
},
];
type ChatCompletionsResponse = { type ChatCompletionsResponse = {
choices?: Array<{ choices?: Array<{

168
src/lib/prompt-loader.ts Normal file
View File

@@ -0,0 +1,168 @@
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { log } from "../log.js";
// ── Types ──
export type SubAgentModule = {
id: string;
name: string;
role: string;
description: string;
service?: "interview-service" | "roleplay-service" | "qscore-service";
toolNames: string[];
};
type AgentFrontmatter = {
id?: string;
name?: string;
role?: string;
service?: string;
tools?: string[];
};
// ── Paths ──
const PROMPTS_DIR = path.resolve(process.cwd(), "prompts");
const AGENTS_DIR = path.resolve(process.cwd(), "agents");
const SYSTEM_PROMPT_FILE = path.join(PROMPTS_DIR, "system.txt");
// ── Frontmatter parser (no external dependencies) ──
function parseFrontmatter(raw: string): { data: AgentFrontmatter; body: string } {
const trimmed = raw.trim();
if (!trimmed.startsWith("---")) return { data: {}, body: trimmed };
const secondDelim = trimmed.indexOf("---", 3);
if (secondDelim === -1) return { data: {}, body: trimmed };
const fmBlock = trimmed.slice(3, secondDelim).trim();
const body = trimmed.slice(secondDelim + 3).trim();
const data: AgentFrontmatter = {};
for (const line of fmBlock.split("\n")) {
const colonIdx = line.indexOf(":");
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
let value: string | string[] = line.slice(colonIdx + 1).trim();
if (key === "tools" && value.startsWith("[") && value.endsWith("]")) {
// Parse inline array: ["tool1", "tool2"]
const inner = value.slice(1, -1);
value = inner
.split(",")
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
.filter(Boolean);
}
if (key === "id") data.id = value as string;
if (key === "name") data.name = value as string;
if (key === "role") data.role = value as string;
if (key === "service") data.service = value as string;
if (key === "tools") data.tools = value as string[];
}
return { data, body };
}
// ── Loader ──
let cachedModules: SubAgentModule[] | null = null;
let cachedSystemPrompt: string | null = null;
export function getSubAgentModules(): SubAgentModule[] {
if (!cachedModules) {
throw new Error("Prompts not loaded — call loadPromptsFromDisk() at startup");
}
return cachedModules;
}
export function getUnifiedSystemPrompt(): string {
if (!cachedSystemPrompt) {
throw new Error("Prompts not loaded — call loadPromptsFromDisk() at startup");
}
return cachedSystemPrompt;
}
export function getSubAgentModule(id: string): SubAgentModule | undefined {
return getSubAgentModules().find((m) => m.id === id);
}
export function jobApplicationModuleIds(): string[] {
return ["resume", "job-search", "job-apply", "sara", "emily", "qscore"];
}
// Load all prompt and agent files from disk.
// Called once at startup. Rebuild the Docker image to pick up changes (§3).
export async function loadPromptsFromDisk(): Promise<void> {
// ── Load agent modules ──
let agentFiles: string[];
try {
agentFiles = (await readdir(AGENTS_DIR)).filter((f) => f.endsWith(".md"));
} catch (err) {
log.warn({ err, dir: AGENTS_DIR }, "agents directory not found — using empty catalog");
agentFiles = [];
}
const modules: SubAgentModule[] = [];
for (const filename of agentFiles) {
const filePath = path.join(AGENTS_DIR, filename);
try {
const raw = await readFile(filePath, "utf8");
const { data, body } = parseFrontmatter(raw);
if (!data.id || !data.name) {
log.warn({ file: filename }, "agent file missing required frontmatter fields (id, name)");
continue;
}
const service = data.service as SubAgentModule["service"] | undefined;
if (
service &&
service !== "interview-service" &&
service !== "roleplay-service" &&
service !== "qscore-service"
) {
log.warn({ file: filename, service }, "unknown service value — treating as no service");
}
modules.push({
id: data.id,
name: data.name,
role: data.role ?? data.name,
description: body || `Agent module: ${data.name}`,
service: service &&
["interview-service", "roleplay-service", "qscore-service"].includes(service)
? (service as SubAgentModule["service"])
: undefined,
toolNames: data.tools ?? [],
});
} catch (err) {
log.error({ err, file: filename }, "failed to load agent module");
}
}
cachedModules = modules;
log.info({ count: modules.length, dir: AGENTS_DIR }, "loaded sub-agent modules from disk");
// ── Load system prompt ──
try {
const template = await readFile(SYSTEM_PROMPT_FILE, "utf8");
const moduleDescriptions = modules
.map(
(m) =>
`- **${m.name}** (${m.id}): ${m.description} ${
m.service ? `[backed by ${m.service}]` : "[local workflow]"
}`,
)
.join("\n");
cachedSystemPrompt = template.replace("{{MODULE_DESCRIPTIONS}}", moduleDescriptions);
log.info({ path: SYSTEM_PROMPT_FILE }, "loaded system prompt from disk");
} catch (err) {
log.error({ err, path: SYSTEM_PROMPT_FILE }, "failed to load system prompt — using fallback");
// Fallback: assemble from modules without a template file.
const fallback = `You are the Grow Agent — a unified AI orchestrator for the GrowQR platform.\n\n## Sub-Agent Capabilities\n\n${modules.map((m) => `- **${m.name}**: ${m.description}`).join("\n")}`;
cachedSystemPrompt = fallback;
}
}

View File

@@ -10,9 +10,8 @@ import { db } from "../db/client.js";
import { actors as actorsTable } from "../db/schema.js"; import { actors as actorsTable } from "../db/schema.js";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
// PRD §5.2 — Actor registry HTTP surface. // Per changes.md §5: ONE unified actor per user.
// All routes are user-scoped via Clerk auth; userId is derived from the // Routes are user-scoped via Clerk auth; userId derived from session token.
// session token, never trusted from the body.
export function actorRoutes() { export function actorRoutes() {
const app = new Hono<AuthContext>(); const app = new Hono<AuthContext>();
app.use("*", requireUser); app.use("*", requireUser);
@@ -34,7 +33,6 @@ export function actorRoutes() {
}); });
app.get("/", async (c) => { app.get("/", async (c) => {
// Admin/debug — returns the caller's stacks only. Tighten further if needed.
const userId = c.get("userId"); const userId = c.get("userId");
const all = await listStacks(); const all = await listStacks();
return c.json({ stacks: all.filter((s) => s.userId === userId) }); return c.json({ stacks: all.filter((s) => s.userId === userId) });

View File

@@ -1,12 +1,13 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { agentCatalog } from "../agents/catalog.js"; import { subAgentModules } from "../agents/catalog.js";
import { requireUser, type AuthContext } from "../auth/clerk.js"; import { requireUser, type AuthContext } from "../auth/clerk.js";
export function agentRoutes() { export function agentRoutes() {
const app = new Hono<AuthContext>(); const app = new Hono<AuthContext>();
app.use("*", requireUser); app.use("*", requireUser);
app.get("/catalog", (c) => c.json({ agents: agentCatalog })); // Returns the sub-agent module catalog (changes.md §2D: prompt modules).
app.get("/catalog", (c) => c.json({ agents: subAgentModules }));
return app; return app;
} }

View File

@@ -5,7 +5,8 @@ import { requireUser, type AuthContext } from "../auth/clerk.js";
import { db } from "../db/client.js"; import { db } from "../db/client.js";
import { repos } from "../db/schema.js"; import { repos } from "../db/schema.js";
// PRD §5.4 — Gitea Docker management API. // Per changes.md §2A: uses CENTRAL Gitea, not per-user Gitea containers.
// All repo operations go through the central org.
export function gitRoutes() { export function gitRoutes() {
const app = new Hono<AuthContext>(); const app = new Hono<AuthContext>();
app.use("*", requireUser); app.use("*", requireUser);
@@ -16,10 +17,8 @@ export function gitRoutes() {
if (!stack) return c.json({ error: "not provisioned" }, 404); if (!stack) return c.json({ error: "not provisioned" }, 404);
return c.json({ return c.json({
gitea: { gitea: {
host: stack.giteaHost, repoOwner: stack.giteaRepoOwner,
port: stack.giteaHttpPort, repoName: stack.giteaRepoName,
sshPort: stack.giteaSshPort,
memoryRepo: stack.giteaMemoryRepo,
}, },
}); });
}); });
@@ -31,10 +30,14 @@ export function gitRoutes() {
.parse(await c.req.json()); .parse(await c.req.json());
const client = await giteaClientFor(userId); const client = await giteaClientFor(userId);
const stack = await getUserStack(userId); const stack = await getUserStack(userId);
if (!client || !stack) { if (!client || !stack?.giteaRepoOwner) {
return c.json({ error: "not provisioned" }, 404); return c.json({ error: "not provisioned" }, 404);
} }
const repo = await client.ensureRepo({ name: body.name, autoInit: true }); const repo = await client.ensureOrgRepo({
org: stack.giteaRepoOwner,
name: body.name,
autoInit: true,
});
await db await db
.insert(repos) .insert(repos)
.values({ .values({
@@ -61,15 +64,12 @@ export function gitRoutes() {
}) })
.parse(await c.req.json()); .parse(await c.req.json());
const client = await giteaClientFor(userId); const client = await giteaClientFor(userId);
if (!client) return c.json({ error: "not provisioned" }, 404);
// Get owner from DB or fall back to memory repo.
const stack = await getUserStack(userId); const stack = await getUserStack(userId);
const owner = stack?.giteaAdminUser ?? ""; if (!client || !stack?.giteaRepoOwner) {
if (!owner) return c.json({ error: "no gitea owner" }, 500); return c.json({ error: "not provisioned" }, 404);
}
const result = await client.putFile({ const result = await client.putFile({
owner, owner: stack.giteaRepoOwner,
repo: repoName, repo: repoName,
path: body.path, path: body.path,
contentUtf8: body.content, contentUtf8: body.content,
@@ -82,19 +82,19 @@ export function gitRoutes() {
app.get("/repos/:name/contents/*", async (c) => { app.get("/repos/:name/contents/*", async (c) => {
const userId = c.get("userId"); const userId = c.get("userId");
const repoName = c.req.param("name"); const repoName = c.req.param("name");
const path = c.req.path.split(`/repos/${repoName}/contents/`)[1] ?? ""; const filePath = c.req.path.split(`/repos/${repoName}/contents/`)[1] ?? "";
const client = await giteaClientFor(userId); const client = await giteaClientFor(userId);
const stack = await getUserStack(userId); const stack = await getUserStack(userId);
if (!client || !stack?.giteaAdminUser) { if (!client || !stack?.giteaRepoOwner) {
return c.json({ error: "not provisioned" }, 404); return c.json({ error: "not provisioned" }, 404);
} }
const content = await client.readFile({ const content = await client.readFile({
owner: stack.giteaAdminUser, owner: stack.giteaRepoOwner,
repo: repoName, repo: repoName,
path, path: filePath,
}); });
if (content == null) return c.json({ error: "not found" }, 404); if (content == null) return c.json({ error: "not found" }, 404);
return c.json({ path, content }); return c.json({ path: filePath, content });
}); });
return app; return app;

View File

@@ -7,8 +7,9 @@ import type { Registry } from "../actors/registry.js";
const client = createClient<Registry>(config.rivetEndpoint); const client = createClient<Registry>(config.rivetEndpoint);
function jobWorkflowFor(userId: string) { // Per changes.md §5: one unified userActor per user.
return client.workflowJob.getOrCreate([userId, "job-application"]); function userActorFor(userId: string) {
return client.userActor.getOrCreate([userId]);
} }
export function workflowRoutes() { export function workflowRoutes() {
@@ -20,41 +21,42 @@ export function workflowRoutes() {
const body = z const body = z
.object({ goal: z.string().min(1).optional() }) .object({ goal: z.string().min(1).optional() })
.parse(await c.req.json().catch(() => ({}))); .parse(await c.req.json().catch(() => ({})));
const handle = jobWorkflowFor(userId); const handle = userActorFor(userId);
const state = await handle.init({ userId, goal: body.goal }); await handle.init({ userId });
const started = await handle.start(); const state = await handle.startWorkflow({ goal: body.goal });
return c.json({ workflow: started ?? state }); return c.json({ workflow: state });
}); });
app.get("/job-application", async (c) => { app.get("/job-application", async (c) => {
const userId = c.get("userId"); const userId = c.get("userId");
const handle = jobWorkflowFor(userId); const handle = userActorFor(userId);
const state = await handle.init({ userId }); await handle.init({ userId });
const state = await handle.getWorkflowStatus();
return c.json({ workflow: state }); return c.json({ workflow: state });
}); });
app.post("/job-application/pause", async (c) => { app.post("/job-application/pause", async (c) => {
const userId = c.get("userId"); const userId = c.get("userId");
const workflow = await jobWorkflowFor(userId).pause(); const workflow = await userActorFor(userId).pauseWorkflow();
return c.json({ workflow }); return c.json({ workflow });
}); });
app.post("/job-application/resume", async (c) => { app.post("/job-application/resume", async (c) => {
const userId = c.get("userId"); const userId = c.get("userId");
const workflow = await jobWorkflowFor(userId).resume(); const workflow = await userActorFor(userId).resumeWorkflow();
return c.json({ workflow }); return c.json({ workflow });
}); });
app.post("/job-application/agents/:agentId/run", async (c) => { app.post("/job-application/agents/:moduleId/run", async (c) => {
const userId = c.get("userId"); const userId = c.get("userId");
const agentId = c.req.param("agentId"); const moduleId = c.req.param("moduleId");
const workflow = await jobWorkflowFor(userId).runAgent({ agentId }); const workflow = await userActorFor(userId).runWorkflowModule({ moduleId });
return c.json({ workflow }); return c.json({ workflow });
}); });
app.post("/job-application/agents/:agentId/score", async (c) => { app.post("/job-application/agents/:moduleId/score", async (c) => {
const userId = c.get("userId"); const userId = c.get("userId");
const agentId = c.req.param("agentId"); const moduleId = c.req.param("moduleId");
const body = z const body = z
.object({ .object({
question: z.string().min(1), question: z.string().min(1),
@@ -63,8 +65,8 @@ export function workflowRoutes() {
notes: z.string().optional(), notes: z.string().optional(),
}) })
.parse(await c.req.json()); .parse(await c.req.json());
const workflow = await jobWorkflowFor(userId).recordQaScore({ const workflow = await userActorFor(userId).recordQaScore({
agentId, moduleId,
...body, ...body,
}); });
return c.json({ workflow }); return c.json({ workflow });

View File

@@ -1,7 +1,16 @@
import { config } from "../config.js"; import { config } from "../config.js";
import type { AgentProfile } from "../agents/catalog.js";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
// Lightweight agent reference (works with both old AgentProfile and new SubAgentModule).
export type ServiceAgentRef = {
id: string;
name: string;
role: string;
kind: string;
description: string;
service?: string;
};
export type ServiceAgentResult = { export type ServiceAgentResult = {
status: "ok" | "unavailable" | "local"; status: "ok" | "unavailable" | "local";
summary: string; summary: string;
@@ -209,7 +218,7 @@ async function runQuinnQScore(ctx: ServiceAgentContext): Promise<ServiceAgentRes
} }
export async function runServiceAgentProbe( export async function runServiceAgentProbe(
agent: AgentProfile, agent: ServiceAgentRef,
ctx?: ServiceAgentContext, ctx?: ServiceAgentContext,
): Promise<ServiceAgentResult> { ): Promise<ServiceAgentResult> {
try { try {