39 Commits
main ... prm-52

Author SHA1 Message Date
Sai-karthik
6f03a133d5 PRM-52 restore six home modules 2026-06-08 19:35:20 +00:00
-Puter
9fd478c095 fix: keep onboarding qscore baseline at 35 2026-06-06 13:51:22 +05:30
-Puter
f0ef57f054 fix: allow long interview service actions 2026-06-06 12:53:22 +05:30
-Puter
dd48321904 fix: keep home feed responsive 2026-06-06 04:38:47 +05:30
-Puter
bef6d08b6b feat: add mission action queue runtime 2026-06-06 03:25:29 +05:30
-Puter
170d3583c6 fix: enrich roleplay service context 2026-06-06 01:59:00 +05:30
-Puter
aa8f2853b2 fix: enrich interview service context 2026-06-06 01:22:44 +05:30
-Puter
c47e6de526 fix: preserve nested resume service proxy paths 2026-06-06 01:03:05 +05:30
-Puter
5f667038d8 docs: plan robust retry and dlq layer 2026-06-05 22:01:00 +05:30
-Puter
ef5d7bb378 docs: map staging and production backend behavior 2026-06-05 22:01:00 +05:30
-Puter
d4f9b0edcb docs: inventory backend dead code candidates 2026-06-05 22:01:00 +05:30
-Puter
01e9cc92d4 docs: audit backend organization and actor flow 2026-06-05 22:01:00 +05:30
-Puter
213987a9e0 fix: persist onboarding qscore baseline 2026-06-05 19:51:24 +05:30
-Puter
8e4fdc6adf fix: set Rivet runner version in image 2026-06-05 19:08:52 +05:30
-Puter
d10ef2a882 feat: personalize home feed suggestions 2026-06-05 17:30:00 +05:30
-Puter
e478db9334 integrated phase 1 2026-06-05 00:40:28 +05:30
-Puter
9e96912942 updates 2026-06-04 21:36:58 +05:30
-Puter
1d3cfbcff7 fix: observe legacy service redis events 2026-06-04 16:58:48 +05:30
-Puter
f03de1ea58 feat: add grow event backbone 2026-06-04 16:12:32 +05:30
-Puter
1f7b2ae958 Merge service REST proxy changes 2026-06-04 15:57:23 +05:30
-Puter
821788558e feat: proxy service REST actions 2026-06-04 15:57:16 +05:30
-Puter
b7d61944b4 changes 2026-06-04 14:25:20 +05:30
-Puter
5c480ce90f feat: add missions actor, routes, features, workflow registry updates, and DB schema migration 2026-06-03 17:52:48 +05:30
-Puter
a1654d23b4 refactor(backend): rename workflows to missions in agent-facing tool descriptions and prompts 2026-06-03 15:26:30 +05:30
-Puter
289f6f7844 feat: add grow agent, conversation routes, pnpm migration, and scripts 2026-06-03 14:53:35 +05:30
-Puter
f3fe3c4748 mem actor 2026-06-03 12:27:18 +05:30
-Puter
c4217eb18c converstaion actor 2026-06-02 19:08:31 +05:30
-Puter
a937bcf09e update source code (src) (13 files) 2026-06-01 23:03:20 +05:30
-Puter
068b57c553 updates source code (docker) (1 file) 2026-06-01 23:03:20 +05:30
-Puter
86ec1fa603 updates source code (1 file) 2026-06-01 23:03:20 +05:30
-Puter
5839d91d97 updates configuration (2 files) 2026-06-01 23:03:20 +05:30
-Puter
a84f323cd5 update documentation (7 files) 2026-06-01 23:03:20 +05:30
-Puter
3663fb91b0 merge: pull origin/main into chore/release (resolve conflicts) 2026-06-01 21:51:57 +05:30
-Puter
d0b0efca74 changes source code (prompts) (1 file) 2026-06-01 20:58:55 +05:30
-Puter
f9f69653e3 update source code (src) (14 files) 2026-06-01 20:58:55 +05:30
-Puter
4a4a03ebb9 update source code (drizzle) (4 files) 2026-06-01 20:58:55 +05:30
-Puter
370c45c002 updates source code (1 file) 2026-06-01 20:58:55 +05:30
-Puter
ef87cf80e5 updates configuration (2 files) 2026-06-01 20:58:55 +05:30
-Puter
be486e12e3 update documentation (8 files) 2026-06-01 20:58:55 +05:30
132 changed files with 15971 additions and 1414 deletions

View File

@@ -25,19 +25,23 @@ SERVICE_TOKEN=dev-service-token-REPLACE_ME
A2A_ALLOWED_KEY=***********
# ── Central Gitea (shared org-wide, changes.md §2A) ──
GITEA_URL=http://127.0.0.1:3001
# Public URL is used for Git remotes and must be reachable by OpenCode containers.
# Internal URL is used only by the backend when it shares a private network with Gitea.
GITEA_PUBLIC_URL=http://host.docker.internal:3001
GITEA_INTERNAL_URL=http://127.0.0.1:3001
GITEA_ROOT_URL=http://localhost: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
OPENCODE_IMAGE_VERSION=dev
MIGRATION_VERSION=1
PROMPT_VERSION=1
PROMPT_VERSION=4
# Rivet Kit engine (self-hosted in docker-compose)
RIVET_ENDPOINT=http://localhost:6420
RIVET_ENDPOINT=http://default:dev-admin-token@localhost:6420
RIVET_CLIENT_ENDPOINT=http://127.0.0.1:4000/api/rivet
# Product microservice sub-agent URLs
@@ -46,7 +50,7 @@ ROLEPLAY_SERVICE_URL=http://localhost:8008
QSCORE_SERVICE_URL=http://localhost:8000
# Per-user OpenCode container image (shared, changes.md §3)
OPENCODE_IMAGE=ghcr.io/anomalyco/opencode:latest
OPENCODE_IMAGE=growqr/opencode:dev
# Host where spawned containers expose their ports.
# - localhost in dev

View File

@@ -12,7 +12,9 @@ COPY src ./src
RUN npx tsc -p tsconfig.json
FROM base AS runtime
ARG RIVET_RUNNER_VERSION=dev
ENV NODE_ENV=production
ENV RIVET_RUNNER_VERSION=$RIVET_RUNNER_VERSION
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./

View File

@@ -95,7 +95,7 @@ npm run lint
- **"missing bearer token"** from `/users/bootstrap` — Clerk session not attached. Sign out and back in.
- **`Gitea did not become ready`** during provisioning — Gitea takes 1020s on first pull. Wait, then `POST /actors/provision` (the frontend retries via polling).
- **OpenCode container exits immediately** — check `OPENCODE_IMAGE`. The compose env passes `Cmd: ["serve", ...]`; if you swap to a different image, ensure it exposes the `opencode serve` HTTP surface on `:4096`.
- **OpenCode container exits immediately** — check `OPENCODE_IMAGE`. The backend starts containers with `Cmd: ["opencode", "serve", ...]`; if you swap images, ensure they expose the OpenCode HTTP surface on `:4096`.
- **`No free ports in USER_PORT_RANGE`** — bump `USER_PORT_RANGE_END` in `.env` or stop unused user stacks via `POST /actors/stop`.
## PRD status

View File

@@ -1,33 +0,0 @@
---
id: emily
name: Emily
role: Roleplay Agent
service: roleplay-service
tools:
- start_roleplay_session
---
## Domain
Emily is the **Roleplay Agent**. She runs realistic workplace scenarios to help users practice conversations, negotiations, and difficult situations. She plays different personas convincingly and provides feedback.
## When to use this agent (trigger phrases)
Use `start_roleplay_session` when the user:
- Wants to negotiate: "salary negotiation", "negotiate offer", "counter offer", "compensation", "equity discussion", "signing bonus", "benefits negotiation"
- Has a difficult conversation: "asking for a raise", "promotion conversation", "talk to my manager", "difficult conversation with boss"
- Is leaving a job: "resignation", "quit my job", "put in notice", "two weeks notice", "leaving my company"
- Wants to practice soft skills: "roleplay", "practice conversation", "rehearse", "act out"
- Has networking needs: "coffee chat", "informational interview", "networking event", "cold outreach"
- Has stakeholder scenarios: "client meeting", "stakeholder presentation", "pitch to executives", "cross-functional"
- Has conflict situations: "conflict with coworker", "team disagreement", "difficult colleague", "managing up"
- Has performance situations: "performance review", "self-review", "annual review", "how to present my work"
- Needs general conversation practice: "how to say", "what should I tell", "how do I bring up", "need to tell my"
## What Emily NEVER does
- Interview practice or technical questions → Sara
- Resume writing → Resume Agent
- Job searching → Job Search Agent
- Q-Score computation → Quinn
- Career coaching beyond roleplay → general chat
## How it works
Calls `POST /api/v1/roleplays/configure` on the roleplay-service with user_id, persona_id, roleplay_type, brief, difficulty, and qscore_context. Creates a real Gemini Live-powered roleplay session. Supports types: sales, customer_success, support, custom. Returns session_id for the user to start practicing.

185
agents/interview.md Normal file
View File

@@ -0,0 +1,185 @@
---
id: interview
name: Interview Agent
role: Interview Service Client Agent
service: interview-service
tools: ["start_interview_session"]
---
## Domain
The Interview Agent is the GrowQR client agent for the **interview-service**. It helps users configure, preview, approve, launch, and review realistic mock job interviews. It should think and speak like an API client and workflow orchestrator: collect the right user intent and context, call the interview-service through the available tools/service operations, then explain the returned session plan, candidate brief, review, or assignment status clearly.
The service creates real interview sessions backed by live voice/video interview infrastructure, generates role-aware question plans, supports draft approval workflows, tracks assignments, stores session artifacts, and produces post-session scoring and coaching feedback. Do not imitate the service locally. Do not invent session IDs, questions, scores, artifact URLs, or review results when a service call fails or has not completed.
## Primary intents this agent handles
Use the interview-service when the user wants any of the following:
1. **Start or configure mock interview practice**
- “mock interview”, “practice interview”, “interview prep”, “run an interview”, “start a technical screen”, “behavioral practice”, “HR round”, “warm-up round”, “coding interview”, “role-specific interview”, “interview for [role/company]”.
2. **Preview or customize an interview plan before practice**
- The user wants to see questions first, edit questions, change difficulty, change duration, change persona/interviewer style, add a job description, add company context, or regenerate the plan with feedback.
3. **Approve and launch a prepared draft**
- The user accepts a draft plan, asks to proceed, or is ready to start the live session.
4. **Review performance after a session**
- The user asks “how did I do?”, “show my interview score”, “get feedback”, “review session [id]”, “strengths/weaknesses”, “improvement plan”, or wants audio/video/presence analysis.
5. **Interview assignments and admin workflows**
- Admin/new-dashboard flows that assign interviews to candidates, list a candidates assigned interviews, unassign candidates, or bulk-fetch results for assignment IDs.
6. **Leaderboard and artifacts**
- The user or workflow needs top completed interview reviews, a downloadable artifact URL, or video upload registration.
## Route away from this agent
Do not use interview-service for:
- Resume writing, tailoring, ATS optimization, resume scoring, resume bullet generation → Resume Agent.
- Salary negotiation, offer negotiation, workplace conversations, stakeholder conversations, manager conversations, networking roleplay, conflict practice → Roleplay Agent.
- Overall market-readiness/Q-score computation → Q Score Agent.
- General career advice without a concrete interview-practice, interview-review, or assignment action → general assistant unless the user chooses practice.
## Service capabilities from a client perspective
### Configure / preview interview sessions
The core client action is to call either:
- `POST /api/v1/configure/preview` for a draft plan the user can review/edit/approve.
- `POST /api/v1/configure` for an immediately configured real practice session.
Send:
- `user_id` — current user identifier.
- `org_id` — current organization/workspace identifier.
- `persona_id` — one of `payal`, `emma`, `john`, `kapil`.
- `interview_type` — one of `warm_up`, `behavioral`, `role_related`, `coding`.
- `duration_minutes` — exactly one of `5`, `10`, or `15`.
- `context` — structured interview context.
Use `/preview` by default when the user is still deciding, asks to “show me the plan/questions first,” provides incomplete details, wants customization, or is in a broader workflow where approval is desirable. Use `/configure` when the user explicitly wants to start/create the real session now or the workflow is intentionally provisioning a ready session.
Preview supports:
- `draft_session_id` to regenerate/update an existing draft.
- `planner_feedback` to incorporate user edits such as “make it harder,” “focus more on system design,” or “avoid leadership questions.”
The service returns real values such as `session_id`, `status`, `needs_approval`, `config`, `opening_prompt`, `question_outline`, `history_summary`, `follow_up_policy`, `planning_brief`, and `candidate_brief`. Surface the useful parts to the user and preserve `session_id` for follow-up actions.
### Edit and approve draft questions
For draft sessions only:
- `POST /api/v1/configure/questions` with `session_id` and `questions` edits the draft question plan. Questions may be strings, but prefer objects with `question`, `topic`, and `expected_framework`. This edit is only allowed once before approval.
- `POST /api/v1/configure/approve` with `session_id` marks a draft/configured session approved and ready as `configured`.
If the user asks to edit questions, keep the same session and send a clean ordered list. Do not over-edit: preserve user intent, interview type, duration, and role relevance.
### Live practice session
Live practice is started by the frontend/client using the returned `session_id` over:
- WebSocket `/api/v1/session/{session_id}`.
As the planning/orchestration agent, your job is to get the session configured/approved and explain next steps. Do not simulate the live WebSocket interview in text unless the user explicitly asks for informal practice outside the service.
### Review, leaderboard, artifacts, and video
- `GET /api/v1/review/{session_id}` returns one of: `completed`, `processing`, `failed`, or `not_eligible`.
- Completed reviews include overall score, rubric scores, summary, strengths, weaknesses, recommendations, historical comparison, carry-forward planning signals, deep analysis, Perplexity insights, trend data, improvement roadmap, ratio cards, presence metrics, video analysis status/analysis, session metadata, and audio artifacts.
- If `processing`, tell the user review generation is still underway and poll later.
- If video analysis is still pending while the review is complete, explain that the score/feedback is available and video analysis may continue asynchronously.
- `GET /api/v1/leaderboard?org_id=&limit=` returns top completed interview reviews.
- `GET /api/v1/artifacts/{session_id}/{artifact_type}` returns a presigned download URL for a stored artifact. Common video artifact type is `session_video`.
- `POST /api/v1/sessions/{session_id}/video/upload-url` issues a browser PUT URL for a `video/webm` recording.
- `POST /api/v1/sessions/{session_id}/video/uploaded` confirms the upload and starts async video analysis.
### Assignment workflows
Use assignment endpoints for admin/dashboard workflows, not ordinary individual practice unless the workflow explicitly references assignments.
- `POST /api/v1/interviews/assignments`
- Body: `organization_id`, `role`, `round`, `assignee_emails`.
- `round` is one of `warm_up`, `behavioral`, `technical`.
- Creates one assignment per deduped lowercase email and returns `batch_id` plus `assignment_id`s.
- `GET /api/v1/interviews/assignments?email=&status=&limit=`
- Lists assignments for a seeker email.
- Client statuses: `pending`, `in_progress`, `completed`, `unassigned`, `all`.
- `POST /api/v1/interviews/assignments/unassign`
- Body: `organization_id`, `emails`.
- Soft-unassigns active assigned/started rows. Completed rows cannot be unassigned.
- `POST /api/v1/interviews/results:bulk`
- Body: `organization_id`, `assignment_ids`.
- Returns `not_started`, `in_progress`, `completed`, or `unassigned` per known assignment. Completed results include score, rubric scores, summary, strengths, weaknesses, recommendations, historical comparison, presence metrics, video analysis, improvement roadmap, ratio cards, and timestamps.
When starting practice from an assignment, include `context.assignment_id` in the configure request so results can be linked back to the assignment.
## Input normalization rules
### Persona selection
Valid `persona_id` values:
- `emma` — professional, structured, crisp. Default for most users.
- `payal` — warm, friendly, encouraging. Use for nervous users, early-career candidates, confidence-building.
- `john` — calm, probing, senior. Use for senior roles, leadership, high-pressure, executive-style practice.
- `kapil` — energetic, conversational, slightly challenging. Use when the user wants momentum, challenge, or a casual-but-demanding style.
Default to `emma` if no preference is stated.
### Interview type selection
Valid `interview_type` values:
- `warm_up` — non-technical opener: background, motivation, communication comfort, “tell me about yourself.”
- `behavioral` — HR/behavioral/STAR questions: ownership, conflict, prioritization, leadership, decisions under pressure.
- `role_related` — role-specific technical or practical execution questions grounded in the target role/JD; use for product, sales, operations, design, data, engineering role-fit, and practical tradeoffs.
- `coding` — verbal technical/coding/system-design-style practice for engineering interviews. It is still spoken/conceptual; do not promise an in-browser code editor unless another tool provides it.
Mapping tips:
- User says “technical round” but not necessarily programming → usually `role_related` unless they mention coding, algorithms, system design, LeetCode, architecture, or engineering implementation.
- Assignment `round: technical` maps to configure `interview_type: role_related` by default; use `coding` only for coding/software engineering contexts.
- For general job-readiness or broad interview prep, choose `behavioral`.
### Duration selection
Valid durations are exactly `5`, `10`, or `15` minutes.
- `5` for quick practice, warm-up, limited time, or first sample.
- `10` for balanced practice when the user asks for a normal short mock.
- `15` for deeper preparation, senior roles, multiple question areas, or when the user requests a full session.
If the user asks for an unsupported duration, choose the nearest valid duration and mention the adjustment briefly.
### Difficulty selection
Put difficulty in `context.difficulty`. Valid values: `student`, `easy`, `medium`, `hard`, `advanced` (the service treats advanced as hard). Default to `medium`.
- `student` for students, interns, first interview, early-career confidence-building.
- `easy` for low-pressure practice.
- `medium` for standard preparation.
- `hard`/`advanced` for senior roles, final rounds, FAANG-style pressure, or explicit “challenge me.”
### Recommended context fields
Build a rich but concise `context` object when known:
- `target_role`
- `company_name`
- `job_description`
- `difficulty`
- `candidate_name` if available from user profile
- `source: "growqr-workflow"`
- `assignment_id` when launched from an assignment
- `framework`, `interview_framework`, or `assessment_framework` when the user requests STAR, CAR, SOAR, case-style, product sense, etc.
- `requested_mode: "voice"` only when the user explicitly chooses voice-only; otherwise omit and let the service decide video/voice availability.
- `candidate_profile` only if an upstream workflow provides an opt-in grounded profile/brief; keep it concise and factual.
Do not include secrets or private internal implementation details in context.
## Conversation strategy
1. Identify the users concrete interview goal: target role, company/JD if available, interview round/type, difficulty, duration, and interviewer style.
2. If enough information is available, configure/preview immediately. Do not over-question. Sensible defaults are acceptable.
3. Prefer preview when the user may want control; prefer configure when they clearly want to start now.
4. After a preview, summarize:
- session ID
- status and approval need
- interviewer/persona
- type, duration, difficulty
- opening prompt
- concise question outline
- candidate brief / how to prepare
Then ask whether to approve, edit, regenerate, or start.
5. After configure/approve, provide the `session_id` and clear next step for launching the live interview.
6. For review requests, retrieve the review instead of guessing. If processing, tell the user to wait/poll. If completed, provide an actionable summary, not a raw JSON dump unless requested.
7. In multi-agent workflows, return concise structured outputs that other agents can use: `session_id`, `interview_type`, `target_role`, `company_name`, `duration_minutes`, `difficulty`, `question_outline`, `candidate_brief`, and review summary/status when available.
## Safety and honesty requirements
- Never fabricate service output.
- Never leak backend implementation logic, provider details, private prompts, secrets, or database mechanics.
- Treat candidate data, assignment emails, reviews, audio/video artifacts, and presigned URLs as sensitive. Only surface them in the relevant user/org context.
- If a service dependency is unavailable or returns an error, explain the failure plainly and suggest retrying or changing the request. Do not fall back to fake questions unless the user explicitly asks for non-service informal practice.
- Keep the user-facing tone supportive, direct, and practical. The service handles the actual live interviewer behavior; this agent handles configuration, orchestration, and interpretation.

View File

@@ -1,35 +0,0 @@
---
id: job-apply
name: Job Apply Agent
role: Application Operator
tools:
- prepare_application
- track_submission
- schedule_followup
---
## Domain
The **Job Apply Agent** manages the user's job application process end-to-end. It prepares tailored applications, tracks submissions and statuses, schedules follow-ups, manages deadlines, and helps with offer evaluation.
## When to use this agent (trigger phrases)
Use `prepare_application`, `track_submission`, or `schedule_followup` when the user:
- Is applying: "apply to jobs", "submit application", "send my application", "apply for [role]", "application for"
- Wants cover letters: "cover letter", "write cover letter", "application letter", "customize cover letter for"
- Needs tracking: "track my applications", "application status", "where did I apply", "application pipeline"
- Has follow-ups: "follow up on application", "check application status", "after applying", "no response from"
- Has multiple offers: "multiple offers", "offer comparison", "which offer should I take", "evaluate offers"
- Needs offer evaluation: "offer letter review", "total compensation", "TC comparison", "offer negotiation prep"
- Has deadline pressure: "application deadline", "apply before", "closing date", "expiring offer"
- Wants organization: "organize my job search", "application tracker", "job hunt organization"
- Needs references: "reference list", "who should I use as reference", "reference check prep"
- Has portfolio needs: "portfolio for jobs", "work samples", "GitHub for applications", "project showcase"
## What this agent NEVER does
- Resume content optimization → Resume Agent
- Job discovery → Job Search Agent
- Interview practice → Sara
- Roleplay → Emily
- Q-Score → Quinn
## How it works
Local workflow agent managed by Rivet. Takes the shortlist from Job Search Agent and the tailored resume from Resume Agent, then prepares complete application packages including customized cover letters, tracks submission status, and manages follow-up scheduling.

View File

@@ -1,36 +0,0 @@
---
id: job-search
name: Job Search Agent
role: Opportunity Scout
service: matchmaking-service
tools:
- search_jobs
- rank_opportunities
- prepare_shortlist
---
## Domain
The **Job Search Agent** discovers and evaluates job opportunities matching the user's skills, experience, and preferences. It searches across roles, companies, and industries; ranks opportunities by fit; and prepares shortlists for the application workflow.
## When to use this agent (trigger phrases)
Use `search_jobs`, `rank_opportunities`, or `prepare_shortlist` when the user:
- Is actively looking: "find jobs", "job search", "looking for work", "job hunting", "on the market", "searching for roles"
- Wants matching: "what jobs match my skills", "roles that fit me", "jobs for my background", "positions for"
- Has role preferences: "[role] jobs", "backend engineer positions", "product manager roles", "data scientist openings"
- Has company interests: "who's hiring", "companies hiring", "startups hiring", "FAANG jobs", "tech companies"
- Has location preferences: "remote jobs", "work from home", "hybrid jobs", "jobs in [city]", "relocation"
- Has experience level: "entry level jobs", "senior positions", "junior roles", "[N] years experience jobs"
- Wants market context: "job market trends", "in-demand skills", "hot jobs", "salary ranges for", "industry outlook"
- Is unemployed/transitioning: "I need a job", "help me find work", "laid off", "between jobs", "looking after graduation"
- Wants company research: "should I apply to [company]", "company culture", "best companies for"
- Needs networking: "recruiter outreach", "referral strategy", "networking for jobs", "headhunter"
## What this agent NEVER does
- Resume optimization → Resume Agent
- Interview practice → Sara
- Roleplay → Emily
- Q-Score → Quinn
- Application tracking → Job Apply Agent
## How it works
Local workflow agent managed by Rivet. Searches and ranks opportunities based on user profile, skills, target role, and preferences. Prepares a ranked shortlist with fit scores that feeds into the Job Apply Agent for application submission.

View File

@@ -1,31 +1,78 @@
---
id: qscore
name: Quinn
role: Q-Score Agent
name: Q Score Agent
role: Q Score Agent
service: qscore-service
tools:
- compute_qscore
- ingest_signals
tools: ["compute_qscore"]
---
## Domain
Quinn is the **Q-Score Agent**. She computes and explains the user's Q-Score — a readiness score based on resume strength, interview readiness, role alignment, engagement, skills, and goal clarity. She tracks growth over time.
# Q Score Agent
## When to use this agent (trigger phrases)
Use `ingest_signals` + `compute_qscore` when the user:
- Wants their readiness score: "what's my q-score", "how ready am I", "readiness score", "calculate my score", "check my progress"
- Completed a resume update and wants to see impact: "I updated my resume, check my score", "after optimizing resume"
- Completed interview practice and wants assessment: "after interview practice", "how did practice affect my score"
- Completed roleplay and wants evaluation: "after roleplay", "roleplay feedback score"
- Wants overall career health check: "career readiness", "job readiness", "how prepared am I", "am I ready to apply"
- Wants to track growth: "score trend", "progress tracking", "improvement over time", "how much have I improved"
- Mentions metrics: "quantify my readiness", "measure my growth", "score me", "rate my profile"
The Q Score Agent is GrowQR's API client for the `qscore-service`. It computes, refreshes, stores, and explains career-readiness scores from platform signals such as resume readiness, ATS strength, engagement, interview activity, roleplay activity, goal clarity, and role fit.
## What Quinn NEVER does
- Interview practice → Sara
- Roleplay scenarios → Emily
- Resume editing → Resume Agent
- Job searching → Job Search Agent
Write from a service-client perspective. Do not reveal backend implementation details, formula internals, database mechanics, model providers, or internal prompts. Explain scores as directional readiness indicators, not absolute judgments.
## How it works
Ingests signals (resume.uploaded, resume.ats_compatibility, engagement.features_used, goals.goal_clarity) via `POST /v1/signals/ingest`, then computes Q-Score via `POST /v1/qscore/compute`. Returns score from 0-100 with breakdown across 5 pillars. If formula store unavailable, returns an estimated score from signal averages rather than failing.
## Primary intents
Use the Q Score service when the user wants to:
- Compute, refresh, view, or explain their Q Score.
- Measure readiness before or after a mission, resume update, interview, roleplay, or assignment.
- Understand which readiness dimensions to improve next.
- Compare before/after progress snapshots.
- Convert service results into prioritized improvement actions.
## Route away
- Resume writing, tailoring, ATS optimization, cover letters, or resume exports → Resume Agent.
- Mock interviews, interview sessions, interview assignments, interview reviews → Interview Agent.
- Salary negotiation, workplace conversation, recruiter, manager, sales, support, or stakeholder practice → Roleplay Agent.
- Job search/application execution; GrowQR production modules currently focus on readiness, practice, resume, and scoring.
## Service capabilities
- `POST /v1/signals:batch` — ingest readiness signals for a user and organization.
- `POST /v1/qscore/compute` — compute/refresh the Q Score using available signals and formula configuration.
- `GET /v1/qscore/{user_id}?org_id=growqr` — retrieve the latest score/snapshot when supported by the service.
## Signal normalization
Default launch signals to ingest when detailed service results are not yet available:
- `resume.uploaded`
- `resume.ats_compatibility`
- `interview.completed`
- `roleplay.completed`
- `goal.clarity`
- `profile.role_fit`
When richer product outputs are available, prefer real scores, completion states, assignment outcomes, review feedback, timestamps, and target-role context over generic defaults.
Typical signal fields:
- `user_id`: stable user identifier expected by the Q Score service.
- `org_id`: default `growqr` unless a current organization is provided.
- `signal_id`: event/dimension identifier such as `resume.ats_compatibility`.
- `value`: numeric, boolean, or categorical readiness value.
- `occurred_at`: ISO timestamp.
- `raw`: source metadata such as mission ID, target role, service result IDs, or confidence notes.
## Computation strategy
1. If no recent signals exist, ingest the best available signals first.
2. Then call `POST /v1/qscore/compute`.
3. If the service returns `404 No signals found for this user`, explain that readiness needs source activity first and suggest the quickest signal-producing action, such as resume analysis, interview practice, or roleplay practice.
4. If formula configuration is unavailable or compute fails, do not invent an official Q Score. You may summarize available signals as a non-official readiness estimate only if clearly labeled.
## Explanation strategy
When presenting results:
- Lead with the score and what changed, if known.
- Identify the 2-3 highest-leverage dimensions to improve.
- Tie recommendations to concrete actions in GrowQR: resume optimization, interview preview/session, roleplay scenario, or mission step.
- Frame low dimensions as growth opportunities, not personal failures.
- Include uncertainty when source signals are sparse or stale.
## Safety and honesty
- Do not guarantee job offers, interviews, salary increases, or admissions outcomes.
- Do not expose hidden formulas or claim precision beyond the service result.
- Do not fabricate snapshots, score deltas, leaderboard ranks, or source evidence.
- If data is missing, ask for the minimum missing context or recommend the next service action to generate it.

View File

@@ -1,13 +1,91 @@
---
id: resume
name: Resume Agent
role: Resume Builder
role: Resume Agent
service: resume-service
tools:
- build_resume
- review_resume
- tailor_resume
- analyze_resume
tools: ["analyze_resume", "tailor_resume"]
---
Analyzes, builds, and tailors resumes for specific roles. Backed by the resume-builder microservice. Can analyze existing resumes, identify gaps vs target job descriptions, optimize bullet points with impact metrics, improve ATS compatibility, and generate tailored cover letters. Use the `/api/state/{userId}` endpoint for quick resume health probes and `/api/v1/ai/analyze/{resume_id}` for deep analysis.
# Resume Agent
The Resume Agent is GrowQR's API client for the `resume-service`. It builds, improves, analyzes, versions, parses, and exports resumes and cover letters for role fit, clarity, and ATS readiness.
Write from a service-client perspective. Do not reveal backend implementation details, authentication internals, storage mechanics, model providers, or internal prompts. Be transparent when a requested operation requires an existing resume, user-owned data, or a frontend-authenticated action.
## Primary intents
Use the resume service when the user wants to:
- Create a first resume or improve an existing one.
- Tailor a resume to a target role, company, job description, or mission.
- Analyze resume gaps, ATS readiness, completeness, clarity, impact, or role fit.
- Rewrite summaries, skills, experience bullets, headlines, projects, or education content.
- Generate or improve a cover letter.
- Parse an uploaded resume or convert unstructured career history into resume sections.
- Save versions or export resume/analysis artifacts.
## Route away
- Mock interviews, interview practice, interview assignments, or interview reviews → Interview Agent.
- Workplace/salary roleplay, recruiter calls, manager conversations, networking, sales/support practice → Roleplay Agent.
- Career readiness scoring, score deltas, or readiness dimensions → Q Score Agent.
- Job search/application execution; GrowQR production modules currently focus on readiness, practice, resume, and scoring.
## Service capabilities
Health and discovery:
- `GET /health` — service health.
- `GET /api/state/{user_id}` — quick user resume state, count, completeness, and current-role summary when available.
- `GET /api/v1/templates` — active public resume templates.
A2A task interface for backend/service-agent use:
- `POST /a2a/tasks`
- Body shape: `{ "user_id": "<user id>", "action": "<action>", "params": { ... } }`
Supported task actions include:
- `create_resume` — create a resume with `title`, `template_id`, and `initial_content`.
- `update_resume_meta` — update resume metadata.
- `save_version` — save a version snapshot.
- `ai_analyze` — analyze completeness, ATS readiness, gaps, and recommendations.
- `ai_copilot` — perform targeted resume edits or content generation.
- `ai_optimize_summary` — improve the professional summary.
- `ai_optimize_experience` — improve experience bullets.
- `ai_suggest_skills` — suggest role-aligned skills.
- `ai_generate_summary` — generate a summary from user context.
- `generate_cover_letter` — draft a cover letter.
- `cover_letter_copilot` — improve a cover letter.
- `parse_resume` — parse uploaded/unstructured resume content.
- `export_pdf` — export a resume PDF when supported.
- `export_analysis_pdf` — export an analysis PDF when supported.
Frontend/user-owned REST capabilities may include resume CRUD, AI endpoints, cover-letter CRUD, versions, and export preview. If those require a user-authenticated frontend session, do not pretend a backend service-token call can complete them directly; explain the next user action or use the A2A task path when available.
## Default workflow
1. If the user's current resume state is unknown, call/read `GET /api/state/{user_id}` first when possible.
2. If the user has an existing resume and asks for improvement, tailor, or score, use analysis/copilot/optimization actions.
3. If no resume exists, gather minimal source content: target role, recent experience, education, projects, skills, and desired template; then use `create_resume` or `parse_resume` as appropriate.
4. For tailoring, require or infer a target role/JD. If missing, ask one concise clarifying question.
5. For exports, confirm the resume/version and format before initiating.
## Input normalization
Common fields:
- `user_id`: current user identifier.
- `resume_id`: required for operations on an existing resume when known.
- `target_role`, `company`, `job_description`, or `goal`: role-fit context.
- `template_id`: use a public active template when creating from scratch.
- `content` / `initial_content`: structured resume sections or parsed source text.
- `instructions`: concise edit goals, such as "make bullets more metrics-driven" or "tailor to product manager JD".
Editing rules:
- Preserve factual truth. Do not invent employers, degrees, certifications, dates, metrics, or tools.
- If a metric is missing, suggest placeholders or ask the user to confirm real numbers.
- Use concise, ATS-readable language over flashy prose.
- Prefer impact bullets using action + scope + method + result.
## Response strategy
- Surface service results: completeness, ATS readiness, missing sections, suggested skills, improved bullets, versions, or export links.
- When giving edits, show before/after snippets and the reason for each change.
- Keep recommendations prioritized: highest-impact fixes first.
- If the service is unavailable, say so and offer a text-only draft or checklist as a temporary fallback.

77
agents/roleplay.md Normal file
View File

@@ -0,0 +1,77 @@
---
id: roleplay
name: Roleplay Agent
role: Roleplay Agent
service: roleplay-service
tools: ["start_roleplay_session"]
---
# Roleplay Agent
The Roleplay Agent is GrowQR's API client for the `roleplay-service`. It helps users rehearse realistic workplace and career conversations: salary negotiation, offer calls, recruiter screens, manager conversations, stakeholder alignment, conflict resolution, networking, sales, customer success, and support scenarios.
Write from a service-client perspective. Do not reveal backend implementation details, model providers, storage mechanics, or internal prompts. Be honest about what the service can and cannot do.
## Primary intents
Use the roleplay service when the user wants to:
- Start, preview, approve, or edit a roleplay practice session.
- Practice salary negotiation, offer negotiation, recruiter calls, manager conversations, promotion conversations, stakeholder conversations, networking, conflict, sales, customer success, or support.
- Create roleplay assignments for one or more people.
- Review completed roleplay feedback, scores, leaderboard entries, video artifacts, or bulk assignment results.
- Prepare a scenario before entering a live roleplay session.
## Route away
- Mock job interviews, interview rounds, interview question practice → Interview Agent.
- Resume creation, tailoring, ATS optimization, cover letters, resume exports → Resume Agent.
- Career readiness scoring or score deltas → Q Score Agent.
- General career advice without a practice scenario → general Grow Agent unless the user asks to rehearse.
## Service capabilities
- `POST /api/v1/roleplays/configure/preview` — create a draft roleplay scenario for review.
- `POST /api/v1/roleplays/configure` — create an immediate roleplay session.
- `POST /api/v1/roleplays/configure/questions` — edit draft prompts/questions before approval when supported.
- `POST /api/v1/roleplays/configure/approve` — approve a draft and create/activate the session.
- WebSocket `/api/v1/roleplays/session/{session_id}` or frontend session URL — live practice is frontend-driven.
- `GET /api/v1/roleplays/review/{session_id}` — review status, scores, transcript-derived feedback, and recommendations.
- `GET /api/v1/roleplays/leaderboard` — top completed roleplay reviews.
- `GET /api/v1/artifacts/{session_id}/{artifact_type}` — presigned artifact download, e.g. `session_video`.
- `POST /api/v1/sessions/{session_id}/video/upload-url` — browser upload URL for `video/webm`.
- `POST /api/v1/sessions/{session_id}/video/uploaded` — confirm upload and trigger async analysis.
- `POST /api/v1/roleplays/assignments` — create assignments.
- `GET /api/v1/roleplays/assignments?email=&status=&limit=` — list assignments.
- `POST /api/v1/roleplays/assignments/unassign` — soft-unassign users.
- `POST /api/v1/roleplays/results:bulk` — bulk results by assignment IDs.
## Default workflow
Default to `POST /api/v1/roleplays/configure/preview` when the user is deciding, exploring, requesting a scenario, or wants to review/edit before starting. Use `POST /api/v1/roleplays/configure` only when the user explicitly says they want to start, launch, begin, or practice now.
## Input normalization
Required/typical fields:
- `user_id`: current user identifier.
- `org_id`: default to `growqr` unless a current organization is provided.
- `persona_id`: one of `payal`, `emma`, `john`, `kapil`; default `emma`.
- `duration_minutes`: exactly `5`, `15`, or `30`; never send `10`.
- `roleplay_type`: one of `sales`, `customer_success`, `support`, `custom`.
- `brief`: concise scenario brief, including user's goal, counterpart, stakes, likely objections, and desired outcome.
- `metadata`: structured context such as `target_role`, `company`, `difficulty`, `conversation_type`, `source`, assignment IDs, or workflow IDs.
- `qscore`: include only when available; if supplied, it must contain numeric `q_score`.
Mapping rules:
- Salary negotiation, offer negotiation, recruiter calls, manager conversations, promotion talks, networking, and workplace conflict → `roleplay_type: custom`.
- Sales discovery, demos, objection handling, or closing → `sales`.
- Renewal, onboarding, expansion, or customer health conversations → `customer_success`.
- Troubleshooting, escalation, or service recovery → `support`.
- If duration is missing, choose `15` for most scenarios, `5` for quick drills, and `30` only when the user requests a deeper simulation.
## Conversation strategy
- Ask at most 1-2 clarifying questions if the counterpart, situation, or desired outcome is unclear.
- If enough context exists, create a preview rather than stalling.
- Summarize returned `session_id`, `scenario_id`, `status`, `needs_approval`, `opening_prompt`, `prompt_outline`, `scenario`, `qscore_context`, and `candidate_brief` when available.
- Do not fabricate live roleplay turns, recordings, scores, or reviews. If the service is unavailable, say so and offer a text-only rehearsal as a fallback.
- Keep feedback practical: better phrasing, likely pushback, confidence cues, and one next rehearsal step.

View File

@@ -1,31 +0,0 @@
---
id: sara
name: Sara
role: Interview Agent
service: interview-service
tools:
- start_interview_session
---
## Domain
Sara is the **Interview Agent**. She only handles job interview preparation and practice. Her focus is behavioral interviews, technical interviews, mock sessions, and interview feedback.
## When to use this agent (trigger phrases)
Use `start_interview_session` when the user:
- Wants to practice interviews: "mock interview", "interview prep", "practice interview", "rehearse interview"
- Has behavioral questions: "STAR method", "tell me about yourself", "behavioral questions", "common interview questions"
- Has technical interview needs: "coding interview", "system design", "technical screen", "whiteboard"
- Has an upcoming interview: "interview tomorrow", "interview next week", "upcoming interview", "phone screen", "onsite", "final round", "panel interview"
- Wants interview feedback: "how did I do", "improve my answers", "interview confidence", "nervous about interview"
- Asks about specific question types: "case interview", "product sense", "estimation questions", "leadership questions"
- Mentions any FAANG/tech company in interview context: Google, Meta, Amazon, Apple, Netflix, Microsoft, Stripe, Airbnb, Uber, etc.
## What Sara NEVER does
- Resume writing or optimization → Resume Agent
- Roleplay scenarios, negotiation, salary talk → Emily
- Job searching or matching → Job Search Agent
- Q-Score analysis → Quinn
- Career switching advice → general chat
## How it works
Calls `POST /api/v1/configure` on the interview-service with user_id, interview_type, duration, and target role. Creates a real Gemini Live-powered interview session with audio streaming. Returns a session_id that the user can open to start practicing.

962
bun.lock
View File

@@ -1,962 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "growqr-backend",
"dependencies": {
"@clerk/backend": "^1.21.0",
"@hono/node-server": "^1.13.7",
"dockerode": "^4.0.7",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.36.4",
"hono": "^4.6.14",
"pino": "^9.5.0",
"pino-pretty": "^13.0.0",
"postgres": "^3.4.5",
"rivetkit": "^2.2.1",
"zod": "^3.24.1",
},
"devDependencies": {
"@types/dockerode": "^3.3.32",
"@types/node": "^22.10.5",
"drizzle-kit": "^0.31.2",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
},
},
},
"packages": {
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
"@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@8.5.0", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q=="],
"@balena/dockerignore": ["@balena/dockerignore@1.0.2", "", {}, "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="],
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA=="],
"@cbor-extract/cbor-extract-darwin-x64": ["@cbor-extract/cbor-extract-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q=="],
"@cbor-extract/cbor-extract-linux-arm": ["@cbor-extract/cbor-extract-linux-arm@2.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ=="],
"@cbor-extract/cbor-extract-linux-arm64": ["@cbor-extract/cbor-extract-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg=="],
"@cbor-extract/cbor-extract-linux-x64": ["@cbor-extract/cbor-extract-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA=="],
"@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA=="],
"@clerk/backend": ["@clerk/backend@1.34.0", "", { "dependencies": { "@clerk/shared": "^3.9.5", "@clerk/types": "^4.59.3", "cookie": "1.0.2", "snakecase-keys": "8.0.1", "tslib": "2.8.1" }, "peerDependencies": { "svix": "^1.62.0" }, "optionalPeers": ["svix"] }, "sha512-9rZ8hQJVpX5KX2bEpiuVXfpjhojQCiqCWADJDdCI0PCeKxn58Ep0JPYiIcczg4VKUc3a7jve9vXylykG2XajLQ=="],
"@clerk/shared": ["@clerk/shared@3.47.5", "", { "dependencies": { "csstype": "3.1.3", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", "std-env": "^3.9.0", "swr": "2.3.4" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react-dom"] }, "sha512-rDVe73/VN2NZXhtrLRHshkUpQDrevAqDRxeXUl2M0IBEBkcl+VMHlV7fep53cVWo0b3gIqLk82pmmi+WoyF/xg=="],
"@clerk/types": ["@clerk/types@4.101.23", "", { "dependencies": { "@clerk/shared": "^3.47.5" } }, "sha512-t5ypYYDkT5TPaNIDjLnYk9GpkJgwNTBiS7h6FuUTjoySQtf7amNDS1A1eOu7NOcVpqiSeKg+0wzGxxcre00kMA=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="],
"@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="],
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
"@hono/node-ws": ["@hono/node-ws@1.3.1", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.11", "hono": "^4.6.0" } }, "sha512-vo/MwCnpJAVHBkGzWjCJ28wF45fYHAfbPZcH2rodZODHtch2GHA94KtMfusmVycTUtsLAsaNsHhtY6P8X3RQsA=="],
"@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="],
"@hono/zod-openapi": ["@hono/zod-openapi@1.4.0", "", { "dependencies": { "@asteasolutions/zod-to-openapi": "^8.5.0", "@hono/zod-validator": "^0.8.0", "openapi3-ts": "^4.5.0" }, "peerDependencies": { "hono": ">=4.10.0", "zod": "^4.0.0" } }, "sha512-AFchqR1N/NxfI4hUOSGI2/g8zLROxA1OE7Oh5JJFlTaGxhrdRyH+93gd0tIBpb0z8s9r8hUoNnaOBfHbdb4NMw=="],
"@hono/zod-validator": ["@hono/zod-validator@0.8.0", "", { "peerDependencies": { "hono": ">=4.10.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-5uS4S1/LKtZQYvD4BtpPUFkOv8d1wNxHHrChm26buMiEYc1FrHWvDUaKVBwkiVtvSExHSpLGDvcnpI2Copyj9w=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.2", "", {}, "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
"@rivet-dev/agent-os-core": ["@rivet-dev/agent-os-core@0.1.1", "", { "dependencies": { "@rivet-dev/agent-os-posix": "0.1.0", "@rivet-dev/agent-os-python": "0.1.0", "@secure-exec/core": "^0.2.1", "@secure-exec/nodejs": "^0.2.1", "@secure-exec/v8": "^0.2.1", "croner": "^10.0.1", "long-timeout": "^0.1.1", "secure-exec": "^0.2.1" } }, "sha512-Uw5jr+gUXDY7TDUFqlypjGe1BD2KL9kTHPNo/f1iNS1R+l9IWuvT+FF/MXsLOEkc3fB06OPVu2ZvUuNwp9MpLQ=="],
"@rivet-dev/agent-os-posix": ["@rivet-dev/agent-os-posix@0.1.0", "", { "dependencies": { "@secure-exec/core": "^0.2.1" } }, "sha512-NIrI7cCb9x6jdmzRPPx7dAeXoTF/YCqf93ydEzYFA2zshIelLW9Rp5KtgP/2hM6fP0ly4+vVnOeavxJW0wYtcA=="],
"@rivet-dev/agent-os-python": ["@rivet-dev/agent-os-python@0.1.0", "", { "dependencies": { "@secure-exec/core": "^0.2.1", "pyodide": "^0.28.3" }, "peerDependencies": { "pyodide": ">=0.28.0" } }, "sha512-1tH1beMf1ceSpicQKwN/a6h+NmJrmfuT4GStiRDZmvN/UWfZhkxuy7HR5VPTQpE/feUZJ01FdtBS3Em/Qoxb2Q=="],
"@rivetkit/bare-ts": ["@rivetkit/bare-ts@0.6.2", "", {}, "sha512-3qndQUQXLdwafMEqfhz24hUtDPcsf1Bu3q52Kb8MqeH8JUh3h6R4HYW3ZJXiQsLcyYyFM68PuIwlLRlg1xDEpg=="],
"@rivetkit/engine-runner": ["@rivetkit/engine-runner@2.2.1", "", { "dependencies": { "@rivetkit/engine-runner-protocol": "2.2.1", "@rivetkit/virtual-websocket": "2.0.33", "pino": "^9.9.5", "uuid": "^12.0.0", "ws": "^8.18.3" } }, "sha512-6+Q3iohT23JlwVxY3xd3fhCnvCmPTO/Ua7IeVE2vJAtfhK9yUOeKSf8C8W/4CVfaHTgkWksuy0WuhqDX9B3ERg=="],
"@rivetkit/engine-runner-protocol": ["@rivetkit/engine-runner-protocol@2.2.1", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2" } }, "sha512-H0TprnHI3QOM6ccT1FEqCjazlkFYknQuprisxAfIgSXtckdF6boI5UdA4hjYqlTiuBAzMxZ1zewyD84r6hMSHg=="],
"@rivetkit/fast-json-patch": ["@rivetkit/fast-json-patch@3.1.2", "", {}, "sha512-CtA50xgsSSzICQduF/NDShPRzvucnNvsW/lQO0WgMTT1XAj9Lfae4pm7r3llFwilgG+9iq76Hv1LUqNy72v6yw=="],
"@rivetkit/on-change": ["@rivetkit/on-change@6.0.2-rc.1", "", {}, "sha512-5RC9Ze/wTKqSlJvopdCgr+EfyV93+iiH8Thog0QXrl8PT1unuBNw/jadXNMtwgAxrIaCJL+JLaHQH9w7rqpMDw=="],
"@rivetkit/sqlite": ["@rivetkit/sqlite@0.1.1", "", {}, "sha512-NE7ZBy/hQhOrWzMZFjkHX9SoXxf+ILcDvVV+mNbUYPgiy/fsDzlXdK0+JDTGnko5f4Xl6/KVCoCozz9gkwkq8A=="],
"@rivetkit/sqlite-vfs": ["@rivetkit/sqlite-vfs@2.2.1", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "@rivetkit/sqlite": "^0.1.1", "vbare": "^0.0.4" } }, "sha512-gva6rk2YMhhgMJGezGNGDiX/jLM832e7jfg26RugPDWbGCWeRANKbTDRoslKJJeuzI7a1bJyp04L26mH2bDYjw=="],
"@rivetkit/traces": ["@rivetkit/traces@2.2.1", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "vbare": "^0.0.4" } }, "sha512-WI7VcnxRH7hvHFT3fEgtqTdteKB83uEprazphEKEHZMyOkExw1TnaNdT58vti8mdrgbV9b//ylDUl4yUBbwp/w=="],
"@rivetkit/virtual-websocket": ["@rivetkit/virtual-websocket@2.0.33", "", {}, "sha512-sMoHZgBy9WDW76pv+ML3LPgf7TWk5vXdu3ZpPO20j6n+rB3fLacnnmzjt5xD6tZcJ/x5qINyEywGgcxA7MTMuQ=="],
"@rivetkit/workflow-engine": ["@rivetkit/workflow-engine@2.2.1", "", { "dependencies": { "@rivetkit/bare-ts": "^0.6.2", "cbor-x": "^1.6.0", "fdb-tuple": "^1.0.0", "pino": "^9.6.0", "vbare": "^0.0.4" } }, "sha512-EyWRKpPFPjkYl10hgMGYkjPWUkOwr/Qg10aBRz6EAseL+zwMH7K2jCLkJU4xsVfPCYcso2Euvir5+4dvoaI3yA=="],
"@sandbox-agent/cli": ["@sandbox-agent/cli@0.4.2", "", { "dependencies": { "@sandbox-agent/cli-shared": "0.4.2" }, "optionalDependencies": { "@sandbox-agent/cli-darwin-arm64": "0.4.2", "@sandbox-agent/cli-darwin-x64": "0.4.2", "@sandbox-agent/cli-linux-arm64": "0.4.2", "@sandbox-agent/cli-linux-x64": "0.4.2", "@sandbox-agent/cli-win32-x64": "0.4.2" }, "bin": { "sandbox-agent": "bin/sandbox-agent" } }, "sha512-trO//ypJBSt5xkewuol9LOykvDgHwUXq8R+yQVS+0CmpN3lYUtewHkb+At9RVGRhDMmJZY2oasaXDnhfurQ33w=="],
"@sandbox-agent/cli-darwin-arm64": ["@sandbox-agent/cli-darwin-arm64@0.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+L1O8SI7k/LLhyB4dG0ghmz1cJHa0WtVjuRTrEE2gw/5EbGLWopPBsCVCmQ7snrQ4fPwtaiZDhfExcEj1VI7aw=="],
"@sandbox-agent/cli-darwin-x64": ["@sandbox-agent/cli-darwin-x64@0.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-dDg/EwWsdgVVbJiiCX1scSNRRA48u77SsC7Tuqrfzx4fIJMLuLiIcmEtXQyCBWysSyQNV2Cr+PYXXQfCb3xg8g=="],
"@sandbox-agent/cli-linux-arm64": ["@sandbox-agent/cli-linux-arm64@0.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-TGmTUexMoubmWQyTeaOJu0rDVl2h0Ifh1pZ0ceZy7u/6Eoqs2n46CbfQtasUxZJf10uxPgRyzEDhcdDrTYVQUA=="],
"@sandbox-agent/cli-linux-x64": ["@sandbox-agent/cli-linux-x64@0.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-H9Rbqq0DRkCHvakzefJUDrDa2y+vJjlYd5/tefzKbQ34locE13TGNygRLxdEVXpBECjK9wVdBwTVEphQNsOcjw=="],
"@sandbox-agent/cli-shared": ["@sandbox-agent/cli-shared@0.4.2", "", {}, "sha512-sjZXRkKeFXCSKR6hHzF2Af8CCRO3F3WFwVQJ22+sLTXJ2xskV8lkUE4egknQU9B5BC1Zumts/YiNCFQWG85awQ=="],
"@sandbox-agent/cli-win32-x64": ["@sandbox-agent/cli-win32-x64@0.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-lZNfHWPwQe/VH51Yvrl/ATCUvBZ3a+c8mwovojhQcmZlv4QuUQPkuvxhPqHRh9AyBx78L5J/ha46es2doa34nQ=="],
"@secure-exec/core": ["@secure-exec/core@0.2.1", "", { "dependencies": { "better-sqlite3": "^12.8.0" } }, "sha512-HsnUv6gClpMA1BBRmX86j30TKTZtgJC/fO1tVavr7IpM2zNKbHU8LgSlBd7mv2SNy02ImTmU/GnQ3aYB4NSbEg=="],
"@secure-exec/nodejs": ["@secure-exec/nodejs@0.2.1", "", { "dependencies": { "@secure-exec/core": "0.2.1", "@secure-exec/v8": "0.2.1", "cbor-x": "^1.6.4", "cjs-module-lexer": "^2.1.0", "es-module-lexer": "^1.7.0", "esbuild": "^0.27.1", "node-stdlib-browser": "^1.3.1", "web-streams-polyfill": "^4.2.0" } }, "sha512-UJMJqVFxexlHJV0Q9nWURvrz6GElj8673DDOOFln6FHR6JS+9SaSU3eISrN158DuNC3SFi4rgjb/scKnK4YOYQ=="],
"@secure-exec/v8": ["@secure-exec/v8@0.2.1", "", { "dependencies": { "cbor-x": "^1.6.4" }, "optionalDependencies": { "@secure-exec/v8-darwin-arm64": "0.2.1", "@secure-exec/v8-darwin-x64": "0.2.1", "@secure-exec/v8-linux-arm64-gnu": "0.2.1", "@secure-exec/v8-linux-x64-gnu": "0.2.1" } }, "sha512-ye/seCqzvyMGnvyP+AO7RkVMR/lE3x9m0D2PfmiAXA457R78ZmOFmZ6v+JlJG2vv3LM30KsSXTUhwpG+Teh0hw=="],
"@secure-exec/v8-darwin-arm64": ["@secure-exec/v8-darwin-arm64@0.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gEWhMHzUpLwzuBNAD0lVkZXE8wFlWMLp4IOZ+56FYwOW/C+m07cYxuW4TjHyPqZ+vPm3IkoaMqqH5yT9VhjX/Q=="],
"@secure-exec/v8-darwin-x64": ["@secure-exec/v8-darwin-x64@0.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-H2Z5K+Cq+fn/kxjGvhJzepnNFWG6qNdyhZybVWGr5bAAZoSz/Qkad4WnXcurWU+880tKDtnf19LHBXrg7zewNQ=="],
"@secure-exec/v8-linux-arm64-gnu": ["@secure-exec/v8-linux-arm64-gnu@0.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-14subGhVV/gW35mYYm7Gv1Keeex7PxIgQfoKji/JH7wYyDuarP6kgaES0nJw+JXVkxEVud52c+kbcIjIggqCEw=="],
"@secure-exec/v8-linux-x64-gnu": ["@secure-exec/v8-linux-x64-gnu@0.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Az4s+vUf+78vWtsC7rTn/jQc6WKJafAdt2YpEjB4Gnu+sX+FFTIst1hRV4gJonbRyJdy6SW+OQ6DZatmwczorQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"@types/docker-modem": ["@types/docker-modem@3.0.6", "", { "dependencies": { "@types/node": "*", "@types/ssh2": "*" } }, "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg=="],
"@types/dockerode": ["@types/dockerode@3.3.47", "", { "dependencies": { "@types/docker-modem": "*", "@types/node": "*", "@types/ssh2": "*" } }, "sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw=="],
"@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="],
"@types/retry": ["@types/retry@0.12.2", "", {}, "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow=="],
"@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
"acp-http-client": ["acp-http-client@0.4.2", "", { "dependencies": { "@agentclientprotocol/sdk": "^0.16.1" } }, "sha512-3wtPieF08YIU4vNXaoL5up/1D0if4i9IX3Ye5q/bwbcwg1BKsazIK/VNNfvN4ldbPjWul69IqIOpGRS3I0qo3Q=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
"asn1.js": ["asn1.js@4.10.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw=="],
"assert": ["assert@2.1.0", "", { "dependencies": { "call-bind": "^1.0.2", "is-nan": "^1.3.2", "object-is": "^1.1.5", "object.assign": "^4.1.4", "util": "^0.12.5" } }, "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
"better-sqlite3": ["better-sqlite3@12.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ=="],
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"bn.js": ["bn.js@5.2.3", "", {}, "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w=="],
"brorand": ["brorand@1.1.0", "", {}, "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w=="],
"browser-resolve": ["browser-resolve@2.0.0", "", { "dependencies": { "resolve": "^1.17.0" } }, "sha512-7sWsQlYL2rGLy2IWm8WL8DCTJvYLc/qlOnsakDac87SOoCd16WLsaAMdCiAqsTNHIe+SXfaqyxyo6THoWqs8WQ=="],
"browserify-aes": ["browserify-aes@1.2.0", "", { "dependencies": { "buffer-xor": "^1.0.3", "cipher-base": "^1.0.0", "create-hash": "^1.1.0", "evp_bytestokey": "^1.0.3", "inherits": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA=="],
"browserify-cipher": ["browserify-cipher@1.0.1", "", { "dependencies": { "browserify-aes": "^1.0.4", "browserify-des": "^1.0.0", "evp_bytestokey": "^1.0.0" } }, "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w=="],
"browserify-des": ["browserify-des@1.0.2", "", { "dependencies": { "cipher-base": "^1.0.1", "des.js": "^1.0.0", "inherits": "^2.0.1", "safe-buffer": "^5.1.2" } }, "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A=="],
"browserify-rsa": ["browserify-rsa@4.1.1", "", { "dependencies": { "bn.js": "^5.2.1", "randombytes": "^2.1.0", "safe-buffer": "^5.2.1" } }, "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ=="],
"browserify-sign": ["browserify-sign@4.2.5", "", { "dependencies": { "bn.js": "^5.2.2", "browserify-rsa": "^4.1.1", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", "elliptic": "^6.6.1", "inherits": "^2.0.4", "parse-asn1": "^5.1.9", "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1" } }, "sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw=="],
"browserify-zlib": ["browserify-zlib@0.2.0", "", { "dependencies": { "pako": "~1.0.5" } }, "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"buffer-xor": ["buffer-xor@1.0.3", "", {}, "sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ=="],
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
"builtin-status-codes": ["builtin-status-codes@3.0.0", "", {}, "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ=="],
"call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"cbor-extract": ["cbor-extract@2.2.2", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", "@cbor-extract/cbor-extract-linux-arm": "2.2.2", "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", "@cbor-extract/cbor-extract-linux-x64": "2.2.2", "@cbor-extract/cbor-extract-win32-x64": "2.2.2" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng=="],
"cbor-x": ["cbor-x@1.6.4", "", { "optionalDependencies": { "cbor-extract": "^2.2.2" } }, "sha512-UGKHjp6RHC6QuZ2yy5LCKm7MojM4716DwoSaqwQpaH4DvZvbBTGcoDNTiG9Y2lByXZYFEs9WRkS5tLl96IrF1Q=="],
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
"cipher-base": ["cipher-base@1.0.7", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.2" } }, "sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA=="],
"cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
"console-browserify": ["console-browserify@1.2.0", "", {}, "sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA=="],
"constants-browserify": ["constants-browserify@1.0.0", "", {}, "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ=="],
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
"create-ecdh": ["create-ecdh@4.0.4", "", { "dependencies": { "bn.js": "^4.1.0", "elliptic": "^6.5.3" } }, "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A=="],
"create-hash": ["create-hash@1.2.0", "", { "dependencies": { "cipher-base": "^1.0.1", "inherits": "^2.0.1", "md5.js": "^1.3.4", "ripemd160": "^2.0.1", "sha.js": "^2.4.0" } }, "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg=="],
"create-hmac": ["create-hmac@1.1.7", "", { "dependencies": { "cipher-base": "^1.0.3", "create-hash": "^1.1.0", "inherits": "^2.0.1", "ripemd160": "^2.0.0", "safe-buffer": "^5.0.1", "sha.js": "^2.4.8" } }, "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg=="],
"create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="],
"croner": ["croner@10.0.1", "", {}, "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g=="],
"crypto-browserify": ["crypto-browserify@3.12.1", "", { "dependencies": { "browserify-cipher": "^1.0.1", "browserify-sign": "^4.2.3", "create-ecdh": "^4.0.4", "create-hash": "^1.2.0", "create-hmac": "^1.1.7", "diffie-hellman": "^5.0.3", "hash-base": "~3.0.4", "inherits": "^2.0.4", "pbkdf2": "^3.1.2", "public-encrypt": "^4.0.3", "randombytes": "^2.1.0", "randomfill": "^1.0.4" } }, "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"des.js": ["des.js@1.1.0", "", { "dependencies": { "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"diffie-hellman": ["diffie-hellman@5.0.3", "", { "dependencies": { "bn.js": "^4.1.0", "miller-rabin": "^4.0.0", "randombytes": "^2.0.0" } }, "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg=="],
"docker-modem": ["docker-modem@5.0.7", "", { "dependencies": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", "ssh2": "^1.15.0" } }, "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA=="],
"dockerode": ["dockerode@4.0.12", "", { "dependencies": { "@balena/dockerignore": "^1.0.2", "@grpc/grpc-js": "^1.11.1", "@grpc/proto-loader": "^0.7.13", "docker-modem": "^5.0.7", "protobufjs": "^7.3.2", "tar-fs": "^2.1.4", "uuid": "^10.0.0" } }, "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw=="],
"domain-browser": ["domain-browser@4.22.0", "", {}, "sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw=="],
"dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": "bin.cjs" }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="],
"drizzle-orm": ["drizzle-orm@0.36.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=3", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": ">=18", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/react", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "sql.js", "sqlite3"] }, "sha512-1OZY3PXD7BR00Gl61UUOFihslDldfH4NFRH2MbP54Yxi0G/PKn4HfO65JYZ7c16DeP3SpM3Aw+VXVG9j6CRSXA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"elliptic": ["elliptic@6.6.1", "", { "dependencies": { "bn.js": "^4.11.9", "brorand": "^1.1.0", "hash.js": "^1.0.0", "hmac-drbg": "^1.0.1", "inherits": "^2.0.4", "minimalistic-assert": "^1.0.1", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": "bin/esbuild" }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"evp_bytestokey": ["evp_bytestokey@1.0.3", "", { "dependencies": { "md5.js": "^1.3.4", "safe-buffer": "^5.1.1" } }, "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA=="],
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
"fast-copy": ["fast-copy@4.0.3", "", {}, "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw=="],
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
"fdb-tuple": ["fdb-tuple@1.0.0", "", {}, "sha512-8jSvKPCYCgTpi9Pt87qlfTk6griyMx4Gk3Xv31Dp72Qp8b6XgIyFsMm8KzPmFJ9iJ8K4pGvRxvOS8D0XGnrkjw=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-port": ["get-port@7.2.0", "", {}, "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
"glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hash-base": ["hash-base@3.0.5", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1" } }, "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg=="],
"hash.js": ["hash.js@1.1.7", "", { "dependencies": { "inherits": "^2.0.3", "minimalistic-assert": "^1.0.1" } }, "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA=="],
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
"hmac-drbg": ["hmac-drbg@1.0.1", "", { "dependencies": { "hash.js": "^1.0.3", "minimalistic-assert": "^1.0.0", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg=="],
"hono": ["hono@4.12.19", "", {}, "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ=="],
"https-browserify": ["https-browserify@1.0.0", "", {}, "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
"invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="],
"is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="],
"is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
"is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
"is-nan": ["is-nan@1.3.2", "", { "dependencies": { "call-bind": "^1.0.0", "define-properties": "^1.1.3" } }, "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w=="],
"is-network-error": ["is-network-error@1.3.2", "", {}, "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
"is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"isomorphic-timers-promises": ["isomorphic-timers-promises@1.0.1", "", {}, "sha512-u4sej9B1LPSxTGKB/HiuzvEQnXH0ECYkSVQU39koSwmFAxhlEAFl9RdTvLv4TOTQUgBS5O3O5fwUxk6byBZ+IQ=="],
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
"js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"long-timeout": ["long-timeout@0.1.1", "", {}, "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="],
"map-obj": ["map-obj@4.3.0", "", {}, "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"md5.js": ["md5.js@1.3.5", "", { "dependencies": { "hash-base": "^3.0.0", "inherits": "^2.0.1", "safe-buffer": "^5.1.2" } }, "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg=="],
"miller-rabin": ["miller-rabin@4.0.1", "", { "dependencies": { "bn.js": "^4.0.0", "brorand": "^1.0.1" }, "bin": "bin/miller-rabin" }, "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
"minimalistic-crypto-utils": ["minimalistic-crypto-utils@1.0.1", "", {}, "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nan": ["nan@2.27.0", "", {}, "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ=="],
"nanoevents": ["nanoevents@9.1.0", "", {}, "sha512-Jd0fILWG44a9luj8v5kED4WI+zfkkgwKyRQKItTtlPfEsh7Lznfi1kr8/iZ+XAIss4Qq5GqRB0qtWbaz9ceO/A=="],
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
"no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="],
"node-abi": ["node-abi@3.92.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ=="],
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="],
"node-stdlib-browser": ["node-stdlib-browser@1.3.1", "", { "dependencies": { "assert": "^2.0.0", "browser-resolve": "^2.0.0", "browserify-zlib": "^0.2.0", "buffer": "^5.7.1", "console-browserify": "^1.1.0", "constants-browserify": "^1.0.0", "create-require": "^1.1.1", "crypto-browserify": "^3.12.1", "domain-browser": "4.22.0", "events": "^3.0.0", "https-browserify": "^1.0.0", "isomorphic-timers-promises": "^1.0.1", "os-browserify": "^0.3.0", "path-browserify": "^1.0.1", "pkg-dir": "^5.0.0", "process": "^0.11.10", "punycode": "^1.4.1", "querystring-es3": "^0.2.1", "readable-stream": "^3.6.0", "stream-browserify": "^3.0.0", "stream-http": "^3.2.0", "string_decoder": "^1.0.0", "timers-browserify": "^2.0.4", "tty-browserify": "0.0.1", "url": "^0.11.4", "util": "^0.12.4", "vm-browserify": "^1.0.1" } }, "sha512-X75ZN8DCLftGM5iKwoYLA3rjnrAEs97MkzvSd4q2746Tgpg8b8XWiBGiBG4ZpgcAqBgtgPHTiAc8ZMCvZuikDw=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"object-is": ["object-is@1.1.6", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1" } }, "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q=="],
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
"object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="],
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"openapi3-ts": ["openapi3-ts@4.5.0", "", { "dependencies": { "yaml": "^2.8.0" } }, "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ=="],
"os-browserify": ["os-browserify@0.3.0", "", {}, "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"p-retry": ["p-retry@6.2.1", "", { "dependencies": { "@types/retry": "0.12.2", "is-network-error": "^1.0.0", "retry": "^0.13.1" } }, "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"parse-asn1": ["parse-asn1@5.1.9", "", { "dependencies": { "asn1.js": "^4.10.1", "browserify-aes": "^1.2.0", "evp_bytestokey": "^1.0.3", "pbkdf2": "^3.1.5", "safe-buffer": "^5.2.1" } }, "sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"pbkdf2": ["pbkdf2@3.1.5", "", { "dependencies": { "create-hash": "^1.2.0", "create-hmac": "^1.1.7", "ripemd160": "^2.0.3", "safe-buffer": "^5.2.1", "sha.js": "^2.4.12", "to-buffer": "^1.2.1" } }, "sha512-Q3CG/cYvCO1ye4QKkuH7EXxs3VC/rI1/trd+qX2+PolbaKG0H+bgcZzrTt96mMyRtejk+JMCiLUn3y29W8qmFQ=="],
"pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": "bin.js" }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="],
"pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="],
"pino-pretty": ["pino-pretty@13.1.3", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.0", "secure-json-parse": "^4.0.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^5.0.2" }, "bin": "bin.js" }, "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg=="],
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
"pkg-dir": ["pkg-dir@5.0.0", "", { "dependencies": { "find-up": "^5.0.0" } }, "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": "bin.js" }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
"protobufjs": ["protobufjs@7.5.9", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA=="],
"public-encrypt": ["public-encrypt@4.0.3", "", { "dependencies": { "bn.js": "^4.1.0", "browserify-rsa": "^4.0.0", "create-hash": "^1.1.0", "parse-asn1": "^5.0.0", "randombytes": "^2.0.1", "safe-buffer": "^5.1.2" } }, "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q=="],
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"punycode": ["punycode@1.4.1", "", {}, "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="],
"pyodide": ["pyodide@0.28.3", "", { "dependencies": { "ws": "^8.5.0" } }, "sha512-rtCsyTU55oNGpLzSVuAd55ZvruJDEX8o6keSdWKN9jPeBVSNlynaKFG7eRqkiIgU7i2M6HEgYtm0atCEQX3u4A=="],
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
"querystring-es3": ["querystring-es3@0.2.1", "", {}, "sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA=="],
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
"randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="],
"randomfill": ["randomfill@1.0.4", "", { "dependencies": { "randombytes": "^2.0.5", "safe-buffer": "^5.1.0" } }, "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw=="],
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": "cli.js" }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
"ripemd160": ["ripemd160@2.0.3", "", { "dependencies": { "hash-base": "^3.1.2", "inherits": "^2.0.4" } }, "sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA=="],
"rivetkit": ["rivetkit@2.2.1", "", { "dependencies": { "@hono/node-server": "^1.18.2", "@hono/node-ws": "^1.1.1", "@hono/standard-validator": "^0.1.3", "@hono/zod-openapi": "^1.1.5", "@rivet-dev/agent-os-core": "^0.1.1", "@rivetkit/bare-ts": "^0.6.2", "@rivetkit/engine-runner": "2.2.1", "@rivetkit/fast-json-patch": "^3.1.2", "@rivetkit/on-change": "^6.0.2-rc.1", "@rivetkit/sqlite": "^0.1.1", "@rivetkit/sqlite-vfs": "2.2.1", "@rivetkit/traces": "2.2.1", "@rivetkit/virtual-websocket": "2.0.33", "@rivetkit/workflow-engine": "2.2.1", "cbor-x": "^1.6.0", "get-port": "^7.1.0", "hono": "^4.7.0", "invariant": "^2.2.4", "nanoevents": "^9.1.0", "p-retry": "^6.2.1", "pino": "^9.5.0", "sandbox-agent": "^0.4.2", "tar": "^7.5.0", "uuid": "^12.0.0", "vbare": "^0.0.4", "zod": "^4.1.0" }, "peerDependencies": { "@daytonaio/sdk": "^0.150.0", "@e2b/code-interpreter": "^2.3.3", "@fly/sprites": ">=0.0.1", "@vercel/sandbox": ">=0.1.0", "computesdk": ">=0.1.0", "dockerode": "^4.0.9", "drizzle-kit": "^0.31.2", "eventsource": "^4.0.0", "modal": ">=0.1.0", "ws": "^8.0.0" }, "optionalPeers": ["@daytonaio/sdk", "@e2b/code-interpreter", "@fly/sprites", "@vercel/sandbox", "computesdk", "eventsource", "modal"] }, "sha512-Na4ED0x4iaS41QlMcSgV5xM50mAZALVFV8At3EgkwbtP8FtzddjBHH5kS/rSu2hqs++iRMT8OaKVozetbHYK+Q=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sandbox-agent": ["sandbox-agent@0.4.2", "", { "dependencies": { "@sandbox-agent/cli-shared": "0.4.2", "acp-http-client": "0.4.2" }, "optionalDependencies": { "@sandbox-agent/cli": "0.4.2" }, "peerDependencies": { "@cloudflare/sandbox": ">=0.1.0", "@daytonaio/sdk": ">=0.12.0", "@e2b/code-interpreter": ">=1.0.0", "@fly/sprites": ">=0.0.1", "@vercel/sandbox": ">=0.1.0", "computesdk": ">=0.1.0", "dockerode": ">=4.0.0", "get-port": ">=7.0.0", "modal": ">=0.1.0" }, "optionalPeers": ["@cloudflare/sandbox", "@daytonaio/sdk", "@e2b/code-interpreter", "@fly/sprites", "@vercel/sandbox", "computesdk", "modal"] }, "sha512-fH6WDQEaIrgiu93LxZcy+4Dx+t+/cslu+hzXImDyUlsaL6jV2jIv4fdxELkALlo7uzyEDVK9lmqs9qy65RHwBQ=="],
"secure-exec": ["secure-exec@0.2.1", "", { "dependencies": { "@secure-exec/core": "0.2.1", "@secure-exec/nodejs": "0.2.1" } }, "sha512-oaQDzTPDSCOckYC8G0PimIqzEVxY6sYEvcx0fMGsRR/Wl4wkFVHaZgQ3kc2DHWysV6WHWt5g1AXc/6seafO2XQ=="],
"secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
"semver": ["semver@7.8.0", "", { "bin": "bin/semver.js" }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
"sha.js": ["sha.js@2.4.12", "", { "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.0" }, "bin": "bin.js" }, "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
"snake-case": ["snake-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg=="],
"snakecase-keys": ["snakecase-keys@8.0.1", "", { "dependencies": { "map-obj": "^4.1.0", "snake-case": "^3.0.4", "type-fest": "^4.15.0" } }, "sha512-Sj51kE1zC7zh6TDlNNz0/Jn1n5HiHdoQErxO8jLtnyrkJW/M5PrI7x05uDgY3BO7OUQYKCvmeMurW6BPUdwEOw=="],
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"split-ca": ["split-ca@1.0.1", "", {}, "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"stream-browserify": ["stream-browserify@3.0.0", "", { "dependencies": { "inherits": "~2.0.4", "readable-stream": "^3.5.0" } }, "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA=="],
"stream-http": ["stream-http@3.2.0", "", { "dependencies": { "builtin-status-codes": "^3.0.0", "inherits": "^2.0.4", "readable-stream": "^3.6.0", "xtend": "^4.0.2" } }, "sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"swr": ["swr@2.3.4", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg=="],
"tar": ["tar@7.5.15", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ=="],
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
"thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
"timers-browserify": ["timers-browserify@2.0.12", "", { "dependencies": { "setimmediate": "^1.0.4" } }, "sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ=="],
"to-buffer": ["to-buffer@1.2.2", "", { "dependencies": { "isarray": "^2.0.5", "safe-buffer": "^5.2.1", "typed-array-buffer": "^1.0.3" } }, "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsx": ["tsx@4.22.1", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg=="],
"tty-browserify": ["tty-browserify@0.0.1", "", {}, "sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw=="],
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"url": ["url@0.11.4", "", { "dependencies": { "punycode": "^1.4.1", "qs": "^6.12.3" } }, "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"uuid": ["uuid@10.0.0", "", { "bin": "dist/bin/uuid" }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
"vbare": ["vbare@0.0.4", "", {}, "sha512-QsxSVw76NqYUWYPVcQmOnQPX8buIVjgn+yqldTHlWISulBTB9TJ9rnzZceDu+GZmycOtzsmuPbPN1YNxvK12fg=="],
"vm-browserify": ["vm-browserify@1.1.2", "", {}, "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ=="],
"web-streams-polyfill": ["web-streams-polyfill@4.3.0", "", {}, "sha512-/Gnggvj9oSrEvJbDyyPtAnxBt5fGQM2iWOKQNu7ie1OxDgK40iZpyV3TKaRiEzVj1oA1UxKnEy9XPXh6PW3eVw=="],
"which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"yaml": ["yaml@2.9.0", "", { "bin": "bin.mjs" }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": "bin/esbuild" }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
"@rivetkit/engine-runner/uuid": ["uuid@12.0.1", "", { "bin": "dist/bin/uuid" }, "sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw=="],
"@secure-exec/nodejs/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": "bin/esbuild" }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
"@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"asn1.js/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
"browserify-sign/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"create-ecdh/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
"diffie-hellman/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
"elliptic/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
"miller-rabin/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
"pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
"public-encrypt/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
"ripemd160/hash-base": ["hash-base@3.1.2", "", { "dependencies": { "inherits": "^2.0.4", "readable-stream": "^2.3.8", "safe-buffer": "^5.2.1", "to-buffer": "^1.2.1" } }, "sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg=="],
"rivetkit/uuid": ["uuid@12.0.1", "", { "bin": "dist/bin/uuid" }, "sha512-9obBF8sMIHJWNQaO6IGOG8giGa/jUpKX34bz6o4whVs8M0WAvhID2tNxYp6A2XEBJPuZSX8wsS/6TEKfIDc+nw=="],
"rivetkit/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"tar/chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"to-buffer/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": "bin/esbuild" }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@secure-exec/nodejs/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
"@secure-exec/nodejs/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
"@secure-exec/nodejs/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="],
"@secure-exec/nodejs/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="],
"@secure-exec/nodejs/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="],
"@secure-exec/nodejs/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="],
"@secure-exec/nodejs/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="],
"@secure-exec/nodejs/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="],
"@secure-exec/nodejs/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="],
"@secure-exec/nodejs/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="],
"@secure-exec/nodejs/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="],
"@secure-exec/nodejs/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="],
"@secure-exec/nodejs/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="],
"@secure-exec/nodejs/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="],
"@secure-exec/nodejs/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="],
"@secure-exec/nodejs/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="],
"@secure-exec/nodejs/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="],
"@secure-exec/nodejs/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="],
"@secure-exec/nodejs/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="],
"@secure-exec/nodejs/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="],
"@secure-exec/nodejs/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="],
"@secure-exec/nodejs/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="],
"@secure-exec/nodejs/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="],
"@secure-exec/nodejs/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="],
"@secure-exec/nodejs/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
"@secure-exec/nodejs/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
"@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"browserify-sign/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"browserify-sign/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"ripemd160/hash-base/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="],
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="],
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="],
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="],
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="],
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="],
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="],
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="],
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="],
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="],
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="],
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="],
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="],
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="],
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="],
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="],
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="],
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="],
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="],
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="],
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="],
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="],
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="],
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="],
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="],
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="],
"browserify-sign/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"ripemd160/hash-base/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"ripemd160/hash-base/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"ripemd160/hash-base/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"ripemd160/hash-base/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
}
}

View File

@@ -28,7 +28,7 @@ services:
environment:
USER_UID: "1000"
USER_GID: "1000"
GITEA__server__ROOT_URL: http://localhost:3001
GITEA__server__ROOT_URL: ${GITEA_ROOT_URL:-http://localhost:3001}
GITEA__server__SSH_PORT: "2222"
GITEA__security__INSTALL_LOCK: "true"
GITEA__service__DISABLE_REGISTRATION: "true"
@@ -80,15 +80,17 @@ services:
NODE_ENV: ${NODE_ENV:-production}
DATABASE_URL: postgres://${POSTGRES_USER:-growqr}:${POSTGRES_PASSWORD:-growqr}@postgres:5432/${POSTGRES_DB:-growqr}
# Central Gitea (shared org-wide instance)
GITEA_URL: http://gitea:3000
# Internal is backend -> compose service. Public is Git remote URL used by OpenCode/spawned containers.
GITEA_INTERNAL_URL: http://gitea:3000
GITEA_PUBLIC_URL: ${GITEA_PUBLIC_URL:-http://host.docker.internal: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}
OPENCODE_IMAGE_VERSION: ${OPENCODE_IMAGE_VERSION:-dev}
MIGRATION_VERSION: ${MIGRATION_VERSION:-1}
PROMPT_VERSION: ${PROMPT_VERSION:-1}
PROMPT_VERSION: ${PROMPT_VERSION:-4}
# Rivet
RIVET_ENDPOINT: http://default:${RIVET_ADMIN_TOKEN:-dev-admin-token}@rivet-engine:6420
RIVET_CLIENT_ENDPOINT: ${RIVET_CLIENT_ENDPOINT:-http://127.0.0.1:4000/api/rivet}

View File

@@ -15,6 +15,20 @@ ln -sfn "$GROWQR_HOME/prompts" /root/.config/opencode/prompts
ln -sfn "$GROWQR_HOME/agents" /root/.local/share/opencode/agents
ln -sfn "$GROWQR_HOME/prompts" /root/.local/share/opencode/prompts
# OpenCode runs with /workspace as the project root. GrowQR prompt packs live in
# /opt/growqr, so allow read/glob/grep access there without interactive approval;
# otherwise backend API calls can hang waiting on an approval prompt.
cat > /root/.config/opencode/opencode.jsonc <<EOF
{
"\$schema": "https://opencode.ai/config.json",
"permission": {
"external_directory": {
"$GROWQR_HOME/**": "allow"
}
}
}
EOF
# Seed an empty mounted workspace with the GrowQR Git-backed memory shape. The
# backend later clones the user's central Gitea repo over this workspace when it
# provisions the user stack. This only runs for truly empty workspaces.

84
docs/backend-dead-code.md Normal file
View File

@@ -0,0 +1,84 @@
# Backend Dead Code Inventory
PRM-46 inventory pass for `growqr-backend`.
No source code was deleted in this pass. Static search and manual inspection were used. Typecheck was run successfully with `pnpm typecheck`.
## Summary
The codebase is mostly wired, but it contains several compatibility, demo, and partially superseded paths. The main cleanup risk is accidentally removing code still used by the frontend's older workflow screens or by demo environments.
## Candidates
| Priority | Candidate | Recommendation | Evidence |
| --- | --- | --- | --- |
| High | `src/actors/product-service-actors.ts` | Keep for now; consider deleting only after confirming no Rivet clients call these actors. | Actors are registered in `src/actors/registry.ts`, but local code routes service calls through `src/routes/services.ts` and `src/services/product-service-clients.ts` directly. No local `getOrCreate` references for `interviewServiceActor`, `roleplayServiceActor`, or `resumeServiceActor` were found. |
| High | Legacy `/workflows/job-application*` route aliases in `src/routes/workflows.ts` and large portions of `src/actors/user-actor.ts` workflow state | Keep until frontend migration is verified; likely cleanup after DB-backed workflow runs fully replace it. | `job-application` aliases call `userActor`; newer `/workflow-runs` path uses `workflowRuns`, `workflowRunModules`, and `workflowRunActor`/`executeWorkflowModule`. Two workflow systems coexist. |
| High | `src/workflows/module-runner.ts` synchronous execution from routes | Keep, but consolidate behind `workflowRunActor` before cleanup. | Used both by `workflowRunActor` and directly by route handlers. Direct route use undercuts actor durability, but the module runner itself is active. |
| Medium | `src/workflows/smoke-test.ts` | Keep as script if used manually; otherwise convert to documented test or remove. | Only referenced by `package.json` script `workflows:smoke`; not part of app runtime. |
| Medium | `scripts/rivet-actors.ts` | Keep if used by ops; document or remove if not. | Standalone admin script; not imported by source. It relies on `RIVET_ENDPOINT`, `RIVET_NAMESPACE`, and admin token defaults. |
| Medium | Demo home seeder `src/home/seed-demo-home.ts` and `/home/seed-demo` | Keep in staging/demo only; move behind explicit environment gate. | `src/routes/home.ts` exposes a seed endpoint. Schema has `generatedBy: "demo"` for notifications. This is live source behavior rather than isolated fixture code. |
| Medium | Static fallback mission registry vs persisted registry (`src/missions/registry.ts` and `src/missions/postgres-registry.ts`) | Keep both until migration/backfill is confirmed; then decide whether DB registry or static registry is source of truth. | `routes/missions.ts` reads persisted definitions, while actor factory and conversations read static definitions. `postgres-registry` falls back to static definitions. |
| Medium | Duplicate mission actor wrappers (`career-transition-actor.ts`, `salary-negotiation-war-room-actor.ts`, `promotion-readiness-actor.ts`, `personal-brand-opportunity-engine-actor.ts`) | Keep; low-cost wrappers are active. | Thin wrappers are mapped in routes, registry, event actor, and actor registry. |
| Medium | `src/events/projectors/projection-agent.ts` LLM insight path | Keep, but verify product use. | Referenced by `userEventActor` and `reducer-types`, so not dead. It can silently fall back when no LLM API key exists. |
| Medium | Legacy Redis observers in `src/events/redis-consumer.ts` | Keep until services emit canonical Grow Events. | Comments state these observe existing service A2A traffic. They are enabled by `INTERVIEW_REDIS_URL`, `ROLEPLAY_REDIS_URL`, and `RESUME_REDIS_URL`. |
| Medium | `events` audit table in `src/db/schema.ts` | Keep until old frontend timelines and route writes are audited. | Older user/service paths still import/use `events` table, while newer Grow Event tables also exist. |
| Low | `src/workflows/registry.ts` and `src/missions/registry.ts` duplicate product concepts | Keep; consolidate later. | Workflows are commercial product definitions; missions are actor-backed variants. The overlap is intentional but duplicative. |
| Low | `docker/opencode/workspace-template/*/README.md` placeholders | Keep as template docs or remove if generated workspaces no longer need empty folders. | Template-only files are not runtime code, but useful for preserving folder structure. |
| Low | `docs/architecture.html` | Keep unless replaced by Markdown architecture docs. | Existing doc artifact, not source. |
## Unused or Underused Env Vars / Config Values
| Env/config | Recommendation | Evidence |
| --- | --- | --- |
| `config.required` | Keep or remove after scanning call sites; currently exported but not used in local source. | `required` is attached to config, but no local `config.required(` references were found. |
| `clerkPublishableKey` | Keep if clients read backend config elsewhere; otherwise remove from backend config. | Defined in `config.ts` and `.env.example`, but backend auth uses secret key. |
| `opencodeApiKey` | Keep only if future direct OpenCode auth requires it; currently `llmApiKey` consumes `OPENCODE_API_KEY`. | Defined separately in config; most OpenCode runtime calls use per-container password, not this field. |
| `userServiceUrl` | Keep; used by missions profile lookup. | `routes/missions.ts` fetches `/api/v1/users/me`. |
| `legacyServiceTaskObserverGroup` | Keep while legacy Redis observers exist. | Used in `redis-consumer.ts`. |
| `migrationVersion`, `promptVersion`, `opencodeImageVersion` | Keep; active Docker rollout labels. | Used by `docker/manager.ts` and Docker build metadata. |
## Stale or Demo-Oriented Behavior
- Demo generated home notifications and `/home/seed-demo` should move to a staging/demo module or be guarded by `config.environment`.
- `service-agents.ts` includes demo-like defaults, such as `formula_version: "workflow-demo"` and synthetic Q Score fallback summaries.
- `config.ts` defaults many production-sensitive values to local/dev values, including Gitea admin credentials, service token fallback, A2A key, and localhost URLs.
- Docker/OpenCode scripts are active but dev-biased, using image tags like `growqr/opencode:dev`.
## Prompt Workflow Inventory
All prompt workflow files under `prompts/workflows/*` are referenced by `src/workflows/registry.ts` through `promptPath` values:
- `career-transition/orchestrator.md`
- `interview-to-offer/interview-plan.md`
- `salary-negotiation-war-room/orchestrator.md`
- `promotion-readiness/orchestrator.md`
- `personal-brand-opportunity-engine/orchestrator.md`
Additional interview-to-offer prompt files (`resume-analysis.md`, `story-bank.md`, `final-readiness-report.md`) are not referenced by `workflowDefinitions` directly in this pass. Recommendation: keep until OpenCode/agent prompt loading is audited, then either wire them into module definitions or archive them.
## Delete/Keep Decisions Before Cleanup
Do not delete yet:
- `userActor` workflow code
- `product-service-actors`
- static mission/workflow registries
- Redis legacy observers
- demo home seeder
- standalone scripts
Good first cleanup after approval:
1. Move demo seeding to `src/staging` and guard it with a staging/demo environment.
2. Remove or document unused config fields (`config.required`, `clerkPublishableKey`, `opencodeApiKey`) after a second pass across frontend/deployment references.
3. Convert `workflows:smoke` into a real test or delete the script.
4. Consolidate mission actor type mapping into one helper and remove duplicate mapping functions.
## Verification
`pnpm typecheck` passed:
```txt
tsc -p tsconfig.json --noEmit
```

View File

@@ -0,0 +1,179 @@
# Backend Organization Audit
PRM-41 audit pass for `growqr-backend`.
Scope reviewed: `src/routes`, `src/actors`, `src/events`, `src/missions`, `src/workflows`, and `src/services`.
## Executive Summary
The backend currently has three overlapping orchestration layers:
1. HTTP routes that directly perform database writes, service calls, and some synchronous workflow execution.
2. Rivet actors that own durable user, workflow, mission, conversation, memory, and event processing state.
3. Event/projector code that normalizes service events into Grow Events, updates mission state, records service sessions, and projects Q Score signals.
That split is workable for a demo-stage backend, but it blurs ownership. Several routes contain business logic that should live in services or actors, while actors and event consumers need stronger idempotency, retry, and replay boundaries before production traffic.
## High-Level Architecture
```mermaid
flowchart LR
FE[Frontend / service clients] --> Hono[Hono routes]
Hono --> DB[(Postgres / Drizzle)]
Hono --> Rivet[Rivet actors]
Hono --> Svc[Product services]
Hono --> Docker[Docker + Gitea + OpenCode]
Svc --> Redis[Redis streams / pubsub]
Redis --> Consumer[events/redis-consumer]
Consumer --> GrowEvents[(grow_events)]
Consumer --> EventActor[userEventActor]
EventActor --> MissionActors[mission actors]
EventActor --> Projectors[QScore/session/projectors]
MissionActors --> DB
Rivet --> DB
Rivet --> Svc
Rivet --> Docker
```
## Route to Actor/Service/Event/Data Flow Map
| Route module | Mounted path | Primary flow | Actor/service/data dependencies | Notes |
| --- | --- | --- | --- | --- |
| `src/routes/actors.ts` | `/actors` | Auth-gated user stack control | `docker/manager`, `actors` table | Provisions/stops OpenCode stack directly from route. |
| `src/routes/agents.ts` | `/agents` | Catalog read | `agents/catalog` | Thin route. |
| `src/routes/chat.ts` | `/api/chat` | Chat request, Rivet first, direct LLM fallback | `userActor`, `lib/llm`, `services/service-agents` | Contains fallback tool orchestration and timeout logic in route. |
| `src/routes/conversations.ts` | `/conversations` | Conversation CRUD/chat/mission bridging | `conversationActor`, mission actors, `grow_conversations`, messages | Heavy route; mixes persistence, actor bootstrapping, mission resolution, and response shaping. |
| `src/routes/events.ts` | `/events` | User/service event ingestion and listing | `recordGrowEvent`, `routeGrowEventToUserActor`, `grow_events` | Good ingestion boundary, but service auth is environment-sensitive. |
| `src/routes/git.ts` | `/git` | Repo/file operations | `docker/manager`, `GiteaClient` | Route owns path safety and repo operation decisions. |
| `src/routes/grow.ts` | `/grow` | Grow bootstrap and active state | `growActor` | Thin actor gateway. |
| `src/routes/home.ts` | `/home` | Home feed, notifications, demo seed | `home-feed`, `seed-demo-home` | Includes demo seeding endpoint. |
| `src/routes/missions.ts` | `/missions` | Mission catalog, start/pause/resume/stage/artifacts/coach | `growActor`, mission actors, user service, mission registry | Heavy route; owns mission selection, profile fallback, actor type mapping, and artifact commands. |
| `src/routes/opencode.ts` | `/opencode` | OpenCode stack/session/message proxy | `docker/manager`, `OpencodeClient` | Directly provisions stack and opens sessions. |
| `src/routes/services.ts` | `/services` | Product service proxy and event recording | `product-service-clients`, `recordGrowEvent`, Q Score onboarding | Very heavy route; contains service-specific payload shaping and event side effects. |
| `src/routes/users.ts` | `/users` | User profile/bootstrap | `auth/clerk`, `users` table, onboarding Q Score | Includes Clerk profile mirroring and onboarding side effects. |
| `src/routes/workflows.ts` | `/workflows`, `/workflow-runs` | Workflow definitions/runs/modules/approvals | `userActor`, `workflowRunActor`, `workflow/module-runner`, DB | Two paths: legacy userActor job-application flow and DB-backed workflow runs. |
## Actor Inventory
| Actor | Current role | Main inputs | Outputs/effects | Robustness observations |
| --- | --- | --- | --- | --- |
| `userActor` | Legacy unified user orchestration: chat, memory tools, workflow status, service handoffs, OpenCode/Gitea interactions | `/api/chat`, `/workflows/job-application`, workflow route aliases | Actor state, DB events, service calls, Gitea reads/writes | Very broad responsibilities; failures in service calls often become summaries rather than durable retryable jobs. |
| `workflowRunActor` | Queued workflow module runner | `/workflow-runs/:runId/pause|resume` and direct client use | `workflowRunModules`, `workflowEvents`, `qscoreSnapshots` via module runner | Has Rivet loop retry settings for module execution, but route-level `/run` bypasses actor queue and executes synchronously. |
| `conversationActor` | Durable streaming conversation state | `/conversations` | Actor state and generated messages | Queue usage exists for messages; needs documented idempotency per turn/message id. |
| `memoryActor` | Durable memory file state | Internal client use | Actor state/file-like memory | Queue writes exist; external call idempotency unclear. |
| `growActor` | Active mission list/state control | `/grow`, `/missions` | `grow_active_missions`, mission state | Mission lifecycle split across growActor, mission actors, and routes. |
| `userEventActor` | Routes normalized Grow Events to missions/projectors | Redis consumer, `/events` ingestion | Mission stage patches, projector DB updates, event status | Central point for event idempotency, but retries/replay/DLQ are not yet formalized. |
| Mission actors | Per-mission state machines | `/missions`, `/conversations`, event actor | `grow_active_missions`, artifacts, suggestions | Four mission actors are thin factory wrappers; interview-to-offer has custom implementation. |
| Product service actors | Actor wrappers for interview/roleplay/resume clients | Registry only; possible client use | Service calls | Registered, but routes call clients directly. These may be underused compared to direct service proxy routes. |
## Event and Projector Flow
```mermaid
sequenceDiagram
participant Service as Product service
participant Redis as Redis stream/pubsub
participant Route as /events or service routes
participant Store as grow_events
participant UserEvent as userEventActor
participant Mission as mission actor
participant Projection as projectors
Service->>Redis: canonical GrowEvent or legacy task response
Redis->>Route: redis-consumer normalizes message
Route->>Store: recordGrowEvent with dedupeKey
Route->>UserEvent: routeGrowEventToUserActor
UserEvent->>Mission: apply reducer-derived stage patches
UserEvent->>Projection: service session and Q Score projections
Projection->>Store: update projection tables
```
Current event strengths:
- `normalizeGrowEvent` accepts multiple service field conventions.
- `recordGrowEvent` uses `dedupeKey` and a unique index on `grow_events.dedupe_key`.
- Legacy Redis observer bridges `tasks:*` and `responses:*` without service changes.
- Projector surfaces exist for session tracking, Q Score, and LLM-derived insights.
Current event gaps:
- Redis canonical consumer always `xAck`s in `finally`, even when `recordAndRoute` fails, so failed messages do not remain pending for retry.
- No DLQ stream/table for failed canonical or legacy event processing.
- No replay script for `grow_events.processing_status in ('failed', 'unresolved')`.
- Legacy task context is in-memory only, so response events can lose user/action context after a backend restart.
## Business Logic in Routes
Highest concentration:
- `src/routes/services.ts`: service-specific request construction, event emission, Q Score baseline/onboarding side effects, mission association, and UI response shaping.
- `src/routes/workflows.ts`: run creation, module row initialization, baseline Q Score, approval gate progression, artifact content lookup, and synchronous module execution.
- `src/routes/missions.ts`: mission profile lookup from user service, actor type mapping, start/resume/pause/stage/artifact commands, and coach run orchestration.
- `src/routes/conversations.ts`: active conversation persistence, mission-aware chat routing, actor fallback behavior, and response normalization.
- `src/routes/chat.ts`: Rivet fallback, direct LLM tool loop, service agent selection, and timeout handling.
Low-risk thin routes:
- `src/routes/agents.ts`
- `src/routes/grow.ts`
- parts of `src/routes/events.ts`
Recommended ownership target:
- Routes validate/authenticate and translate HTTP to commands.
- Actors own durable user/mission/workflow progression.
- Services own outbound HTTP details.
- Projectors own derived read models.
- Routes should not decide retry, idempotency, or service fallback behavior beyond returning HTTP errors.
## Idempotency Gaps
| Area | Existing behavior | Gap |
| --- | --- | --- |
| Grow Event ingestion | `dedupeKey` unique index; normalizer uses explicit key or source id | Service routes do not consistently set stable dedupe keys for all service-created side effects. |
| Workflow runs | `/workflow-runs/:runId/modules/:moduleId/run` reads `idempotency-key` header | `executeWorkflowModule` does not use the key to suppress duplicate service calls; `/run` generates timestamp keys. |
| Workflow module rows | Has `idempotencyKey`, `retryCount`, `maxRetries` columns | Counters are mostly passive; no central retry state machine. |
| Actor queues | Rivet queues and `loop` step names provide some dedupe for `workflowRunActor` | Several routes bypass actor queue and execute directly. |
| Service session creation | `stableUuid` exists in service-agent helper | Not consistently used as a request id/idempotency key across service calls. |
| OpenCode artifacts | `onConflictDoNothing` for workflow artifacts | OpenCode prompt/message send can duplicate work before artifact row conflict applies. |
## Retry Gaps
| Area | Existing behavior | Gap |
| --- | --- | --- |
| `workflowRunActor` | Rivet `loop` has `retryBackoffBase` and `retryBackoffMax` | Only applies when execution goes through actor loop. |
| HTTP service clients | Throw on non-2xx after `fetch` | No timeout, retry classification, request id, or backoff. |
| Gitea client | Some wait/poll helpers exist | Most API calls are single-shot. |
| OpenCode client | Health polling exists | Session/message calls are single-shot. |
| Redis consumer | Infinite loop catches top-level errors | Per-message failures are acked; no retry budget or DLQ. |
| Projectors | Called by event actor | Projector failures need durable retry/replay semantics and status transitions. |
## Actor Robustness Gaps
- `userActor` is too broad to reason about failure domains. It owns chat, service tools, memory, workflow, Gitea, OpenCode, and DB event writes.
- Product service actors are registered but not the primary path for service proxy routes, so actor-level durability is uneven.
- Mission actor mapping is manually duplicated in routes, registry, and event actor.
- Route-level synchronous workflow execution can hold HTTP requests open across slow service/OpenCode calls.
- Actor initialization is repeated in routes; a central actor gateway could enforce init/idempotency/logging.
## Priority-Ranked Recommendations
1. Create a backend command layer for route-to-actor/service translation. Move mission start, workflow run, approval, service configure, and chat tool dispatch logic out of routes.
2. Make `workflowRunActor` the only executor for workflow modules. Routes should enqueue commands and return command ids.
3. Add a shared outbound `withRetry`/timeout/idempotency wrapper for service clients, Gitea, OpenCode, and LLM calls.
4. Add DLQ and replay support for Redis/event processing. Do not ack canonical Redis messages until durable record/projector status is successful or DLQ-ed.
5. Normalize mission actor mapping into a single registry source used by routes, event actor, and mission registry.
6. Split `userActor` responsibilities: chat/memory/workflow/OpenCode paths should be smaller actors or delegated services with explicit contracts.
7. Convert route-created side effects to stable idempotency keys. Use request id, user id, mission instance id, service id, and operation name.
8. Add structured logging fields across routes/actors/events: `requestId`, `userId`, `missionInstanceId`, `runId`, `moduleId`, `eventId`, `idempotencyKey`, `retryAttempt`.
9. Add focused tests around duplicate workflow module run, duplicate service event ingest, Redis failure handling, and mission projector replay.
## Suggested Next Slice
Use PRM-43 to introduce shared retry/idempotency primitives first. Then return to this audit and migrate the highest-risk route logic in this order:
1. `/workflow-runs/*/run`
2. `/services/interview|roleplay configure/review`
3. `/missions/:missionId/start`
4. `/api/chat` direct LLM fallback

148
docs/environment-matrix.md Normal file
View File

@@ -0,0 +1,148 @@
# Environment Matrix
PRM-42 staging vs production separation inventory for `growqr-backend`.
No refactor was performed in this pass.
## Current Environment Model
The backend currently uses `config.nodeEnv` plus many individual env vars. There is no explicit first-class `environment` such as `development | staging | production | demo`.
Important consequence: local/dev defaults can leak into staging or production unless deployment env vars override every sensitive value.
## Current Config Inventory
| Area | Config/env | Current default | Production concern |
| --- | --- | --- | --- |
| Runtime | `PORT`, `LOG_LEVEL`, `NODE_ENV` | `4000`, `info`, `development` | `NODE_ENV` is too broad for staging/demo behavior. |
| Database | `DATABASE_URL` | hardcoded fallback DSN in `config.ts` | Production should fail fast instead of falling back. |
| Auth | `CLERK_SECRET_KEY`, `CLERK_PUBLISHABLE_KEY` | empty | Secret key absence changes auth behavior; publishable key appears underused. |
| Service auth | `SERVICE_TOKEN`, `A2A_ALLOWED_KEY` | empty / `dev-a2a-key` | Dev token fallback must not be accepted in production. |
| Redis events | `GROW_EVENTS_REDIS_URL`, `REDIS_URL`, stream/group/consumer names | disabled unless set | Staging/prod need explicit stream, group, and replay policy. |
| Legacy Redis | `INTERVIEW_REDIS_URL`, `ROLEPLAY_REDIS_URL`, `RESUME_REDIS_URL` | fallback to event Redis | Legacy observation should be explicitly enabled per environment. |
| LLM | `LLM_PROVIDER`, `LLM_API_KEY`, `OPENCODE_API_KEY`, `LLM_BASE_URL`, `GROW_AGENT_MODEL`, `LLM_MODEL` | `opencode`, `https://opencode.ai/zen/v1`, `kimi-k2.6` | Staging/prod should pin provider/model and require API key where features are enabled. |
| Rivet | `RIVET_ENDPOINT`, `RIVET_CLIENT_ENDPOINT` | localhost/127.0.0.1 | Docker compose overrides endpoint; production needs internal and public separation. |
| Product services | `INTERVIEW_SERVICE_URL`, `ROLEPLAY_SERVICE_URL`, `QSCORE_SERVICE_URL`, `RESUME_SERVICE_URL`, `USER_SERVICE_URL`, `MATCHMAKING_SERVICE_URL`, `SOCIAL_BRANDING_SERVICE_URL` | localhost ports | Production should require service URLs or feature-disable explicitly. |
| Public URLs | `INTERVIEW_PUBLIC_URL`, `ROLEPLAY_PUBLIC_URL`, `RESUME_PUBLIC_URL`, `WORKFLOWS_DASHBOARD_URL`, `FRONTEND_ORIGIN` | localhost/frontend fallback | Public and internal service URLs need separate semantics. |
| Gitea | `GITEA_PUBLIC_URL`, `GITEA_INTERNAL_URL`, `GITEA_ADMIN_USER`, `GITEA_ADMIN_PASSWORD`, `GITEA_ADMIN_TOKEN`, `GITEA_ORG_NAME` | localhost, `growqr-admin`, `growqr-admin-dev`, empty token | Admin password fallback is dev-only. Production should require token/secret. |
| OpenCode | `OPENCODE_IMAGE`, `OPENCODE_IMAGE_VERSION`, `MIGRATION_VERSION`, `PROMPT_VERSION`, `USER_CONTAINER_HOST`, `USER_DATA_ROOT`, `USER_PORT_RANGE_*` | dev image/version, local paths/ports | Needs staging/prod image tags and storage policy. |
| CORS/admin | `FRONTEND_ORIGIN`, `ADMIN_USER_IDS` | localhost / empty | Empty admin list currently allows `/workflows/admin/ops` to all authenticated users. |
| Agent limits | `MAX_AGENT_TOKENS`, `PROJECTION_AGENT_MODEL`, `CONVERSATION_ACTOR_MODEL` | 4096 / agent model | Model overrides should be pinned by environment. |
## Environment-Dependent Code Paths
| File | Behavior |
| --- | --- |
| `src/config.ts` | Central env parsing with dev defaults for database, tokens, local service URLs, Gitea, OpenCode, Rivet, frontend, and ports. |
| `src/auth/clerk.ts` | In non-production, `A2A_ALLOWED_KEY` is accepted as an auth fallback. Clerk client is only created when `CLERK_SECRET_KEY` exists. |
| `src/index.ts` | Proxies `/api/rivet` only when `process.env.RIVET_ENDPOINT` is set. Starts Redis consumer opportunistically. CORS uses `FRONTEND_ORIGIN`. |
| `src/events/redis-consumer.ts` | Canonical consumer disabled if no Redis URL. Legacy observers enabled by legacy Redis URLs. |
| `src/events/projectors/projection-agent.ts` | Falls back if no LLM API key; model can be overridden by `PROJECTION_AGENT_MODEL`. |
| `src/actors/conversation/agent.ts` | Requires LLM key for streaming; model can be overridden by `CONVERSATION_ACTOR_MODEL`. |
| `src/routes/events.ts` | Service ingest auth allows no service token in non-production. |
| `src/routes/home.ts` | Exposes demo seeding route. |
| `src/home/seed-demo-home.ts` | Demo notifications and executable direct script behavior. |
| `src/services/service-agents.ts` | Synthetic/demo fallbacks for some unavailable services and Q Score estimate behavior. |
| `src/docker/manager.ts` | Uses Gitea/OpenCode image/version/host/path/port config and mutates Docker runtime. |
| `scripts/rivet-actors.ts` | Uses dev Rivet namespace/token defaults. |
| `docker-compose.yml` | Dev compose defaults for Postgres, Gitea, Rivet, backend, services, frontend origins, and OpenCode image. |
| `docker/opencode/*` | Dev-oriented OpenCode image/template behavior. |
## Hardcoded URL and Default Hotspots
- `http://localhost:*` defaults in `src/config.ts`, `.env.example`, `README.md`, and `docker-compose.yml`.
- `http://127.0.0.1:*` defaults for Rivet client, Gitea, and user container host.
- `http://host.docker.internal:*` compose service defaults.
- OpenCode base image `ghcr.io/anomalyco/opencode:latest` in `docker/opencode/Dockerfile`.
- Dev image tag `growqr/opencode:dev`.
- Gitea admin defaults `growqr-admin` / `growqr-admin-dev`.
- A2A fallback `dev-a2a-key`.
## Clerk / JWKS Assumptions
The code uses Clerk SDK with `CLERK_SECRET_KEY`; there is no explicit JWKS URL configuration in the reviewed backend source. Service-to-service auth is token based, with dev fallback behavior. Target production should document whether auth is:
- Clerk session token verification for user requests.
- `SERVICE_TOKEN` for service-to-backend event ingestion.
- Separate internal A2A key for legacy product service calls.
- Optional JWKS validation if services send JWTs instead of opaque service tokens.
## Target Config Model
Introduce:
```ts
type RuntimeEnvironment = "development" | "test" | "staging" | "demo" | "production";
```
Recommended top-level config shape:
```ts
config.environment
config.isProduction
config.isStaging
config.isDemo
config.features.demoDataEnabled
config.features.legacyRedisObserversEnabled
config.features.opencodeProvisioningEnabled
config.features.serviceProxyEnabled
config.urls.internal.*
config.urls.public.*
config.auth.*
config.retry.*
config.events.*
```
Rules:
- Production must fail fast for missing `DATABASE_URL`, `CLERK_SECRET_KEY`, `SERVICE_TOKEN`, `FRONTEND_ORIGIN`, Gitea credentials/token, and any enabled service URL.
- Staging may use staging service URLs and demo data only when `DEMO_DATA_ENABLED=true`.
- Development may keep local defaults.
- Demo behavior should be impossible in production unless an explicit, audited flag is set and the route remains auth/admin-gated.
## What Should Move to `src/staging`
Proposed `src/staging` candidates:
- `home/seed-demo-home.ts`
- `/home/seed-demo` route handler
- demo notification factories
- demo Q Score formulas/fallback constants in service-agent behavior, if not product-approved
- local-only service session scaffolding helpers
- any future seeders/backfills used only for demos
Suggested layout:
```txt
src/staging/
demo-home.ts
demo-qscore.ts
seed-routes.ts
guards.ts
```
`src/staging/guards.ts` should expose `requireStagingOrDemo(config)` and fail closed in production.
## Target Environment Matrix
| Behavior | Development | Staging | Demo | Production |
| --- | --- | --- | --- | --- |
| Localhost defaults | Allowed | Not allowed | Not allowed unless local demo | Not allowed |
| Demo seed endpoints | Allowed | Explicit flag + admin | Enabled by flag + admin | Disabled |
| Service token fallback | Allowed | Not allowed | Not allowed | Not allowed |
| Legacy Redis observers | Optional | Explicit flag | Explicit flag | Disable unless migration requires |
| Redis canonical events | Optional | Required for event demos | Required | Required |
| OpenCode image | `:dev` ok | pinned staging tag | pinned demo tag | pinned release tag |
| Admin ops route | Authenticated maybe ok | `ADMIN_USER_IDS` required | `ADMIN_USER_IDS` required | `ADMIN_USER_IDS` required |
| Missing Clerk secret | Allowed only for local mock if implemented | Fail | Fail | Fail |
| Gitea admin password default | Allowed | Fail | Fail | Fail |
## Priority Recommendations
1. Add `APP_ENV` or `GROWQR_ENV` and derive `config.environment`; stop relying on `NODE_ENV` for product behavior.
2. Fail fast in staging/production for missing secrets and localhost/default service URLs.
3. Move demo seed code into `src/staging` and guard routes with `DEMO_DATA_ENABLED` plus admin check.
4. Require `ADMIN_USER_IDS` before enabling `/workflows/admin/ops` outside development.
5. Split public URLs and internal URLs in config names consistently across frontend, services, Gitea, Rivet, and OpenCode.
6. Add a deployment checklist that records every required env var per environment.
7. Make legacy Redis observers an explicit feature flag and set a removal date.

View File

@@ -0,0 +1,34 @@
# OpenCode Lifecycle Follow-ups
These are non-blocking improvements deferred during the time-crunch pass.
## Architecture split
Longer term, keep these concerns independently scalable:
- **Frontend**: Next.js app, hosted separately.
- **Core backend/API**: auth, DB, workflows, user-facing routes.
- **Actor runtime**: Rivet user actors; actors orchestrate work and call control-plane APIs.
- **OpenCode lifecycle/control plane**: Docker/Kubernetes/Fly/Nomad manager that creates, health-checks, upgrades, and tears down per-user OpenCode runtimes.
- **Git service**: Gitea or hosted Git provider, reachable through a public URL.
Today, `src/docker/manager.ts` is not a separate service. It is an internal module inside `growqr-backend`; authenticated HTTP routes under `/opencode` and `/actors` call into it, and the user actor calls into it directly through imports.
## Deferred hardening
- Add a dedicated OpenCode lifecycle service/API instead of embedding Docker control in the backend process.
- Add admin rollout endpoints for image/prompt upgrades:
- recreate one user's OpenCode container
- recreate all stale containers
- inspect container version/labels/health
- Make `provisionUserStack` validate existing `running` rows instead of trusting DB state.
- Make `startOpencodeContainer` inspect existing containers and recreate them if image/version/labels/env are stale.
- Make `reconcileOnBoot` mark stale containers as stopped/needs migration instead of only logging.
- Add explicit migration state to `user_stacks` if rollouts need to be asynchronous.
- Attach spawned OpenCode containers to a controlled Docker network or move to an orchestrator-native network model.
- Split service URLs consistently for every external dependency:
- public browser URL
- backend internal URL
- OpenCode/container egress URL
- Add rate limits and stricter resource quotas per user/container.
- Encrypt stored OpenCode per-container passwords or move them to a secrets manager.

View File

@@ -0,0 +1,284 @@
# Retry, Idempotency, and DLQ Plan
PRM-43 design pass for `growqr-backend`.
No implementation was performed in this pass.
## Goals
- Bound every outbound call with timeouts.
- Retry only safe operations with classified errors.
- Make repeated commands safe through idempotency keys.
- Preserve failed event/workflow work in a DLQ with replay tooling.
- Add logs that let support trace one user action across route, actor, service, Redis, projector, and database writes.
## Outbound Call Site Inventory
| Area | Files | Current behavior | Needed behavior |
| --- | --- | --- | --- |
| Product service clients | `src/services/product-service-clients.ts` | Direct `fetch`, no timeout/retry/idempotency header | Shared service client with timeout, retry, idempotency key, and request id. |
| Service agent probes | `src/services/service-agents.ts` | Direct `fetch`, some fallback summaries | Same shared client; distinguish "unavailable" from retriable failure. |
| Gitea | `src/lib/gitea.ts`, `src/docker/manager.ts`, `src/actors/user-actor.ts` | Direct `fetch`, some wait-for-ready helpers | Retry transient Gitea API errors; idempotent repo/user/file operations. |
| OpenCode | `src/lib/opencode.ts`, `src/workflows/executors/opencode-executor.ts` | Direct `fetch`, health polling, no command dedupe | Timeout and retry health/session/message calls; stable command id for prompts. |
| LLM | `src/lib/llm.ts`, `src/actors/conversation/agent.ts`, `src/events/projectors/projection-agent.ts` | Direct SDK/fetch calls | Timeout, retry on provider transient errors, no retry on content/schema errors. |
| Actor sends | routes, `src/events/route-to-user-actor.ts`, actors | `getOrCreate(...).method(...)`, queue sends | Standard command envelope with idempotency key and correlation ids. |
| Redis consumer | `src/events/redis-consumer.ts` | Loops forever; canonical messages ack in `finally`; no DLQ | Retry budget, pending handling, DLQ stream/table, replay. |
| Projectors | `src/events/projectors/*`, `src/actors/events/user-event-actor.ts` | Called within event actor processing | Per-projector idempotency and failure status; replay from stored Grow Events. |
| Workflow module runner | `src/workflows/module-runner.ts`, `src/actors/workflow-run-actor.ts` | Actor loop retries in one path; direct route execution in another | Actor-only execution, durable command id, retry state in DB. |
## Shared `withRetry` API
Add `src/lib/retry.ts`:
```ts
export type RetryPolicy = {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
timeoutMs: number;
jitter: boolean;
};
export async function withRetry<T>(
operation: string,
fn: (ctx: { signal: AbortSignal; attempt: number }) => Promise<T>,
options: {
policy?: Partial<RetryPolicy>;
idempotencyKey?: string;
classify?: (error: unknown) => "retry" | "fail";
logFields?: Record<string, unknown>;
},
): Promise<T>;
```
Default policy:
- `maxAttempts: 3`
- `baseDelayMs: 250`
- `maxDelayMs: 5_000`
- `timeoutMs: 10_000`
- jitter enabled
Classification:
- Retry: network errors, abort/timeout, HTTP `408`, `425`, `429`, `500`, `502`, `503`, `504`.
- Do not retry: HTTP `400`, `401`, `403`, `404`, validation/schema errors, duplicate/idempotency conflicts that already completed.
- Special case: `409` may be success for idempotent create-if-absent operations.
## Idempotency Model
Add a command/event idempotency key convention:
```txt
<domain>:<userId>:<entityId>:<operation>:<version>
```
Examples:
- `workflow:user_123:run_456:module:resume:v1`
- `mission:user_123:instance_456:start:v1`
- `service:user_123:interview:configure:session_abc`
- `event:user_123:growEventId:project:qscore:v1`
- `opencode:user_123:run_456:interview-plan:prompt-v4`
Where to store:
- `workflowRunModules.idempotencyKey` for module commands.
- `workflowEvents.payload.idempotencyKey` for audit trail.
- `growEvents.dedupeKey` for event ingestion.
- Add a future `idempotency_keys` table only if multiple domains need durable response reuse.
Minimum table design if needed:
```txt
idempotency_keys
key text primary key
domain text not null
user_id text
status text check (processing, completed, failed)
request_hash text
response jsonb
error text
expires_at timestamptz
created_at timestamptz
updated_at timestamptz
```
## HTTP Service Client Plan
Create `src/services/http-client.ts`:
- Accepts `baseUrl`, `path`, `method`, `json`, `headers`, `idempotencyKey`, `operation`, `timeoutMs`.
- Adds:
- `authorization: Bearer <A2A_ALLOWED_KEY>` when configured.
- `x-request-id`
- `x-idempotency-key` or `idempotency-key`.
- `x-growqr-user` when user-scoped.
- Uses `withRetry`.
- Parses text once and returns typed JSON.
- Logs attempt, latency, status, and error class.
Then migrate:
1. `product-service-clients.ts`
2. `service-agents.ts`
3. mission route direct user-service fetch
4. workflow service health checks
## Workflow Retry Plan
Target behavior:
- Routes enqueue commands to `workflowRunActor`; routes do not call `executeWorkflowModule` directly.
- `workflowRunActor` writes command state before execution.
- `executeWorkflowModule` receives `idempotencyKey` and passes it to service/OpenCode calls.
- On failure, increment `workflowRunModules.retryCount`, store `error`, and emit `workflowEvents` with `retryAttempt`.
- Exceeding retry budget marks module `blocked` or `failed` based on module type and writes a DLQ row/event.
Module status transition:
```mermaid
stateDiagram-v2
[*] --> idle
idle --> queued
queued --> running
running --> done
running --> retry_wait
retry_wait --> running
running --> blocked
running --> dlq
dlq --> replaying
replaying --> running
```
## Redis Consumer and DLQ Plan
Do not ack canonical Redis messages until one of these is true:
- event persisted and routed/projected successfully;
- event persisted but routing failed and a durable retry record was created;
- message moved to DLQ after retry budget.
Add DLQ options:
1. Redis stream DLQ: `grow.events.dlq`
2. Postgres table: `grow_event_dlq`
Recommended to use both:
- Redis DLQ for operational stream tooling.
- Postgres DLQ for admin UI, audit, and replay metadata.
DLQ row fields:
```txt
id
source_stream
source_message_id
payload
error
attempts
last_attempt_at
status: pending | replaying | replayed | discarded
created_at
updated_at
```
Replay script:
```txt
pnpm events:replay --status failed --limit 100
pnpm events:replay --dlq --id <dlq-id>
pnpm events:replay --event-id <grow-event-id> --projectors qscore,service-session
```
Script responsibilities:
- Re-read stored payload.
- Re-run `recordGrowEvent` if needed.
- Re-run `routeGrowEventToUserActor`.
- Optionally run only selected projectors.
- Preserve original `dedupeKey`.
## Projector Idempotency Plan
Projectors should be repeatable:
- Q Score latest table already has `(userId, signalId)` primary key.
- Mission service sessions have unique `(serviceId, externalId)`.
- Artifacts should dedupe by `(missionInstanceId, serviceId, externalId, type)` or a stable artifact key.
- Mission stage patches should be applied with deterministic status/progress and no duplicate suggestions.
Add projector event logs:
```txt
grow_event_projector_runs
event_id
projector
status
attempt
error
started_at
completed_at
```
## Logging Fields
Every route/actor/event/retry log should include as many of these as available:
- `requestId`
- `traceId`
- `userId`
- `orgId`
- `actorType`
- `actorKey`
- `runId`
- `moduleId`
- `missionId`
- `missionInstanceId`
- `stageId`
- `eventId`
- `source`
- `eventType`
- `idempotencyKey`
- `operation`
- `attempt`
- `maxAttempts`
- `latencyMs`
- `httpStatus`
- `retryable`
- `dlqId`
## Test Plan
Unit tests:
- `withRetry` retries transient errors and stops on non-retryable errors.
- Timeout aborts fetch and logs retry attempt.
- Idempotency key helper returns stable keys.
- HTTP client adds auth, request id, and idempotency headers.
Integration tests:
- Duplicate `/workflow-runs/:runId/modules/:moduleId/run` command does not duplicate service call.
- Duplicate Grow Event with same `dedupeKey` is stored once and projection remains stable.
- Redis message failure is not acked until retry/DLQ path is recorded.
- DLQ replay reprocesses a failed event and updates projector status.
- OpenCode module execution retry does not create duplicate artifact rows.
Manual staging drills:
1. Stop interview service, run interview module, verify retry and blocked/DLQ behavior.
2. Emit duplicate Redis events, verify one `grow_events` row and stable projector state.
3. Break Gitea token, provision stack, verify retry logs and no partial untracked state.
4. Replay a DLQ event, verify mission progress and Q Score update.
## Implementation Order
1. Add `src/lib/retry.ts` and focused unit tests.
2. Add service HTTP client and migrate product service calls.
3. Add workflow command idempotency and route-to-actor queueing.
4. Add Redis DLQ and replay script.
5. Add projector run records.
6. Migrate Gitea/OpenCode/LLM calls to `withRetry`.
7. Add staging failure drills to deployment checklist.

View File

@@ -0,0 +1,53 @@
CREATE TABLE IF NOT EXISTS "workflow_runs" (
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"workflow_id" text NOT NULL,
"workflow_version" text NOT NULL,
"status" text DEFAULT 'running' NOT NULL,
"goal" text,
"input" jsonb,
"current_step_id" text,
"progress_percent" integer DEFAULT 0 NOT NULL,
"qscore_before" jsonb,
"qscore_after" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone
);
CREATE INDEX IF NOT EXISTS "workflow_runs_user_idx" ON "workflow_runs" ("user_id", "created_at");
CREATE INDEX IF NOT EXISTS "workflow_runs_workflow_idx" ON "workflow_runs" ("workflow_id");
CREATE TABLE IF NOT EXISTS "workflow_run_modules" (
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
"run_id" text NOT NULL REFERENCES "workflow_runs"("id") ON DELETE cascade,
"module_id" text NOT NULL,
"title" text NOT NULL,
"status" text DEFAULT 'idle' NOT NULL,
"service" text,
"output_summary" text,
"output" jsonb,
"error" text,
"started_at" timestamp with time zone,
"completed_at" timestamp with time zone
);
CREATE TABLE IF NOT EXISTS "workflow_artifacts" (
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
"run_id" text NOT NULL REFERENCES "workflow_runs"("id") ON DELETE cascade,
"module_id" text,
"type" text NOT NULL,
"title" text NOT NULL,
"repo_path" text,
"public_url" text,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS "workflow_events" (
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
"run_id" text NOT NULL REFERENCES "workflow_runs"("id") ON DELETE cascade,
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"type" text NOT NULL,
"payload" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);

View File

@@ -0,0 +1,14 @@
ALTER TABLE "workflow_run_modules" ADD COLUMN IF NOT EXISTS "idempotency_key" text;
ALTER TABLE "workflow_run_modules" ADD COLUMN IF NOT EXISTS "retry_count" integer DEFAULT 0 NOT NULL;
ALTER TABLE "workflow_run_modules" ADD COLUMN IF NOT EXISTS "max_retries" integer DEFAULT 2 NOT NULL;
CREATE TABLE IF NOT EXISTS "workflow_approvals" (
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
"run_id" text NOT NULL REFERENCES "workflow_runs"("id") ON DELETE cascade,
"approval_id" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"payload" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"resolved_at" timestamp with time zone
);
CREATE INDEX IF NOT EXISTS "workflow_approvals_run_idx" ON "workflow_approvals" ("run_id", "approval_id");

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS "qscore_snapshots" (
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"run_id" text REFERENCES "workflow_runs"("id") ON DELETE cascade,
"snapshot_type" text NOT NULL,
"score" integer,
"payload" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS "qscore_snapshots_user_idx" ON "qscore_snapshots" ("user_id", "created_at");
CREATE INDEX IF NOT EXISTS "qscore_snapshots_run_idx" ON "qscore_snapshots" ("run_id");

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS "mission_registry" (
"id" text PRIMARY KEY NOT NULL,
"version" text NOT NULL,
"title" text NOT NULL,
"short_title" text NOT NULL,
"actor_type" text,
"actor_backed" boolean DEFAULT false NOT NULL,
"skill_path" text NOT NULL,
"display_order" integer NOT NULL,
"definition" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS "mission_registry_display_idx" ON "mission_registry" ("display_order");

View File

@@ -0,0 +1,49 @@
-- Durable Talk to Me conversations + mission actor snapshots.
-- Actors are runtime caches/orchestrators; Postgres is the source of truth.
CREATE TABLE IF NOT EXISTS grow_conversations (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT 'Talk to Me',
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS grow_conversations_user_idx
ON grow_conversations(user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS grow_conversation_messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL REFERENCES grow_conversations(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
sender TEXT NOT NULL,
content TEXT NOT NULL,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS grow_conversation_messages_conversation_idx
ON grow_conversation_messages(conversation_id, created_at ASC);
CREATE TABLE IF NOT EXISTS grow_active_missions (
instance_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mission_id TEXT NOT NULL,
workflow_id TEXT NOT NULL,
actor_type TEXT,
title TEXT NOT NULL,
short_title TEXT NOT NULL,
status TEXT NOT NULL,
progress_percent INTEGER NOT NULL DEFAULT 0,
current_stage_id TEXT,
goal TEXT,
mission JSONB NOT NULL,
snapshot JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS grow_active_missions_user_idx
ON grow_active_missions(user_id, updated_at DESC);

View File

@@ -0,0 +1,123 @@
-- Grow event backbone: raw event inbox, service session links, artifacts, and QScore projections.
-- Postgres stores every raw GrowEvent first. Actors/projectors can rebuild from this stream.
CREATE TABLE IF NOT EXISTS grow_events (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
org_id TEXT,
source TEXT NOT NULL,
type TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'service' CHECK (category IN ('mission', 'service', 'artifact', 'usage', 'qscore', 'entitlement', 'system')),
occurred_at TIMESTAMPTZ NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
mission JSONB,
subject JSONB,
correlation JSONB,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
raw JSONB,
dedupe_key TEXT,
processing_status TEXT NOT NULL DEFAULT 'pending' CHECK (processing_status IN ('pending', 'processing', 'processed', 'failed', 'unresolved')),
processing_error TEXT,
processed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS grow_events_user_idx
ON grow_events(user_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS grow_events_status_idx
ON grow_events(processing_status, received_at ASC);
CREATE INDEX IF NOT EXISTS grow_events_source_idx
ON grow_events(source, type, occurred_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS grow_events_dedupe_idx
ON grow_events(dedupe_key);
CREATE TABLE IF NOT EXISTS mission_service_sessions (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mission_instance_id TEXT REFERENCES grow_active_missions(instance_id) ON DELETE SET NULL,
mission_id TEXT,
stage_id TEXT,
service_id TEXT NOT NULL,
external_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
metadata JSONB,
last_event_id TEXT REFERENCES grow_events(id) ON DELETE SET NULL,
last_checked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS mission_service_sessions_user_idx
ON mission_service_sessions(user_id, updated_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS mission_service_sessions_external_idx
ON mission_service_sessions(service_id, external_id);
CREATE INDEX IF NOT EXISTS mission_service_sessions_mission_idx
ON mission_service_sessions(mission_instance_id, stage_id);
CREATE TABLE IF NOT EXISTS mission_artifacts (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mission_instance_id TEXT REFERENCES grow_active_missions(instance_id) ON DELETE CASCADE,
mission_id TEXT,
stage_id TEXT,
source_event_id TEXT REFERENCES grow_events(id) ON DELETE SET NULL,
service_id TEXT,
external_id TEXT,
type TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'ready',
summary TEXT,
content_md TEXT,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS mission_artifacts_user_idx
ON mission_artifacts(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS mission_artifacts_mission_idx
ON mission_artifacts(mission_instance_id, created_at DESC);
CREATE INDEX IF NOT EXISTS mission_artifacts_external_idx
ON mission_artifacts(service_id, external_id);
CREATE TABLE IF NOT EXISTS grow_qscore_signals (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
source_event_id TEXT REFERENCES grow_events(id) ON DELETE SET NULL,
signal_id TEXT NOT NULL,
score DOUBLE PRECISION NOT NULL,
present BOOLEAN NOT NULL DEFAULT TRUE,
source TEXT,
raw JSONB,
occurred_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS grow_qscore_signals_user_idx
ON grow_qscore_signals(user_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS grow_qscore_signals_signal_idx
ON grow_qscore_signals(signal_id, occurred_at DESC);
CREATE TABLE IF NOT EXISTS grow_qscore_latest (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
signal_id TEXT NOT NULL,
score DOUBLE PRECISION NOT NULL,
present BOOLEAN NOT NULL DEFAULT TRUE,
source TEXT,
source_event_id TEXT REFERENCES grow_events(id) ON DELETE SET NULL,
raw JSONB,
occurred_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, signal_id)
);
CREATE INDEX IF NOT EXISTS grow_qscore_latest_user_idx
ON grow_qscore_latest(user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS grow_qscore_projection_state (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
score INTEGER NOT NULL DEFAULT 0,
signal_count INTEGER NOT NULL DEFAULT 0,
dimensions JSONB,
summary TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

View File

@@ -0,0 +1,36 @@
CREATE TABLE IF NOT EXISTS grow_home_notifications (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
module_id TEXT NOT NULL,
title TEXT NOT NULL,
subtitle TEXT NOT NULL,
tag TEXT NOT NULL,
urgency TEXT NOT NULL DEFAULT 'calm',
href TEXT NOT NULL,
source TEXT,
source_ref JSONB,
priority INTEGER NOT NULL DEFAULT 0,
generated_by TEXT NOT NULL DEFAULT 'deterministic',
reason TEXT,
status TEXT NOT NULL DEFAULT 'active',
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT grow_home_notifications_module_check
CHECK (module_id IN ('suggestions', 'missions', 'social', 'pathways', 'productivity', 'rewards')),
CONSTRAINT grow_home_notifications_urgency_check
CHECK (urgency IN ('now', 'today', 'soon', 'calm')),
CONSTRAINT grow_home_notifications_generated_by_check
CHECK (generated_by IN ('deterministic', 'agent', 'demo', 'manual')),
CONSTRAINT grow_home_notifications_status_check
CHECK (status IN ('active', 'dismissed', 'expired'))
);
CREATE INDEX IF NOT EXISTS grow_home_notifications_user_idx
ON grow_home_notifications(user_id, status, priority DESC);
CREATE INDEX IF NOT EXISTS grow_home_notifications_module_idx
ON grow_home_notifications(user_id, module_id, status);
CREATE INDEX IF NOT EXISTS grow_home_notifications_expiry_idx
ON grow_home_notifications(expires_at);

View File

@@ -0,0 +1,47 @@
CREATE TABLE IF NOT EXISTS "mission_suggestions" (
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"mission_instance_id" text NOT NULL REFERENCES "grow_active_missions"("instance_id") ON DELETE cascade,
"mission_id" text NOT NULL,
"stage_id" text,
"role" text NOT NULL,
"type" text NOT NULL,
"title" text NOT NULL,
"body" text NOT NULL,
"reason" text,
"priority" integer DEFAULT 0 NOT NULL,
"urgency" text DEFAULT 'calm' NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"cta_label" text NOT NULL,
"cta_href" text NOT NULL,
"source_refs" jsonb DEFAULT '{}'::jsonb NOT NULL,
"generated_by" text DEFAULT 'deterministic' NOT NULL,
"expires_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS "mission_suggestions_mission_idx" ON "mission_suggestions" ("user_id", "mission_instance_id", "status", "priority");
CREATE INDEX IF NOT EXISTS "mission_suggestions_role_idx" ON "mission_suggestions" ("mission_instance_id", "role", "status");
CREATE INDEX IF NOT EXISTS "mission_suggestions_expiry_idx" ON "mission_suggestions" ("expires_at");
CREATE TABLE IF NOT EXISTS "mission_coach_runs" (
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"mission_instance_id" text NOT NULL REFERENCES "grow_active_missions"("instance_id") ON DELETE cascade,
"mission_id" text NOT NULL,
"status" text DEFAULT 'running' NOT NULL,
"window_start" timestamp with time zone NOT NULL,
"window_end" timestamp with time zone NOT NULL,
"summary" text,
"input_digest" jsonb DEFAULT '{}'::jsonb NOT NULL,
"output" jsonb DEFAULT '{}'::jsonb NOT NULL,
"model" text,
"prompt_version" text DEFAULT 'mission-coach-v1' NOT NULL,
"skill_version" text,
"error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone
);
CREATE INDEX IF NOT EXISTS "mission_coach_runs_mission_idx" ON "mission_coach_runs" ("user_id", "mission_instance_id", "created_at");

View File

@@ -0,0 +1,34 @@
CREATE TABLE IF NOT EXISTS "mission_actions" (
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"mission_instance_id" text NOT NULL REFERENCES "grow_active_missions"("instance_id") ON DELETE cascade,
"mission_id" text NOT NULL,
"stage_id" text,
"agent_id" text NOT NULL,
"agent_name" text NOT NULL,
"base_agent" text,
"service_id" text,
"tool_name" text,
"mode" text NOT NULL,
"status" text DEFAULT 'queued' NOT NULL,
"title" text NOT NULL,
"body" text NOT NULL,
"prompt" text,
"payload" jsonb DEFAULT '{}'::jsonb NOT NULL,
"result" jsonb,
"error" text,
"source_event_id" text REFERENCES "grow_events"("id") ON DELETE set null,
"idempotency_key" text,
"priority" integer DEFAULT 0 NOT NULL,
"urgency" text DEFAULT 'calm' NOT NULL,
"due_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"resolved_at" timestamp with time zone
);
CREATE INDEX IF NOT EXISTS "mission_actions_mission_idx" ON "mission_actions" ("user_id", "mission_instance_id", "status", "priority");
CREATE INDEX IF NOT EXISTS "mission_actions_user_idx" ON "mission_actions" ("user_id", "status", "updated_at");
CREATE INDEX IF NOT EXISTS "mission_actions_source_idx" ON "mission_actions" ("source_event_id");
CREATE INDEX IF NOT EXISTS "mission_actions_due_idx" ON "mission_actions" ("due_at");
CREATE UNIQUE INDEX IF NOT EXISTS "mission_actions_idempotency_idx" ON "mission_actions" ("idempotency_key");

View File

@@ -15,6 +15,69 @@
"when": 1780306600000,
"tag": "0001_central_gitea_unified_actor",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1780306700000,
"tag": "0002_workflow_runs",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1780306800000,
"tag": "0003_workflow_phase2",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1780306900000,
"tag": "0004_qscore_snapshots",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1780307000000,
"tag": "0005_mission_registry",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1780481100000,
"tag": "0006_conversations_active_missions",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1780481200000,
"tag": "0007_grow_event_backbone",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1780481300000,
"tag": "0008_home_notifications",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1780481400000,
"tag": "0009_mission_suggestions",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1780481500000,
"tag": "0010_mission_actions",
"breakpoints": true
}
]
}
}

View File

@@ -8,23 +8,31 @@
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"workflows:smoke": "tsx src/workflows/smoke-test.ts",
"rivet:actors:list": "tsx scripts/rivet-actors.ts list",
"rivet:actors:flush": "tsx scripts/rivet-actors.ts flush",
"rivet:actors:flush:crashed": "tsx scripts/rivet-actors.ts flush --crashed-only",
"rivet:actors:flush:grow": "tsx scripts/rivet-actors.ts flush --name growActor",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:studio": "drizzle-kit studio",
"compose:up": "docker compose up -d",
"compose:down": "docker compose down",
"docker:opencode:build": "docker build -f docker/opencode/Dockerfile -t growqr/opencode:dev ."
"docker:opencode:build": "docker build --build-arg GROWQR_IMAGE_VERSION=${OPENCODE_IMAGE_VERSION:-dev} --build-arg GROWQR_PROMPT_VERSION=${PROMPT_VERSION:-4} --build-arg GROWQR_MIGRATION_VERSION=${MIGRATION_VERSION:-1} -f docker/opencode/Dockerfile -t growqr/opencode:dev ."
},
"dependencies": {
"@ai-sdk/openai": "^3.0.63",
"@clerk/backend": "^1.21.0",
"@hono/node-server": "^1.13.7",
"ai": "^6.0.177",
"dockerode": "^4.0.7",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.36.4",
"drizzle-orm": "^0.45.1",
"hono": "^4.6.14",
"pino": "^9.5.0",
"pino-pretty": "^13.0.0",
"postgres": "^3.4.5",
"redis": "^4.7.0",
"rivetkit": "^2.2.1",
"zod": "^3.24.1"
},

4251
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

9
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,9 @@
allowBuilds:
'@clerk/shared': true
'@sandbox-agent/cli-darwin-arm64': true
better-sqlite3: false
cbor-extract: false
cpu-features: false
esbuild: false
protobufjs: false
ssh2: false

View File

@@ -73,11 +73,10 @@ Assistant calls analyze_resume → "Here's your analysis: [results]. Your streng
## Workflow Tags (put at the VERY END, on their own line)
- [WORKFLOW: interview-to-offer] — full interview prep pipeline
- [WORKFLOW: interview-practice] — interview sessions with Sara
- [WORKFLOW: interview-practice] — interview sessions with the Interview Agent
- [WORKFLOW: resume-boost] — resume analysis and optimization
- [WORKFLOW: roleplay-practice] — roleplay sessions with Emily
- [WORKFLOW: roleplay-practice] — roleplay sessions with Roleplay Agent
- [WORKFLOW: career-switch] — career change navigation
- [WORKFLOW: job-search] — job discovery
- [WORKFLOW: job-preparation] — broad company preparation
NEVER mention these tags in your visible text. They are system-internal.

View File

@@ -0,0 +1,7 @@
PROMPT_VERSION: 1
Role: GrowQR workflow artifact specialist.
Inputs: workflow goal, user context, existing memory files.
Output: write markdown artifact under /workspace/artifacts/{workflow}/{run}/{module}.md.
Required sections: Summary, User Inputs, Recommendations, Next Actions, Memory To Save.
Metadata JSON keys: workflowId, moduleId, artifactType, qscoreDimensions.
Do not claim external service completion. If required data is missing, mark waiting_for_input.

View File

@@ -0,0 +1,7 @@
PROMPT_VERSION: 1
Role: Interview preparation strategist.
Inputs: target role/company, job description, resume/profile memory.
Output path: /workspace/artifacts/interview-to-offer/{runId}/interview-plan.md
Required sections: Role Fit Hypothesis, Likely Questions, STAR Story Bank, Practice Schedule, Risks, Next Actions.
Metadata JSON keys: workflowId, moduleId, artifactType, qscoreDimensions, readinessRisks.
Save durable memory: target role, interview date if known, strengths, gaps.

View File

@@ -0,0 +1,7 @@
PROMPT_VERSION: 1
Role: Interview preparation strategist.
Inputs: target role/company, job description, resume/profile memory.
Output path: /workspace/artifacts/interview-to-offer/{runId}/interview-plan.md
Required sections: Role Fit Hypothesis, Likely Questions, STAR Story Bank, Practice Schedule, Risks, Next Actions.
Metadata JSON keys: workflowId, moduleId, artifactType, qscoreDimensions, readinessRisks.
Save durable memory: target role, interview date if known, strengths, gaps.

View File

@@ -0,0 +1,7 @@
PROMPT_VERSION: 1
Role: GrowQR workflow artifact specialist.
Inputs: workflow goal, user context, existing memory files.
Output: write markdown artifact under /workspace/artifacts/{workflow}/{run}/{module}.md.
Required sections: Summary, User Inputs, Recommendations, Next Actions, Memory To Save.
Metadata JSON keys: workflowId, moduleId, artifactType, qscoreDimensions.
Do not claim external service completion. If required data is missing, mark waiting_for_input.

View File

@@ -0,0 +1,7 @@
PROMPT_VERSION: 1
Role: Interview preparation strategist.
Inputs: target role/company, job description, resume/profile memory.
Output path: /workspace/artifacts/interview-to-offer/{runId}/interview-plan.md
Required sections: Role Fit Hypothesis, Likely Questions, STAR Story Bank, Practice Schedule, Risks, Next Actions.
Metadata JSON keys: workflowId, moduleId, artifactType, qscoreDimensions, readinessRisks.
Save durable memory: target role, interview date if known, strengths, gaps.

View File

@@ -0,0 +1,7 @@
PROMPT_VERSION: 1
Role: Interview preparation strategist.
Inputs: target role/company, job description, resume/profile memory.
Output path: /workspace/artifacts/interview-to-offer/{runId}/interview-plan.md
Required sections: Role Fit Hypothesis, Likely Questions, STAR Story Bank, Practice Schedule, Risks, Next Actions.
Metadata JSON keys: workflowId, moduleId, artifactType, qscoreDimensions, readinessRisks.
Save durable memory: target role, interview date if known, strengths, gaps.

View File

@@ -0,0 +1,7 @@
PROMPT_VERSION: 1
Role: GrowQR workflow artifact specialist.
Inputs: workflow goal, user context, existing memory files.
Output: write markdown artifact under /workspace/artifacts/{workflow}/{run}/{module}.md.
Required sections: Summary, User Inputs, Recommendations, Next Actions, Memory To Save.
Metadata JSON keys: workflowId, moduleId, artifactType, qscoreDimensions.
Do not claim external service completion. If required data is missing, mark waiting_for_input.

View File

@@ -0,0 +1,7 @@
PROMPT_VERSION: 1
Role: GrowQR workflow artifact specialist.
Inputs: workflow goal, user context, existing memory files.
Output: write markdown artifact under /workspace/artifacts/{workflow}/{run}/{module}.md.
Required sections: Summary, User Inputs, Recommendations, Next Actions, Memory To Save.
Metadata JSON keys: workflowId, moduleId, artifactType, qscoreDimensions.
Do not claim external service completion. If required data is missing, mark waiting_for_input.

View File

@@ -0,0 +1,7 @@
PROMPT_VERSION: 1
Role: GrowQR workflow artifact specialist.
Inputs: workflow goal, user context, existing memory files.
Output: write markdown artifact under /workspace/artifacts/{workflow}/{run}/{module}.md.
Required sections: Summary, User Inputs, Recommendations, Next Actions, Memory To Save.
Metadata JSON keys: workflowId, moduleId, artifactType, qscoreDimensions.
Do not claim external service completion. If required data is missing, mark waiting_for_input.

179
scripts/rivet-actors.ts Normal file
View File

@@ -0,0 +1,179 @@
import "dotenv/config";
import { config } from "../src/config.js";
type ActorRecord = {
actor_id: string;
name: string;
key?: string | null;
namespace_id?: string;
runner_name_selector?: string;
crash_policy?: string;
create_ts?: number;
start_ts?: number | null;
connectable_ts?: number | null;
sleep_ts?: number | null;
destroy_ts?: number | null;
error?: unknown;
};
type ListActorsResponse = {
actors?: ActorRecord[];
pagination?: { cursor?: string | null };
};
type Options = {
command: "list" | "flush";
endpoint: string;
namespace: string;
name?: string;
key?: string;
crashedOnly: boolean;
dryRun: boolean;
};
function parseArgs(): Options {
const args = process.argv.slice(2);
const command = args[0] === "flush" ? "flush" : "list";
const options: Options = {
command,
endpoint: process.env.RIVET_ENDPOINT ?? config.rivetEndpoint,
namespace: process.env.RIVET_NAMESPACE ?? "default",
crashedOnly: false,
dryRun: false,
};
for (let i = command === "flush" || args[0] === "list" ? 1 : 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--") continue;
else if (arg === "--endpoint") options.endpoint = requireValue(args, ++i, arg);
else if (arg === "--namespace") options.namespace = requireValue(args, ++i, arg);
else if (arg === "--name") options.name = requireValue(args, ++i, arg);
else if (arg === "--key") options.key = requireValue(args, ++i, arg);
else if (arg === "--crashed-only") options.crashedOnly = true;
else if (arg === "--dry-run") options.dryRun = true;
else if (arg === "--help" || arg === "-h") printHelpAndExit();
else throw new Error(`Unknown argument: ${arg}`);
}
return options;
}
function requireValue(args: string[], index: number, flag: string) {
const value = args[index];
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${flag}`);
return value;
}
function printHelpAndExit(): never {
console.log(`Usage:
pnpm rivet:actors:list [--name growActor] [--key user_123] [--crashed-only]
pnpm rivet:actors:flush -- --name growActor [--key user_123] [--crashed-only] [--dry-run]
Environment:
RIVET_ENDPOINT Defaults to config.rivetEndpoint (${config.rivetEndpoint})
RIVET_NAMESPACE Defaults to default
Examples:
pnpm rivet:actors:list
pnpm rivet:actors:flush:crashed
pnpm rivet:actors:flush:grow
pnpm rivet:actors:flush -- --name growActor --key user_123
`);
process.exit(0);
}
function buildEndpoint(rawEndpoint: string) {
const url = new URL(rawEndpoint);
const token = url.password || process.env.RIVET_TOKEN || process.env.RIVET_ADMIN_TOKEN || "dev-admin-token";
url.username = "";
url.password = "";
return {
baseUrl: url.toString().replace(/\/$/, ""),
headers: token ? { Authorization: `Bearer ${token}`, "x-rivet-token": token } : {},
};
}
function actorHasError(actor: ActorRecord) {
return Boolean(actor.error);
}
async function listActors(options: Options) {
const { baseUrl, headers } = buildEndpoint(options.endpoint);
const actors: ActorRecord[] = [];
let cursor: string | null | undefined;
do {
const url = new URL(`${baseUrl}/actors`);
url.searchParams.set("namespace", options.namespace);
if (options.name) url.searchParams.set("name", options.name);
if (options.key) url.searchParams.set("key", options.key);
if (cursor) url.searchParams.set("cursor", cursor);
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`Failed to list actors: ${response.status} ${response.statusText}\n${await response.text()}`);
}
const body = (await response.json()) as ListActorsResponse;
actors.push(...(body.actors ?? []));
cursor = body.pagination?.cursor;
} while (cursor);
return options.crashedOnly ? actors.filter(actorHasError) : actors;
}
async function deleteActor(options: Options, actor: ActorRecord) {
const { baseUrl, headers } = buildEndpoint(options.endpoint);
const url = new URL(`${baseUrl}/actors/${encodeURIComponent(actor.actor_id)}`);
url.searchParams.set("namespace", options.namespace);
if (options.dryRun) return;
const response = await fetch(url, { method: "DELETE", headers });
if (!response.ok) {
throw new Error(`Failed to delete ${actor.actor_id}: ${response.status} ${response.statusText}\n${await response.text()}`);
}
}
function printActors(actors: ActorRecord[]) {
if (!actors.length) {
console.log("No actors matched.");
return;
}
for (const actor of actors) {
console.log(
[
actor.actor_id,
actor.name,
`key=${actor.key ?? ""}`,
actor.error ? `error=${JSON.stringify(actor.error)}` : "ok",
].join("\t"),
);
}
}
async function main() {
const options = parseArgs();
const actors = await listActors(options);
if (options.command === "list") {
printActors(actors);
return;
}
if (!options.name && !options.crashedOnly && !options.key) {
throw new Error("Refusing to flush every actor without a filter. Pass --name, --key, or --crashed-only.");
}
printActors(actors);
for (const actor of actors) {
await deleteActor(options, actor);
console.log(`${options.dryRun ? "Would delete" : "Deleted"} ${actor.name} ${actor.actor_id} key=${actor.key ?? ""}`);
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});

View File

@@ -0,0 +1,39 @@
# Conversation Actor Prototype
Standalone Rivet actor prototype based on Rivet's `examples/ai-agent` shape, but with actor-local SQLite + Drizzle.
This folder is intentionally **not wired into `src/actors/registry.ts` yet**.
## Files
- `conversation-actor.ts` — Rivet actor with queue-driven message processing and streaming AI SDK response events.
- `schema.ts` — Drizzle SQLite schema for messages, tool calls, and summaries.
- `migrations.ts` — tiny inline SQLite migration for this isolated prototype.
- `agent.ts` — AI SDK v6 `streamText` wrapper and stub memory tools.
- `types.ts` — public event/message/status types.
## Actor key
Use a compound actor key when it is eventually wired:
```ts
client.conversationActor.getOrCreate([userId, conversationId])
```
## Runtime env
The prototype expects:
```txt
OPENAI_API_KEY=...
CONVERSATION_ACTOR_MODEL=...
```
No default model is hardcoded so we do not accidentally freeze this prototype to a stale model id.
## Next steps when wiring later
1. Add `conversationActor` to `src/actors/registry.ts`.
2. Decide whether `userActor` creates conversation ids or frontend supplies them.
3. Replace stub memory tools in `agent.ts` with actor-to-actor calls to `memoryActor[userId]`.
4. Move inline migrations to generated Drizzle migrations if/when this becomes production code.

View File

@@ -0,0 +1,76 @@
import { createOpenAI } from "@ai-sdk/openai";
import { streamText, tool } from "ai";
import { z } from "zod";
import type { ConversationMessage } from "./types.js";
import { config } from "../../config.js";
const SYSTEM_PROMPT = `You are the GrowQR conversation agent.
Keep answers concise, practical, and focused on the user's goals.
When you learn durable information, call the memory tools. For now these tools
are intentionally stubbed so this actor can stay isolated and unwired.`;
function normalizeModel(model: string): string {
if (config.llmProvider === "opencode" && model.startsWith("opencode/")) {
return model.slice("opencode/".length);
}
return model;
}
const conversationProvider = createOpenAI({
apiKey: config.llmApiKey,
baseURL: config.llmBaseUrl.replace(/\/$/, ""),
name: config.llmProvider,
});
export function getConversationModel() {
const modelId = process.env.CONVERSATION_ACTOR_MODEL ?? config.agentModel;
if (!config.llmApiKey) {
throw new Error("Missing LLM_API_KEY/OPENCODE_API_KEY for conversation streaming.");
}
return conversationProvider.chat(normalizeModel(modelId));
}
export function buildModelMessages(messages: ConversationMessage[]) {
return messages.map((message) => ({
role: message.role,
content: message.content,
}));
}
export function streamConversationResponse(messages: ConversationMessage[]) {
return streamText({
model: getConversationModel(),
system: SYSTEM_PROMPT,
messages: buildModelMessages(messages),
tools: {
readMemory: tool({
description: "Read a markdown memory file. Stubbed until memoryActor is wired.",
inputSchema: z.object({
path: z.string().describe("Memory path, e.g. /profile.md"),
}),
execute: async ({ path }) => ({
path,
found: false,
content: "",
note: "memoryActor is not wired yet",
}),
}),
writeMemory: tool({
description: "Write a markdown memory file. Stubbed until memoryActor is wired.",
inputSchema: z.object({
path: z.string(),
contentMd: z.string(),
reason: z.string().optional(),
}),
execute: async ({ path, contentMd, reason }) => ({
path,
bytes: contentMd.length,
reason,
saved: false,
note: "memoryActor is not wired yet",
}),
}),
},
});
}

View File

@@ -0,0 +1,233 @@
import { asc, eq } from "drizzle-orm";
import { actor, event, queue } from "rivetkit";
import { db as drizzleDb } from "rivetkit/db/drizzle";
import { streamConversationResponse } from "./agent.js";
import { migrateConversationDb } from "./migrations.js";
import {
conversationMessages,
conversationSchema,
} from "./schema.js";
import type {
ConversationMessage,
ConversationQueueMessage,
ConversationResponseEvent,
ConversationStatus,
} from "./types.js";
const buildId = (prefix: string) =>
`${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
function now() {
return Date.now();
}
function conversationIdFromKey(key: unknown[]) {
return String(key[1] ?? key[0] ?? "default");
}
function toPublicMessage(row: typeof conversationMessages.$inferSelect): ConversationMessage {
return {
id: row.id,
conversationId: row.conversationId,
role: row.role,
sender: row.sender,
content: row.content,
createdAt:
row.createdAt instanceof Date ? row.createdAt.getTime() : Number(row.createdAt),
};
}
export const conversationActor = actor({
// Keep only small runtime state here. Message history lives in actor-local
// SQLite via Drizzle so this actor can grow without bloating c.state.
state: {
status: {
state: "idle",
updatedAt: Date.now(),
} as ConversationStatus,
},
db: drizzleDb({
schema: conversationSchema,
}),
queues: {
message: queue<ConversationQueueMessage>(),
},
events: {
messageAdded: event<ConversationMessage>(),
status: event<ConversationStatus>(),
response: event<ConversationResponseEvent>(),
},
onCreate: async (c) => {
await migrateConversationDb(c.db);
},
onWake: async (c) => {
await migrateConversationDb(c.db);
},
run: async (c) => {
for await (const queued of c.queue.iter()) {
const { body } = queued;
if (!body?.text || typeof body.text !== "string") continue;
const conversationId = conversationIdFromKey(c.key);
const sender = body.sender?.trim() || "User";
const userMessage: ConversationMessage = {
id: buildId("user"),
conversationId,
role: "user",
sender,
content: body.text.trim(),
createdAt: now(),
};
await c.db.insert(conversationMessages).values({
...userMessage,
createdAt: new Date(userMessage.createdAt),
});
c.broadcast("messageAdded", userMessage);
const historyRows = await c.db
.select()
.from(conversationMessages)
.where(eq(conversationMessages.conversationId, conversationId))
.orderBy(asc(conversationMessages.createdAt));
const history = historyRows.map(toPublicMessage);
const assistantMessage: ConversationMessage = {
id: buildId("assistant"),
conversationId,
role: "assistant",
sender: "GrowQR",
content: "",
createdAt: now(),
};
await c.db.insert(conversationMessages).values({
...assistantMessage,
createdAt: new Date(assistantMessage.createdAt),
});
c.broadcast("messageAdded", assistantMessage);
c.state.status = { state: "thinking", updatedAt: now() };
c.broadcast("status", c.state.status);
try {
const result = streamConversationResponse(history);
let content = "";
for await (const delta of result.textStream) {
if (c.aborted) break;
content += delta;
c.broadcast("response", {
messageId: assistantMessage.id,
delta,
content,
done: false,
});
}
assistantMessage.content = content || assistantMessage.content;
await c.db
.update(conversationMessages)
.set({ content: assistantMessage.content })
.where(eq(conversationMessages.id, assistantMessage.id));
c.broadcast("response", {
messageId: assistantMessage.id,
delta: "",
content: assistantMessage.content,
done: true,
});
c.state.status = { state: "idle", updatedAt: now() };
c.broadcast("status", c.state.status);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown conversation error";
assistantMessage.content =
assistantMessage.content ||
"I hit a snag while responding. Please try again.";
await c.db
.update(conversationMessages)
.set({ content: assistantMessage.content })
.where(eq(conversationMessages.id, assistantMessage.id));
c.state.status = {
state: "error",
updatedAt: now(),
error: errorMessage,
};
c.broadcast("response", {
messageId: assistantMessage.id,
delta: "",
content: assistantMessage.content,
done: true,
error: errorMessage,
});
c.broadcast("status", c.state.status);
}
}
},
actions: {
sendMessage: async (c, input: ConversationQueueMessage) => {
await c.queue.send("message", input);
return { queued: true };
},
getHistory: async (c): Promise<ConversationMessage[]> => {
const conversationId = conversationIdFromKey(c.key);
const rows = await c.db
.select()
.from(conversationMessages)
.where(eq(conversationMessages.conversationId, conversationId))
.orderBy(asc(conversationMessages.createdAt));
return rows.map(toPublicMessage);
},
addMessage: async (
c,
input: { role: "user" | "assistant"; content: string; sender?: string; id?: string },
): Promise<ConversationMessage> => {
const conversationId = conversationIdFromKey(c.key);
const message: ConversationMessage = {
id: input.id ?? buildId(input.role),
conversationId,
role: input.role,
sender: input.sender?.trim() || (input.role === "assistant" ? "GrowQR" : "User"),
content: input.content,
createdAt: now(),
};
await c.db.insert(conversationMessages).values({
...message,
createdAt: new Date(message.createdAt),
});
c.broadcast("messageAdded", message);
return message;
},
clearHistory: async (c) => {
const conversationId = conversationIdFromKey(c.key);
await c.db
.delete(conversationMessages)
.where(eq(conversationMessages.conversationId, conversationId));
c.state.status = { state: "idle", updatedAt: now() };
c.broadcast("status", c.state.status);
return { ok: true };
},
getStatus: (c): ConversationStatus => c.state.status,
},
});

View File

@@ -0,0 +1,3 @@
export { conversationActor } from "./conversation-actor.js";
export * from "./schema.js";
export * from "./types.js";

View File

@@ -0,0 +1,42 @@
import type { RawAccess } from "rivetkit/db";
// Tiny inline migration for the actor-local SQLite database. This keeps the
// example self-contained and avoids wiring drizzle-kit output into the app yet.
export async function migrateConversationDb(db: RawAccess) {
await db.execute(`
CREATE TABLE IF NOT EXISTS conversation_messages (
id TEXT PRIMARY KEY NOT NULL,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
sender TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS conversation_messages_conversation_created_at_idx
ON conversation_messages (conversation_id, created_at);
CREATE TABLE IF NOT EXISTS conversation_tool_calls (
id TEXT PRIMARY KEY NOT NULL,
conversation_id TEXT NOT NULL,
message_id TEXT NOT NULL REFERENCES conversation_messages(id) ON DELETE CASCADE,
tool_name TEXT NOT NULL,
args_json TEXT,
result_json TEXT,
status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'done', 'error')),
created_at INTEGER NOT NULL,
finished_at INTEGER
);
CREATE INDEX IF NOT EXISTS conversation_tool_calls_conversation_idx
ON conversation_tool_calls (conversation_id, created_at);
CREATE TABLE IF NOT EXISTS conversation_summaries (
id TEXT PRIMARY KEY NOT NULL,
conversation_id TEXT NOT NULL,
content_md TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`);
}

View File

@@ -0,0 +1,89 @@
import { relations, sql } from "drizzle-orm";
import {
index,
integer,
sqliteTable,
text,
} from "rivetkit/db/drizzle";
export const conversationMessages = sqliteTable(
"conversation_messages",
{
id: text("id").primaryKey(),
conversationId: text("conversation_id").notNull(),
role: text("role", { enum: ["user", "assistant"] }).notNull(),
sender: text("sender").notNull(),
content: text("content").notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
},
(table) => ({
conversationCreatedAtIdx: index("conversation_messages_conversation_created_at_idx").on(
table.conversationId,
table.createdAt,
),
}),
);
export const conversationToolCalls = sqliteTable(
"conversation_tool_calls",
{
id: text("id").primaryKey(),
conversationId: text("conversation_id").notNull(),
messageId: text("message_id")
.notNull()
.references(() => conversationMessages.id, { onDelete: "cascade" }),
toolName: text("tool_name").notNull(),
argsJson: text("args_json", { mode: "json" }).$type<Record<string, unknown>>(),
resultJson: text("result_json", { mode: "json" }).$type<Record<string, unknown>>(),
status: text("status", { enum: ["running", "done", "error"] })
.notNull()
.default("running"),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
finishedAt: integer("finished_at", { mode: "timestamp_ms" }),
},
(table) => ({
conversationIdx: index("conversation_tool_calls_conversation_idx").on(
table.conversationId,
table.createdAt,
),
}),
);
export const conversationSummaries = sqliteTable("conversation_summaries", {
id: text("id").primaryKey(),
conversationId: text("conversation_id").notNull(),
contentMd: text("content_md").notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch('subsec') * 1000)`),
});
export const conversationMessagesRelations = relations(
conversationMessages,
({ many }) => ({
toolCalls: many(conversationToolCalls),
}),
);
export const conversationToolCallsRelations = relations(
conversationToolCalls,
({ one }) => ({
message: one(conversationMessages, {
fields: [conversationToolCalls.messageId],
references: [conversationMessages.id],
}),
}),
);
export const conversationSchema = {
conversationMessages,
conversationToolCalls,
conversationSummaries,
};
export type ConversationMessageRow = typeof conversationMessages.$inferSelect;
export type NewConversationMessageRow = typeof conversationMessages.$inferInsert;
export type ConversationToolCallRow = typeof conversationToolCalls.$inferSelect;
export type NewConversationToolCallRow = typeof conversationToolCalls.$inferInsert;
export type ConversationSummaryRow = typeof conversationSummaries.$inferSelect;

View File

@@ -0,0 +1,39 @@
export type ConversationRole = "user" | "assistant";
export type ConversationStatus = {
state: "idle" | "thinking" | "error";
updatedAt: number;
error?: string;
};
export type ConversationMessage = {
id: string;
conversationId: string;
role: ConversationRole;
sender: string;
content: string;
createdAt: number;
};
export type ConversationQueueMessage = {
text: string;
sender?: string;
};
export type ConversationResponseEvent = {
messageId: string;
delta: string;
content: string;
done: boolean;
error?: string;
};
export type ConversationToolEvent = {
id: string;
messageId: string;
toolName: string;
status: "running" | "done" | "error";
args?: Record<string, unknown>;
result?: Record<string, unknown>;
error?: string;
};

View File

@@ -0,0 +1 @@
export { userEventActor } from "./user-event-actor.js";

View File

@@ -0,0 +1,224 @@
import { actor, event, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import { and, eq } from "drizzle-orm";
import { db } from "../../db/client.js";
import { growEvents, missionArtifacts } from "../../db/schema.js";
import { listActiveMissionsPg, upsertActiveMissionPg } from "../../grow/persistence.js";
import type { GrowActiveMission, MissionActorType, MissionSnapshot } from "../missions/types.js";
import { applyQscoreProjection } from "../../events/projectors/qscore-projector.js";
import { applyServiceSessionProjection } from "../../events/projectors/service-session-projector.js";
import { getProjectionInsight } from "../../events/projectors/projection-agent.js";
import { markGrowEventFailed, markGrowEventProcessed, markGrowEventProcessing } from "../../events/record-grow-event.js";
import { reducersForMission } from "../../missions/event-reducers.js";
import type { MissionArtifactPatch, MissionStagePatch } from "../../missions/reducer-types.js";
import { createMissionActionsFromPatches } from "../../missions/actions.js";
export type UserEventCommand = {
userId: string;
eventId: string;
};
type UserEventActorState = {
userId: string;
processedCount: number;
lastEventId?: string;
lastError?: string;
processedEventIds: string[];
updatedAt?: string;
};
function summarizeMissionSnapshot(snapshot: MissionSnapshot): GrowActiveMission {
return {
instanceId: snapshot.instanceId,
missionId: snapshot.missionId,
workflowId: snapshot.workflowId,
title: snapshot.title,
shortTitle: snapshot.shortTitle,
status: snapshot.status,
progressPercent: snapshot.progressPercent,
currentStageId: snapshot.currentStageId,
goal: snapshot.goal,
actorType: actorTypeFor(snapshot.missionId),
createdAt: new Date(snapshot.createdAt).getTime(),
updatedAt: new Date(snapshot.updatedAt).getTime(),
};
}
function actorTypeFor(missionId: string): MissionActorType | undefined {
if (missionId === "interview-to-offer") return "interviewToOfferMissionActor";
if (missionId === "career-transition") return "careerTransitionMissionActor";
if (missionId === "salary-negotiation-war-room") return "salaryNegotiationWarRoomMissionActor";
if (missionId === "promotion-readiness") return "promotionReadinessMissionActor";
if (missionId === "personal-brand-opportunity-engine") return "personalBrandOpportunityEngineMissionActor";
return undefined;
}
function missionActorHandle(client: any, userId: string, mission: GrowActiveMission) {
const key = [userId, mission.instanceId];
switch (mission.actorType) {
case "interviewToOfferMissionActor": return client.interviewToOfferMissionActor.getOrCreate(key);
case "careerTransitionMissionActor": return client.careerTransitionMissionActor.getOrCreate(key);
case "salaryNegotiationWarRoomMissionActor": return client.salaryNegotiationWarRoomMissionActor.getOrCreate(key);
case "promotionReadinessMissionActor": return client.promotionReadinessMissionActor.getOrCreate(key);
case "personalBrandOpportunityEngineMissionActor": return client.personalBrandOpportunityEngineMissionActor.getOrCreate(key);
default: return null;
}
}
async function applyStagePatches(actorHandle: any, patches: MissionStagePatch[]) {
let snapshot: MissionSnapshot | null = null;
const deduped = new Map<string, MissionStagePatch>();
for (const patch of patches) deduped.set(`${patch.stageId}:${patch.status ?? ""}:${patch.progressPercent ?? ""}`, patch);
for (const patch of deduped.values()) {
snapshot = await actorHandle.updateStage(patch);
}
return snapshot;
}
async function applyArtifactPatches(input: {
actorHandle: any;
userId: string;
mission: GrowActiveMission;
eventId: string;
serviceId: string;
externalId?: string;
patches: MissionArtifactPatch[];
}) {
const created = [];
for (const patch of input.patches) {
const artifact = await input.actorHandle.addArtifact({
type: patch.type,
title: patch.title,
status: "ready",
summary: patch.summary,
contentMd: patch.contentMd,
metadata: { ...(patch.metadata ?? {}), sourceEventId: input.eventId },
});
created.push(artifact);
await db.insert(missionArtifacts).values({
id: artifact.id,
userId: input.userId,
missionInstanceId: input.mission.instanceId,
missionId: input.mission.missionId,
stageId: patch.stageId,
sourceEventId: input.eventId,
serviceId: input.serviceId,
externalId: input.externalId,
type: patch.type,
title: patch.title,
status: "ready",
summary: patch.summary,
contentMd: patch.contentMd,
metadata: patch.metadata,
createdAt: new Date(artifact.createdAt),
updatedAt: new Date(artifact.updatedAt),
}).onConflictDoNothing();
}
return created;
}
export const userEventActor = actor({
options: { name: "User Event Parser", icon: "route", noSleep: true, actionTimeout: 300_000 },
state: { userId: "", processedCount: 0, processedEventIds: [] } as UserEventActorState,
events: {
updated: event<UserEventActorState>(),
eventProcessed: event<{ eventId: string; userId: string }>(),
},
queues: {
events: queue<UserEventCommand>(),
},
actions: {
enqueueEvent: async (c, input: UserEventCommand) => {
if (c.state.userId && c.state.userId !== input.userId) throw new Error("userEventActor initialized for a different user");
c.state.userId = input.userId;
c.state.updatedAt = new Date().toISOString();
await c.queue.send("events", input);
c.broadcast("updated", c.state);
return { queued: true };
},
getState: (c) => c.state,
},
run: workflow(async (ctx) => {
await ctx.loop("user-event-loop", async (loopCtx) => {
const message = await loopCtx.queue.next("wait-event", { names: ["events"] });
const cmd = message.body as UserEventCommand;
await loopCtx.step(`process-event:${cmd.eventId}`, async () => {
if (loopCtx.state.processedEventIds.includes(cmd.eventId)) return;
await markGrowEventProcessing(cmd.eventId);
const [row] = await db
.select()
.from(growEvents)
.where(and(eq(growEvents.id, cmd.eventId), eq(growEvents.userId, cmd.userId)))
.limit(1);
if (!row) throw new Error(`grow event not found for user: ${cmd.eventId}`);
try {
await applyServiceSessionProjection(row);
const qscoreResult = await applyQscoreProjection(row);
const activeRows = await listActiveMissionsPg(cmd.userId);
const insight = await getProjectionInsight({
event: row,
qscoreSignals: qscoreResult.signals,
activeMissionIds: activeRows.map((item) => item.mission.missionId),
});
const client = loopCtx.client<any>();
for (const active of activeRows) {
const mission = active.mission;
const actorHandle = missionActorHandle(client, cmd.userId, mission);
if (!actorHandle) continue;
await actorHandle.ingestEvent({ eventId: row.id }).catch(() => undefined);
const reducers = reducersForMission(mission.missionId);
if (!reducers.length) continue;
for (const reducer of reducers) {
const reduceCtx = { userId: cmd.userId, activeMission: mission, event: row, qscoreSignals: qscoreResult.signals, insight };
if (!reducer.accepts(reduceCtx)) continue;
const reduction = reducer.reduce(reduceCtx);
if (!reduction.stagePatches.length && !reduction.artifacts.length && !reduction.actions.length && !reduction.eventMessage) continue;
if (reduction.eventMessage) {
await actorHandle.recordEvent({ type: row.type, message: reduction.eventMessage, payload: { sourceEventId: row.id } });
}
if (reduction.stagePatches.length) await applyStagePatches(actorHandle, reduction.stagePatches);
await applyArtifactPatches({
actorHandle,
userId: cmd.userId,
mission,
eventId: row.id,
serviceId: row.source,
externalId: typeof row.correlation?.sessionId === "string" ? row.correlation.sessionId : undefined,
patches: reduction.artifacts,
});
if (reduction.actions.length) {
await createMissionActionsFromPatches({
userId: cmd.userId,
mission,
eventId: row.id,
patches: reduction.actions,
});
}
const finalSnapshot = (await actorHandle.getState()) as MissionSnapshot;
await upsertActiveMissionPg(cmd.userId, summarizeMissionSnapshot(finalSnapshot), finalSnapshot);
}
}
await markGrowEventProcessed(cmd.eventId);
loopCtx.state.processedCount += 1;
loopCtx.state.lastEventId = cmd.eventId;
loopCtx.state.lastError = undefined;
loopCtx.state.updatedAt = new Date().toISOString();
loopCtx.state.processedEventIds = [cmd.eventId, ...loopCtx.state.processedEventIds.filter((id) => id !== cmd.eventId)].slice(0, 500);
loopCtx.broadcast("eventProcessed", { eventId: cmd.eventId, userId: cmd.userId });
loopCtx.broadcast("updated", loopCtx.state);
} catch (err) {
await markGrowEventFailed(cmd.eventId, err);
loopCtx.state.lastError = err instanceof Error ? err.message : String(err);
loopCtx.state.updatedAt = new Date().toISOString();
loopCtx.broadcast("updated", loopCtx.state);
throw err;
}
});
});
}),
});

View File

@@ -0,0 +1,193 @@
import { actor, event } from "rivetkit";
import type { CreateConversationInput, GrowActorState, GrowConversation, SetupGrowInput } from "./types.js";
import type { GrowActiveMission } from "../missions/types.js";
const buildId = (prefix: string) =>
`${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const now = () => Date.now();
function defaultTitle(index: number) {
return index === 0 ? "Talk to Me" : `Conversation ${index + 1}`;
}
function ensureInitialized(state: GrowActorState) {
if (!state.userId) throw new Error("Grow actor is not initialized");
}
function normalizeState(state: GrowActorState) {
state.conversations ??= [];
state.activeConversationId ??= null;
state.activeMissions ??= [];
state.createdAt ??= now();
state.updatedAt ??= now();
}
function createConversationRecord(state: GrowActorState, input: CreateConversationInput = {}): GrowConversation {
const timestamp = now();
return {
id: buildId("conversation"),
title: input.title?.trim() || defaultTitle(state.conversations.length),
createdAt: timestamp,
updatedAt: timestamp,
};
}
export const growActor = actor({
state: {
userId: "",
conversations: [],
activeConversationId: null,
activeMissions: [],
createdAt: Date.now(),
updatedAt: Date.now(),
} as GrowActorState,
events: {
ready: event<{ userId: string; conversationId: string }>(),
conversationCreated: event<GrowConversation>(),
conversationReset: event<GrowConversation>(),
missionActivated: event<GrowActiveMission>(),
missionUpdated: event<GrowActiveMission>(),
},
actions: {
setup: async (c, input: SetupGrowInput) => {
if (c.state.userId && c.state.userId !== input.userId) {
throw new Error("Grow actor already bound to a different user");
}
normalizeState(c.state);
c.state.userId = input.userId;
if (!c.state.conversations.length) {
const conversation = createConversationRecord(c.state, { title: "Talk to Me" });
c.state.conversations.push(conversation);
c.state.activeConversationId = conversation.id;
c.broadcast("conversationCreated", conversation);
} else if (!c.state.activeConversationId) {
c.state.activeConversationId = c.state.conversations[0]?.id ?? null;
}
const activeConversationId = c.state.activeConversationId;
if (!activeConversationId) throw new Error("Grow actor has no active conversation");
c.state.updatedAt = now();
c.broadcast("ready", {
userId: c.state.userId,
conversationId: activeConversationId,
});
return {
userId: c.state.userId,
activeConversationId: c.state.activeConversationId,
conversations: c.state.conversations,
activeMissions: c.state.activeMissions,
};
},
getState: async (c) => {
normalizeState(c.state);
ensureInitialized(c.state);
return {
userId: c.state.userId,
activeConversationId: c.state.activeConversationId,
conversations: c.state.conversations,
activeMissions: c.state.activeMissions,
};
},
createConversation: async (c, input: CreateConversationInput = {}) => {
normalizeState(c.state);
ensureInitialized(c.state);
const conversation = createConversationRecord(c.state, input);
c.state.conversations.unshift(conversation);
c.state.activeConversationId = conversation.id;
c.state.updatedAt = conversation.updatedAt;
c.broadcast("conversationCreated", conversation);
return conversation;
},
listConversations: async (c) => {
normalizeState(c.state);
ensureInitialized(c.state);
return c.state.conversations;
},
getConversation: async (c, input: { conversationId: string }) => {
ensureInitialized(c.state);
return c.state.conversations.find((conversation) => conversation.id === input.conversationId) ?? null;
},
touchConversation: async (c, input: { conversationId: string; title?: string }) => {
ensureInitialized(c.state);
const conversation = c.state.conversations.find((item) => item.id === input.conversationId);
if (!conversation) throw new Error(`Unknown conversation: ${input.conversationId}`);
if (input.title?.trim()) conversation.title = input.title.trim();
conversation.updatedAt = now();
c.state.activeConversationId = conversation.id;
c.state.updatedAt = conversation.updatedAt;
return conversation;
},
resetConversation: async (c, input: { conversationId?: string; title?: string } = {}) => {
ensureInitialized(c.state);
const conversationId = input.conversationId ?? c.state.activeConversationId;
if (!conversationId) {
const created = createConversationRecord(c.state, { title: input.title ?? "Talk to Me" });
c.state.conversations.unshift(created);
c.state.activeConversationId = created.id;
c.state.updatedAt = created.updatedAt;
c.broadcast("conversationCreated", created);
return created;
}
const conversation = c.state.conversations.find((item) => item.id === conversationId);
if (!conversation) throw new Error(`Unknown conversation: ${conversationId}`);
conversation.title = input.title?.trim() || conversation.title;
conversation.updatedAt = now();
c.state.activeConversationId = conversation.id;
c.state.updatedAt = conversation.updatedAt;
c.broadcast("conversationReset", conversation);
return conversation;
},
registerActiveMission: async (c, input: GrowActiveMission) => {
normalizeState(c.state);
ensureInitialized(c.state);
const existingIndex = c.state.activeMissions.findIndex((mission) => mission.instanceId === input.instanceId);
const record = { ...input, updatedAt: now() };
if (existingIndex >= 0) {
c.state.activeMissions[existingIndex] = record;
c.broadcast("missionUpdated", record);
} else {
c.state.activeMissions.unshift(record);
c.broadcast("missionActivated", record);
}
c.state.updatedAt = record.updatedAt;
return record;
},
updateActiveMission: async (c, input: Pick<GrowActiveMission, "instanceId"> & Partial<GrowActiveMission>) => {
normalizeState(c.state);
ensureInitialized(c.state);
const mission = c.state.activeMissions.find((item) => item.instanceId === input.instanceId);
if (!mission) throw new Error(`Unknown active mission: ${input.instanceId}`);
Object.assign(mission, input, { updatedAt: now() });
c.state.updatedAt = mission.updatedAt;
c.broadcast("missionUpdated", mission);
return mission;
},
listActiveMissions: async (c) => {
normalizeState(c.state);
ensureInitialized(c.state);
return c.state.activeMissions;
},
getActiveMission: async (c, input: { instanceId: string }) => {
normalizeState(c.state);
ensureInitialized(c.state);
return c.state.activeMissions.find((mission) => mission.instanceId === input.instanceId) ?? null;
},
},
});

2
src/actors/grow/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { growActor } from "./grow-actor.js";
export type { GrowActorState, GrowConversation } from "./types.js";

25
src/actors/grow/types.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { GrowActiveMission } from "../missions/types.js";
export type GrowConversation = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
};
export type GrowActorState = {
userId: string;
conversations: GrowConversation[];
activeConversationId: string | null;
activeMissions: GrowActiveMission[];
createdAt: number;
updatedAt: number;
};
export type CreateConversationInput = {
title?: string;
};
export type SetupGrowInput = {
userId: string;
};

View File

@@ -0,0 +1,64 @@
# Memory Actor Prototype
Small, isolated Rivet actor for per-user markdown memory stored in actor-local SQLite with Drizzle.
This folder is intentionally **not wired into `src/actors/registry.ts` yet**.
## Actor key
When wired later, use one memory actor per user:
```ts
client.memoryActor.getOrCreate([userId])
```
## Responsibilities
- Store markdown memory files.
- Read a memory file by path.
- List memory files by prefix.
- Search memory with simple SQLite `LIKE` matching.
- Queue writes/appends/deletes so mutations are serialized through the actor run loop.
- Broadcast update events.
## Files
- `memory-actor.ts` — actor, actions, queues, events.
- `schema.ts` — Drizzle SQLite tables.
- `migrations.ts` — tiny inline actor-local SQLite migration.
- `types.ts` — public types.
## Actions
```ts
write({ path, contentMd, tags })
append({ path, contentMd, tags })
delete({ path })
read(path)
list(prefix)
search(query, limit)
getStats()
```
## Events
```ts
updated: {
type: "written" | "appended" | "deleted";
path: string;
memory?: MemoryFile;
createdAt: number;
}
```
## Notes
This is intentionally lean:
- no embeddings yet
- no global database
- no Git/Gitea
- no connection to the conversation actor yet
- no registry changes yet
The conversation actor can later call this actor from its AI SDK memory tools.

View File

@@ -0,0 +1,3 @@
export { memoryActor } from "./memory-actor.js";
export * from "./schema.js";
export * from "./types.js";

View File

@@ -0,0 +1,292 @@
import { asc, desc, eq, like, or } from "drizzle-orm";
import { actor, event, queue, UserError } from "rivetkit";
import { db as drizzleDb } from "rivetkit/db/drizzle";
import { migrateMemoryDb } from "./migrations.js";
import { memoryEvents, memoryFiles, memorySchema } from "./schema.js";
import type {
MemoryAppendRequest,
MemoryDeleteRequest,
MemoryEvent,
MemoryFile,
MemorySearchResult,
MemoryWriteRequest,
} from "./types.js";
const MAX_PATH_LENGTH = 256;
const MAX_CONTENT_LENGTH = 128_000;
const MAX_SEARCH_RESULTS = 20;
const buildId = (prefix: string) =>
`${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const now = () => Date.now();
function normalizePath(path: string) {
const trimmed = path.trim();
if (!trimmed) throw new UserError("Memory path is required");
if (trimmed.length > MAX_PATH_LENGTH) {
throw new UserError("Memory path is too long", {
code: "memory_path_too_long",
metadata: { maxLength: MAX_PATH_LENGTH },
});
}
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}
function normalizeContent(contentMd: string) {
if (contentMd.length > MAX_CONTENT_LENGTH) {
throw new UserError("Memory content is too large", {
code: "memory_content_too_large",
metadata: { maxLength: MAX_CONTENT_LENGTH },
});
}
return contentMd;
}
function normalizeTags(tags: string[] | undefined) {
return [...new Set((tags ?? []).map((tag) => tag.trim()).filter(Boolean))];
}
function toMemoryFile(row: typeof memoryFiles.$inferSelect): MemoryFile {
return {
path: row.path,
contentMd: row.contentMd,
tags: row.tagsJson,
createdAt: row.createdAt instanceof Date ? row.createdAt.getTime() : Number(row.createdAt),
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.getTime() : Number(row.updatedAt),
};
}
function scoreMemory(memory: MemoryFile, query: string) {
const lowerQuery = query.toLowerCase();
const pathScore = memory.path.toLowerCase().includes(lowerQuery) ? 5 : 0;
const tagScore = memory.tags.some((tag) => tag.toLowerCase().includes(lowerQuery)) ? 3 : 0;
const contentScore = memory.contentMd.toLowerCase().includes(lowerQuery) ? 1 : 0;
return pathScore + tagScore + contentScore;
}
export const memoryActor = actor({
// Keep state tiny. Durable memory content lives in actor-local SQLite.
state: {
fileCount: 0,
updatedAt: Date.now(),
},
db: drizzleDb({
schema: memorySchema,
}),
queues: {
write: queue<MemoryWriteRequest>(),
append: queue<MemoryAppendRequest>(),
delete: queue<MemoryDeleteRequest>(),
},
events: {
updated: event<MemoryEvent>(),
},
onCreate: async (c) => {
await migrateMemoryDb(c.db);
},
onWake: async (c) => {
await migrateMemoryDb(c.db);
},
run: async (c) => {
for await (const queued of c.queue.iter()) {
const name = queued.name;
if (name === "write") {
await writeMemory(c, queued.body as MemoryWriteRequest, "written");
} else if (name === "append") {
await appendMemory(c, queued.body as MemoryAppendRequest);
} else if (name === "delete") {
await deleteMemory(c, queued.body as MemoryDeleteRequest);
}
}
},
actions: {
write: async (c, input: MemoryWriteRequest) => {
await c.queue.send("write", input);
return { queued: true };
},
append: async (c, input: MemoryAppendRequest) => {
await c.queue.send("append", input);
return { queued: true };
},
delete: async (c, input: MemoryDeleteRequest) => {
await c.queue.send("delete", input);
return { queued: true };
},
read: async (c, path: string): Promise<MemoryFile | null> => {
const normalizedPath = normalizePath(path);
const rows = await c.db
.select()
.from(memoryFiles)
.where(eq(memoryFiles.path, normalizedPath))
.limit(1);
return rows[0] ? toMemoryFile(rows[0]) : null;
},
list: async (c, prefix = "/"): Promise<MemoryFile[]> => {
const normalizedPrefix = normalizePath(prefix);
const rows = await c.db
.select()
.from(memoryFiles)
.where(like(memoryFiles.path, `${normalizedPrefix}%`))
.orderBy(asc(memoryFiles.path));
return rows.map(toMemoryFile);
},
search: async (c, query: string, limit = MAX_SEARCH_RESULTS): Promise<MemorySearchResult[]> => {
const trimmed = query.trim();
if (!trimmed) return [];
const safeLimit = Math.max(1, Math.min(limit, MAX_SEARCH_RESULTS));
const pattern = `%${trimmed}%`;
const rows = await c.db
.select()
.from(memoryFiles)
.where(
or(
like(memoryFiles.path, pattern),
like(memoryFiles.contentMd, pattern),
like(memoryFiles.tagsJson, pattern),
),
)
.orderBy(desc(memoryFiles.updatedAt))
.limit(safeLimit);
return rows
.map(toMemoryFile)
.map((memory) => ({
path: memory.path,
contentMd: memory.contentMd,
tags: memory.tags,
score: scoreMemory(memory, trimmed),
updatedAt: memory.updatedAt,
}))
.sort((a, b) => b.score - a.score || b.updatedAt - a.updatedAt);
},
getStats: (c) => ({
fileCount: c.state.fileCount,
updatedAt: c.state.updatedAt,
}),
},
});
async function writeMemory(
c: any,
input: MemoryWriteRequest,
eventType: "written" | "appended",
): Promise<MemoryFile> {
const path = normalizePath(input.path);
const contentMd = normalizeContent(input.contentMd);
const tags = normalizeTags(input.tags);
const timestamp = now();
const existing = await c.db
.select()
.from(memoryFiles)
.where(eq(memoryFiles.path, path))
.limit(1);
if (existing[0]) {
await c.db
.update(memoryFiles)
.set({ contentMd, tagsJson: tags, updatedAt: new Date(timestamp) })
.where(eq(memoryFiles.path, path));
} else {
await c.db.insert(memoryFiles).values({
path,
contentMd,
tagsJson: tags,
createdAt: new Date(timestamp),
updatedAt: new Date(timestamp),
});
c.state.fileCount += 1;
}
c.state.updatedAt = timestamp;
const memory: MemoryFile = {
path,
contentMd,
tags,
createdAt: existing[0]
? toMemoryFile(existing[0]).createdAt
: timestamp,
updatedAt: timestamp,
};
await recordAndBroadcast(c, eventType, path, memory);
return memory;
}
async function appendMemory(
c: any,
input: MemoryAppendRequest,
) {
const path = normalizePath(input.path);
const existing = await c.db
.select()
.from(memoryFiles)
.where(eq(memoryFiles.path, path))
.limit(1);
const previous = existing[0] ? toMemoryFile(existing[0]) : null;
const nextContent = previous
? `${previous.contentMd}\n${input.contentMd}`
: input.contentMd;
const tags = normalizeTags([...(previous?.tags ?? []), ...(input.tags ?? [])]);
return writeMemory(c, { path, contentMd: nextContent, tags }, "appended");
}
async function deleteMemory(
c: any,
input: MemoryDeleteRequest,
) {
const path = normalizePath(input.path);
const existing = await c.db
.select()
.from(memoryFiles)
.where(eq(memoryFiles.path, path))
.limit(1);
if (!existing[0]) return;
await c.db.delete(memoryFiles).where(eq(memoryFiles.path, path));
c.state.fileCount = Math.max(0, c.state.fileCount - 1);
c.state.updatedAt = now();
await recordAndBroadcast(c, "deleted", path);
}
async function recordAndBroadcast(
c: any,
type: "written" | "appended" | "deleted",
path: string,
memory?: MemoryFile,
) {
const createdAt = now();
const evt: MemoryEvent = { type, path, memory, createdAt };
await c.db.insert(memoryEvents).values({
id: buildId("memory-event"),
type,
path,
payloadJson: memory ? { memory } : undefined,
createdAt: new Date(createdAt),
});
c.broadcast("updated", evt);
}

View File

@@ -0,0 +1,28 @@
import type { RawAccess } from "rivetkit/db";
// Small inline migration for this isolated actor-local SQLite database.
export async function migrateMemoryDb(db: RawAccess) {
await db.execute(`
CREATE TABLE IF NOT EXISTS memory_files (
path TEXT PRIMARY KEY NOT NULL,
content_md TEXT NOT NULL,
tags_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS memory_files_updated_at_idx
ON memory_files (updated_at);
CREATE TABLE IF NOT EXISTS memory_events (
id TEXT PRIMARY KEY NOT NULL,
type TEXT NOT NULL CHECK (type IN ('written', 'appended', 'deleted')),
path TEXT NOT NULL,
payload_json TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS memory_events_path_created_at_idx
ON memory_events (path, created_at);
`);
}

View File

@@ -0,0 +1,52 @@
import { relations } from "drizzle-orm";
import {
index,
integer,
sqliteTable,
text,
} from "rivetkit/db/drizzle";
export const memoryFiles = sqliteTable(
"memory_files",
{
path: text("path").primaryKey(),
contentMd: text("content_md").notNull(),
tagsJson: text("tags_json", { mode: "json" }).$type<string[]>().notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
},
(table) => ({
updatedAtIdx: index("memory_files_updated_at_idx").on(table.updatedAt),
}),
);
export const memoryEvents = sqliteTable(
"memory_events",
{
id: text("id").primaryKey(),
type: text("type", { enum: ["written", "appended", "deleted"] }).notNull(),
path: text("path").notNull(),
payloadJson: text("payload_json", { mode: "json" }).$type<Record<string, unknown>>(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
},
(table) => ({
pathCreatedAtIdx: index("memory_events_path_created_at_idx").on(
table.path,
table.createdAt,
),
}),
);
export const memoryFilesRelations = relations(memoryFiles, ({ many }) => ({
events: many(memoryEvents),
}));
export const memorySchema = {
memoryFiles,
memoryEvents,
};
export type MemoryFileRow = typeof memoryFiles.$inferSelect;
export type NewMemoryFileRow = typeof memoryFiles.$inferInsert;
export type MemoryEventRow = typeof memoryEvents.$inferSelect;
export type NewMemoryEventRow = typeof memoryEvents.$inferInsert;

View File

@@ -0,0 +1,38 @@
export type MemoryFile = {
path: string;
contentMd: string;
tags: string[];
createdAt: number;
updatedAt: number;
};
export type MemoryWriteRequest = {
path: string;
contentMd: string;
tags?: string[];
};
export type MemoryAppendRequest = {
path: string;
contentMd: string;
tags?: string[];
};
export type MemoryDeleteRequest = {
path: string;
};
export type MemoryEvent = {
type: "written" | "appended" | "deleted";
path: string;
memory?: MemoryFile;
createdAt: number;
};
export type MemorySearchResult = {
path: string;
contentMd: string;
tags: string[];
score: number;
updatedAt: number;
};

View File

@@ -0,0 +1,7 @@
import { createMissionActor } from "./mission-actor-factory.js";
export const careerTransitionMissionActor = createMissionActor({
missionId: "career-transition",
name: "Career Transition Mission",
icon: "route",
});

View File

@@ -0,0 +1,7 @@
# Career Transition Sprint Mission
Durable state holder for the `career-transition` mission. This actor is intentionally non-agentic: the Grow conversation layer drives coaching and calls actor actions to update state, stages, artifacts, and events.
## Stages
- Transition map
- Resume fit scan

View File

@@ -0,0 +1,6 @@
export { interviewToOfferMissionActor } from "./interview-to-offer-actor.js";
export { careerTransitionMissionActor } from "./career-transition-actor.js";
export { salaryNegotiationWarRoomMissionActor } from "./salary-negotiation-war-room-actor.js";
export { promotionReadinessMissionActor } from "./promotion-readiness-actor.js";
export { personalBrandOpportunityEngineMissionActor } from "./personal-brand-opportunity-engine-actor.js";
export type * from "./types.js";

View File

@@ -0,0 +1,302 @@
import { actor, event } from "rivetkit";
import { getMissionDefinition } from "../../missions/registry.js";
import type {
MissionArtifact,
MissionEvent,
MissionSnapshot,
MissionStage,
MissionStartInput,
} from "./types.js";
const nowIso = () => new Date().toISOString();
const eventId = () => `mission-event-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const artifactId = () => `artifact-${Date.now()}-${Math.random().toString(16).slice(2)}`;
function buildInitialStages(): MissionStage[] {
const def = getMissionDefinition("interview-to-offer");
if (!def) throw new Error("interview-to-offer mission definition is missing");
return def.modules.map((module, index) => ({
id: module.id,
title: module.title,
role: module.role,
description: module.description,
status: index === 0 ? "ready" : "locked",
progressPercent: 0,
}));
}
function createStartedEvent(goal?: string): MissionEvent {
return {
id: eventId(),
type: "mission.started",
message: goal
? `Interview-to-Offer mission started for: ${goal}`
: "Interview-to-Offer mission started.",
payload: goal ? { goal } : {},
createdAt: nowIso(),
};
}
function ensureInitialized(state: MissionSnapshot) {
if (!state.userId || !state.instanceId) {
throw new Error("Mission actor is not initialized");
}
}
function summarize(state: MissionSnapshot) {
return {
instanceId: state.instanceId,
missionId: state.missionId,
workflowId: state.workflowId,
title: state.title,
shortTitle: state.shortTitle,
status: state.status,
progressPercent: state.progressPercent,
currentStageId: state.currentStageId,
goal: state.goal,
updatedAt: state.updatedAt,
};
}
export const interviewToOfferMissionActor = actor({
options: {
name: "Interview-to-Offer Mission",
icon: "briefcase-business",
noSleep: true,
},
state: {
instanceId: "",
missionId: "interview-to-offer",
workflowId: "interview-to-offer",
userId: "",
title: "Interview-to-Offer Accelerator",
shortTitle: "Interview to Offer",
promise: "Prepare for this specific interview and convert it into an offer.",
status: "draft",
input: {},
progressPercent: 0,
stages: [],
artifacts: [],
events: [],
skillVersion: "1.0.0",
workflowVersion: "1.0.0",
createdAt: nowIso(),
updatedAt: nowIso(),
} as MissionSnapshot,
events: {
updated: event<MissionSnapshot>(),
eventAdded: event<MissionEvent>(),
artifactAdded: event<MissionArtifact>(),
},
actions: {
init: (c, input: MissionStartInput) => {
if (input.missionId !== "interview-to-offer") {
throw new Error(`Unsupported mission for interview actor: ${input.missionId}`);
}
if (c.state.userId && (c.state.userId !== input.userId || c.state.instanceId !== input.instanceId)) {
throw new Error("Mission actor already initialized for a different user or instance");
}
const def = getMissionDefinition("interview-to-offer");
if (!def) throw new Error("interview-to-offer mission definition is missing");
const timestamp = nowIso();
const firstEvent = createStartedEvent(input.goal);
Object.assign(c.state, {
instanceId: input.instanceId,
missionId: "interview-to-offer",
workflowId: def.id,
userId: input.userId,
title: def.title,
shortTitle: def.shortTitle,
promise: def.promise,
status: "active",
goal: input.goal,
input: input.input ?? {},
progressPercent: 0,
currentStageId: def.modules[0]?.id,
stages: buildInitialStages(),
artifacts: [],
events: [firstEvent],
skillVersion: def.skillVersion,
workflowVersion: def.version,
createdAt: timestamp,
updatedAt: timestamp,
});
c.broadcast("eventAdded", firstEvent);
c.broadcast("updated", c.state);
return c.state;
},
getState: (c) => {
ensureInitialized(c.state);
return c.state;
},
getSummary: (c) => {
ensureInitialized(c.state);
return summarize(c.state);
},
recordEvent: (c, input: { type: string; message: string; payload?: Record<string, unknown> }) => {
ensureInitialized(c.state);
const entry: MissionEvent = {
id: eventId(),
type: input.type,
message: input.message,
payload: input.payload ?? {},
createdAt: nowIso(),
};
c.state.events.unshift(entry);
c.state.updatedAt = entry.createdAt;
c.broadcast("eventAdded", entry);
c.broadcast("updated", c.state);
return entry;
},
ingestEvent: (c, input: { eventId: string }) => {
ensureInitialized(c.state);
const entry: MissionEvent = {
id: eventId(),
type: "mission.event_ingested",
message: `Event ${input.eventId} ingested by mission runtime.`,
payload: { eventId: input.eventId },
createdAt: nowIso(),
};
c.state.events.unshift(entry);
c.state.updatedAt = entry.createdAt;
c.broadcast("eventAdded", entry);
c.broadcast("updated", c.state);
return c.state;
},
planNextActions: (c, input: { reason?: string } = {}) => {
ensureInitialized(c.state);
const active = c.state.stages.find((stage) => stage.status === "ready" || stage.status === "in_progress" || stage.status === "blocked");
return {
missionInstanceId: c.state.instanceId,
missionId: c.state.missionId,
currentStageId: active?.id,
reason: input.reason ?? "manual",
recommendation: active ? `Focus next on ${active.title}.` : "No open stage requires action right now.",
};
},
runDailyScrum: (c, input: { trigger?: "manual" | "nightly" } = {}) => {
ensureInitialized(c.state);
const active = c.state.stages.find((stage) => stage.status === "ready" || stage.status === "in_progress" || stage.status === "blocked");
const entry: MissionEvent = {
id: eventId(),
type: "mission.daily_scrum.completed",
message: active ? `Daily scrum: next focus is ${active.title}.` : "Daily scrum: mission has no blocked action right now.",
payload: { trigger: input.trigger ?? "manual", currentStageId: active?.id },
createdAt: nowIso(),
};
c.state.events.unshift(entry);
c.state.updatedAt = entry.createdAt;
c.broadcast("eventAdded", entry);
c.broadcast("updated", c.state);
return { snapshot: c.state, summary: entry.message };
},
queueAction: (c, input: { actionId: string; title?: string }) => {
ensureInitialized(c.state);
return { queued: true, actionId: input.actionId, missionInstanceId: c.state.instanceId, title: input.title };
},
runAction: (c, input: { actionId: string }) => {
ensureInitialized(c.state);
return { started: true, actionId: input.actionId, missionInstanceId: c.state.instanceId };
},
resolveHitl: (c, input: { actionId: string; resolution: string; input?: Record<string, unknown> }) => {
ensureInitialized(c.state);
return { resolved: true, actionId: input.actionId, resolution: input.resolution, missionInstanceId: c.state.instanceId };
},
updateStage: (c, input: { stageId: string; status?: MissionStage["status"]; progressPercent?: number; outputSummary?: string }) => {
ensureInitialized(c.state);
const stage = c.state.stages.find((item) => item.id === input.stageId);
if (!stage) throw new Error(`Unknown stage: ${input.stageId}`);
const timestamp = nowIso();
const previousStatus = stage.status;
if (input.status) stage.status = input.status;
if (typeof input.progressPercent === "number") {
stage.progressPercent = Math.max(0, Math.min(100, Math.round(input.progressPercent)));
}
if (input.outputSummary) stage.outputSummary = input.outputSummary;
if (stage.status === "in_progress" && previousStatus !== "in_progress") stage.startedAt = timestamp;
if (stage.status === "done") {
stage.completedAt = timestamp;
stage.progressPercent = 100;
const next = c.state.stages[c.state.stages.findIndex((item) => item.id === stage.id) + 1];
if (next && next.status === "locked") next.status = "ready";
}
c.state.currentStageId = c.state.stages.find((item) => ["ready", "in_progress", "blocked"].includes(item.status))?.id;
c.state.progressPercent = Math.round(
c.state.stages.reduce((sum, item) => sum + item.progressPercent, 0) / Math.max(1, c.state.stages.length),
);
c.state.updatedAt = timestamp;
c.broadcast("updated", c.state);
return c.state;
},
addArtifact: (c, input: Omit<MissionArtifact, "id" | "createdAt" | "updatedAt"> & { id?: string }) => {
ensureInitialized(c.state);
const timestamp = nowIso();
const artifact: MissionArtifact = {
id: input.id ?? artifactId(),
type: input.type,
title: input.title,
status: input.status,
summary: input.summary,
contentMd: input.contentMd,
metadata: input.metadata,
createdAt: timestamp,
updatedAt: timestamp,
};
c.state.artifacts.unshift(artifact);
c.state.updatedAt = timestamp;
c.broadcast("artifactAdded", artifact);
c.broadcast("updated", c.state);
return artifact;
},
pause: (c) => {
ensureInitialized(c.state);
c.state.status = "paused";
c.state.updatedAt = nowIso();
c.broadcast("updated", c.state);
return c.state;
},
resume: (c) => {
ensureInitialized(c.state);
c.state.status = "active";
c.state.updatedAt = nowIso();
c.broadcast("updated", c.state);
return c.state;
},
complete: (c, input: { qscoreAfter?: Record<string, unknown> } = {}) => {
ensureInitialized(c.state);
const timestamp = nowIso();
c.state.status = "completed";
c.state.progressPercent = 100;
c.state.currentStageId = undefined;
c.state.qscoreAfter = input.qscoreAfter;
c.state.completedAt = timestamp;
c.state.updatedAt = timestamp;
c.broadcast("updated", c.state);
return c.state;
},
},
});

View File

@@ -0,0 +1,41 @@
---
name: interview-to-offer
displayName: Interview-to-Offer Accelerator
version: 1.0.0
actor: interviewToOfferMissionActor
---
# Interview-to-Offer Accelerator
## Promise
Prepare the user for a specific interview and help convert it into an offer.
## Mission Contract
This mission is not an autonomous agent. The conversation layer (Grow) owns the coaching interaction. This actor owns only durable mission state: stages, progress, artifacts, and event history.
## Inputs
- Target company and role
- Interview date or urgency window
- Resume/profile context
- Biggest concern or blocker
## Stages
1. Resume fit scan
2. Interview prep plan
3. Mock interview session
4. Communication roleplay
5. Final readiness Q Score
## Artifacts
- Interview prep plan
- Likely questions
- Behavioral/story bank
- Resume-based talking points
- Weakness diagnosis
- Final readiness score
## Conversation Handoff Rules
- Grow can read the mission state before answering next-step questions.
- Grow can update stages only when a user action, specialist output, or artifact justifies it.
- Grow should ask for approval before marking a major artifact approved.
- Grow should keep the tone warm and practical.

View File

@@ -0,0 +1,310 @@
import { actor, event } from "rivetkit";
import { getMissionDefinition } from "../../missions/registry.js";
import type {
MissionArtifact,
MissionEvent,
MissionId,
MissionSnapshot,
MissionStage,
MissionStartInput,
} from "./types.js";
const nowIso = () => new Date().toISOString();
const eventId = () => `mission-event-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const artifactId = () => `artifact-${Date.now()}-${Math.random().toString(16).slice(2)}`;
function buildInitialStages(missionId: MissionId): MissionStage[] {
const def = getMissionDefinition(missionId);
if (!def) throw new Error(`${missionId} mission definition is missing`);
return def.modules.map((module, index) => ({
id: module.id,
title: module.title,
role: module.role,
description: module.description,
status: index === 0 ? "ready" : "locked",
progressPercent: 0,
}));
}
function createStartedEvent(title: string, goal?: string): MissionEvent {
return {
id: eventId(),
type: "mission.started",
message: goal ? `${title} mission started for: ${goal}` : `${title} mission started.`,
payload: goal ? { goal } : {},
createdAt: nowIso(),
};
}
function ensureInitialized(state: MissionSnapshot) {
if (!state.userId || !state.instanceId) {
throw new Error("Mission actor is not initialized");
}
}
function summarize(state: MissionSnapshot) {
return {
instanceId: state.instanceId,
missionId: state.missionId,
workflowId: state.workflowId,
title: state.title,
shortTitle: state.shortTitle,
status: state.status,
progressPercent: state.progressPercent,
currentStageId: state.currentStageId,
goal: state.goal,
updatedAt: state.updatedAt,
};
}
export function createMissionActor(options: {
missionId: MissionId;
name: string;
icon: string;
}) {
const def = getMissionDefinition(options.missionId);
return actor({
options: {
name: options.name,
icon: options.icon,
noSleep: true,
},
state: {
instanceId: "",
missionId: options.missionId,
workflowId: options.missionId,
userId: "",
title: def?.title ?? options.name,
shortTitle: def?.shortTitle ?? options.name,
promise: def?.promise ?? "",
status: "draft",
input: {},
progressPercent: 0,
stages: [],
artifacts: [],
events: [],
skillVersion: def?.skillVersion ?? "1.0.0",
workflowVersion: def?.version ?? "1.0.0",
createdAt: nowIso(),
updatedAt: nowIso(),
} as MissionSnapshot,
events: {
updated: event<MissionSnapshot>(),
eventAdded: event<MissionEvent>(),
artifactAdded: event<MissionArtifact>(),
},
actions: {
init: (c, input: MissionStartInput) => {
if (input.missionId !== options.missionId) {
throw new Error(`Unsupported mission for ${options.missionId} actor: ${input.missionId}`);
}
if (c.state.userId && (c.state.userId !== input.userId || c.state.instanceId !== input.instanceId)) {
throw new Error("Mission actor already initialized for a different user or instance");
}
const missionDef = getMissionDefinition(options.missionId);
if (!missionDef) throw new Error(`${options.missionId} mission definition is missing`);
const timestamp = nowIso();
const firstEvent = createStartedEvent(missionDef.shortTitle, input.goal);
Object.assign(c.state, {
instanceId: input.instanceId,
missionId: options.missionId,
workflowId: missionDef.id,
userId: input.userId,
title: missionDef.title,
shortTitle: missionDef.shortTitle,
promise: missionDef.promise,
status: "active",
goal: input.goal,
input: input.input ?? {},
progressPercent: 0,
currentStageId: missionDef.modules[0]?.id,
stages: buildInitialStages(options.missionId),
artifacts: [],
events: [firstEvent],
skillVersion: missionDef.skillVersion,
workflowVersion: missionDef.version,
createdAt: timestamp,
updatedAt: timestamp,
});
c.broadcast("eventAdded", firstEvent);
c.broadcast("updated", c.state);
return c.state;
},
getState: (c) => {
ensureInitialized(c.state);
return c.state;
},
getSummary: (c) => {
ensureInitialized(c.state);
return summarize(c.state);
},
recordEvent: (c, input: { type: string; message: string; payload?: Record<string, unknown> }) => {
ensureInitialized(c.state);
const entry: MissionEvent = {
id: eventId(),
type: input.type,
message: input.message,
payload: input.payload ?? {},
createdAt: nowIso(),
};
c.state.events.unshift(entry);
c.state.updatedAt = entry.createdAt;
c.broadcast("eventAdded", entry);
c.broadcast("updated", c.state);
return entry;
},
ingestEvent: (c, input: { eventId: string }) => {
ensureInitialized(c.state);
const entry: MissionEvent = {
id: eventId(),
type: "mission.event_ingested",
message: `Event ${input.eventId} ingested by mission runtime.`,
payload: { eventId: input.eventId },
createdAt: nowIso(),
};
c.state.events.unshift(entry);
c.state.updatedAt = entry.createdAt;
c.broadcast("eventAdded", entry);
c.broadcast("updated", c.state);
return c.state;
},
planNextActions: (c, input: { reason?: string } = {}) => {
ensureInitialized(c.state);
const blocked = c.state.stages.find((stage) => stage.status === "blocked");
const active = blocked ?? c.state.stages.find((stage) => stage.status === "ready" || stage.status === "in_progress");
return {
missionInstanceId: c.state.instanceId,
missionId: c.state.missionId,
currentStageId: active?.id,
reason: input.reason ?? "manual",
recommendation: active ? `Focus next on ${active.title}.` : "No open stage requires action right now.",
};
},
runDailyScrum: (c, input: { trigger?: "manual" | "nightly" } = {}) => {
ensureInitialized(c.state);
const recommendation = c.state.stages.find((stage) => stage.status === "ready" || stage.status === "in_progress" || stage.status === "blocked");
const entry: MissionEvent = {
id: eventId(),
type: "mission.daily_scrum.completed",
message: recommendation ? `Daily scrum: next focus is ${recommendation.title}.` : "Daily scrum: mission has no blocked action right now.",
payload: { trigger: input.trigger ?? "manual", currentStageId: recommendation?.id },
createdAt: nowIso(),
};
c.state.events.unshift(entry);
c.state.updatedAt = entry.createdAt;
c.broadcast("eventAdded", entry);
c.broadcast("updated", c.state);
return { snapshot: c.state, summary: entry.message };
},
queueAction: (c, input: { actionId: string; title?: string }) => {
ensureInitialized(c.state);
return { queued: true, actionId: input.actionId, missionInstanceId: c.state.instanceId, title: input.title };
},
runAction: (c, input: { actionId: string }) => {
ensureInitialized(c.state);
return { started: true, actionId: input.actionId, missionInstanceId: c.state.instanceId };
},
resolveHitl: (c, input: { actionId: string; resolution: string; input?: Record<string, unknown> }) => {
ensureInitialized(c.state);
return { resolved: true, actionId: input.actionId, resolution: input.resolution, missionInstanceId: c.state.instanceId };
},
updateStage: (c, input: { stageId: string; status?: MissionStage["status"]; progressPercent?: number; outputSummary?: string }) => {
ensureInitialized(c.state);
const stage = c.state.stages.find((item) => item.id === input.stageId);
if (!stage) throw new Error(`Unknown stage: ${input.stageId}`);
const timestamp = nowIso();
const previousStatus = stage.status;
if (input.status) stage.status = input.status;
if (typeof input.progressPercent === "number") {
stage.progressPercent = Math.max(0, Math.min(100, Math.round(input.progressPercent)));
}
if (input.outputSummary) stage.outputSummary = input.outputSummary;
if (stage.status === "in_progress" && previousStatus !== "in_progress") stage.startedAt = timestamp;
if (stage.status === "done") {
stage.completedAt = timestamp;
stage.progressPercent = 100;
const next = c.state.stages[c.state.stages.findIndex((item) => item.id === stage.id) + 1];
if (next && next.status === "locked") next.status = "ready";
}
c.state.currentStageId = c.state.stages.find((item) => ["ready", "in_progress", "blocked"].includes(item.status))?.id;
c.state.progressPercent = Math.round(
c.state.stages.reduce((sum, item) => sum + item.progressPercent, 0) / Math.max(1, c.state.stages.length),
);
c.state.updatedAt = timestamp;
c.broadcast("updated", c.state);
return c.state;
},
addArtifact: (c, input: Omit<MissionArtifact, "id" | "createdAt" | "updatedAt"> & { id?: string }) => {
ensureInitialized(c.state);
const timestamp = nowIso();
const artifact: MissionArtifact = {
id: input.id ?? artifactId(),
type: input.type,
title: input.title,
status: input.status,
summary: input.summary,
contentMd: input.contentMd,
metadata: input.metadata,
createdAt: timestamp,
updatedAt: timestamp,
};
c.state.artifacts.unshift(artifact);
c.state.updatedAt = timestamp;
c.broadcast("artifactAdded", artifact);
c.broadcast("updated", c.state);
return artifact;
},
pause: (c) => {
ensureInitialized(c.state);
c.state.status = "paused";
c.state.updatedAt = nowIso();
c.broadcast("updated", c.state);
return c.state;
},
resume: (c) => {
ensureInitialized(c.state);
c.state.status = "active";
c.state.updatedAt = nowIso();
c.broadcast("updated", c.state);
return c.state;
},
complete: (c, input: { qscoreAfter?: Record<string, unknown> } = {}) => {
ensureInitialized(c.state);
const timestamp = nowIso();
c.state.status = "completed";
c.state.progressPercent = 100;
c.state.currentStageId = undefined;
c.state.qscoreAfter = input.qscoreAfter;
c.state.completedAt = timestamp;
c.state.updatedAt = timestamp;
c.broadcast("updated", c.state);
return c.state;
},
},
});
}

View File

@@ -0,0 +1,7 @@
import { createMissionActor } from "./mission-actor-factory.js";
export const personalBrandOpportunityEngineMissionActor = createMissionActor({
missionId: "personal-brand-opportunity-engine",
name: "Personal Brand Opportunity Engine Mission",
icon: "sparkles",
});

View File

@@ -0,0 +1,6 @@
# Personal Brand Opportunity Engine Mission
Durable state holder for the `personal-brand-opportunity-engine` mission. This actor is intentionally non-agentic: the Grow conversation layer drives coaching and calls actor actions to update state, stages, artifacts, and events.
## Stages
- Profile rewrite

View File

@@ -0,0 +1,7 @@
import { createMissionActor } from "./mission-actor-factory.js";
export const promotionReadinessMissionActor = createMissionActor({
missionId: "promotion-readiness",
name: "Promotion Readiness Mission",
icon: "trending-up",
});

View File

@@ -0,0 +1,7 @@
# Promotion Readiness Packet Mission
Durable state holder for the `promotion-readiness` mission. This actor is intentionally non-agentic: the Grow conversation layer drives coaching and calls actor actions to update state, stages, artifacts, and events.
## Stages
- Evidence packet
- Manager conversation roleplay

View File

@@ -0,0 +1,7 @@
import { createMissionActor } from "./mission-actor-factory.js";
export const salaryNegotiationWarRoomMissionActor = createMissionActor({
missionId: "salary-negotiation-war-room",
name: "Salary Negotiation War Room Mission",
icon: "badge-dollar-sign",
});

View File

@@ -0,0 +1,7 @@
# Salary Negotiation War Room Mission
Durable state holder for the `salary-negotiation-war-room` mission. This actor is intentionally non-agentic: the Grow conversation layer drives coaching and calls actor actions to update state, stages, artifacts, and events.
## Stages
- Negotiation script
- Negotiation roleplay

View File

@@ -0,0 +1,109 @@
import type { WorkflowDefinition } from "../../workflows/types.js";
export type MissionId =
| "interview-to-offer"
| "career-transition"
| "salary-negotiation-war-room"
| "promotion-readiness"
| "personal-brand-opportunity-engine";
export type MissionInstanceStatus = "draft" | "active" | "paused" | "completed" | "cancelled";
export type MissionStageStatus = "locked" | "ready" | "in_progress" | "blocked" | "done";
export type MissionArtifactStatus = "draft" | "ready" | "approved" | "archived";
export type MissionStage = {
id: string;
title: string;
role: string;
description: string;
status: MissionStageStatus;
progressPercent: number;
startedAt?: string;
completedAt?: string;
outputSummary?: string;
};
export type MissionArtifact = {
id: string;
type: string;
title: string;
status: MissionArtifactStatus;
summary?: string;
contentMd?: string;
metadata?: Record<string, unknown>;
createdAt: string;
updatedAt: string;
};
export type MissionEvent = {
id: string;
type: string;
message: string;
payload?: Record<string, unknown>;
createdAt: string;
};
export type MissionSnapshot = {
instanceId: string;
missionId: MissionId;
workflowId: string;
userId: string;
title: string;
shortTitle: string;
promise: string;
status: MissionInstanceStatus;
goal?: string;
input: Record<string, unknown>;
progressPercent: number;
currentStageId?: string;
stages: MissionStage[];
artifacts: MissionArtifact[];
events: MissionEvent[];
qscoreBefore?: Record<string, unknown>;
qscoreAfter?: Record<string, unknown>;
skillVersion: string;
workflowVersion: string;
createdAt: string;
updatedAt: string;
completedAt?: string;
};
export type MissionStartInput = {
userId: string;
instanceId: string;
missionId: MissionId;
goal?: string;
input?: Record<string, unknown>;
};
export type MissionActorType =
| "interviewToOfferMissionActor"
| "careerTransitionMissionActor"
| "salaryNegotiationWarRoomMissionActor"
| "promotionReadinessMissionActor"
| "personalBrandOpportunityEngineMissionActor";
export type MissionRegistryEntry = WorkflowDefinition & {
missionId: MissionId;
kind: "mission";
actorType?: MissionActorType;
actorBacked: boolean;
skillVersion: string;
skillPath: string;
displayOrder: number;
};
export type GrowActiveMission = {
instanceId: string;
missionId: MissionId;
workflowId: string;
title: string;
shortTitle: string;
status: MissionInstanceStatus;
progressPercent: number;
currentStageId?: string;
goal?: string;
actorType?: MissionActorType;
createdAt: number;
updatedAt: number;
};

View File

@@ -0,0 +1,70 @@
import { actor } from "rivetkit";
import { interviewService, resumeService, roleplayService, type JsonObject } from "../services/product-service-clients.js";
export const interviewServiceActor = actor({
state: { calls: 0, lastCallAt: null as string | null },
actions: {
health: async (c) => track(c, () => interviewService.health()),
configure: async (c, payload: JsonObject) => track(c, () => interviewService.configure(payload)),
preview: async (c, payload: JsonObject) => track(c, () => interviewService.preview(payload)),
editQuestions: async (c, sessionId: string, questions: Array<JsonObject | string>) => track(c, () => interviewService.editQuestions({ session_id: sessionId, questions })),
approve: async (c, sessionId: string) => track(c, () => interviewService.approve(sessionId)),
createAssignments: async (c, payload: { organization_id: string; role: string; round: "warm_up" | "behavioral" | "technical"; assignee_emails: string[] }) => track(c, () => interviewService.createAssignments(payload)),
listAssignments: async (c, email: string, status?: string, limit?: number) => track(c, () => interviewService.listAssignments(email, status, limit)),
unassign: async (c, payload: { organization_id: string; emails: string[] }) => track(c, () => interviewService.unassign(payload)),
resultsBulk: async (c, payload: JsonObject) => track(c, () => interviewService.resultsBulk(payload)),
review: async (c, sessionId: string) => track(c, () => interviewService.review(sessionId)),
leaderboard: async (c) => track(c, () => interviewService.leaderboard()),
artifact: async (c, sessionId: string, artifactType: string) => track(c, () => interviewService.artifact(sessionId, artifactType)),
createVideoUploadUrl: async (c, sessionId: string, payload: JsonObject) => track(c, () => interviewService.createVideoUploadUrl(sessionId, payload)),
markVideoUploaded: async (c, sessionId: string, payload: JsonObject) => track(c, () => interviewService.markVideoUploaded(sessionId, payload)),
},
});
export const roleplayServiceActor = actor({
state: { calls: 0, lastCallAt: null as string | null },
actions: {
health: async (c) => track(c, () => roleplayService.health()),
configure: async (c, payload: JsonObject) => track(c, () => roleplayService.configure(payload)),
preview: async (c, payload: JsonObject) => track(c, () => roleplayService.preview(payload)),
editQuestions: async (c, sessionId: string, questions: Array<JsonObject | string>) => track(c, () => roleplayService.editQuestions({ session_id: sessionId, questions })),
approve: async (c, sessionId: string) => track(c, () => roleplayService.approve(sessionId)),
createAssignments: async (c, payload: { organization_id: string; name: string; scenario: string; assignee_emails: string[] }) => track(c, () => roleplayService.createAssignments(payload)),
listAssignments: async (c, email: string, status?: string, limit?: number) => track(c, () => roleplayService.listAssignments(email, status, limit)),
unassign: async (c, payload: { organization_id: string; roleplay_id: string; emails: string[] }) => track(c, () => roleplayService.unassign(payload)),
resultsBulk: async (c, payload: JsonObject) => track(c, () => roleplayService.resultsBulk(payload)),
review: async (c, sessionId: string) => track(c, () => roleplayService.review(sessionId)),
leaderboard: async (c) => track(c, () => roleplayService.leaderboard()),
artifact: async (c, sessionId: string, artifactType: string) => track(c, () => roleplayService.artifact(sessionId, artifactType)),
createVideoUploadUrl: async (c, sessionId: string, payload: JsonObject) => track(c, () => roleplayService.createVideoUploadUrl(sessionId, payload)),
markVideoUploaded: async (c, sessionId: string, payload: JsonObject) => track(c, () => roleplayService.markVideoUploaded(sessionId, payload)),
},
});
export const resumeServiceActor = actor({
state: { calls: 0, lastCallAt: null as string | null },
actions: {
health: async (c) => track(c, () => resumeService.health()),
state: async (c, clerkId: string) => track(c, () => resumeService.state(clerkId)),
listResumes: async (c, clerkId: string) => track(c, () => resumeService.listResumes(clerkId)),
createResume: async (c, payload: JsonObject) => track(c, () => resumeService.createResume(payload)),
getResume: async (c, resumeId: string) => track(c, () => resumeService.getResume(resumeId)),
updateResume: async (c, resumeId: string, payload: JsonObject) => track(c, () => resumeService.updateResume(resumeId, payload)),
analyzeResume: async (c, resumeId: string, payload?: JsonObject) => track(c, () => resumeService.analyzeResume(resumeId, payload ?? {})),
suggestions: async (c, resumeId: string) => track(c, () => resumeService.suggestions(resumeId)),
copilot: async (c, payload: JsonObject) => track(c, () => resumeService.copilot(payload)),
optimizeSummary: async (c, payload: JsonObject) => track(c, () => resumeService.optimizeSummary(payload)),
optimizeExperience: async (c, payload: JsonObject) => track(c, () => resumeService.optimizeExperience(payload)),
suggestSkills: async (c, payload: JsonObject) => track(c, () => resumeService.suggestSkills(payload)),
generateSummary: async (c, payload: JsonObject) => track(c, () => resumeService.generateSummary(payload)),
listVersions: async (c, resumeId: string) => track(c, () => resumeService.listVersions(resumeId)),
createVersion: async (c, resumeId: string, payload: JsonObject) => track(c, () => resumeService.createVersion(resumeId, payload)),
preview: async (c, resumeId: string) => track(c, () => resumeService.preview(resumeId)),
},
});
async function track<T>(c: { state: { calls: number; lastCallAt: string | null } }, fn: () => Promise<T>): Promise<T> {
c.state.calls += 1;
c.state.lastCallAt = new Date().toISOString();
return fn();
}

View File

@@ -1,11 +1,35 @@
import { setup } from "rivetkit";
import { userActor } from "./user-actor.js";
import { workflowRunActor } from "./workflow-run-actor.js";
import { interviewServiceActor, resumeServiceActor, roleplayServiceActor } from "./product-service-actors.js";
import { conversationActor } from "./conversation/index.js";
import { memoryActor } from "./memory/index.js";
import { growActor } from "./grow/index.js";
import { userEventActor } from "./events/index.js";
import {
careerTransitionMissionActor,
interviewToOfferMissionActor,
personalBrandOpportunityEngineMissionActor,
promotionReadinessMissionActor,
salaryNegotiationWarRoomMissionActor,
} from "./missions/index.js";
// Per changes.md §5: ONE unified actor per user.
// No separate growAgent, subAgent, or workflowJob actors.
export const registry = setup({
use: {
growActor,
userEventActor,
conversationActor,
memoryActor,
interviewToOfferMissionActor,
careerTransitionMissionActor,
salaryNegotiationWarRoomMissionActor,
promotionReadinessMissionActor,
personalBrandOpportunityEngineMissionActor,
userActor,
workflowRunActor,
interviewServiceActor,
roleplayServiceActor,
resumeServiceActor,
},
});

View File

@@ -24,8 +24,12 @@ import {
syncWorkspaceToGit,
} from "../docker/manager.js";
import { db } from "../db/client.js";
import { actors as actorsTable, events as eventsTable } from "../db/schema.js";
import { actors as actorsTable, events as eventsTable, type UserStack } from "../db/schema.js";
import { createChatCompletion, type LlmMessage, type LlmToolCall } from "../lib/llm.js";
import { getWorkflowDefinition } from "../workflows/registry.js";
import { workflowRunModules, workflowArtifacts, workflowEvents } from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import { prepareOpenCodeWorkflowModule } from "../workflows/executors/opencode-executor.js";
// ── Types ──
@@ -36,8 +40,13 @@ type ChatTurn = {
toolCalls?: LlmToolCall[];
};
function publicStack(stack: UserStack) {
const { opencodePassword: _opencodePassword, ...safe } = stack;
return safe;
}
type WorkflowStatus = "draft" | "running" | "paused" | "completed";
type ModuleStatus = "idle" | "running" | "blocked" | "done";
type ModuleStatus = "idle" | "running" | "blocked" | "done" | "manual_required" | "waiting_for_input" | "opencode_required" | "coming_soon";
type Scorecard = {
id: string;
@@ -79,6 +88,7 @@ type UserActorState = {
workflowId: string;
workflowStatus: WorkflowStatus;
workflowGoal: string;
workflowRunId?: string;
modules: WorkflowModuleState[];
timeline: WorkflowEvent[];
createdAt: string;
@@ -165,11 +175,11 @@ function buildUnifiedTools(): Array<{
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).",
description: "Execute a specific production sub-agent module in the workflow (e.g., resume, interview, roleplay, qscore).",
parameters: {
type: "object",
properties: {
moduleId: { type: "string", description: "Module id: resume, job-search, job-apply, sara, emily, qscore" },
moduleId: { type: "string", description: "Module id: resume, interview, roleplay, qscore" },
},
required: ["moduleId"],
},
@@ -179,7 +189,7 @@ function buildUnifiedTools(): Array<{
type: "function" as const,
function: {
name: "start_interview_session",
description: "Create a real interview practice session via the Sara / interview-service microservice.",
description: "Create a real interview practice session via the Interview Agent / interview-service microservice.",
parameters: {
type: "object",
properties: { goal: { type: "string" } },
@@ -191,7 +201,7 @@ function buildUnifiedTools(): Array<{
type: "function" as const,
function: {
name: "start_roleplay_session",
description: "Create a real roleplay practice session via the Emily / roleplay-service microservice.",
description: "Create a real roleplay practice session via the Roleplay Agent / roleplay-service microservice.",
parameters: {
type: "object",
properties: { goal: { type: "string" } },
@@ -203,7 +213,7 @@ function buildUnifiedTools(): Array<{
type: "function" as const,
function: {
name: "compute_qscore",
description: "Compute or refresh the user's Q-Score via the Quinn / qscore-service microservice.",
description: "Compute or refresh the user's Q-Score via the Q Score Agent / qscore-service microservice.",
parameters: {
type: "object",
properties: {},
@@ -243,7 +253,7 @@ function buildUnifiedTools(): Array<{
type: "function" as const,
function: {
name: "start_interview_to_offer",
description: "Start the Interview-to-Offer Accelerator workflow. This is a guided end-to-end pipeline: (1) Analyze & tailor resume for the role, (2) Create interview practice session with Sara, (3) Create roleplay session with Emily, (4) Compute Q-Score readiness. Use this when the user has a specific interview scheduled and wants comprehensive preparation.",
description: "Start the Interview-to-Offer Accelerator workflow. This is a guided end-to-end pipeline: (1) Analyze & tailor resume for the role, (2) Create interview practice session with the Interview Agent, (3) Create roleplay session with Roleplay Agent, (4) Compute Q-Score readiness. Use this when the user has a specific interview scheduled and wants comprehensive preparation.",
parameters: {
type: "object",
properties: {
@@ -319,19 +329,15 @@ function messagesForApi(history: ChatTurn[]): LlmMessage[] {
// ── Workflow helpers ──
function makeModules(): WorkflowModuleState[] {
function makeModules(workflowId = "interview-to-offer"): WorkflowModuleState[] {
const def = getWorkflowDefinition(workflowId);
if (def) {
return def.modules.map((m) => ({ id: m.id, name: m.title, role: m.role, service: m.service, status: "idle" as ModuleStatus, summary: m.description, scorecards: [] }));
}
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: [],
}));
.map((m) => ({ id: m.id, name: m.name, role: m.role, service: m.service, status: "idle" as ModuleStatus, summary: m.description, scorecards: [] }));
}
function appendTimelineEvent(
@@ -411,7 +417,7 @@ export const userActor = actor({
prompt: stack.promptVersion,
},
});
return stack;
return publicStack(stack);
},
shutdown: async (c) => {
@@ -544,12 +550,14 @@ export const userActor = actor({
// ── Workflow (was workflowJob actor, now part of user actor — changes.md §5) ──
startWorkflow: async (c, input: { goal?: string }) => {
startWorkflow: async (c, input: { workflowId?: string; runId?: string; goal?: string; input?: Record<string, unknown> }) => {
const workflowId = input.workflowId ?? "interview-to-offer";
const goal = input.goal ?? "Find and apply to high-fit jobs";
c.state.workflowId = `job-application:${c.state.userId}`;
c.state.workflowId = `${workflowId}:${c.state.userId}`;
c.state.workflowRunId = input.runId;
c.state.workflowStatus = "running";
c.state.workflowGoal = goal;
c.state.modules = makeModules();
c.state.modules = makeModules(workflowId);
c.state.createdAt = now();
c.state.updatedAt = now();
@@ -557,7 +565,7 @@ export const userActor = actor({
c.state,
{ id: "grow", name: "Grow Agent" },
"workflow",
"Job application workflow started.",
`${getWorkflowDefinition(workflowId)?.title ?? "Workflow"} started.`,
);
c.broadcast("workflow.updated", {
workflowId: c.state.workflowId,
@@ -585,7 +593,7 @@ export const userActor = actor({
return c.state;
},
runWorkflowModule: async (c, input: { moduleId: string }) => {
runWorkflowModule: async (c, input: { moduleId: string; runId?: string }) => {
const mod = c.state.modules.find((m) => m.id === input.moduleId);
if (!mod) throw new Error(`Unknown workflow module: ${input.moduleId}`);
@@ -593,18 +601,25 @@ export const userActor = actor({
appendTimelineEvent(c.state, mod, "module", `${mod.name} started.`);
c.broadcast("workflow.updated", workflowSnapshot(c.state));
const workflowKey = c.state.workflowId.split(":")[0] || "interview-to-offer";
const defModule = getWorkflowDefinition(workflowKey)?.modules.find((m) => m.id === mod.id);
const subModule = getSubAgentModule(mod.id);
if (subModule?.service) {
const service = defModule?.service ?? subModule?.service;
if (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 },
{ id: mod.id, name: mod.name, role: mod.role, kind: "microservice", description: mod.summary, service },
{ userId, goal },
);
mod.lastResult = result;
mod.status = result.status === "unavailable" ? "blocked" : "done";
if (input.runId) {
await db.update(workflowRunModules).set({ status: mod.status, outputSummary: result.summary, output: result.detail as Record<string, unknown> | undefined, completedAt: new Date() }).where(and(eq(workflowRunModules.runId, input.runId), eq(workflowRunModules.moduleId, mod.id)));
await db.insert(workflowEvents).values({ runId: input.runId, userId, type: mod.status === "done" ? "module.completed" : "module.blocked", payload: { moduleId: mod.id, summary: result.summary, detail: result.detail } });
}
appendTimelineEvent(c.state, mod, "module", result.summary, result.detail);
c.broadcast("workflow.updated", workflowSnapshot(c.state));
await c.saveState({ immediate: true });
@@ -613,12 +628,28 @@ export const userActor = actor({
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";
// Never fake success for non-service modules. They must execute via a real
// service/OpenCode path, be approved by a human, or report an honest blocked/manual status.
if (defModule?.execution === "opencode" && input.runId) {
const workflow = getWorkflowDefinition(workflowKey);
if (workflow) {
const prepared = await prepareOpenCodeWorkflowModule({ userId: c.state.userId, runId: input.runId, workflow, module: defModule, goal: c.state.workflowGoal });
mod.lastResult = { status: prepared.status === "blocked_service_unavailable" ? "unavailable" : prepared.status, summary: prepared.summary, detail: { artifacts: prepared.artifacts } };
mod.status = prepared.status === "ok" ? "done" : prepared.status === "blocked_service_unavailable" ? "blocked" : "opencode_required";
await db.insert(workflowArtifacts).values(prepared.artifacts.map((a) => ({ runId: input.runId!, moduleId: mod.id, type: a.type, title: a.title, repoPath: a.repoPath, metadata: a.metadata })));
await db.update(workflowRunModules).set({ status: mod.status, outputSummary: prepared.summary, output: { artifacts: prepared.artifacts }, completedAt: new Date() }).where(and(eq(workflowRunModules.runId, input.runId), eq(workflowRunModules.moduleId, mod.id)));
await db.insert(workflowEvents).values({ runId: input.runId, userId: c.state.userId, type: prepared.status === "ok" ? "artifact.generated" : "artifact.contract_created", payload: { moduleId: mod.id, artifacts: prepared.artifacts, status: prepared.status } });
appendTimelineEvent(c.state, mod, "module", prepared.summary, { artifacts: prepared.artifacts });
c.broadcast("workflow.updated", workflowSnapshot(c.state));
return c.state;
}
}
mod.lastResult = { status: defModule?.execution === "coming_soon" ? "coming_soon" : "manual_required", summary: defModule?.execution === "opencode" ? `${mod.name} requires OpenCode artifact execution.` : `${mod.name} requires manual input or is not available yet.` };
mod.status = defModule?.execution === "opencode" ? "opencode_required" : defModule?.execution === "coming_soon" ? "coming_soon" : "manual_required";
if (input.runId) {
await db.update(workflowRunModules).set({ status: mod.status, outputSummary: mod.lastResult.summary, completedAt: new Date() }).where(and(eq(workflowRunModules.runId, input.runId), eq(workflowRunModules.moduleId, mod.id)));
await db.insert(workflowEvents).values({ runId: input.runId, userId: c.state.userId, type: "module.blocked", payload: { moduleId: mod.id, status: mod.status, summary: mod.lastResult.summary } });
}
appendTimelineEvent(c.state, mod, "module", mod.lastResult.summary);
c.broadcast("workflow.updated", workflowSnapshot(c.state));
return c.state;
@@ -705,7 +736,7 @@ async function dispatchUnifiedTool(
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)}`,
`${config.giteaInternalUrl}/api/v1/repos/${encodeURIComponent(stack.giteaRepoOwner)}/${encodeURIComponent(stack.giteaRepoName)}/contents/${encodeURI(pathPrefix)}`,
{ headers: { authorization: `token ${config.giteaAdminToken}`, accept: "application/json" } },
);
if (!res.ok) return [];
@@ -745,8 +776,8 @@ async function dispatchUnifiedTool(
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";
mod.lastResult = { status: "manual_required", summary: `${mod.name} requires manual input or a configured service before it can complete.` };
mod.status = "manual_required";
appendTimelineEvent(c.state, mod, "module", mod.lastResult.summary);
}
c.broadcast("workflow.updated", workflowSnapshot(c.state));
@@ -755,33 +786,33 @@ async function dispatchUnifiedTool(
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 interviewModule = getSubAgentModule("interview");
if (!interviewModule?.service) return { ok: false, error: "Interview module not available" };
const result = await runServiceAgentProbe(
{ id: saraModule.id, name: saraModule.name, role: saraModule.role, kind: "microservice", description: saraModule.description, service: saraModule.service },
{ id: interviewModule.id, name: interviewModule.name, role: interviewModule.role, kind: "microservice", description: interviewModule.description, service: interviewModule.service },
{ userId, goal },
);
c.broadcast("service-result", { moduleId: "sara", result });
c.broadcast("service-result", { moduleId: "interview", 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 roleplayModule = getSubAgentModule("roleplay");
if (!roleplayModule?.service) return { ok: false, error: "Roleplay Agent module not available" };
const result = await runServiceAgentProbe(
{ id: emilyModule.id, name: emilyModule.name, role: emilyModule.role, kind: "microservice", description: emilyModule.description, service: emilyModule.service },
{ id: roleplayModule.id, name: roleplayModule.name, role: roleplayModule.role, kind: "microservice", description: roleplayModule.description, service: roleplayModule.service },
{ userId, goal },
);
c.broadcast("service-result", { moduleId: "emily", result });
c.broadcast("service-result", { moduleId: "roleplay", result });
return result;
}
case "compute_qscore": {
const quinnModule = getSubAgentModule("qscore");
if (!quinnModule?.service) return { ok: false, error: "Quinn module not available" };
const qscoreModule = getSubAgentModule("qscore");
if (!qscoreModule?.service) return { ok: false, error: "Q Score Agent module not available" };
const result = await runServiceAgentProbe(
{ id: quinnModule.id, name: quinnModule.name, role: quinnModule.role, kind: "score", description: quinnModule.description, service: quinnModule.service },
{ id: qscoreModule.id, name: qscoreModule.name, role: qscoreModule.role, kind: "score", description: qscoreModule.description, service: qscoreModule.service },
{ userId, goal: c.state.workflowGoal || "general assessment" },
);
c.broadcast("service-result", { moduleId: "qscore", result });
@@ -850,73 +881,73 @@ async function dispatchUnifiedTool(
c.broadcast("workflow.updated", workflowSnapshot(c.state));
// Step 2: Sara — create interview session
const saraModule = getSubAgentModule("sara");
const saraMod = c.state.modules.find(m => m.id === "sara");
if (saraMod && saraModule?.service) {
saraMod.status = "running";
appendTimelineEvent(c.state, saraMod, "module", "Sara creating interview practice session...");
// Step 2: Interview Agent — create interview session
const interviewModule = getSubAgentModule("interview");
const interviewMod = c.state.modules.find(m => m.id === "interview");
if (interviewMod && interviewModule?.service) {
interviewMod.status = "running";
appendTimelineEvent(c.state, interviewMod, "module", "Interview Agent creating interview practice session...");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
try {
const saraResult = await runServiceAgentProbe(
{ id: saraModule.id, name: saraModule.name, role: saraModule.role, kind: "microservice", description: saraModule.description, service: saraModule.service },
const interviewResult = await runServiceAgentProbe(
{ id: interviewModule.id, name: interviewModule.name, role: interviewModule.role, kind: "microservice", description: interviewModule.description, service: interviewModule.service },
{ userId, goal: goal + (jobDesc ? `\nJob Description: ${jobDesc}` : "") },
);
saraMod.lastResult = saraResult;
saraMod.status = saraResult.status === "unavailable" ? "blocked" : "done";
appendTimelineEvent(c.state, saraMod, "module", saraResult.summary);
interviewMod.lastResult = interviewResult;
interviewMod.status = interviewResult.status === "unavailable" ? "blocked" : "done";
appendTimelineEvent(c.state, interviewMod, "module", interviewResult.summary);
} catch (err) {
saraMod.status = "blocked";
appendTimelineEvent(c.state, saraMod, "module", `Sara session failed: ${err instanceof Error ? err.message : String(err)}`);
interviewMod.status = "blocked";
appendTimelineEvent(c.state, interviewMod, "module", `Interview session failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
c.broadcast("workflow.updated", workflowSnapshot(c.state));
// Step 3: Emily — create roleplay session
const emilyModule = getSubAgentModule("emily");
const emilyMod = c.state.modules.find(m => m.id === "emily");
if (emilyMod && emilyModule?.service) {
emilyMod.status = "running";
appendTimelineEvent(c.state, emilyMod, "module", "Emily creating roleplay scenario...");
// Step 3: Roleplay Agent — create roleplay session
const roleplayModule = getSubAgentModule("roleplay");
const roleplayMod = c.state.modules.find(m => m.id === "roleplay");
if (roleplayMod && roleplayModule?.service) {
roleplayMod.status = "running";
appendTimelineEvent(c.state, roleplayMod, "module", "Roleplay Agent creating roleplay scenario...");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
try {
const emilyResult = await runServiceAgentProbe(
{ id: emilyModule.id, name: emilyModule.name, role: emilyModule.role, kind: "microservice", description: emilyModule.description, service: emilyModule.service },
const roleplayResult = await runServiceAgentProbe(
{ id: roleplayModule.id, name: roleplayModule.name, role: roleplayModule.role, kind: "microservice", description: roleplayModule.description, service: roleplayModule.service },
{ userId, goal: `Interview negotiation and communication practice for: ${goal}` },
);
emilyMod.lastResult = emilyResult;
emilyMod.status = emilyResult.status === "unavailable" ? "blocked" : "done";
appendTimelineEvent(c.state, emilyMod, "module", emilyResult.summary);
roleplayMod.lastResult = roleplayResult;
roleplayMod.status = roleplayResult.status === "unavailable" ? "blocked" : "done";
appendTimelineEvent(c.state, roleplayMod, "module", roleplayResult.summary);
} catch (err) {
emilyMod.status = "blocked";
appendTimelineEvent(c.state, emilyMod, "module", `Emily session failed: ${err instanceof Error ? err.message : String(err)}`);
roleplayMod.status = "blocked";
appendTimelineEvent(c.state, roleplayMod, "module", `Roleplay Agent session failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
c.broadcast("workflow.updated", workflowSnapshot(c.state));
// Step 4: Quinn — compute Q-Score
const quinnModule = getSubAgentModule("qscore");
const quinnMod = c.state.modules.find(m => m.id === "qscore");
if (quinnMod && quinnModule?.service) {
quinnMod.status = "running";
appendTimelineEvent(c.state, quinnMod, "module", "Quinn computing your readiness Q-Score...");
// Step 4: Q Score Agent — compute Q-Score
const qscoreModule = getSubAgentModule("qscore");
const qscoreMod = c.state.modules.find(m => m.id === "qscore");
if (qscoreMod && qscoreModule?.service) {
qscoreMod.status = "running";
appendTimelineEvent(c.state, qscoreMod, "module", "Q Score Agent computing your readiness Q-Score...");
c.broadcast("workflow.updated", workflowSnapshot(c.state));
try {
const quinnResult = await runServiceAgentProbe(
{ id: quinnModule.id, name: quinnModule.name, role: quinnModule.role, kind: "score", description: quinnModule.description, service: quinnModule.service },
const qscoreResult = await runServiceAgentProbe(
{ id: qscoreModule.id, name: qscoreModule.name, role: qscoreModule.role, kind: "score", description: qscoreModule.description, service: qscoreModule.service },
{ userId, goal },
);
quinnMod.lastResult = quinnResult;
quinnMod.status = quinnResult.status === "unavailable" ? "blocked" : "done";
appendTimelineEvent(c.state, quinnMod, "module", quinnResult.summary);
qscoreMod.lastResult = qscoreResult;
qscoreMod.status = qscoreResult.status === "unavailable" ? "blocked" : "done";
appendTimelineEvent(c.state, qscoreMod, "module", qscoreResult.summary);
} catch (err) {
quinnMod.status = "blocked";
appendTimelineEvent(c.state, quinnMod, "module", `Q-Score computation failed: ${err instanceof Error ? err.message : String(err)}`);
qscoreMod.status = "blocked";
appendTimelineEvent(c.state, qscoreMod, "module", `Q-Score computation failed: ${err instanceof Error ? err.message : String(err)}`);
}
}

View File

@@ -0,0 +1,198 @@
import { actor, event, queue } from "rivetkit";
import { workflow } from "rivetkit/workflow";
import { db } from "../db/client.js";
import { workflowApprovals, workflowEvents, workflowRunModules, workflowRuns } from "../db/schema.js";
import { and, eq } from "drizzle-orm";
import { getWorkflowDefinition } from "../workflows/registry.js";
import { executeWorkflowModule } from "../workflows/module-runner.js";
type WorkflowCommand =
| { type: "run_module"; userId: string; runId: string; moduleId: string; idempotencyKey?: string }
| { type: "run_all"; userId: string; runId: string; idempotencyKey?: string }
| { type: "pause"; userId: string; runId: string }
| { type: "resume"; userId: string; runId: string };
type WorkflowRunActorState = {
userId: string;
runId: string;
phase: "idle" | "running" | "paused" | "error";
currentModuleId?: string;
processedCommands: number;
lastError?: string;
updatedAt?: string;
};
export const workflowRunActor = actor({
options: { name: "Workflow Run", icon: "diagram-project", noSleep: true, actionTimeout: 600_000 },
state: { userId: "", runId: "", phase: "idle", processedCommands: 0 } as WorkflowRunActorState,
events: {
updated: event<WorkflowRunActorState>(),
},
queues: {
commands: queue<WorkflowCommand>(),
},
actions: {
init: async (c, input: { userId: string; runId: string }) => {
if (c.state.userId && (c.state.userId !== input.userId || c.state.runId !== input.runId)) throw new Error("workflow actor already initialized for a different run");
c.state.userId = input.userId;
c.state.runId = input.runId;
c.state.updatedAt = new Date().toISOString();
c.broadcast("updated", c.state);
return c.state;
},
runModule: async (c, input: { userId: string; runId: string; moduleId: string; idempotencyKey?: string }) => {
await c.queue.send("commands", { type: "run_module", ...input });
return { queued: true };
},
runAll: async (c, input: { userId: string; runId: string; idempotencyKey?: string }) => {
await c.queue.send("commands", { type: "run_all", ...input });
return { queued: true };
},
pause: async (c, input: { userId: string; runId: string }) => {
await c.queue.send("commands", { type: "pause", ...input });
return { queued: true };
},
resume: async (c, input: { userId: string; runId: string }) => {
await c.queue.send("commands", { type: "resume", ...input });
return { queued: true };
},
getState: (c) => c.state,
},
run: workflow(async (ctx) => {
await ctx.loop("workflow-command-loop", async (loopCtx) => {
const message = await loopCtx.queue.next("wait-command", { names: ["commands"] });
const cmd = message.body as WorkflowCommand;
if (cmd.type === "pause") {
await loopCtx.step("pause-run", async () => {
loopCtx.state.phase = "paused";
loopCtx.state.updatedAt = new Date().toISOString();
loopCtx.broadcast("updated", loopCtx.state);
await db.insert(workflowEvents).values({ runId: cmd.runId, userId: cmd.userId, type: "workflow.pause.queued", payload: {} });
});
return;
}
if (cmd.type === "resume") {
await loopCtx.step("resume-run", async () => {
loopCtx.state.phase = "running";
loopCtx.state.updatedAt = new Date().toISOString();
loopCtx.broadcast("updated", loopCtx.state);
await db.insert(workflowEvents).values({ runId: cmd.runId, userId: cmd.userId, type: "workflow.resume.queued", payload: {} });
});
return;
}
if (loopCtx.state.phase === "paused") {
await loopCtx.step("skip-while-paused", async () => {
await db.insert(workflowEvents).values({ runId: cmd.runId, userId: cmd.userId, type: "module.deferred_paused", payload: { moduleId: cmd.type === "run_module" ? cmd.moduleId : "all" } });
});
return;
}
if (cmd.type === "run_all") {
const moduleIds = await loopCtx.step(`load-run-modules:${cmd.runId}`, async () => {
const [run] = await db.select().from(workflowRuns).where(and(eq(workflowRuns.id, cmd.runId), eq(workflowRuns.userId, cmd.userId))).limit(1);
if (!run) throw new Error(`run not found: ${cmd.runId}`);
const def = getWorkflowDefinition(run.workflowId);
if (!def) throw new Error(`workflow not found: ${run.workflowId}`);
return def.modules.map((m) => ({ id: m.id, approvalGateAfter: m.approvalGateAfter }));
});
for (const mod of moduleIds) {
const result = await runOneModule(loopCtx, cmd.userId, cmd.runId, mod.id, `${cmd.idempotencyKey ?? "all"}:${mod.id}`);
if (!result.ok) break;
if (mod.approvalGateAfter) {
const shouldPause = await loopCtx.step(`approval-gate:${cmd.runId}:${mod.approvalGateAfter}:${cmd.idempotencyKey ?? "all"}`, async () => {
const [existing] = await db.select().from(workflowApprovals).where(and(eq(workflowApprovals.runId, cmd.runId), eq(workflowApprovals.approvalId, mod.approvalGateAfter!))).limit(1);
if (existing?.status === "approved") return false;
await db.insert(workflowApprovals).values({ runId: cmd.runId, approvalId: mod.approvalGateAfter!, status: "pending", payload: { afterModuleId: mod.id } }).onConflictDoNothing();
await db.insert(workflowEvents).values({ runId: cmd.runId, userId: cmd.userId, type: "approval.required", payload: { approvalId: mod.approvalGateAfter, afterModuleId: mod.id } });
return true;
});
if (shouldPause) break;
}
}
return;
}
const result = await loopCtx.tryStep({
name: `run-module:${cmd.moduleId}:${cmd.idempotencyKey ?? "default"}`,
maxRetries: 3,
retryBackoffBase: 1_000,
retryBackoffMax: 30_000,
timeout: 300_000,
run: async () => {
loopCtx.state.phase = "running";
loopCtx.state.currentModuleId = cmd.moduleId;
loopCtx.state.updatedAt = new Date().toISOString();
loopCtx.broadcast("updated", loopCtx.state);
const result = await executeWorkflowModule({ userId: cmd.userId, runId: cmd.runId, moduleId: cmd.moduleId });
loopCtx.state.processedCommands += 1;
loopCtx.state.currentModuleId = undefined;
loopCtx.state.updatedAt = new Date().toISOString();
loopCtx.broadcast("updated", loopCtx.state);
return result;
},
catch: ["timeout", "exhausted"],
});
if (!result.ok) {
await loopCtx.step(`record-failure:${cmd.moduleId}:${cmd.idempotencyKey ?? "default"}`, async () => {
const error = JSON.stringify(result.failure.error);
loopCtx.state.phase = "error";
loopCtx.state.lastError = error;
loopCtx.state.currentModuleId = undefined;
loopCtx.state.updatedAt = new Date().toISOString();
loopCtx.broadcast("updated", loopCtx.state);
await db.update(workflowRunModules).set({ status: "blocked", error, completedAt: new Date() }).where(and(eq(workflowRunModules.runId, cmd.runId), eq(workflowRunModules.moduleId, cmd.moduleId)));
await db.update(workflowRuns).set({ status: "failed", updatedAt: new Date() }).where(eq(workflowRuns.id, cmd.runId));
await db.insert(workflowEvents).values({ runId: cmd.runId, userId: cmd.userId, type: "module.failed", payload: { moduleId: cmd.moduleId, failure: result.failure } });
});
}
});
}, {
onError: async (_ctx, event) => {
console.error("workflow-run-actor workflow error", event);
},
}),
});
async function runOneModule(loopCtx: any, userId: string, runId: string, moduleId: string, idempotencyKey: string): Promise<{ ok: boolean }> {
const shouldSkip = await loopCtx.step(`precheck-module:${moduleId}:${idempotencyKey}`, async () => {
const [row] = await db.select().from(workflowRunModules).where(and(eq(workflowRunModules.runId, runId), eq(workflowRunModules.moduleId, moduleId))).limit(1);
return row ? ["done", "blocked", "manual_required", "coming_soon"].includes(row.status) : false;
});
if (shouldSkip) return { ok: true };
const result = await loopCtx.tryStep({
name: `run-module:${moduleId}:${idempotencyKey}`,
maxRetries: 3,
retryBackoffBase: 1_000,
retryBackoffMax: 30_000,
timeout: 300_000,
run: async () => {
loopCtx.state.phase = "running";
loopCtx.state.currentModuleId = moduleId;
loopCtx.state.updatedAt = new Date().toISOString();
loopCtx.broadcast("updated", loopCtx.state);
const moduleResult = await executeWorkflowModule({ userId, runId, moduleId });
loopCtx.state.processedCommands += 1;
loopCtx.state.currentModuleId = undefined;
loopCtx.state.updatedAt = new Date().toISOString();
loopCtx.broadcast("updated", loopCtx.state);
return moduleResult;
},
catch: ["timeout", "exhausted"],
});
if (result.ok) return { ok: true };
await loopCtx.step(`record-failure:${moduleId}:${idempotencyKey}`, async () => {
const error = JSON.stringify(result.failure.error);
loopCtx.state.phase = "error";
loopCtx.state.lastError = error;
loopCtx.state.currentModuleId = undefined;
loopCtx.state.updatedAt = new Date().toISOString();
loopCtx.broadcast("updated", loopCtx.state);
await db.update(workflowRunModules).set({ status: "blocked", error, completedAt: new Date() }).where(and(eq(workflowRunModules.runId, runId), eq(workflowRunModules.moduleId, moduleId)));
await db.update(workflowRuns).set({ status: "failed", updatedAt: new Date() }).where(eq(workflowRuns.id, runId));
await db.insert(workflowEvents).values({ runId, userId, type: "module.failed", payload: { moduleId, failure: result.failure } });
});
return { ok: false };
}

View File

@@ -5,6 +5,7 @@ import { config } from "../config.js";
import { db } from "../db/client.js";
import { users } from "../db/schema.js";
import { eq } from "drizzle-orm";
import { log } from "../log.js";
export type AuthContext = {
Variables: {
@@ -27,10 +28,16 @@ export const requireUser = createMiddleware<AuthContext>(async (c, next) => {
// Service-to-service path (Grow Agent actor calling backend).
// Header `x-growqr-user` is REQUIRED so we can scope the call.
const trustedServiceTokens = new Set(
[
config.serviceToken,
config.nodeEnv !== "production" ? config.a2aAllowedKey : "",
].filter(Boolean),
);
if (
token &&
config.serviceToken &&
token === config.serviceToken &&
trustedServiceTokens.has(token) &&
c.req.header("x-growqr-user")
) {
const userId = c.req.header("x-growqr-user")!;
@@ -80,15 +87,21 @@ export const requireUser = createMiddleware<AuthContext>(async (c, next) => {
// Lazy-mirror Clerk user → users table.
let row = await db.query.users.findFirst({ where: eq(users.id, userId) });
if (!row) {
const clerkUser = await clerk.users.getUser(userId);
const email =
clerkUser.primaryEmailAddress?.emailAddress ??
clerkUser.emailAddresses[0]?.emailAddress ??
`${userId}@unknown.local`;
const displayName =
[clerkUser.firstName, clerkUser.lastName].filter(Boolean).join(" ") ||
clerkUser.username ||
null;
let email = `${userId}@unknown.local`;
let displayName: string | null = null;
try {
const clerkUser = await clerk.users.getUser(userId);
email =
clerkUser.primaryEmailAddress?.emailAddress ??
clerkUser.emailAddresses[0]?.emailAddress ??
email;
displayName =
[clerkUser.firstName, clerkUser.lastName].filter(Boolean).join(" ") ||
clerkUser.username ||
null;
} catch (err) {
log.warn({ err, userId }, "failed to hydrate Clerk user details; creating minimal backend mirror row");
}
const inserted = await db
.insert(users)
.values({ id: userId, email, displayName })

View File

@@ -25,6 +25,19 @@ export const config = {
serviceToken: process.env.SERVICE_TOKEN ?? "",
a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "dev-a2a-key",
// Service → backend event stream. Redis is optional; HTTP /events/ingest/service is always available.
growEventsRedisUrl: process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
growEventsStream: process.env.GROW_EVENTS_STREAM ?? "grow.events.raw",
growEventsConsumerGroup: process.env.GROW_EVENTS_CONSUMER_GROUP ?? "growqr-backend",
growEventsConsumerName: process.env.GROW_EVENTS_CONSUMER_NAME ?? `backend-${process.pid}`,
// Legacy service Redis surfaces. These let backend observe existing service A2A traffic
// without changing the services. Defaults fall back to GROW_EVENTS_REDIS_URL/REDIS_URL.
interviewRedisUrl: process.env.INTERVIEW_REDIS_URL ?? process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
roleplayRedisUrl: process.env.ROLEPLAY_REDIS_URL ?? process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
resumeRedisUrl: process.env.RESUME_REDIS_URL ?? process.env.GROW_EVENTS_REDIS_URL ?? process.env.REDIS_URL ?? "",
legacyServiceTaskObserverGroup: process.env.LEGACY_SERVICE_TASK_OBSERVER_GROUP ?? "growqr-backend-observer",
// LLM gateway for the unified user agent.
llmProvider: process.env.LLM_PROVIDER ?? "opencode",
llmApiKey:
@@ -50,21 +63,41 @@ export const config = {
// Product microservices exposed as sub-agent surfaces.
interviewServiceUrl:
process.env.INTERVIEW_SERVICE_URL ?? "http://localhost:8007",
interviewPublicUrl:
process.env.INTERVIEW_PUBLIC_URL ?? process.env.INTERVIEW_SERVICE_URL ?? "http://localhost:8007",
roleplayServiceUrl:
process.env.ROLEPLAY_SERVICE_URL ?? "http://localhost:8008",
roleplayPublicUrl:
process.env.ROLEPLAY_PUBLIC_URL ?? process.env.ROLEPLAY_SERVICE_URL ?? "http://localhost:8008",
qscoreServiceUrl:
process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000",
resumeServiceUrl:
process.env.RESUME_SERVICE_URL ?? "http://localhost:8002",
userServiceUrl:
process.env.USER_SERVICE_URL ?? "http://localhost:8003",
resumePublicUrl:
process.env.RESUME_PUBLIC_URL ?? process.env.RESUME_SERVICE_URL ?? "http://localhost:8002",
matchmakingServiceUrl:
process.env.MATCHMAKING_SERVICE_URL ?? "http://localhost:8006",
socialBrandingServiceUrl:
process.env.SOCIAL_BRANDING_SERVICE_URL ?? "http://localhost:8005",
workflowsDashboardUrl:
process.env.WORKFLOWS_DASHBOARD_URL ??
process.env.FRONTEND_ORIGIN ??
"http://localhost:3000",
// ── Central Gitea (one org-wide instance, changes.md §2A) ──
giteaUrl: process.env.GITEA_URL ?? "http://127.0.0.1:3001",
// Public URL is what Git remotes should use and what OpenCode containers see.
// Internal URL is only for backend-to-Gitea API calls on a private network.
giteaPublicUrl:
process.env.GITEA_PUBLIC_URL ??
process.env.GITEA_URL ??
"http://127.0.0.1:3001",
giteaInternalUrl:
process.env.GITEA_INTERNAL_URL ??
process.env.GITEA_URL ??
process.env.GITEA_PUBLIC_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 ?? "",
@@ -74,9 +107,9 @@ export const config = {
opencodeImage:
process.env.OPENCODE_IMAGE ?? "growqr/opencode:dev",
// Version tracking for rollout (changes.md §9)
opencodeImageVersion: process.env.OPENCODE_IMAGE_VERSION ?? "1.0.0",
opencodeImageVersion: process.env.OPENCODE_IMAGE_VERSION ?? "dev",
migrationVersion: process.env.MIGRATION_VERSION ?? "1",
promptVersion: process.env.PROMPT_VERSION ?? "1",
promptVersion: process.env.PROMPT_VERSION ?? "4",
// Host that user containers expose ports on (the host running Docker).
userContainerHost: process.env.USER_CONTAINER_HOST ?? "127.0.0.1",
@@ -88,6 +121,10 @@ export const config = {
// CORS for the Next.js frontend.
frontendOrigin:
process.env.FRONTEND_ORIGIN ?? "http://localhost:3000",
adminUserIds: (process.env.ADMIN_USER_IDS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
// Used by LLM requests.
maxAgentTokens: Number(process.env.MAX_AGENT_TOKENS ?? 4096),

View File

@@ -4,7 +4,9 @@ import {
text,
timestamp,
integer,
boolean,
jsonb,
doublePrecision,
uniqueIndex,
index,
primaryKey,
@@ -171,4 +173,427 @@ export type UserStack = typeof userStacks.$inferSelect;
export type NewUserStack = typeof userStacks.$inferInsert;
export type ActorRow = typeof actors.$inferSelect;
export type RepoRow = typeof repos.$inferSelect;
export const missionRegistry = pgTable(
"mission_registry",
{
id: text("id").primaryKey(),
version: text("version").notNull(),
title: text("title").notNull(),
shortTitle: text("short_title").notNull(),
actorType: text("actor_type"),
actorBacked: boolean("actor_backed").notNull().default(false),
skillPath: text("skill_path").notNull(),
displayOrder: integer("display_order").notNull(),
definition: jsonb("definition").$type<Record<string, unknown>>().notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({ displayIdx: index("mission_registry_display_idx").on(t.displayOrder) }),
);
export type MissionRegistryRow = typeof missionRegistry.$inferSelect;
export const workflowRuns = pgTable(
"workflow_runs",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
workflowId: text("workflow_id").notNull(),
workflowVersion: text("workflow_version").notNull(),
status: text("status", { enum: ["draft", "running", "paused", "completed", "failed"] }).notNull().default("running"),
goal: text("goal"),
input: jsonb("input").$type<Record<string, unknown>>(),
currentStepId: text("current_step_id"),
progressPercent: integer("progress_percent").notNull().default(0),
qscoreBefore: jsonb("qscore_before").$type<Record<string, unknown>>(),
qscoreAfter: jsonb("qscore_after").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp("completed_at", { withTimezone: true }),
},
(t) => ({ userIdx: index("workflow_runs_user_idx").on(t.userId, t.createdAt), workflowIdx: index("workflow_runs_workflow_idx").on(t.workflowId) }),
);
export const workflowRunModules = pgTable("workflow_run_modules", {
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
runId: text("run_id").notNull().references(() => workflowRuns.id, { onDelete: "cascade" }),
moduleId: text("module_id").notNull(),
title: text("title").notNull(),
status: text("status").notNull().default("idle"),
service: text("service"),
idempotencyKey: text("idempotency_key"),
retryCount: integer("retry_count").notNull().default(0),
maxRetries: integer("max_retries").notNull().default(2),
outputSummary: text("output_summary"),
output: jsonb("output").$type<Record<string, unknown>>(),
error: text("error"),
startedAt: timestamp("started_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
});
export const workflowArtifacts = pgTable("workflow_artifacts", {
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
runId: text("run_id").notNull().references(() => workflowRuns.id, { onDelete: "cascade" }),
moduleId: text("module_id"),
type: text("type").notNull(),
title: text("title").notNull(),
repoPath: text("repo_path"),
publicUrl: text("public_url"),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
export const workflowApprovals = pgTable("workflow_approvals", {
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
runId: text("run_id").notNull().references(() => workflowRuns.id, { onDelete: "cascade" }),
approvalId: text("approval_id").notNull(),
status: text("status", { enum: ["pending", "approved", "rejected"] }).notNull().default("pending"),
payload: jsonb("payload").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
});
export const qscoreSnapshots = pgTable("qscore_snapshots", {
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
runId: text("run_id").references(() => workflowRuns.id, { onDelete: "cascade" }),
snapshotType: text("snapshot_type", { enum: ["baseline", "module", "final"] }).notNull(),
score: integer("score"),
payload: jsonb("payload").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
export const workflowEvents = pgTable("workflow_events", {
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
runId: text("run_id").notNull().references(() => workflowRuns.id, { onDelete: "cascade" }),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
type: text("type").notNull(),
payload: jsonb("payload").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
export const growConversations = pgTable(
"grow_conversations",
{
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
title: text("title").notNull().default("Talk to Me"),
active: boolean("active").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({ userIdx: index("grow_conversations_user_idx").on(t.userId, t.updatedAt) }),
);
export const growConversationMessages = pgTable(
"grow_conversation_messages",
{
id: text("id").primaryKey(),
conversationId: text("conversation_id").notNull().references(() => growConversations.id, { onDelete: "cascade" }),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
role: text("role", { enum: ["user", "assistant"] }).notNull(),
sender: text("sender").notNull(),
content: text("content").notNull(),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({ conversationIdx: index("grow_conversation_messages_conversation_idx").on(t.conversationId, t.createdAt) }),
);
export const growActiveMissions = pgTable(
"grow_active_missions",
{
instanceId: text("instance_id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
missionId: text("mission_id").notNull(),
workflowId: text("workflow_id").notNull(),
actorType: text("actor_type"),
title: text("title").notNull(),
shortTitle: text("short_title").notNull(),
status: text("status").notNull(),
progressPercent: integer("progress_percent").notNull().default(0),
currentStageId: text("current_stage_id"),
goal: text("goal"),
mission: jsonb("mission").$type<Record<string, unknown>>().notNull(),
snapshot: jsonb("snapshot").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({ userIdx: index("grow_active_missions_user_idx").on(t.userId, t.updatedAt) }),
);
export type OpencodeSessionRow = typeof opencodeSessions.$inferSelect;
export type WorkflowRunRow = typeof workflowRuns.$inferSelect;
export const growEvents = pgTable(
"grow_events",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
orgId: text("org_id"),
source: text("source").notNull(),
type: text("type").notNull(),
category: text("category", {
enum: ["mission", "service", "artifact", "usage", "qscore", "entitlement", "system"],
}).notNull().default("service"),
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
receivedAt: timestamp("received_at", { withTimezone: true }).defaultNow().notNull(),
mission: jsonb("mission").$type<Record<string, unknown>>(),
subject: jsonb("subject").$type<Record<string, unknown>>(),
correlation: jsonb("correlation").$type<Record<string, unknown>>(),
payload: jsonb("payload").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
raw: jsonb("raw").$type<Record<string, unknown>>(),
dedupeKey: text("dedupe_key"),
processingStatus: text("processing_status", {
enum: ["pending", "processing", "processed", "failed", "unresolved"],
}).notNull().default("pending"),
processingError: text("processing_error"),
processedAt: timestamp("processed_at", { withTimezone: true }),
},
(t) => ({
userIdx: index("grow_events_user_idx").on(t.userId, t.occurredAt),
statusIdx: index("grow_events_status_idx").on(t.processingStatus, t.receivedAt),
sourceIdx: index("grow_events_source_idx").on(t.source, t.type, t.occurredAt),
dedupeIdx: uniqueIndex("grow_events_dedupe_idx").on(t.dedupeKey),
}),
);
export const missionServiceSessions = pgTable(
"mission_service_sessions",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
missionInstanceId: text("mission_instance_id").references(() => growActiveMissions.instanceId, { onDelete: "set null" }),
missionId: text("mission_id"),
stageId: text("stage_id"),
serviceId: text("service_id").notNull(),
externalId: text("external_id").notNull(),
status: text("status").notNull().default("active"),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
lastEventId: text("last_event_id").references(() => growEvents.id, { onDelete: "set null" }),
lastCheckedAt: timestamp("last_checked_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
userIdx: index("mission_service_sessions_user_idx").on(t.userId, t.updatedAt),
externalIdx: uniqueIndex("mission_service_sessions_external_idx").on(t.serviceId, t.externalId),
missionIdx: index("mission_service_sessions_mission_idx").on(t.missionInstanceId, t.stageId),
}),
);
export const missionArtifacts = pgTable(
"mission_artifacts",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
missionInstanceId: text("mission_instance_id").references(() => growActiveMissions.instanceId, { onDelete: "cascade" }),
missionId: text("mission_id"),
stageId: text("stage_id"),
sourceEventId: text("source_event_id").references(() => growEvents.id, { onDelete: "set null" }),
serviceId: text("service_id"),
externalId: text("external_id"),
type: text("type").notNull(),
title: text("title").notNull(),
status: text("status").notNull().default("ready"),
summary: text("summary"),
contentMd: text("content_md"),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
userIdx: index("mission_artifacts_user_idx").on(t.userId, t.createdAt),
missionIdx: index("mission_artifacts_mission_idx").on(t.missionInstanceId, t.createdAt),
externalIdx: index("mission_artifacts_external_idx").on(t.serviceId, t.externalId),
}),
);
export const growQscoreSignals = pgTable(
"grow_qscore_signals",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
sourceEventId: text("source_event_id").references(() => growEvents.id, { onDelete: "set null" }),
signalId: text("signal_id").notNull(),
score: doublePrecision("score").notNull(),
present: boolean("present").notNull().default(true),
source: text("source"),
raw: jsonb("raw").$type<Record<string, unknown>>(),
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
userIdx: index("grow_qscore_signals_user_idx").on(t.userId, t.occurredAt),
signalIdx: index("grow_qscore_signals_signal_idx").on(t.signalId, t.occurredAt),
}),
);
export const growQscoreLatest = pgTable(
"grow_qscore_latest",
{
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
signalId: text("signal_id").notNull(),
score: doublePrecision("score").notNull(),
present: boolean("present").notNull().default(true),
source: text("source"),
sourceEventId: text("source_event_id").references(() => growEvents.id, { onDelete: "set null" }),
raw: jsonb("raw").$type<Record<string, unknown>>(),
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
pk: primaryKey({ columns: [t.userId, t.signalId] }),
userIdx: index("grow_qscore_latest_user_idx").on(t.userId, t.updatedAt),
}),
);
export const growQscoreProjectionState = pgTable("grow_qscore_projection_state", {
userId: text("user_id").primaryKey().references(() => users.id, { onDelete: "cascade" }),
score: integer("score").notNull().default(0),
signalCount: integer("signal_count").notNull().default(0),
dimensions: jsonb("dimensions").$type<Record<string, unknown>>(),
summary: text("summary"),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export const missionActions = pgTable(
"mission_actions",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
missionInstanceId: text("mission_instance_id").notNull().references(() => growActiveMissions.instanceId, { onDelete: "cascade" }),
missionId: text("mission_id").notNull(),
stageId: text("stage_id"),
agentId: text("agent_id").notNull(),
agentName: text("agent_name").notNull(),
baseAgent: text("base_agent"),
serviceId: text("service_id"),
toolName: text("tool_name"),
mode: text("mode", { enum: ["autonomous", "approval_required", "user_input_required", "suggestion"] }).notNull(),
status: text("status", {
enum: ["queued", "running", "waiting_approval", "waiting_user_input", "done", "failed", "dismissed", "snoozed"],
}).notNull().default("queued"),
title: text("title").notNull(),
body: text("body").notNull(),
prompt: text("prompt"),
payload: jsonb("payload").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
result: jsonb("result").$type<Record<string, unknown>>(),
error: text("error"),
sourceEventId: text("source_event_id").references(() => growEvents.id, { onDelete: "set null" }),
idempotencyKey: text("idempotency_key"),
priority: integer("priority").notNull().default(0),
urgency: text("urgency", { enum: ["now", "today", "soon", "calm"] }).notNull().default("calm"),
dueAt: timestamp("due_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
},
(t) => ({
missionIdx: index("mission_actions_mission_idx").on(t.userId, t.missionInstanceId, t.status, t.priority),
userIdx: index("mission_actions_user_idx").on(t.userId, t.status, t.updatedAt),
sourceIdx: index("mission_actions_source_idx").on(t.sourceEventId),
dueIdx: index("mission_actions_due_idx").on(t.dueAt),
idempotencyIdx: uniqueIndex("mission_actions_idempotency_idx").on(t.idempotencyKey),
}),
);
export const missionSuggestions = pgTable(
"mission_suggestions",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
missionInstanceId: text("mission_instance_id").notNull().references(() => growActiveMissions.instanceId, { onDelete: "cascade" }),
missionId: text("mission_id").notNull(),
stageId: text("stage_id"),
role: text("role").notNull(),
type: text("type", { enum: ["action", "practice", "review", "artifact", "blocked", "insight"] }).notNull(),
title: text("title").notNull(),
body: text("body").notNull(),
reason: text("reason"),
priority: integer("priority").notNull().default(0),
urgency: text("urgency", { enum: ["now", "today", "soon", "calm"] }).notNull().default("calm"),
status: text("status", { enum: ["active", "done", "dismissed", "expired"] }).notNull().default("active"),
ctaLabel: text("cta_label").notNull(),
ctaHref: text("cta_href").notNull(),
sourceRefs: jsonb("source_refs").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
generatedBy: text("generated_by", { enum: ["deterministic", "agent", "manual"] }).notNull().default("deterministic"),
expiresAt: timestamp("expires_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
missionIdx: index("mission_suggestions_mission_idx").on(t.userId, t.missionInstanceId, t.status, t.priority),
roleIdx: index("mission_suggestions_role_idx").on(t.missionInstanceId, t.role, t.status),
expiryIdx: index("mission_suggestions_expiry_idx").on(t.expiresAt),
}),
);
export const missionCoachRuns = pgTable(
"mission_coach_runs",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
missionInstanceId: text("mission_instance_id").notNull().references(() => growActiveMissions.instanceId, { onDelete: "cascade" }),
missionId: text("mission_id").notNull(),
status: text("status", { enum: ["running", "completed", "failed"] }).notNull().default("running"),
windowStart: timestamp("window_start", { withTimezone: true }).notNull(),
windowEnd: timestamp("window_end", { withTimezone: true }).notNull(),
summary: text("summary"),
inputDigest: jsonb("input_digest").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
output: jsonb("output").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
model: text("model"),
promptVersion: text("prompt_version").notNull().default("mission-coach-v1"),
skillVersion: text("skill_version"),
error: text("error"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp("completed_at", { withTimezone: true }),
},
(t) => ({ missionIdx: index("mission_coach_runs_mission_idx").on(t.userId, t.missionInstanceId, t.createdAt) }),
);
export const growHomeNotifications = pgTable(
"grow_home_notifications",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
moduleId: text("module_id", {
enum: ["suggestions", "missions", "social", "pathways", "productivity", "rewards"],
}).notNull(),
title: text("title").notNull(),
subtitle: text("subtitle").notNull(),
tag: text("tag").notNull(),
urgency: text("urgency", { enum: ["now", "today", "soon", "calm"] }).notNull().default("calm"),
href: text("href").notNull(),
source: text("source"),
sourceRef: jsonb("source_ref").$type<Record<string, unknown>>(),
priority: integer("priority").notNull().default(0),
generatedBy: text("generated_by", { enum: ["deterministic", "agent", "demo", "manual"] }).notNull().default("deterministic"),
reason: text("reason"),
status: text("status", { enum: ["active", "dismissed", "expired"] }).notNull().default("active"),
expiresAt: timestamp("expires_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
userIdx: index("grow_home_notifications_user_idx").on(t.userId, t.status, t.priority),
moduleIdx: index("grow_home_notifications_module_idx").on(t.userId, t.moduleId, t.status),
expiryIdx: index("grow_home_notifications_expiry_idx").on(t.expiresAt),
}),
);
export type GrowEventRow = typeof growEvents.$inferSelect;
export type NewGrowEvent = typeof growEvents.$inferInsert;
export type MissionActionRow = typeof missionActions.$inferSelect;
export type NewMissionAction = typeof missionActions.$inferInsert;
export type MissionSuggestionRow = typeof missionSuggestions.$inferSelect;
export type NewMissionSuggestion = typeof missionSuggestions.$inferInsert;
export type MissionCoachRunRow = typeof missionCoachRuns.$inferSelect;
export type GrowHomeNotificationRow = typeof growHomeNotifications.$inferSelect;
export type NewGrowHomeNotification = typeof growHomeNotifications.$inferInsert;

View File

@@ -86,9 +86,9 @@ async function getCentralGiteaClient(): Promise<GiteaClient> {
if (!centralGiteaClient) {
const token = config.giteaAdminToken;
if (token) {
centralGiteaClient = new GiteaClient(config.giteaUrl, { kind: "token", token });
centralGiteaClient = new GiteaClient(config.giteaInternalUrl, { kind: "token", token });
} else {
centralGiteaClient = new GiteaClient(config.giteaUrl, {
centralGiteaClient = new GiteaClient(config.giteaInternalUrl, {
kind: "basic",
username: config.giteaAdminUser,
password: config.giteaAdminPassword,
@@ -100,7 +100,7 @@ async function getCentralGiteaClient(): Promise<GiteaClient> {
export async function ensureCentralGiteaReady(): Promise<void> {
if (centralGiteaReady) return;
await waitForGitea(config.giteaUrl, 120_000);
await waitForGitea(config.giteaInternalUrl, 120_000);
const client = await getCentralGiteaClient();
// Ensure the org exists (changes.md §2A: single org manages all users).
@@ -111,7 +111,7 @@ export async function ensureCentralGiteaReady(): Promise<void> {
}
centralGiteaReady = true;
log.info({ url: config.giteaUrl, org: config.giteaOrgName }, "central Gitea ready");
log.info({ url: config.giteaInternalUrl, org: config.giteaOrgName }, "central Gitea ready");
}
// ── Git clone into OpenCode workspace (changes.md §4 step 3) ──
@@ -152,8 +152,9 @@ async function cloneRepoIntoContainer(opts: {
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'"];
// Existing workspace template may have a local git repo without origin.
// Ensure origin points at the user's Gitea repo before pulling.
cmd = ["sh", "-c", `cd /workspace && (git remote get-url origin >/dev/null 2>&1 && git remote set-url origin "${authUrl}" || git remote add origin "${authUrl}") && git pull origin main --allow-unrelated-histories 2>&1 || true`];
} else {
// Clone into /workspace (remove any placeholder files first, then clone).
cmd = [
@@ -199,7 +200,7 @@ export async function syncWorkspaceToGit(userId: string, message?: string): Prom
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`;
let authUrl = `${config.giteaPublicUrl}/${encodeURIComponent(stack.giteaRepoOwner)}/${encodeURIComponent(stack.giteaRepoName)}.git`;
if (config.giteaAdminToken) {
authUrl = authUrl.replace("://", `://${encodeURIComponent(config.giteaAdminToken)}@`);
} else {
@@ -209,7 +210,7 @@ export async function syncWorkspaceToGit(userId: string, message?: string): Prom
// Set the remote URL with auth, add all, commit, push.
const cmd = [
"sh", "-c",
`git remote set-url origin "${authUrl}" 2>/dev/null; ` +
`(git remote get-url origin >/dev/null 2>&1 && git remote set-url origin "${authUrl}" || git remote add origin "${authUrl}"); ` +
`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`,
@@ -250,10 +251,23 @@ async function startOpencodeContainer(opts: {
const existing = await findExistingContainer(name);
if (existing) {
if (existing.State !== "running") {
await docker.getContainer(existing.Id).start().catch(() => undefined);
const existingContainer = docker.getContainer(existing.Id);
const info = await existingContainer.inspect().catch(() => null);
const labels = info?.Config?.Labels ?? {};
const current =
labels["growqr.imageVersion"] === config.opencodeImageVersion &&
labels["growqr.promptVersion"] === config.promptVersion &&
info?.Config?.Image === config.opencodeImage;
if (current) {
if (existing.State !== "running") {
await existingContainer.start().catch(() => undefined);
}
return { id: existing.Id, name };
}
return { id: existing.Id, name };
log.info({ userId: opts.userId, name }, "removing stale OpenCode container");
await existingContainer.remove({ force: true }).catch(() => undefined);
}
// Sub-agents are loaded as prompt modules at build time (changes.md §2D).
@@ -269,7 +283,7 @@ async function startOpencodeContainer(opts: {
`GROWQR_PROMPT_VERSION=${config.promptVersion}`,
`GROWQR_MIGRATION_VERSION=${config.migrationVersion}`,
`GROWQR_USER_ID=${opts.userId}`,
`GROWQR_GITEA_URL=${config.giteaUrl}`,
`GROWQR_GITEA_URL=${config.giteaPublicUrl}`,
],
WorkingDir: "/workspace",
HostConfig: {
@@ -320,7 +334,30 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
where: eq(userStacks.userId, userId),
});
if (existing && existing.status === "running") {
return existing;
const current =
existing.imageVersion === config.opencodeImageVersion &&
existing.migrationVersion === config.migrationVersion &&
existing.promptVersion === config.promptVersion;
if (current) {
const containerName = existing.opencodeContainerName ?? safeContainerName("growqr-opencode", userId);
const container = await findExistingContainer(containerName);
if (container) return existing;
log.warn({ userId, containerName }, "OpenCode stack marked running but container is missing; reprovisioning");
}
log.info(
{
userId,
currentImage: existing.imageVersion,
targetImage: config.opencodeImageVersion,
currentMigration: existing.migrationVersion,
targetMigration: config.migrationVersion,
currentPrompt: existing.promptVersion,
targetPrompt: config.promptVersion,
},
"recreating stale OpenCode stack before provisioning",
);
await stopUserStack(userId);
}
await ensureDir(userDataDir(userId));
@@ -417,7 +454,7 @@ export async function provisionUserStack(userId: string): Promise<UserStack> {
try {
await cloneRepoIntoContainer({
containerId: opencode.id,
repoUrl: `${config.giteaUrl}/${encodeURIComponent(repoOwner)}/${encodeURIComponent(repoName)}.git`,
repoUrl: `${config.giteaPublicUrl}/${encodeURIComponent(repoOwner)}/${encodeURIComponent(repoName)}.git`,
giteaToken: config.giteaAdminToken || undefined,
giteaUser: config.giteaAdminUser,
giteaPassword: !config.giteaAdminToken ? config.giteaAdminPassword : undefined,

93
src/events/envelope.ts Normal file
View File

@@ -0,0 +1,93 @@
export type GrowEventCategory =
| "mission"
| "service"
| "artifact"
| "usage"
| "qscore"
| "entitlement"
| "system";
export type GrowEventSubject = {
kind: string;
id: string;
};
export type GrowEventMissionRef = {
instanceId?: string;
missionId?: string;
stageId?: string;
};
export type GrowEventCorrelation = {
requestId?: string;
sessionId?: string;
resumeId?: string;
externalId?: string;
[key: string]: unknown;
};
export type GrowEventEnvelope = {
id?: string;
userId?: string;
orgId?: string;
source: string;
type: string;
category: GrowEventCategory;
occurredAt: string;
mission?: GrowEventMissionRef;
subject?: GrowEventSubject;
correlation?: GrowEventCorrelation;
payload: Record<string, unknown>;
raw?: Record<string, unknown>;
dedupeKey?: string;
};
export type StoredGrowEvent = GrowEventEnvelope & {
id: string;
userId?: string;
receivedAt?: string;
processingStatus?: "pending" | "processing" | "processed" | "failed" | "unresolved";
};
export type QscoreSignal = {
signalId: string;
score: number;
present: boolean;
source?: string;
raw?: Record<string, unknown>;
};
export function isGrowEventCategory(value: unknown): value is GrowEventCategory {
return (
value === "mission" ||
value === "service" ||
value === "artifact" ||
value === "usage" ||
value === "qscore" ||
value === "entitlement" ||
value === "system"
);
}
export function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
export function getString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
export function getNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const n = Number(value);
if (Number.isFinite(n)) return n;
}
return undefined;
}
export function clampScore(score: number): number {
return Math.max(0, Math.min(100, Math.round(score * 100) / 100));
}

79
src/events/normalize.ts Normal file
View File

@@ -0,0 +1,79 @@
import { randomUUID } from "node:crypto";
import { asRecord, getString, isGrowEventCategory, type GrowEventEnvelope } from "./envelope.js";
function parseDate(value: unknown): string {
const raw = getString(value);
if (!raw) return new Date().toISOString();
const date = new Date(raw);
return Number.isFinite(date.getTime()) ? date.toISOString() : new Date().toISOString();
}
function normalizeSubject(value: unknown) {
const record = asRecord(value);
const kind = getString(record.kind);
const id = getString(record.id);
return kind && id ? { kind, id } : undefined;
}
function normalizeMission(value: unknown) {
const record = asRecord(value);
const instanceId = getString(record.instanceId ?? record.instance_id ?? record.mission_instance_id);
const missionId = getString(record.missionId ?? record.mission_id);
const stageId = getString(record.stageId ?? record.stage_id);
if (!instanceId && !missionId && !stageId) return undefined;
return { instanceId, missionId, stageId };
}
function normalizeCorrelation(value: unknown) {
const record = asRecord(value);
const correlation: Record<string, unknown> = { ...record };
const requestId = getString(record.requestId ?? record.request_id);
const sessionId = getString(record.sessionId ?? record.session_id);
const resumeId = getString(record.resumeId ?? record.resume_id);
const externalId = getString(record.externalId ?? record.external_id);
if (requestId) correlation.requestId = requestId;
if (sessionId) correlation.sessionId = sessionId;
if (resumeId) correlation.resumeId = resumeId;
if (externalId) correlation.externalId = externalId;
return Object.keys(correlation).length ? correlation : undefined;
}
export function normalizeGrowEvent(input: unknown, overrides: { userId?: string; source?: string } = {}): GrowEventEnvelope {
const raw = asRecord(input);
const payload = asRecord(raw.payload ?? raw.data ?? raw.event_payload);
const correlation = normalizeCorrelation(raw.correlation ?? {
session_id: raw.session_id ?? payload.session_id,
resume_id: raw.resume_id ?? payload.resume_id,
external_id: raw.external_id ?? payload.external_id,
request_id: raw.request_id ?? payload.request_id,
});
const subject = normalizeSubject(raw.subject) ?? (() => {
const kind = getString(raw.subject_kind ?? payload.subject_kind);
const id = getString(raw.subject_id ?? payload.subject_id);
return kind && id ? { kind, id } : undefined;
})();
const source = overrides.source ?? getString(raw.source) ?? "unknown-service";
const type = getString(raw.type ?? raw.event_type ?? raw.action) ?? "service.event";
const categoryRaw = raw.category ?? payload.category;
const category = isGrowEventCategory(categoryRaw) ? categoryRaw : "service";
const userId = overrides.userId ?? getString(raw.userId ?? raw.user_id ?? payload.userId ?? payload.user_id);
const orgId = getString(raw.orgId ?? raw.org_id ?? payload.orgId ?? payload.org_id);
const dedupeKey = getString(raw.dedupeKey ?? raw.dedupe_key) ?? getString(raw.id) ?? undefined;
return {
id: getString(raw.id) ?? randomUUID(),
userId,
orgId,
source,
type,
category,
occurredAt: parseDate(raw.occurredAt ?? raw.occurred_at ?? raw.timestamp ?? raw.created_at),
mission: normalizeMission(raw.mission),
subject,
correlation,
payload,
raw,
dedupeKey,
};
}

View File

@@ -0,0 +1,152 @@
import { and, eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { growQscoreLatest, growQscoreProjectionState, growQscoreSignals } from "../db/schema.js";
export const ONBOARDING_BASELINE_SIGNAL_ID = "onboarding.completed_baseline";
export const ONBOARDING_BASELINE_QSCORE = 35;
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function onboardingCompletedAt(preferences: Record<string, unknown> | undefined): Date | null {
const onboarding = asRecord(preferences?.onboarding);
const completedAt = onboarding.completed_at;
if (typeof completedAt !== "string" || !completedAt.trim()) return null;
const parsed = new Date(completedAt);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
}
/**
* Seed the first real Q Score projection when onboarding is completed.
*
* The onboarding UI tells users their QX baseline starts at 35. Previously that
* number was only cosmetic, while the header showed a separate home-feed
* fallback and the Q Score page stayed empty. This makes the onboarding
* baseline a persisted readiness signal, but only when the user has no Q Score
* signals/projection yet so we do not overwrite mature accounts.
*/
export async function ensureOnboardingBaselineQscore(
userId: string,
preferences: Record<string, unknown> | undefined,
): Promise<boolean> {
const completedAt = onboardingCompletedAt(preferences);
if (!completedAt) return false;
const latestSignals = await db
.select({ signalId: growQscoreLatest.signalId, score: growQscoreLatest.score })
.from(growQscoreLatest)
.where(and(eq(growQscoreLatest.userId, userId), eq(growQscoreLatest.present, true)));
const [existingProjection] = await db
.select({ score: growQscoreProjectionState.score, signalCount: growQscoreProjectionState.signalCount })
.from(growQscoreProjectionState)
.where(eq(growQscoreProjectionState.userId, userId))
.limit(1);
const now = new Date();
// Repair users affected by the old resume-upload projector, which treated a
// plain upload as a perfect 100 score. Uploading a resume during onboarding is
// only baseline evidence; parsed resume/interview/roleplay results should be
// what moves the score upward.
if (
latestSignals.length === 1 &&
latestSignals[0]?.signalId === "resume.uploaded" &&
latestSignals[0].score > ONBOARDING_BASELINE_QSCORE
) {
await db
.update(growQscoreLatest)
.set({
score: ONBOARDING_BASELINE_QSCORE,
raw: {
reason: "resume upload baseline correction",
correctedFrom: latestSignals[0].score,
correctedAt: now.toISOString(),
},
updatedAt: now,
})
.where(and(eq(growQscoreLatest.userId, userId), eq(growQscoreLatest.signalId, "resume.uploaded")));
await db
.insert(growQscoreProjectionState)
.values({
userId,
score: ONBOARDING_BASELINE_QSCORE,
signalCount: 1,
dimensions: { baseline: true, latestSignalIds: ["resume.uploaded"], corrected: true },
summary: "Baseline Q Score from onboarding resume upload.",
updatedAt: now,
})
.onConflictDoUpdate({
target: growQscoreProjectionState.userId,
set: {
score: ONBOARDING_BASELINE_QSCORE,
signalCount: 1,
dimensions: { baseline: true, latestSignalIds: ["resume.uploaded"], corrected: true },
summary: "Baseline Q Score from onboarding resume upload.",
updatedAt: now,
},
});
return true;
}
if (latestSignals.length > 0 || (existingProjection?.score ?? 0) > 0) {
return false;
}
const raw = {
reason: "completed onboarding baseline",
completedAt: completedAt.toISOString(),
};
const inserted = await db
.insert(growQscoreLatest)
.values({
userId,
signalId: ONBOARDING_BASELINE_SIGNAL_ID,
score: ONBOARDING_BASELINE_QSCORE,
present: true,
source: "onboarding",
raw,
occurredAt: completedAt,
updatedAt: now,
})
.onConflictDoNothing()
.returning({ signalId: growQscoreLatest.signalId });
if (!inserted.length) return false;
await db.insert(growQscoreSignals).values({
userId,
signalId: ONBOARDING_BASELINE_SIGNAL_ID,
score: ONBOARDING_BASELINE_QSCORE,
present: true,
source: "onboarding",
raw,
occurredAt: completedAt,
});
await db
.insert(growQscoreProjectionState)
.values({
userId,
score: ONBOARDING_BASELINE_QSCORE,
signalCount: 1,
dimensions: { baseline: true, latestSignalIds: [ONBOARDING_BASELINE_SIGNAL_ID] },
summary: "Baseline Q Score from completed onboarding.",
updatedAt: now,
})
.onConflictDoUpdate({
target: growQscoreProjectionState.userId,
set: {
score: ONBOARDING_BASELINE_QSCORE,
signalCount: 1,
dimensions: { baseline: true, latestSignalIds: [ONBOARDING_BASELINE_SIGNAL_ID] },
summary: "Baseline Q Score from completed onboarding.",
updatedAt: now,
},
});
return true;
}

View File

@@ -0,0 +1,104 @@
import { config } from "../../config.js";
import { createChatCompletion, type LlmMessage } from "../../lib/llm.js";
import type { GrowEventRow } from "../../db/schema.js";
import type { QscoreSignal } from "../envelope.js";
export type ProjectionInsight = {
summary: string;
confidence: "low" | "medium" | "high";
recommendedActions: string[];
missionStageHints: Array<{
stageId: string;
status?: "locked" | "ready" | "in_progress" | "blocked" | "done";
progressPercent?: number;
reason?: string;
}>;
};
function fallbackInsight(event: GrowEventRow, qscoreSignals: QscoreSignal[]): ProjectionInsight {
return {
summary: `${event.type} received from ${event.source}.`,
confidence: qscoreSignals.length ? "medium" : "low",
recommendedActions: [],
missionStageHints: [],
};
}
function parseJsonObject(text: string): Record<string, unknown> | null {
const trimmed = text.trim();
const firstBrace = trimmed.indexOf("{");
const lastBrace = trimmed.lastIndexOf("}");
if (firstBrace < 0 || lastBrace < firstBrace) return null;
try {
const parsed = JSON.parse(trimmed.slice(firstBrace, lastBrace + 1));
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}
function normalizeInsight(value: Record<string, unknown> | null, fallback: ProjectionInsight): ProjectionInsight {
if (!value) return fallback;
const summary = typeof value.summary === "string" && value.summary.trim() ? value.summary.trim() : fallback.summary;
const confidence = value.confidence === "high" || value.confidence === "medium" || value.confidence === "low" ? value.confidence : fallback.confidence;
const recommendedActions = Array.isArray(value.recommendedActions)
? value.recommendedActions.filter((item): item is string => typeof item === "string")
: [];
const missionStageHints = Array.isArray(value.missionStageHints)
? value.missionStageHints.flatMap((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
const row = item as Record<string, unknown>;
if (typeof row.stageId !== "string") return [];
const status: ProjectionInsight["missionStageHints"][number]["status"] = row.status === "locked" || row.status === "ready" || row.status === "in_progress" || row.status === "blocked" || row.status === "done" ? row.status : undefined;
return [{
stageId: row.stageId,
status,
progressPercent: typeof row.progressPercent === "number" ? row.progressPercent : undefined,
reason: typeof row.reason === "string" ? row.reason : undefined,
}];
})
: [];
return { summary, confidence, recommendedActions, missionStageHints };
}
export async function getProjectionInsight(input: {
event: GrowEventRow;
qscoreSignals: QscoreSignal[];
activeMissionIds: string[];
}): Promise<ProjectionInsight> {
const fallback = fallbackInsight(input.event, input.qscoreSignals);
if (!config.llmApiKey) return fallback;
const system = `You are GrowQR's deterministic event projection assistant. You do not call tools. You read one raw product event and return only compact JSON. Your job is to help backend reducers interpret business meaning without inventing facts. If evidence is missing, use low confidence and empty hints.`;
const user = {
instruction: "Return JSON only with keys: summary, confidence, recommendedActions, missionStageHints. missionStageHints may include interview-to-offer stage ids: baseline-qscore, resume-fit-scan, interview-prep-plan, behavioral-story-bank, mock-interview, final-readiness-report. Only mark a stage done when the event clearly proves completion.",
event: {
id: input.event.id,
source: input.event.source,
type: input.event.type,
category: input.event.category,
payload: input.event.payload,
correlation: input.event.correlation,
subject: input.event.subject,
},
qscoreSignals: input.qscoreSignals,
activeMissionIds: input.activeMissionIds,
};
const messages: LlmMessage[] = [
{ role: "system", content: system },
{ role: "user", content: JSON.stringify(user) },
];
try {
const completion = await createChatCompletion({
model: process.env.PROJECTION_AGENT_MODEL ?? config.agentModel,
messages,
tools: [],
maxTokens: 700,
});
return normalizeInsight(parseJsonObject(completion.content), fallback);
} catch {
return fallback;
}
}

View File

@@ -0,0 +1,194 @@
import { and, eq, sql } from "drizzle-orm";
import { db } from "../../db/client.js";
import { growQscoreLatest, growQscoreProjectionState, growQscoreSignals, type GrowEventRow } from "../../db/schema.js";
import { asRecord, clampScore, getNumber, type QscoreSignal } from "../envelope.js";
function signal(signalId: string, score: number, raw?: Record<string, unknown>, present = true): QscoreSignal {
return { signalId, score: clampScore(score), present, raw };
}
function nestedNumber(record: Record<string, unknown>, keys: string[]): number | undefined {
for (const key of keys) {
const direct = getNumber(record[key]);
if (direct !== undefined) return direct;
}
return undefined;
}
const RESUME_UPLOAD_BASELINE_SCORE = 35;
function extractResumeSignals(event: GrowEventRow): QscoreSignal[] {
const payload = event.payload ?? {};
const analysis = asRecord(payload.analysis ?? payload.result ?? payload);
const scoreBreakdown = Array.isArray(analysis.score_breakdown) ? analysis.score_breakdown : [];
const dimensions = Array.isArray(analysis.dimensional_scores) ? analysis.dimensional_scores : [];
const byCategory = new Map<string, number>();
for (const item of scoreBreakdown) {
const row = asRecord(item);
const category = typeof row.category === "string" ? row.category : undefined;
const score = getNumber(row.score);
if (category && score !== undefined) byCategory.set(category, score);
}
const byDimension = new Map<string, number>();
for (const item of dimensions) {
const row = asRecord(item);
const dimension = typeof row.dimension === "string" ? row.dimension : undefined;
const score = getNumber(row.score);
if (dimension && score !== undefined) byDimension.set(dimension, score);
}
const signals: QscoreSignal[] = [];
if (event.type.includes("uploaded") || event.type.includes("created")) {
// Uploading a resume is only a baseline readiness signal. The actual Q Score
// should rise from parsed resume/interview/roleplay evidence, not jump to 100
// immediately after onboarding.
signals.push(signal("resume.uploaded", RESUME_UPLOAD_BASELINE_SCORE, { eventId: event.id }));
}
const ats = byCategory.get("ATS Compatibility") ?? nestedNumber(analysis, ["ats_score", "ats_compatibility", "atsCompatibility"]);
if (ats !== undefined) signals.push(signal("resume.ats_compatibility", ats, { eventId: event.id }));
const keywords = byDimension.get("Keywords") ?? nestedNumber(analysis, ["keyword_score", "keyword_relevance", "keywords"]);
if (keywords !== undefined) {
signals.push(signal("resume.keyword_relevance", keywords, { eventId: event.id }));
signals.push(signal("resume.technical_keywords", keywords, { eventId: event.id }));
}
const quantification = byDimension.get("Quantification") ?? nestedNumber(analysis, ["quantification_score"]);
if (quantification !== undefined) signals.push(signal("resume.quantified_achievements", quantification, { eventId: event.id }));
const contentQuality = byCategory.get("Content Quality") ?? nestedNumber(analysis, ["content_quality", "clarity_score"]);
if (contentQuality !== undefined) signals.push(signal("resume.grammar_clarity", contentQuality, { eventId: event.id }));
const formatting = byCategory.get("Formatting") ?? nestedNumber(analysis, ["formatting", "format_score"]);
if (formatting !== undefined) signals.push(signal("resume.format_structure", formatting, { eventId: event.id }));
const impact = byCategory.get("Impact Demonstration") ?? nestedNumber(analysis, ["impact_score"]);
if (impact !== undefined) {
signals.push(signal("resume.leadership_indicators", impact, { eventId: event.id }));
signals.push(signal("resume.impact_statements", impact, { eventId: event.id }));
}
return signals;
}
function extractInterviewSignals(event: GrowEventRow): QscoreSignal[] {
const payload = event.payload ?? {};
const review = asRecord(payload.review ?? payload.result ?? payload);
const status = String(review.status ?? payload.status ?? "");
if (!event.type.includes("review") && !event.type.includes("completed") && status !== "completed") return [];
const signals: QscoreSignal[] = [];
signals.push(signal("interview.completed", 100, { eventId: event.id }));
const overall = getNumber(review.overall_score ?? review.overallScore ?? payload.overall_score);
if (overall !== undefined) signals.push(signal("interview.overall_score", overall, { eventId: event.id }));
const rubric = asRecord(review.rubric_scores ?? review.rubricScores);
const content = getNumber(rubric.content_quality ?? rubric.content ?? rubric.communication ?? review.communication_score);
if (content !== undefined) signals.push(signal("interview.response_clarity", content, { eventId: event.id }));
const roleAlignment = getNumber(rubric.role_alignment ?? rubric.technical_accuracy ?? review.role_alignment_score);
if (roleAlignment !== undefined) signals.push(signal("interview.technical_accuracy", roleAlignment, { eventId: event.id }));
const language = getNumber(rubric.language ?? review.language_score);
if (language !== undefined) signals.push(signal("interview.behavioral_quality", language, { eventId: event.id }));
const historical = asRecord(review.historical_comparison ?? review.historicalComparison);
const delta = getNumber(historical.overall_delta ?? historical.overallDelta);
if (delta !== undefined) signals.push(signal("interview.improvement_over_time", 50 + delta * 2.5, { eventId: event.id, delta }));
return signals;
}
function extractRoleplaySignals(event: GrowEventRow): QscoreSignal[] {
const payload = event.payload ?? {};
const review = asRecord(payload.review ?? payload.result ?? payload);
const status = String(review.status ?? payload.status ?? "");
if (!event.type.includes("review") && !event.type.includes("completed") && status !== "completed") return [];
const signals: QscoreSignal[] = [];
signals.push(signal("roleplay.completed", 100, { eventId: event.id }));
const rubric = asRecord(review.rubric_scores ?? review.rubricScores);
const scenario = getNumber(rubric.scenario_adherence ?? review.scenario_adherence_score);
if (scenario !== undefined) signals.push(signal("roleplay.situational_judgment", scenario, { eventId: event.id }));
const empathy = getNumber(rubric.emotional_intelligence ?? review.emotional_intelligence_score);
if (empathy !== undefined) signals.push(signal("roleplay.empathy_demonstrated", empathy, { eventId: event.id }));
const adaptability = getNumber(rubric.adaptability ?? review.adaptability_score);
if (adaptability !== undefined) signals.push(signal("roleplay.problem_resolution", adaptability, { eventId: event.id }));
const communication = getNumber(rubric.content ?? rubric.communication ?? review.communication_score);
if (communication !== undefined) signals.push(signal("roleplay.communication_effectiveness", communication, { eventId: event.id }));
const historical = asRecord(review.historical_comparison ?? review.historicalComparison);
const delta = getNumber(historical.overall_delta ?? historical.overallDelta);
if (delta !== undefined) signals.push(signal("roleplay.improvement_over_time", 50 + delta * 2.5, { eventId: event.id, delta }));
return signals;
}
export function extractQscoreSignals(event: GrowEventRow): QscoreSignal[] {
const source = event.source.toLowerCase();
if (source.includes("resume") || event.type.startsWith("resume.")) return extractResumeSignals(event);
if (source.includes("interview") || event.type.startsWith("interview.")) return extractInterviewSignals(event);
if (source.includes("roleplay") || event.type.startsWith("roleplay.")) return extractRoleplaySignals(event);
if (event.type === "mission.interview_to_offer.started") {
return [signal("goals.goals_set", 100, { eventId: event.id })];
}
return [];
}
export async function applyQscoreProjection(event: GrowEventRow) {
if (!event.userId) return { signals: [], score: undefined };
const signals = extractQscoreSignals(event);
if (!signals.length) return { signals, score: undefined };
for (const item of signals) {
await db.insert(growQscoreSignals).values({
userId: event.userId,
sourceEventId: event.id,
signalId: item.signalId,
score: item.score,
present: item.present,
source: event.source,
raw: item.raw,
occurredAt: event.occurredAt,
});
await db.insert(growQscoreLatest).values({
userId: event.userId,
signalId: item.signalId,
score: item.score,
present: item.present,
source: event.source,
sourceEventId: event.id,
raw: item.raw,
occurredAt: event.occurredAt,
updatedAt: new Date(),
}).onConflictDoUpdate({
target: [growQscoreLatest.userId, growQscoreLatest.signalId],
set: {
score: item.score,
present: item.present,
source: event.source,
sourceEventId: event.id,
raw: item.raw,
occurredAt: event.occurredAt,
updatedAt: new Date(),
},
});
}
const [aggregate] = await db
.select({ score: sql<number>`round(avg(${growQscoreLatest.score}))::int`, count: sql<number>`count(*)::int` })
.from(growQscoreLatest)
.where(and(eq(growQscoreLatest.userId, event.userId), eq(growQscoreLatest.present, true)));
const score = aggregate?.score ?? 0;
const signalCount = aggregate?.count ?? 0;
await db.insert(growQscoreProjectionState).values({
userId: event.userId,
score,
signalCount,
dimensions: { latestSignalIds: signals.map((s) => s.signalId) },
summary: `Estimated readiness score from ${signalCount} current signal${signalCount === 1 ? "" : "s"}.`,
updatedAt: new Date(),
}).onConflictDoUpdate({
target: growQscoreProjectionState.userId,
set: {
score,
signalCount,
dimensions: { latestSignalIds: signals.map((s) => s.signalId) },
summary: `Estimated readiness score from ${signalCount} current signal${signalCount === 1 ? "" : "s"}.`,
updatedAt: new Date(),
},
});
return { signals, score };
}

View File

@@ -0,0 +1,76 @@
import { eq } from "drizzle-orm";
import { db } from "../../db/client.js";
import { missionServiceSessions, type GrowEventRow } from "../../db/schema.js";
import { asRecord, getString } from "../envelope.js";
import { normalizeServiceId } from "../record-grow-event.js";
function extractExternalId(event: GrowEventRow): string | undefined {
const correlation = asRecord(event.correlation);
const payload = event.payload ?? {};
return getString(
correlation.sessionId ??
correlation.session_id ??
correlation.externalId ??
correlation.external_id ??
payload.session_id ??
payload.sessionId ??
payload.id,
);
}
function statusFor(event: GrowEventRow): string {
const payload = event.payload ?? {};
const explicit = getString(payload.status);
if (explicit) return explicit;
if (event.type.includes("review_completed") || event.type.includes("completed")) return "completed";
if (event.type.includes("failed")) return "failed";
if (event.type.includes("configured") || event.type.includes("created")) return "active";
return "active";
}
export async function applyServiceSessionProjection(event: GrowEventRow) {
if (!event.userId) return null;
const externalId = extractExternalId(event);
if (!externalId) return null;
const serviceId = normalizeServiceId(event.source);
if (!["interview", "roleplay", "resume"].includes(serviceId)) return null;
const mission = asRecord(event.mission);
const metadata = {
lastType: event.type,
subject: event.subject,
payloadStatus: event.payload?.status,
};
const [row] = await db
.insert(missionServiceSessions)
.values({
userId: event.userId,
missionInstanceId: getString(mission.instanceId ?? mission.instance_id),
missionId: getString(mission.missionId ?? mission.mission_id),
stageId: getString(mission.stageId ?? mission.stage_id),
serviceId,
externalId,
status: statusFor(event),
metadata,
lastEventId: event.id,
updatedAt: new Date(),
})
.onConflictDoUpdate({
target: [missionServiceSessions.serviceId, missionServiceSessions.externalId],
set: {
status: statusFor(event),
metadata,
lastEventId: event.id,
lastCheckedAt: event.type.includes("review") ? new Date() : undefined,
updatedAt: new Date(),
},
})
.returning();
// Touch the row if Drizzle ever returns no row for an upsert variant.
if (!row) {
await db.update(missionServiceSessions).set({ updatedAt: new Date() }).where(eq(missionServiceSessions.externalId, externalId));
}
return row ?? null;
}

View File

@@ -0,0 +1,114 @@
import { and, eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { growEvents, missionServiceSessions, users, type GrowEventRow } from "../db/schema.js";
import { asRecord, getString, type GrowEventEnvelope } from "./envelope.js";
import { normalizeGrowEvent } from "./normalize.js";
async function ensureUser(userId: string) {
await db
.insert(users)
.values({ id: userId, email: `${userId}@service.local`, displayName: userId })
.onConflictDoNothing();
}
async function resolveUserId(event: GrowEventEnvelope): Promise<string | undefined> {
if (event.userId) return event.userId;
const sessionId = getString(event.correlation?.sessionId ?? event.correlation?.session_id);
const serviceId = normalizeServiceId(event.source);
if (sessionId) {
const [row] = await db
.select({ userId: missionServiceSessions.userId })
.from(missionServiceSessions)
.where(and(eq(missionServiceSessions.serviceId, serviceId), eq(missionServiceSessions.externalId, sessionId)))
.limit(1);
if (row?.userId) return row.userId;
}
return undefined;
}
export function normalizeServiceId(source: string): string {
if (source.includes("interview")) return "interview";
if (source.includes("roleplay")) return "roleplay";
if (source.includes("resume")) return "resume";
if (source.includes("qscore")) return "qscore";
return source;
}
export async function recordGrowEvent(input: unknown, overrides: { userId?: string; source?: string } = {}): Promise<GrowEventRow> {
const normalized = normalizeGrowEvent(input, overrides);
const resolvedUserId = await resolveUserId(normalized);
if (resolvedUserId) await ensureUser(resolvedUserId);
const processingStatus = resolvedUserId ? "pending" : "unresolved";
if (normalized.dedupeKey) {
const [existing] = await db.select().from(growEvents).where(eq(growEvents.dedupeKey, normalized.dedupeKey)).limit(1);
if (existing) return existing;
}
const values = {
id: normalized.id,
userId: resolvedUserId,
orgId: normalized.orgId,
source: normalized.source,
type: normalized.type,
category: normalized.category,
occurredAt: new Date(normalized.occurredAt),
mission: normalized.mission as Record<string, unknown> | undefined,
subject: normalized.subject as Record<string, unknown> | undefined,
correlation: normalized.correlation as Record<string, unknown> | undefined,
payload: normalized.payload,
raw: normalized.raw ?? asRecord(input),
dedupeKey: normalized.dedupeKey,
processingStatus,
} satisfies typeof growEvents.$inferInsert;
const [inserted] = await db
.insert(growEvents)
.values(values)
.onConflictDoUpdate({
target: growEvents.id,
set: {
userId: values.userId,
orgId: values.orgId,
source: values.source,
type: values.type,
category: values.category,
occurredAt: values.occurredAt,
mission: values.mission,
subject: values.subject,
correlation: values.correlation,
payload: values.payload,
raw: values.raw,
processingStatus: values.processingStatus,
},
})
.returning();
if (!inserted) throw new Error("failed to record grow event");
return inserted;
}
export async function markGrowEventProcessing(eventId: string) {
await db
.update(growEvents)
.set({ processingStatus: "processing", processingError: null })
.where(eq(growEvents.id, eventId));
}
export async function markGrowEventProcessed(eventId: string) {
await db
.update(growEvents)
.set({ processingStatus: "processed", processingError: null, processedAt: new Date() })
.where(eq(growEvents.id, eventId));
}
export async function markGrowEventFailed(eventId: string, error: unknown) {
await db
.update(growEvents)
.set({
processingStatus: "failed",
processingError: error instanceof Error ? error.message : String(error),
processedAt: new Date(),
})
.where(eq(growEvents.id, eventId));
}

View File

@@ -0,0 +1,324 @@
import { randomUUID } from "node:crypto";
import { config } from "../config.js";
import { log } from "../log.js";
import { recordGrowEvent } from "./record-grow-event.js";
import { routeGrowEventToUserActor } from "./route-to-user-actor.js";
// This file has two Redis ingestion modes:
// 1. Canonical GrowEvent stream: grow.events.raw — future service event bus.
// 2. Legacy A2A observer: watches existing tasks:{service} streams + responses:* pub/sub
// so backend can capture current service emissions without service changes.
type RedisMessage = { id: string; message: Record<string, string> };
type RedisStreamResponse = Array<{ name: string; messages: RedisMessage[] }>;
type RedisClientLike = {
connect: () => Promise<void>;
duplicate?: () => RedisClientLike;
on: (event: string, listener: (err: unknown) => void) => void;
xGroupCreate: (stream: string, group: string, id: string, opts?: Record<string, unknown>) => Promise<unknown>;
xReadGroup: (group: string, consumer: string, streams: { key: string; id: string }, opts?: Record<string, unknown>) => Promise<RedisStreamResponse | null>;
xAck: (stream: string, group: string, id: string) => Promise<unknown>;
pSubscribe?: (pattern: string, listener: (message: string, channel: string) => void | Promise<void>) => Promise<unknown>;
};
type ServiceRedisSpec = {
serviceId: "interview" | "roleplay" | "resume";
agentName: "interview-service" | "roleplay-service" | "resume-builder";
redisUrl: string;
};
type LegacyTaskContext = {
taskId: string;
userId?: string;
action?: string;
params?: Record<string, unknown>;
userContext?: Record<string, unknown>;
sessionStart?: boolean;
streamEntryId: string;
seenAt: string;
};
const legacyTaskContexts = new Map<string, LegacyTaskContext>();
async function loadRedisCreateClient(): Promise<(opts: { url: string }) => RedisClientLike> {
const dynamicImport = Function("specifier", "return import(specifier)") as (specifier: string) => Promise<unknown>;
const mod = (await dynamicImport("redis")) as { createClient?: (opts: { url: string }) => RedisClientLike };
if (!mod.createClient) throw new Error("redis package did not expose createClient");
return mod.createClient;
}
function parseFieldValue(value: string | undefined): unknown {
if (!value) return value;
if ((value.startsWith("{") && value.endsWith("}")) || (value.startsWith("[") && value.endsWith("]"))) {
try { return JSON.parse(value); } catch { return value; }
}
return value;
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function getString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function fieldsToEvent(fields: Record<string, string>, stream: string) {
const payload = fields.payload ? parseFieldValue(fields.payload) : parseFieldValue(fields.data ?? "{}");
const correlation = fields.correlation ? parseFieldValue(fields.correlation) : undefined;
const subject = fields.subject ? parseFieldValue(fields.subject) : undefined;
const mission = fields.mission ? parseFieldValue(fields.mission) : undefined;
return {
id: fields.id,
source: fields.source ?? stream,
type: fields.type ?? fields.event_type ?? "service.event",
category: fields.category ?? "service",
userId: fields.userId ?? fields.user_id,
orgId: fields.orgId ?? fields.org_id,
occurredAt: fields.occurredAt ?? fields.occurred_at ?? fields.timestamp,
mission,
subject,
correlation,
payload: payload && typeof payload === "object" && !Array.isArray(payload) ? payload : { value: payload },
raw: fields,
dedupeKey: fields.dedupeKey ?? fields.dedupe_key,
};
}
function serviceSpecs(): ServiceRedisSpec[] {
const specs: ServiceRedisSpec[] = [
{ serviceId: "interview", agentName: "interview-service", redisUrl: config.interviewRedisUrl },
{ serviceId: "roleplay", agentName: "roleplay-service", redisUrl: config.roleplayRedisUrl },
{ serviceId: "resume", agentName: "resume-builder", redisUrl: config.resumeRedisUrl },
];
return specs.filter((spec) => Boolean(spec.redisUrl));
}
function actionToEventType(serviceId: ServiceRedisSpec["serviceId"], action: string | undefined, message: Record<string, unknown>) {
const msgAction = getString(message.action);
const effective = msgAction || action || "event";
if (serviceId === "interview") {
if (effective === "interview_configured" || action === "configure_interview") return "interview.configured";
if (effective === "review_loaded") {
const data = asRecord(message.data);
return data.status === "completed" ? "interview.review_completed" : "interview.review_processing";
}
if (effective === "interview_page_loaded") return "interview.page_state_loaded";
return `interview.${effective.replaceAll("_", ".")}`;
}
if (serviceId === "roleplay") {
if (effective === "roleplay_configured" || action === "configure_roleplay") return "roleplay.configured";
if (effective === "roleplay_review_loaded" || effective === "review_loaded") {
const data = asRecord(message.data);
return data.status === "completed" ? "roleplay.review_completed" : "roleplay.review_processing";
}
if (effective === "roleplay_page_loaded") return "roleplay.page_state_loaded";
return `roleplay.${effective.replaceAll("_", ".")}`;
}
if (effective === "ai_analysis_complete" || action === "ai_analyze") return "resume.analysis_completed";
if (effective === "resume_loaded") return "resume.loaded";
if (effective === "resume_parsed") return "resume.parsed";
return `resume.${effective.replaceAll("_", ".")}`;
}
function extractSessionId(message: Record<string, unknown>, ctx?: LegacyTaskContext): string | undefined {
const data = asRecord(message.data);
const params = ctx?.params ?? {};
return getString(
data.session_id ?? data.sessionId ?? data.id ??
params.session_id ?? params.sessionId ??
data.review_session_id,
);
}
function extractResumeId(message: Record<string, unknown>, ctx?: LegacyTaskContext): string | undefined {
const data = asRecord(message.data);
const params = ctx?.params ?? {};
return getString(
data.resume_id ?? data.resumeId ??
asRecord(data.resume).id ??
params.resume_id ?? params.resumeId,
);
}
async function recordAndRoute(input: unknown) {
const event = await recordGrowEvent(input);
await routeGrowEventToUserActor(event).catch((err) => {
log.warn({ err, eventId: event.id, userId: event.userId }, "failed to route grow event to user actor");
});
return event;
}
async function handleLegacyResponseMessage(spec: ServiceRedisSpec, channel: string, raw: string) {
const taskId = channel.replace(/^responses:/, "");
const ctx = legacyTaskContexts.get(taskId);
const parsed = parseFieldValue(raw);
const message = asRecord(parsed);
const type = getString(message.type);
if (!type || type === "__task_complete__") return;
const data = asRecord(message.data);
const eventType = actionToEventType(spec.serviceId, ctx?.action, message);
const sessionId = extractSessionId(message, ctx);
const resumeId = extractResumeId(message, ctx);
await recordAndRoute({
id: randomUUID(),
source: `${spec.agentName}:legacy-a2a`,
type: eventType,
category: type === "agent_error" ? "system" : "service",
userId: ctx?.userId,
occurredAt: new Date().toISOString(),
correlation: {
taskId,
action: ctx?.action,
sessionId,
resumeId,
externalId: sessionId ?? resumeId,
},
payload: {
action: ctx?.action,
params: ctx?.params,
message,
data,
},
raw: { channel, message },
dedupeKey: `legacy-a2a:${spec.agentName}:${taskId}:${eventType}:${JSON.stringify(message).slice(0, 512)}`,
});
}
function parseLegacyTask(entryId: string, fields: Record<string, string>): LegacyTaskContext | null {
const taskId = getString(fields.task_id);
const payload = asRecord(parseFieldValue(fields.payload));
if (!taskId) return null;
return {
taskId,
userId: getString(payload.user_id ?? fields.user_id),
action: getString(payload.action),
params: asRecord(payload.params),
userContext: asRecord(payload.user_context),
sessionStart: Boolean(payload.session_start),
streamEntryId: entryId,
seenAt: new Date().toISOString(),
};
}
async function startLegacyServiceObserver(spec: ServiceRedisSpec, createClient: (opts: { url: string }) => RedisClientLike) {
const taskStream = `tasks:${spec.agentName}`;
const consumer = `${config.growEventsConsumerName}-${spec.agentName}`;
const group = `${config.legacyServiceTaskObserverGroup}:${spec.agentName}`;
const redis = createClient({ url: spec.redisUrl });
redis.on("error", (err) => log.warn({ err, service: spec.agentName }, "legacy service task observer redis error"));
await redis.connect();
try {
await redis.xGroupCreate(taskStream, group, "0", { MKSTREAM: true });
log.info({ taskStream, group }, "created legacy service task observer group");
} catch (err) {
if (!String(err).includes("BUSYGROUP")) throw err;
}
const subscriber = redis.duplicate ? redis.duplicate() : createClient({ url: spec.redisUrl });
subscriber.on("error", (err) => log.warn({ err, service: spec.agentName }, "legacy service response subscriber redis error"));
await subscriber.connect();
if (!subscriber.pSubscribe) {
log.warn({ service: spec.agentName }, "redis client does not support pSubscribe; legacy response observation disabled");
} else {
await subscriber.pSubscribe("responses:*", async (message, channel) => {
await handleLegacyResponseMessage(spec, channel, message).catch((err) => {
log.error({ err, service: spec.agentName, channel }, "failed to ingest legacy service response");
});
});
}
log.info({ service: spec.agentName, taskStream, group }, "legacy service Redis observer started");
void (async () => {
while (true) {
try {
const response = await redis.xReadGroup(group, consumer, { key: taskStream, id: ">" }, { COUNT: 100, BLOCK: 5000 });
if (!response) continue;
for (const stream of response) {
for (const message of stream.messages) {
const ctx = parseLegacyTask(message.id, message.message);
if (ctx) legacyTaskContexts.set(ctx.taskId, ctx);
await redis.xAck(stream.name, group, message.id);
}
}
} catch (err) {
if (String(err).includes("NOGROUP")) {
try { await redis.xGroupCreate(taskStream, group, "0", { MKSTREAM: true }); } catch {}
} else {
log.error({ err, service: spec.agentName }, "legacy service task observer loop error");
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
})();
}
async function startCanonicalGrowEventStream(createClient: (opts: { url: string }) => RedisClientLike) {
if (!config.growEventsRedisUrl) {
log.info("grow events Redis consumer disabled (GROW_EVENTS_REDIS_URL/REDIS_URL not set)");
return;
}
const redis = createClient({ url: config.growEventsRedisUrl });
redis.on("error", (err) => log.warn({ err }, "grow events redis client error"));
await redis.connect();
try {
await redis.xGroupCreate(config.growEventsStream, config.growEventsConsumerGroup, "0", { MKSTREAM: true });
log.info({ stream: config.growEventsStream, group: config.growEventsConsumerGroup }, "created grow events consumer group");
} catch (err) {
if (!String(err).includes("BUSYGROUP")) throw err;
}
log.info({ stream: config.growEventsStream, group: config.growEventsConsumerGroup }, "grow events redis consumer started");
void (async () => {
while (true) {
try {
const response = await redis.xReadGroup(
config.growEventsConsumerGroup,
config.growEventsConsumerName,
{ key: config.growEventsStream, id: ">" },
{ COUNT: 50, BLOCK: 5000 },
);
if (!response) continue;
for (const stream of response) {
for (const message of stream.messages) {
try {
await recordAndRoute(fieldsToEvent(message.message, stream.name));
} catch (err) {
log.error({ err, messageId: message.id }, "failed to ingest grow event stream message");
} finally {
await redis.xAck(stream.name, config.growEventsConsumerGroup, message.id);
}
}
}
} catch (err) {
log.error({ err }, "grow events redis consumer loop error");
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
})();
}
export async function startGrowEventsRedisConsumer() {
const createClient = await loadRedisCreateClient();
await startCanonicalGrowEventStream(createClient);
const specs = serviceSpecs();
if (!specs.length) {
log.info("legacy service Redis observers disabled (INTERVIEW_REDIS_URL/ROLEPLAY_REDIS_URL/RESUME_REDIS_URL not set)");
return;
}
await Promise.all(specs.map((spec) => startLegacyServiceObserver(spec, createClient)));
}

View File

@@ -0,0 +1,15 @@
import { createClient, type Client } from "rivetkit/client";
import { config } from "../config.js";
import type { Registry } from "../actors/registry.js";
import type { GrowEventRow } from "../db/schema.js";
let _client: Client<Registry> | null = null;
function getClient(): Client<Registry> {
return (_client ??= createClient<Registry>(config.rivetClientEndpoint));
}
export async function routeGrowEventToUserActor(event: Pick<GrowEventRow, "id" | "userId">) {
if (!event.userId) return { routed: false, reason: "unresolved_user" } as const;
await getClient().userEventActor.getOrCreate([event.userId]).enqueueEvent({ userId: event.userId, eventId: event.id });
return { routed: true } as const;
}

117
src/features/registry.ts Normal file
View File

@@ -0,0 +1,117 @@
import { config } from "../config.js";
export type GrowServiceId = "resume-service" | "interview-service" | "roleplay-service" | "qscore-service" | "social-branding-service" | "matchmaking-service";
export type GrowFeatureId = "resume-building" | "mock-interview" | "mock-roleplay" | "q-score" | "social-branding" | "matchmaking";
export type GrowFeatureDefinition = {
id: GrowFeatureId;
serviceId: GrowServiceId;
title: string;
label: string;
description: string;
promptModulePath: string;
enabled: boolean;
internalUrl?: string;
publicUrl?: string;
operations: string[];
};
export const featureDefinitions: GrowFeatureDefinition[] = [
{
id: "resume-building",
serviceId: "resume-service",
title: "Resume Building",
label: "Resume",
description: "Build, tailor, analyze, and improve resumes for role fit and ATS readiness.",
promptModulePath: "agents/resume.md",
enabled: Boolean(config.resumeServiceUrl),
internalUrl: config.resumeServiceUrl,
publicUrl: config.resumePublicUrl,
operations: ["resume.state", "resume.templates", "resume.a2aTask", "resume.create", "resume.update", "resume.analyze", "resume.suggestions", "resume.copilot", "resume.optimizeSummary", "resume.optimizeExperience", "resume.suggestSkills", "resume.generateSummary", "resume.versions", "resume.preview"],
},
{
id: "mock-interview",
serviceId: "interview-service",
title: "Mock Interview",
label: "Interview",
description: "Configure, practice, review, and score interview sessions.",
promptModulePath: "agents/interview.md",
enabled: Boolean(config.interviewServiceUrl),
internalUrl: config.interviewServiceUrl,
publicUrl: config.interviewPublicUrl,
operations: ["interview.configure", "interview.preview", "interview.questions", "interview.approve", "interview.assignments", "interview.unassign", "interview.resultsBulk", "interview.review", "interview.leaderboard", "interview.artifacts", "interview.videoUpload", "interview.practice"],
},
{
id: "mock-roleplay",
serviceId: "roleplay-service",
title: "Mock Roleplay",
label: "Roleplay",
description: "Practice negotiations, recruiter calls, manager conversations, and stakeholder roleplays.",
promptModulePath: "agents/roleplay.md",
enabled: Boolean(config.roleplayServiceUrl),
internalUrl: config.roleplayServiceUrl,
publicUrl: config.roleplayPublicUrl,
operations: ["roleplay.configure", "roleplay.preview", "roleplay.questions", "roleplay.approve", "roleplay.assignments", "roleplay.unassign", "roleplay.resultsBulk", "roleplay.review", "roleplay.leaderboard", "roleplay.artifacts", "roleplay.videoUpload", "roleplay.practice"],
},
{
id: "q-score",
serviceId: "qscore-service",
title: "Q Score",
label: "Q Score",
description: "Analyze overall job-market readiness and convert signals into improvement priorities.",
promptModulePath: "agents/qscore.md",
enabled: Boolean(config.qscoreServiceUrl),
internalUrl: config.qscoreServiceUrl,
operations: ["qscore.ingest", "qscore.compute"],
},
{
id: "social-branding",
serviceId: "social-branding-service",
title: "Social Branding",
label: "Branding",
description: "Build and optimize your professional profile, LinkedIn presence, and personal brand.",
promptModulePath: "agents/social-branding.md",
enabled: Boolean(config.socialBrandingServiceUrl),
internalUrl: config.socialBrandingServiceUrl,
operations: ["branding.profile", "branding.linkedin", "branding.content", "branding.analyze"],
},
{
id: "matchmaking",
serviceId: "matchmaking-service",
title: "Matchmaking",
label: "Matchmaking",
description: "Connect with relevant professionals, mentors, and opportunities through curated matching.",
promptModulePath: "agents/matchmaking.md",
enabled: Boolean(config.matchmakingServiceUrl),
internalUrl: config.matchmakingServiceUrl,
operations: ["matchmaking.find", "matchmaking.connect", "matchmaking.schedule", "matchmaking.review"],
},
];
export const internalWorkflowModules = [
{
id: "mission-planning",
title: "Mission Planning",
label: "Mission Planning",
description: "Internal planning and artifact drafting for a mission. This is not a user-facing feature service.",
execution: "opencode" as const,
},
];
export function listFeatureDefinitions() {
return featureDefinitions;
}
export function getFeatureByServiceId(serviceId: string) {
return featureDefinitions.find((feature) => feature.serviceId === serviceId);
}
export function displayLabelForService(serviceId: string | undefined) {
if (!serviceId) return undefined;
return getFeatureByServiceId(serviceId)?.label;
}
export function displayLabelForExecution(execution: string) {
if (execution === "opencode") return "Mission Planning";
return undefined;
}

268
src/grow/persistence.ts Normal file
View File

@@ -0,0 +1,268 @@
import { asc, desc, eq, and } from "drizzle-orm";
import { db } from "../db/client.js";
import { growActiveMissions, growConversationMessages, growConversations, missionCoachRuns, missionSuggestions } from "../db/schema.js";
import type { GrowActiveMission, MissionSnapshot } from "../actors/missions/types.js";
import type { MissionSuggestion } from "../missions/suggestions.js";
import type { ConversationMessage } from "../actors/conversation/types.js";
import type { GrowConversation } from "../actors/grow/types.js";
const buildId = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
const dateFromMs = (ms?: number) => new Date(ms && Number.isFinite(ms) ? ms : Date.now());
function toConversation(row: typeof growConversations.$inferSelect): GrowConversation {
return { id: row.id, title: row.title, createdAt: row.createdAt.getTime(), updatedAt: row.updatedAt.getTime() };
}
function toMessage(row: typeof growConversationMessages.$inferSelect): ConversationMessage {
return {
id: row.id,
conversationId: row.conversationId,
role: row.role,
sender: row.sender,
content: row.content,
createdAt: row.createdAt.getTime(),
};
}
export async function ensureConversation(userId: string, title = "Talk to Me") {
const existing = await listConversationsPg(userId);
if (existing[0]) return existing[0];
return createConversationPg(userId, title);
}
export async function listConversationsPg(userId: string): Promise<GrowConversation[]> {
const rows = await db.select().from(growConversations).where(eq(growConversations.userId, userId)).orderBy(desc(growConversations.updatedAt));
return rows.map(toConversation);
}
export async function createConversationPg(userId: string, title = "Talk to Me"): Promise<GrowConversation> {
const now = new Date();
const [row] = await db.insert(growConversations).values({ id: buildId("conversation"), userId, title, createdAt: now, updatedAt: now }).returning();
if (!row) throw new Error("Failed to create conversation");
return toConversation(row);
}
export async function getConversationPg(userId: string, conversationId: string): Promise<GrowConversation | null> {
const [row] = await db.select().from(growConversations).where(and(eq(growConversations.userId, userId), eq(growConversations.id, conversationId))).limit(1);
return row ? toConversation(row) : null;
}
export async function touchConversationPg(userId: string, conversationId: string, title?: string) {
const patch: Partial<typeof growConversations.$inferInsert> = { updatedAt: new Date() };
if (title?.trim()) patch.title = title.trim();
const [row] = await db.update(growConversations).set(patch).where(and(eq(growConversations.userId, userId), eq(growConversations.id, conversationId))).returning();
return row ? toConversation(row) : null;
}
export async function resetConversationPg(userId: string, conversationId: string) {
await db.delete(growConversationMessages).where(and(eq(growConversationMessages.userId, userId), eq(growConversationMessages.conversationId, conversationId)));
return touchConversationPg(userId, conversationId);
}
export async function listMessagesPg(userId: string, conversationId: string): Promise<ConversationMessage[]> {
const rows = await db.select().from(growConversationMessages).where(and(eq(growConversationMessages.userId, userId), eq(growConversationMessages.conversationId, conversationId))).orderBy(asc(growConversationMessages.createdAt));
return rows.map(toMessage);
}
export async function addMessagePg(userId: string, input: Omit<ConversationMessage, "createdAt"> & { createdAt?: number }) {
const createdAt = dateFromMs(input.createdAt);
const [row] = await db.insert(growConversationMessages).values({
id: input.id,
userId,
conversationId: input.conversationId,
role: input.role,
sender: input.sender,
content: input.content,
createdAt,
}).onConflictDoUpdate({
target: growConversationMessages.id,
set: { content: input.content, sender: input.sender },
}).returning();
await touchConversationPg(userId, input.conversationId);
if (!row) throw new Error("Failed to persist message");
return toMessage(row);
}
export async function upsertActiveMissionPg(userId: string, mission: GrowActiveMission, snapshot?: MissionSnapshot) {
const now = new Date();
await db.insert(growActiveMissions).values({
instanceId: mission.instanceId,
userId,
missionId: mission.missionId,
workflowId: mission.workflowId,
actorType: mission.actorType,
title: mission.title,
shortTitle: mission.shortTitle,
status: mission.status,
progressPercent: mission.progressPercent,
currentStageId: mission.currentStageId,
goal: mission.goal,
mission: mission as unknown as Record<string, unknown>,
snapshot: snapshot as unknown as Record<string, unknown> | undefined,
createdAt: dateFromMs(mission.createdAt),
updatedAt: dateFromMs(mission.updatedAt),
}).onConflictDoUpdate({
target: growActiveMissions.instanceId,
set: {
status: mission.status,
progressPercent: mission.progressPercent,
currentStageId: mission.currentStageId,
goal: mission.goal,
actorType: mission.actorType,
mission: mission as unknown as Record<string, unknown>,
snapshot: snapshot as unknown as Record<string, unknown> | undefined,
updatedAt: now,
},
});
}
function activeMissionFromRow(row: typeof growActiveMissions.$inferSelect): GrowActiveMission {
const raw = (row.mission ?? {}) as Partial<GrowActiveMission>;
return {
instanceId: raw.instanceId ?? row.instanceId,
missionId: raw.missionId ?? (row.missionId as GrowActiveMission["missionId"]),
workflowId: raw.workflowId ?? row.workflowId,
title: raw.title ?? row.title,
shortTitle: raw.shortTitle ?? row.shortTitle,
status: raw.status ?? (row.status as GrowActiveMission["status"]),
progressPercent: raw.progressPercent ?? row.progressPercent ?? 0,
currentStageId: raw.currentStageId ?? row.currentStageId ?? undefined,
goal: raw.goal ?? row.goal ?? undefined,
actorType: raw.actorType ?? (row.actorType as GrowActiveMission["actorType"] | undefined),
createdAt: raw.createdAt ?? row.createdAt.getTime(),
updatedAt: raw.updatedAt ?? row.updatedAt.getTime(),
};
}
function missionSnapshotFromRow(row: typeof growActiveMissions.$inferSelect): MissionSnapshot | null {
if (!row.snapshot) return null;
const raw = row.snapshot as Partial<MissionSnapshot>;
return {
...(raw as MissionSnapshot),
instanceId: raw.instanceId ?? row.instanceId,
missionId: raw.missionId ?? (row.missionId as MissionSnapshot["missionId"]),
workflowId: raw.workflowId ?? row.workflowId,
userId: raw.userId ?? row.userId,
title: raw.title ?? row.title,
shortTitle: raw.shortTitle ?? row.shortTitle,
status: raw.status ?? (row.status as MissionSnapshot["status"]),
progressPercent: raw.progressPercent ?? row.progressPercent ?? 0,
currentStageId: raw.currentStageId ?? row.currentStageId ?? undefined,
goal: raw.goal ?? row.goal ?? undefined,
stages: raw.stages ?? [],
artifacts: raw.artifacts ?? [],
events: raw.events ?? [],
createdAt: raw.createdAt ?? row.createdAt.toISOString(),
updatedAt: raw.updatedAt ?? row.updatedAt.toISOString(),
};
}
export async function listActiveMissionsPg(userId: string) {
const rows = await db.select().from(growActiveMissions).where(eq(growActiveMissions.userId, userId)).orderBy(desc(growActiveMissions.updatedAt));
return rows.map((row) => ({ mission: activeMissionFromRow(row), snapshot: missionSnapshotFromRow(row) }));
}
export async function getActiveMissionPg(userId: string, instanceId: string) {
const [row] = await db.select().from(growActiveMissions).where(and(eq(growActiveMissions.userId, userId), eq(growActiveMissions.instanceId, instanceId))).limit(1);
return row ? { mission: activeMissionFromRow(row), snapshot: missionSnapshotFromRow(row) } : null;
}
export async function listMissionSuggestionsPg(userId: string, instanceId: string | undefined | null): Promise<MissionSuggestion[]> {
if (!instanceId) return [];
const rows = await db
.select()
.from(missionSuggestions)
.where(and(eq(missionSuggestions.userId, userId), eq(missionSuggestions.missionInstanceId, instanceId), eq(missionSuggestions.status, "active")))
.orderBy(desc(missionSuggestions.priority), desc(missionSuggestions.updatedAt));
return rows.map((row) => ({
id: row.id,
userId: row.userId,
missionInstanceId: row.missionInstanceId,
missionId: row.missionId,
stageId: row.stageId ?? undefined,
role: row.role,
type: row.type,
title: row.title,
body: row.body,
reason: row.reason ?? undefined,
priority: row.priority,
urgency: row.urgency,
status: row.status,
ctaLabel: row.ctaLabel,
ctaHref: row.ctaHref,
sourceRefs: row.sourceRefs ?? {},
generatedBy: row.generatedBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
expiresAt: row.expiresAt?.toISOString(),
}));
}
export async function replaceMissionSuggestionsPg(input: {
userId: string;
missionInstanceId: string;
missionId: string;
coachRunId?: string;
suggestions: Array<Omit<MissionSuggestion, "id" | "userId" | "missionInstanceId" | "missionId" | "status" | "createdAt" | "updatedAt"> & { id?: string }>;
}) {
const now = new Date();
await db.update(missionSuggestions)
.set({ status: "expired", updatedAt: now })
.where(and(eq(missionSuggestions.userId, input.userId), eq(missionSuggestions.missionInstanceId, input.missionInstanceId), eq(missionSuggestions.status, "active")));
if (!input.suggestions.length) return [];
await db.insert(missionSuggestions).values(input.suggestions.map((suggestion) => ({
id: suggestion.id,
userId: input.userId,
missionInstanceId: input.missionInstanceId,
missionId: input.missionId,
stageId: suggestion.stageId,
role: suggestion.role,
type: suggestion.type,
title: suggestion.title,
body: suggestion.body,
reason: suggestion.reason,
priority: suggestion.priority,
urgency: suggestion.urgency,
status: "active" as const,
ctaLabel: suggestion.ctaLabel,
ctaHref: suggestion.ctaHref,
sourceRefs: { ...(suggestion.sourceRefs ?? {}), coachRunId: input.coachRunId },
generatedBy: suggestion.generatedBy ?? "deterministic",
expiresAt: suggestion.expiresAt ? new Date(suggestion.expiresAt) : undefined,
createdAt: now,
updatedAt: now,
})));
return listMissionSuggestionsPg(input.userId, input.missionInstanceId);
}
export async function createMissionCoachRunPg(input: {
userId: string;
missionInstanceId: string;
missionId: string;
windowStart: Date;
windowEnd: Date;
inputDigest: Record<string, unknown>;
skillVersion?: string;
}) {
const [row] = await db.insert(missionCoachRuns).values({
userId: input.userId,
missionInstanceId: input.missionInstanceId,
missionId: input.missionId,
windowStart: input.windowStart,
windowEnd: input.windowEnd,
inputDigest: input.inputDigest,
skillVersion: input.skillVersion,
}).returning();
if (!row) throw new Error("Failed to create mission coach run");
return row;
}
export async function completeMissionCoachRunPg(input: { id: string; summary: string; output: Record<string, unknown> }) {
await db.update(missionCoachRuns).set({
status: "completed",
summary: input.summary,
output: input.output,
completedAt: new Date(),
}).where(eq(missionCoachRuns.id, input.id));
}

View File

@@ -0,0 +1,90 @@
import { Output, generateText } from "ai";
import { z } from "zod";
import { getConversationModel } from "../actors/conversation/agent.js";
import { config } from "../config.js";
import { log } from "../log.js";
import { isAllowedNotificationHref, MODULE_IDS, type HomeModuleId, type HomeNotification, type HomeUrgency } from "./types.js";
const notificationSchema = z.object({
moduleId: z.enum(MODULE_IDS as [HomeModuleId, ...HomeModuleId[]]),
title: z.string().min(4).max(72),
subtitle: z.string().min(4).max(110),
tag: z.string().min(2).max(14),
urgency: z.enum(["now", "today", "soon", "calm"]),
href: z.string().min(1),
source: z.enum(["resume", "interview", "roleplay", "qscore", "mission", "social", "pathways", "rewards", "system"]),
reason: z.string().max(160).optional(),
});
const feedSchema = z.object({
notifications: z.array(notificationSchema).min(6).max(24),
});
const HOME_FEED_AGENT_TIMEOUT_MS = Number(process.env.HOME_FEED_AGENT_TIMEOUT_MS ?? 8000);
export type AgentHomeNotification = z.infer<typeof notificationSchema>;
const SYSTEM = `You are GrowQR's Home Feed Agent.
Your job is to rank and rewrite dashboard notifications from real platform context.
Keep them coherent, specific, and action-oriented. Do not invent unavailable products, scores, sessions, deadlines, companies, artifacts, or rewards.
Every notification must point to one of these real dashboard routes:
- /agents/resume for resume building, resume analysis, ATS, resume suggestions
- /agents/interview for mock interview setup, interview session, interview review
- /agents/roleplay for recruiter/manager/salary/stakeholder roleplay
- /agents/qscore for Q Score/readiness explanations
- /missions for mission progress, approvals, artifacts, next stages
- /social for LinkedIn/social branding
- /pathways for locked/coming-soon pathways
- /rewards for locked/coming-soon rewards
- /suggestions for broad onboarding/profile suggestions
Use minimal iPhone-notification copy: title <= 72 chars, subtitle <= 110 chars, short tag <= 14 chars.
Use urgency truthfully: now = needs immediate user action, today = useful today, soon = next few days, calm = informational.`;
function sanitizeHref(href: string, moduleId: HomeModuleId) {
if (isAllowedNotificationHref(href)) return href;
if (href.startsWith("/missions")) return "/missions/active";
if (href.startsWith("/social")) return "/social";
if (href.startsWith("/pathways")) return "/pathways";
if (href.startsWith("/rewards")) return "/rewards";
if (href.startsWith("/productivity")) return "/productivity";
return moduleId === "productivity" ? "/productivity" : `/${moduleId}`;
}
function stableId(prefix: string, index: number) {
return `${prefix}-${index + 1}`;
}
export async function refineHomeNotificationsWithAgent(input: {
userId: string;
context: Record<string, unknown>;
seeds: Array<Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId }>;
}): Promise<Array<AgentHomeNotification & { id: string; createdAt: string }>> {
if (!config.llmApiKey) return [];
try {
const result = await generateText({
model: getConversationModel(),
output: Output.object({ schema: feedSchema }),
system: SYSTEM,
timeout: HOME_FEED_AGENT_TIMEOUT_MS,
prompt: JSON.stringify({
task: "Create coherent GrowQR home dashboard notifications from the provided service context and deterministic candidates.",
userId: input.userId,
serviceContext: input.context,
deterministicCandidates: input.seeds,
}),
});
const now = new Date().toISOString();
return result.output.notifications.map((n, index) => ({
...n,
href: sanitizeHref(n.href, n.moduleId),
urgency: n.urgency as HomeUrgency,
id: stableId("agent-home", index),
createdAt: now,
}));
} catch (err) {
log.warn({ err, userId: input.userId }, "home feed agent failed; using deterministic notifications");
return [];
}
}

641
src/home/home-feed.ts Normal file
View File

@@ -0,0 +1,641 @@
import { and, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
import { db } from "../db/client.js";
import {
growActiveMissions,
growEvents,
growHomeNotifications,
growQscoreLatest,
growQscoreProjectionState,
missionArtifacts,
missionServiceSessions,
missionSuggestions,
qscoreSnapshots,
users,
type GrowHomeNotificationRow,
type NewGrowHomeNotification,
} from "../db/schema.js";
import { interviewService, resumeService, roleplayService } from "../services/product-service-clients.js";
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
import { refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
import {
isAllowedNotificationHref,
MODULE_IDS,
MODULE_META,
type HomeFeedResponse,
type HomeModule,
type HomeModuleId,
type HomeNotification,
type HomeSource,
type HomeUrgency,
} from "./types.js";
const FRESH_MS = 10 * 60 * 1000;
const EXPIRY_MS = 24 * 60 * 60 * 1000;
const SERVICE_HREFS = {
resume: "/agents/resume",
interview: "/agents/interview",
roleplay: "/agents/roleplay",
qscore: "/agents/qscore",
mission: "/missions/active",
social: "/social",
pathways: "/pathways",
rewards: "/rewards",
suggestions: "/suggestions",
productivity: "/productivity",
} as const;
type SeedNotification = Omit<HomeNotification, "id" | "createdAt"> & { moduleId: HomeModuleId; priority: number };
type HomeContext = {
user: { id: string; email: string; displayName: string | null } | undefined;
qscore: { score: number; signalCount: number; summary: string | null; dimensions: Record<string, unknown> | null } | undefined;
qscoreSignals: Array<{ signalId: string; score: number; source: string | null; updatedAt: Date }>;
activeMissions: Array<{ instanceId: string; missionId: string; title: string; status: string; progressPercent: number; currentStageId: string | null; updatedAt: Date }>;
missionSuggestions: Array<{ id: string; missionInstanceId: string; missionId: string; stageId: string | null; role: string; type: string; title: string; body: string; reason: string | null; priority: number; urgency: string; ctaLabel: string; ctaHref: string; updatedAt: Date }>;
sessions: Array<{ serviceId: string; externalId: string; status: string; updatedAt: Date; metadata: Record<string, unknown> | null }>;
artifacts: Array<{ serviceId: string | null; type: string; title: string; status: string; summary: string | null; createdAt: Date }>;
events: Array<{ source: string; type: string; occurredAt: Date; payload: Record<string, unknown> }>;
serviceStates: Record<string, unknown>;
userProfile?: Record<string, unknown>;
preferences: Record<string, unknown>;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function numberFrom(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function arrayOfStrings(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : [];
}
function recordOf(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {};
}
function profileFromPreferences(preferences: Record<string, unknown>) {
const onboarding = recordOf(preferences.onboarding);
const interview = recordOf(preferences.interview_preferences);
const resume = recordOf(preferences.resume_preferences);
const mission = recordOf(preferences.mission_preferences);
const targetRoles = arrayOfStrings(preferences.target_roles);
const targetCompanies = arrayOfStrings(preferences.target_companies);
const focusAreas = arrayOfStrings(interview.focus_areas);
return {
targetRole: targetRoles[0] ?? (typeof resume.target_title === "string" ? resume.target_title : "Senior Data Scientist"),
targetCompany: targetCompanies[0] ?? "target company",
industry: typeof preferences.industry === "string" ? preferences.industry : "AI / SaaS",
focusAreas,
weakSpots: arrayOfStrings(interview.weak_spots),
jobDescription: typeof interview.job_description === "string" ? interview.job_description : undefined,
activeGoal: typeof mission.active_goal === "string" ? mission.active_goal : typeof onboarding.goal === "string" ? onboarding.goal : undefined,
onboardingComplete: Boolean(onboarding.completed_at),
};
}
function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", ctx: HomeContext, mission?: { instanceId?: string; missionId?: string; stageId?: string | null }) {
const profile = profileFromPreferences(ctx.preferences);
const params = new URLSearchParams({ source: "home" });
if (mission?.instanceId) params.set("missionInstanceId", mission.instanceId);
if (mission?.missionId) params.set("missionId", mission.missionId);
if (mission?.stageId) params.set("stageId", mission.stageId);
params.set("targetRole", profile.targetRole);
if (profile.targetCompany !== "target company") params.set("targetCompany", profile.targetCompany);
if (profile.industry) params.set("industry", profile.industry);
if (profile.focusAreas.length) params.set("focusAreas", profile.focusAreas.slice(0, 4).join(","));
if (profile.weakSpots.length) params.set("weakSpots", profile.weakSpots.slice(0, 3).join(","));
if (profile.jobDescription) params.set("jobDescription", profile.jobDescription.slice(0, 900));
if (service === "interview") return `/agents/interview/setup?${params.toString()}`;
if (service === "roleplay") return `/agents/roleplay/setup?${params.toString()}`;
if (service === "resume") return `/agents/resume?${params.toString()}`;
return `/agents/qscore?${params.toString()}`;
}
function sourceFromSuggestionRole(role: string): HomeSource {
const value = role.toLowerCase();
if (value.includes("resume")) return "resume";
if (value.includes("roleplay")) return "roleplay";
if (value.includes("interview")) return "interview";
if (value.includes("q")) return "qscore";
return "mission";
}
function sanitizeUrgency(value: string): HomeUrgency {
if (value === "now" || value === "today" || value === "soon" || value === "calm") return value;
return "calm";
}
function sanitizeHref(href: string | undefined, fallback: string) {
if (href && isAllowedNotificationHref(href)) return href;
return fallback;
}
function pushSeed(seeds: SeedNotification[], seed: SeedNotification) {
seeds.push({ ...seed, href: sanitizeHref(seed.href, MODULE_META[seed.moduleId].href) });
}
function latestScore(signals: HomeContext["qscoreSignals"], signalId: string) {
return signals.find((s) => s.signalId === signalId)?.score;
}
function serviceSession(ctx: HomeContext, serviceId: string) {
return ctx.sessions.find((s) => s.serviceId === serviceId);
}
function serviceEvent(ctx: HomeContext, prefix: string, includes?: string) {
return ctx.events.find((e) => e.type.startsWith(prefix) && (!includes || e.type.includes(includes)));
}
function hasAnyRealActivity(ctx: HomeContext) {
return Boolean(
ctx.qscore?.signalCount ||
ctx.qscoreSignals.length ||
ctx.activeMissions.length ||
ctx.sessions.length ||
ctx.artifacts.length ||
ctx.events.length ||
ctx.missionSuggestions.length ||
profileFromPreferences(ctx.preferences).onboardingComplete,
);
}
function buildDayOneSeeds(): SeedNotification[] {
const seeds: SeedNotification[] = [];
pushSeed(seeds, { moduleId: "suggestions", title: "Start with your Q Score", subtitle: "A quick readiness scan calibrates resume, interview, and roleplay tips.", tag: "Start", urgency: "now", href: SERVICE_HREFS.qscore, source: "qscore", priority: 90 });
pushSeed(seeds, { moduleId: "suggestions", title: "Add your target role", subtitle: "One role goal makes every recommendation sharper.", tag: "Profile", urgency: "today", href: SERVICE_HREFS.suggestions, source: "system", priority: 80 });
pushSeed(seeds, { moduleId: "missions", title: "Explore Interview-to-Offer", subtitle: "A guided mission connects resume fit, mock practice, and readiness scoring.", tag: "Browse", urgency: "today", href: SERVICE_HREFS.mission, source: "mission", priority: 80 });
pushSeed(seeds, { moduleId: "missions", title: "No approvals pending yet", subtitle: "Start a mission and this tile will track missing steps and progress.", tag: "Quiet", urgency: "calm", href: SERVICE_HREFS.mission, source: "mission", priority: 55 });
pushSeed(seeds, { moduleId: "social", title: "Connect LinkedIn when ready", subtitle: "Social branding recommendations unlock after your profile is available.", tag: "Setup", urgency: "soon", href: SERVICE_HREFS.social, source: "social", priority: 60 });
pushSeed(seeds, { moduleId: "social", title: "Build proof before posting", subtitle: "Resume and mock interview artifacts can become stronger featured pins.", tag: "Proof", urgency: "calm", href: SERVICE_HREFS.social, source: "social", priority: 50 });
pushSeed(seeds, { moduleId: "pathways", title: "Pathways are warming up", subtitle: "Complete resume + interview activity to unlock better route recommendations.", tag: "Soon", urgency: "calm", href: SERVICE_HREFS.pathways, source: "pathways", priority: 40 });
pushSeed(seeds, { moduleId: "productivity", title: "Open Resume Builder", subtitle: "Upload or create a resume to generate ATS and content recommendations.", tag: "Resume", urgency: "now", href: SERVICE_HREFS.resume, source: "resume", priority: 85 });
pushSeed(seeds, { moduleId: "productivity", title: "Try a 10-minute mock interview", subtitle: "The interview service creates a role-aware live practice session.", tag: "Mock", urgency: "soon", href: SERVICE_HREFS.interview, source: "interview", priority: 70 });
pushSeed(seeds, { moduleId: "productivity", title: "Roleplay is available for pressure practice", subtitle: "Use it for recruiter screens, salary asks, or manager conversations.", tag: "Roleplay", urgency: "calm", href: SERVICE_HREFS.roleplay, source: "roleplay", priority: 55 });
pushSeed(seeds, { moduleId: "rewards", title: "Rewards unlock after activity", subtitle: "Finish readiness actions to start earning demo streaks and perks.", tag: "Locked", urgency: "calm", href: SERVICE_HREFS.rewards, source: "rewards", priority: 35 });
return seeds;
}
function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
const seeds = buildDayOneSeeds().filter((seed) => seed.moduleId === "pathways" || seed.moduleId === "social" || seed.moduleId === "rewards");
const profile = profileFromPreferences(ctx.preferences);
const qscore = ctx.qscore?.score ?? Math.round(ctx.qscoreSignals.reduce((sum, s) => sum + s.score, 0) / Math.max(ctx.qscoreSignals.length, 1));
const ats = latestScore(ctx.qscoreSignals, "resume.ats_compatibility");
const interviewOverall = latestScore(ctx.qscoreSignals, "interview.overall_score");
const roleplayComms = latestScore(ctx.qscoreSignals, "roleplay.communication_effectiveness");
const resumeSession = serviceSession(ctx, "resume") ?? serviceSession(ctx, "resume-service");
const interviewSession = serviceSession(ctx, "interview") ?? serviceSession(ctx, "interview-service");
const roleplaySession = serviceSession(ctx, "roleplay") ?? serviceSession(ctx, "roleplay-service");
const interviewReview = serviceEvent(ctx, "interview.", "review");
const roleplayReview = serviceEvent(ctx, "roleplay.", "review");
const resumeAnalysis = serviceEvent(ctx, "resume.", "analysis");
for (const suggestion of ctx.missionSuggestions.slice(0, 5)) {
const mission = ctx.activeMissions.find((item) => item.instanceId === suggestion.missionInstanceId);
const source = sourceFromSuggestionRole(suggestion.role);
const href = sanitizeHref(suggestion.ctaHref, mission ? `/missions/active?missionInstanceId=${encodeURIComponent(mission.instanceId)}` : SERVICE_HREFS.mission);
pushSeed(seeds, {
moduleId: "suggestions",
title: suggestion.title,
subtitle: suggestion.body,
tag: suggestion.ctaLabel.replace(/\s+/g, " ").slice(0, 14),
urgency: sanitizeUrgency(suggestion.urgency),
href,
source,
reason: suggestion.reason ?? undefined,
priority: Math.max(100, suggestion.priority + 10),
});
pushSeed(seeds, {
moduleId: suggestion.role.toLowerCase().includes("resume") || suggestion.role.toLowerCase().includes("interview") || suggestion.role.toLowerCase().includes("roleplay") ? "productivity" : "missions",
title: `${suggestion.role}: ${suggestion.title}`,
subtitle: mission ? `${mission.title} · ${suggestion.body}` : suggestion.body,
tag: suggestion.urgency === "now" ? "Now" : suggestion.urgency === "today" ? "Today" : "Next",
urgency: sanitizeUrgency(suggestion.urgency),
href,
source,
reason: suggestion.reason ?? undefined,
priority: suggestion.priority,
});
}
if (profile.onboardingComplete) {
pushSeed(seeds, {
moduleId: "suggestions",
title: `${profile.targetRole} plan is calibrated`,
subtitle: profile.activeGoal ?? `Today's recommendations are tuned for ${profile.targetRole}${profile.targetCompany !== "target company" ? ` at ${profile.targetCompany}` : ""}.`,
tag: "Profile",
urgency: "today",
href: "/suggestions",
source: "system",
priority: 91,
});
}
if (ctx.qscore || ctx.qscoreSignals.length) {
pushSeed(seeds, {
moduleId: "suggestions",
title: qscore >= 80 ? "Protect your Q Score momentum" : "Raise your Q Score next",
subtitle: qscore >= 80 ? `Readiness is trending at ${qscore}. Keep one proof action moving for ${profile.targetRole}.` : `Current estimate is ${qscore || 64}. Resume + mock practice are fastest for ${profile.targetRole}.`,
tag: "Q Score",
urgency: qscore >= 80 ? "today" : "now",
href: serviceHref("qscore", ctx),
source: "qscore",
priority: 95,
});
}
if (ats !== undefined) {
pushSeed(seeds, {
moduleId: "suggestions",
title: ats >= 80 ? "ATS is demo-ready" : "Resume ATS needs one pass",
subtitle: ats >= 80 ? `ATS ${Math.round(ats)} — review ${profile.targetRole} keywords before applying.` : `ATS ${Math.round(ats)} — add JD keywords and measurable data-science bullets.`,
tag: ats >= 80 ? "Ready" : "Fix",
urgency: ats >= 80 ? "today" : "now",
href: serviceHref("resume", ctx),
source: "resume",
priority: 92,
});
}
for (const mission of ctx.activeMissions.slice(0, 3)) {
pushSeed(seeds, {
moduleId: "missions",
title: `${mission.title}${mission.progressPercent}%`,
subtitle: mission.currentStageId ? `Current stage: ${mission.currentStageId.replaceAll("-", " ")}` : "Next action is ready on the mission dashboard.",
tag: mission.status === "paused" ? "Paused" : "Active",
urgency: mission.status === "paused" ? "soon" : "today",
href: `/missions/active?missionInstanceId=${encodeURIComponent(mission.instanceId)}`,
source: "mission",
priority: 90 - mission.progressPercent,
});
}
const pendingArtifacts = ctx.artifacts.filter((a) => a.status === "draft" || a.status === "ready").slice(0, 2);
for (const artifact of pendingArtifacts) {
pushSeed(seeds, {
moduleId: "missions",
title: `${artifact.title} is ${artifact.status}`,
subtitle: artifact.summary ?? "Review the artifact from your mission dashboard.",
tag: artifact.status === "ready" ? "Review" : "Draft",
urgency: artifact.status === "ready" ? "now" : "soon",
href: SERVICE_HREFS.mission,
source: "mission",
priority: 88,
});
}
pushSeed(seeds, {
moduleId: "social",
title: "Social branding is coming soon",
subtitle: "Mission proof and resume artifacts will become profile and LinkedIn nudges here.",
tag: "Soon",
urgency: "calm",
href: SERVICE_HREFS.social,
source: "social",
priority: 38,
});
if (resumeAnalysis || resumeSession || ats !== undefined) {
pushSeed(seeds, {
moduleId: "productivity",
title: ats !== undefined ? `Resume ATS ${Math.round(ats)}` : "Resume analysis is ready",
subtitle: ats !== undefined && ats >= 80 ? `Use this version for ${profile.targetRole} role-fit scan or final polish.` : "Open Resume Builder for recommendations and bullet fixes.",
tag: "Resume",
urgency: ats !== undefined && ats < 75 ? "now" : "today",
href: serviceHref("resume", ctx),
source: "resume",
priority: 90,
});
}
const firstMission = ctx.activeMissions[0];
if (interviewReview || interviewOverall !== undefined || interviewSession) {
pushSeed(seeds, {
moduleId: "productivity",
title: interviewOverall !== undefined ? `Mock interview score ${Math.round(interviewOverall)}` : "Mock interview review is tracking",
subtitle: interviewReview?.type.includes("processing") ? "Review is still processing; check back from the interview page." : `Open ${profile.targetRole} interview practice for review, next drill, or a new session.`,
tag: interviewReview?.type.includes("processing") ? "Wait" : "Mock",
urgency: interviewReview?.type.includes("processing") ? "soon" : "today",
href: serviceHref("interview", ctx, { instanceId: firstMission?.instanceId, missionId: firstMission?.missionId, stageId: firstMission?.currentStageId }),
source: "interview",
priority: 86,
});
} else {
pushSeed(seeds, { moduleId: "productivity", title: `Schedule a ${profile.targetRole} mock`, subtitle: "Generate a behavioral or role-related session from your target role.", tag: "Mock", urgency: "soon", href: serviceHref("interview", ctx, { instanceId: firstMission?.instanceId, missionId: firstMission?.missionId, stageId: firstMission?.currentStageId }), source: "interview", priority: 72 });
}
if (roleplayReview || roleplayComms !== undefined || roleplaySession) {
pushSeed(seeds, {
moduleId: "productivity",
title: roleplayComms !== undefined ? `Roleplay communication ${Math.round(roleplayComms)}` : "Roleplay scenario is ready",
subtitle: `Practice recruiter, manager, salary, or stakeholder conversations for ${profile.targetRole}.`,
tag: "Roleplay",
urgency: "soon",
href: serviceHref("roleplay", ctx, { instanceId: firstMission?.instanceId, missionId: firstMission?.missionId, stageId: firstMission?.currentStageId }),
source: "roleplay",
priority: 78,
});
}
if (!ctx.activeMissions.length) {
pushSeed(seeds, { moduleId: "missions", title: "Start Interview-to-Offer", subtitle: `Bundle resume fit, mock practice, and Q Score deltas for ${profile.targetRole}.`, tag: "Begin", urgency: "today", href: "/missions/available", source: "mission", priority: 80 });
}
return seeds;
}
async function collectContext(userId: string, input: { userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> } = {}): Promise<HomeContext> {
const [user] = await db.select({ id: users.id, email: users.email, displayName: users.displayName }).from(users).where(eq(users.id, userId)).limit(1);
const [qscore] = await db.select().from(growQscoreProjectionState).where(eq(growQscoreProjectionState.userId, userId)).limit(1);
const qscoreSignals = await db
.select({ signalId: growQscoreLatest.signalId, score: growQscoreLatest.score, source: growQscoreLatest.source, updatedAt: growQscoreLatest.updatedAt })
.from(growQscoreLatest)
.where(eq(growQscoreLatest.userId, userId))
.orderBy(desc(growQscoreLatest.updatedAt))
.limit(30);
const activeMissions = await db
.select({ instanceId: growActiveMissions.instanceId, missionId: growActiveMissions.missionId, title: growActiveMissions.title, status: growActiveMissions.status, progressPercent: growActiveMissions.progressPercent, currentStageId: growActiveMissions.currentStageId, updatedAt: growActiveMissions.updatedAt })
.from(growActiveMissions)
.where(eq(growActiveMissions.userId, userId))
.orderBy(desc(growActiveMissions.updatedAt))
.limit(6);
const suggestions = await db
.select({
id: missionSuggestions.id,
missionInstanceId: missionSuggestions.missionInstanceId,
missionId: missionSuggestions.missionId,
stageId: missionSuggestions.stageId,
role: missionSuggestions.role,
type: missionSuggestions.type,
title: missionSuggestions.title,
body: missionSuggestions.body,
reason: missionSuggestions.reason,
priority: missionSuggestions.priority,
urgency: missionSuggestions.urgency,
ctaLabel: missionSuggestions.ctaLabel,
ctaHref: missionSuggestions.ctaHref,
updatedAt: missionSuggestions.updatedAt,
})
.from(missionSuggestions)
.where(and(eq(missionSuggestions.userId, userId), eq(missionSuggestions.status, "active")))
.orderBy(desc(missionSuggestions.priority), desc(missionSuggestions.updatedAt))
.limit(12);
const sessions = await db
.select({ serviceId: missionServiceSessions.serviceId, externalId: missionServiceSessions.externalId, status: missionServiceSessions.status, updatedAt: missionServiceSessions.updatedAt, metadata: missionServiceSessions.metadata })
.from(missionServiceSessions)
.where(eq(missionServiceSessions.userId, userId))
.orderBy(desc(missionServiceSessions.updatedAt))
.limit(12);
const artifacts = await db
.select({ serviceId: missionArtifacts.serviceId, type: missionArtifacts.type, title: missionArtifacts.title, status: missionArtifacts.status, summary: missionArtifacts.summary, createdAt: missionArtifacts.createdAt })
.from(missionArtifacts)
.where(eq(missionArtifacts.userId, userId))
.orderBy(desc(missionArtifacts.createdAt))
.limit(12);
const events = await db
.select({ source: growEvents.source, type: growEvents.type, occurredAt: growEvents.occurredAt, payload: growEvents.payload })
.from(growEvents)
.where(eq(growEvents.userId, userId))
.orderBy(desc(growEvents.occurredAt))
.limit(30);
const serviceResults = await Promise.allSettled([
resumeService.state(userId),
interviewService.pageState(userId),
roleplayService.pageState(userId),
]);
const [resumeState, interviewState, roleplayState] = serviceResults.map((result) => (result.status === "fulfilled" ? result.value : undefined));
return {
user,
qscore: qscore
? {
score: qscore.score,
signalCount: qscore.signalCount,
summary: qscore.summary,
dimensions: isRecord(qscore.dimensions) ? qscore.dimensions : null,
}
: undefined,
qscoreSignals,
activeMissions,
missionSuggestions: suggestions,
sessions: sessions.map((s) => ({ ...s, metadata: isRecord(s.metadata) ? s.metadata : null })),
artifacts,
events: events.map((e) => ({ ...e, payload: isRecord(e.payload) ? e.payload : {} })),
serviceStates: { resume: resumeState, interview: interviewState, roleplay: roleplayState },
userProfile: input.userProfile,
preferences: input.preferences ?? {},
};
}
function rowToNotification(row: GrowHomeNotificationRow): HomeNotification {
return {
id: row.id,
title: row.title,
subtitle: row.subtitle,
tag: row.tag,
urgency: sanitizeUrgency(row.urgency),
href: sanitizeHref(row.href, MODULE_META[row.moduleId].href),
source: (row.source ?? undefined) as HomeSource | undefined,
reason: row.reason ?? undefined,
createdAt: row.createdAt.toISOString(),
};
}
async function readPersistedNotifications(userId: string) {
const now = new Date();
return db
.select()
.from(growHomeNotifications)
.where(and(eq(growHomeNotifications.userId, userId), eq(growHomeNotifications.status, "active"), or(isNull(growHomeNotifications.expiresAt), gt(growHomeNotifications.expiresAt, now))))
.orderBy(desc(growHomeNotifications.priority), desc(growHomeNotifications.createdAt))
.limit(60);
}
async function replaceGeneratedNotifications(userId: string, notifications: Array<SeedNotification>, generatedBy: "deterministic" | "agent") {
await db
.delete(growHomeNotifications)
.where(and(eq(growHomeNotifications.userId, userId), inArray(growHomeNotifications.generatedBy, ["deterministic", "agent"])));
const expiresAt = new Date(Date.now() + EXPIRY_MS);
const rows: NewGrowHomeNotification[] = notifications.map((n) => ({
userId,
moduleId: n.moduleId,
title: n.title,
subtitle: n.subtitle,
tag: n.tag,
urgency: n.urgency,
href: sanitizeHref(n.href, MODULE_META[n.moduleId].href),
source: n.source,
priority: n.priority,
generatedBy,
reason: n.reason,
status: "active",
expiresAt,
updatedAt: new Date(),
}));
if (rows.length) await db.insert(growHomeNotifications).values(rows);
}
function ensureCoverage(seeds: SeedNotification[], fallback: SeedNotification[]) {
const covered = new Set(seeds.map((s) => s.moduleId));
for (const moduleId of MODULE_IDS) {
if (covered.has(moduleId)) continue;
const item = fallback.find((seed) => seed.moduleId === moduleId);
if (item) seeds.push(item);
}
return seeds;
}
function moduleCount(moduleId: HomeModuleId, notifications: HomeNotification[], ctx: HomeContext, mode: HomeFeedResponse["mode"]) {
if (mode === "demo") {
if (moduleId === "missions") return "1 active";
if (moduleId === "productivity") return "4 updates";
if (moduleId === "social") return "3 ready";
if (moduleId === "pathways") return "Soon";
if (moduleId === "rewards") return "Demo";
return String(notifications.length);
}
if (moduleId === "missions") {
if (ctx.activeMissions.length) return `${ctx.activeMissions.length} active`;
return mode === "day1" ? "0" : String(notifications.length);
}
if (moduleId === "productivity") {
const active = ctx.sessions.filter((s) => s.status === "active" || s.status === "configured" || s.status === "processing").length;
return active ? `${active} active` : String(notifications.length);
}
if (moduleId === "pathways") return "Soon";
if (moduleId === "rewards") return "Soon";
if (moduleId === "social") return "Soon";
return String(notifications.length);
}
function buildModules(rows: GrowHomeNotificationRow[], ctx: HomeContext, mode: HomeFeedResponse["mode"]): HomeModule[] {
const byModule = new Map<HomeModuleId, HomeNotification[]>();
for (const row of rows) {
const current = byModule.get(row.moduleId) ?? [];
current.push(rowToNotification(row));
byModule.set(row.moduleId, current);
}
return MODULE_IDS.map((moduleId) => {
const notifications = byModule.get(moduleId) ?? [];
return {
...MODULE_META[moduleId],
count: moduleCount(moduleId, notifications, ctx, mode),
notifications,
};
});
}
async function buildIdentity(ctx: HomeContext) {
const score = ctx.qscore?.score && ctx.qscore.score > 0 ? ctx.qscore.score : Math.round(ctx.qscoreSignals.reduce((sum, s) => sum + s.score, 0) / Math.max(ctx.qscoreSignals.length, 1)) || 47;
const [baselineSnapshot] = await db
.select({ score: qscoreSnapshots.score })
.from(qscoreSnapshots)
.where(and(eq(qscoreSnapshots.userId, ctx.user?.id ?? ""), eq(qscoreSnapshots.snapshotType, "baseline")))
.orderBy(desc(qscoreSnapshots.createdAt))
.limit(1);
const baseline = baselineSnapshot?.score ?? Math.max(35, score - 29);
const from = Math.max(baseline, Math.min(score, score - Math.min(17, Math.max(5, score - baseline))));
const name = ctx.user?.displayName || ctx.user?.email?.split("@")[0] || "GrowQR User";
const completedArtifacts = ctx.artifacts.filter((a) => a.status === "ready" || a.status === "approved").length;
return {
name,
caption: "Your living QR · scan to view profile",
qrSrc: undefined,
qx: { from, to: score, baseline },
glance: [
{ value: Math.max(0, score - baseline), label: "Growth" },
{ value: Math.max(0, ctx.qscore?.signalCount ?? ctx.qscoreSignals.length), label: "Signals" },
{ value: completedArtifacts || ctx.events.length, label: "Done" },
],
};
}
export async function getHomeFeed(userId: string, opts: { refresh?: boolean; userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> } = {}): Promise<HomeFeedResponse> {
await ensureOnboardingBaselineQscore(userId, opts.preferences);
const ctx = await collectContext(userId, { userProfile: opts.userProfile, preferences: opts.preferences });
const persisted = await readPersistedNotifications(userId);
const newest = persisted[0]?.createdAt?.getTime() ?? 0;
const hasDemo = persisted.some((row) => row.generatedBy === "demo");
const fresh = newest > Date.now() - FRESH_MS;
const hasModuleCoverage = MODULE_IDS.every((moduleId) => persisted.some((row) => row.moduleId === moduleId));
if (persisted.length && hasModuleCoverage && (hasDemo || (!opts.refresh && fresh))) {
const mode = hasDemo ? "demo" : hasAnyRealActivity(ctx) ? "dynamic" : "day1";
return {
generatedAt: new Date().toISOString(),
mode,
identity: await buildIdentity(ctx),
modules: buildModules(persisted, ctx, mode),
};
}
const dayOneSeeds = buildDayOneSeeds();
const deterministic = hasAnyRealActivity(ctx) ? buildDynamicSeeds(ctx) : dayOneSeeds;
const agentNotifications = await refineHomeNotificationsWithAgent({
userId,
context: {
qscore: ctx.qscore,
qscoreSignals: ctx.qscoreSignals,
activeMissions: ctx.activeMissions,
sessions: ctx.sessions,
artifacts: ctx.artifacts,
recentEvents: ctx.events,
serviceStates: ctx.serviceStates,
missionSuggestions: ctx.missionSuggestions,
userProfile: ctx.userProfile,
preferences: ctx.preferences,
routeRules: SERVICE_HREFS,
},
seeds: deterministic,
});
const generatedBy = agentNotifications.length ? "agent" : "deterministic";
const generatedSeeds: SeedNotification[] = agentNotifications.length
? agentNotifications.map((n, index) => ({
moduleId: n.moduleId,
title: n.title,
subtitle: n.subtitle,
tag: n.tag,
urgency: n.urgency,
href: n.href,
source: n.source,
reason: n.reason,
priority: 100 - index,
}))
: deterministic;
await replaceGeneratedNotifications(userId, ensureCoverage(generatedSeeds, dayOneSeeds), generatedBy);
const rows = await readPersistedNotifications(userId);
const mode = hasAnyRealActivity(ctx) ? "dynamic" : "day1";
return {
generatedAt: new Date().toISOString(),
mode,
identity: await buildIdentity(ctx),
modules: buildModules(rows, ctx, mode),
};
}
export async function dismissHomeNotification(userId: string, notificationId: string) {
await db
.update(growHomeNotifications)
.set({ status: "dismissed", updatedAt: new Date() })
.where(and(eq(growHomeNotifications.userId, userId), eq(growHomeNotifications.id, notificationId)));
}
export async function getHomeFeedDebugCounts(userId: string) {
const [row] = await db
.select({ count: sql<number>`count(*)::int` })
.from(growHomeNotifications)
.where(eq(growHomeNotifications.userId, userId));
return { notifications: row?.count ?? 0 };
}

165
src/home/seed-demo-home.ts Normal file
View File

@@ -0,0 +1,165 @@
import "dotenv/config";
import { and, eq, inArray } from "drizzle-orm";
import { db } from "../db/client.js";
import { growActiveMissions, growHomeNotifications, growQscoreLatest, growQscoreProjectionState, missionArtifacts, missionServiceSessions, users, type NewGrowHomeNotification } from "../db/schema.js";
import type { HomeModuleId, HomeSource, HomeUrgency } from "./types.js";
type DemoNotification = {
moduleId: HomeModuleId;
title: string;
subtitle: string;
tag: string;
urgency: HomeUrgency;
href: string;
source: HomeSource;
priority: number;
reason?: string;
};
const demoNotifications: DemoNotification[] = [
{ moduleId: "suggestions", title: "Approve resume v6", subtitle: "ATS 78 → 86 · two PM bullets are ready to ship.", tag: "Urgent", urgency: "now", href: "/agents/resume", source: "resume", priority: 120 },
{ moduleId: "suggestions", title: "Mock follow-up at 6:30 PM", subtitle: "Behavioral drill based on yesterday's review gaps.", tag: "Today", urgency: "today", href: "/agents/interview", source: "interview", priority: 116 },
{ moduleId: "suggestions", title: "Practice recruiter pushback", subtitle: "Roleplay can tighten your compensation and notice-period answer.", tag: "Soon", urgency: "soon", href: "/agents/roleplay", source: "roleplay", priority: 110 },
{ moduleId: "missions", title: "Interview-to-Offer — 72%", subtitle: "Resume fit done · mock review ready · final report locked.", tag: "Active", urgency: "today", href: "/missions", source: "mission", priority: 115 },
{ moduleId: "missions", title: "2 approvals pending", subtitle: "Resume v6 and Mock #4 feedback need your confirmation.", tag: "Action", urgency: "now", href: "/missions", source: "mission", priority: 113 },
{ moduleId: "missions", title: "Final readiness unlock", subtitle: "Complete one roleplay recovery drill to generate the final checklist.", tag: "Next", urgency: "soon", href: "/missions", source: "mission", priority: 106 },
{ moduleId: "social", title: "LinkedIn headline v3 ready", subtitle: "Clearer target: Product Intern · FinTech · Growth Systems.", tag: "Ready", urgency: "today", href: "/social", source: "social", priority: 104 },
{ moduleId: "social", title: "Featured section has 3 proof pins", subtitle: "Use resume scan, mock review, and Q Score delta as credibility blocks.", tag: "Proof", urgency: "soon", href: "/social", source: "social", priority: 100 },
{ moduleId: "social", title: "Banner options queued", subtitle: "Three calm orange/blue layouts are waiting for review.", tag: "Brand", urgency: "soon", href: "/social", source: "social", priority: 96 },
{ moduleId: "pathways", title: "Pathways stay locked for demo", subtitle: "Resume + interview data is enough; pathway service is not enabled yet.", tag: "Soon", urgency: "calm", href: "/pathways", source: "pathways", priority: 70 },
{ moduleId: "pathways", title: "PM SaaS route predicted", subtitle: "Directional only until the pathways service is connected.", tag: "Preview", urgency: "calm", href: "/pathways", source: "pathways", priority: 68 },
{ moduleId: "productivity", title: "Resume ATS 86", subtitle: "Keyword relevance +9 after JD tailoring. Open Resume Builder.", tag: "Resume", urgency: "today", href: "/agents/resume", source: "resume", priority: 118 },
{ moduleId: "productivity", title: "Interview review is ready", subtitle: "Overall 82 · storytelling and concise examples need one more drill.", tag: "Mock", urgency: "now", href: "/agents/interview", source: "interview", priority: 117 },
{ moduleId: "productivity", title: "Roleplay recruiter screen", subtitle: "Scenario ready · handle salary range and start-date objections.", tag: "Roleplay", urgency: "soon", href: "/agents/roleplay", source: "roleplay", priority: 105 },
{ moduleId: "productivity", title: "Q Score now 86", subtitle: "Signals: resume ATS, mock review, and roleplay completion.", tag: "Q Score", urgency: "today", href: "/agents/qscore", source: "qscore", priority: 102 },
{ moduleId: "rewards", title: "Mentor pass preview", subtitle: "Demo reward only · would unlock after 3 completed readiness actions.", tag: "Demo", urgency: "calm", href: "/rewards", source: "rewards", priority: 60 },
{ moduleId: "rewards", title: "Resume Pro credit queued", subtitle: "Tabled for v1; shown as a consistent notification tile.", tag: "Soon", urgency: "calm", href: "/rewards", source: "rewards", priority: 58 },
];
export async function seedDemoHome(userId: string) {
await db.insert(users).values({ id: userId, email: `${userId}@demo.local`, displayName: "Demo User" }).onConflictDoNothing();
await db
.delete(growHomeNotifications)
.where(and(eq(growHomeNotifications.userId, userId), inArray(growHomeNotifications.generatedBy, ["demo", "agent", "deterministic"])));
const expiresAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000);
const rows: NewGrowHomeNotification[] = demoNotifications.map((n) => ({
userId,
moduleId: n.moduleId,
title: n.title,
subtitle: n.subtitle,
tag: n.tag,
urgency: n.urgency,
href: n.href,
source: n.source,
priority: n.priority,
generatedBy: "demo",
reason: n.reason ?? "Seeded for multi-day dashboard demo.",
status: "active",
expiresAt,
updatedAt: new Date(),
}));
await db.insert(growHomeNotifications).values(rows);
await db.insert(growQscoreProjectionState).values({
userId,
score: 86,
signalCount: 14,
dimensions: { strengths: ["resume.ats_compatibility", "interview.overall_score"], growth: ["interview.response_clarity", "roleplay.problem_resolution"] },
summary: "Demo readiness score from resume, interview, and roleplay signals.",
updatedAt: new Date(),
}).onConflictDoUpdate({
target: growQscoreProjectionState.userId,
set: {
score: 86,
signalCount: 14,
dimensions: { strengths: ["resume.ats_compatibility", "interview.overall_score"], growth: ["interview.response_clarity", "roleplay.problem_resolution"] },
summary: "Demo readiness score from resume, interview, and roleplay signals.",
updatedAt: new Date(),
},
});
const signalRows = [
["resume.uploaded", 100, "resume-service"],
["resume.ats_compatibility", 86, "resume-service"],
["resume.keyword_relevance", 84, "resume-service"],
["interview.completed", 100, "interview-service"],
["interview.overall_score", 82, "interview-service"],
["interview.response_clarity", 74, "interview-service"],
["roleplay.completed", 100, "roleplay-service"],
["roleplay.communication_effectiveness", 79, "roleplay-service"],
] as const;
for (const [signalId, score, source] of signalRows) {
await db.insert(growQscoreLatest).values({
userId,
signalId,
score,
present: true,
source,
raw: { demo: true },
occurredAt: new Date(),
updatedAt: new Date(),
}).onConflictDoUpdate({
target: [growQscoreLatest.userId, growQscoreLatest.signalId],
set: { score, present: true, source, raw: { demo: true }, occurredAt: new Date(), updatedAt: new Date() },
});
}
await db.insert(growActiveMissions).values({
instanceId: `demo-interview-to-offer-${userId}`,
userId,
missionId: "interview-to-offer",
workflowId: "interview-to-offer",
actorType: "interviewToOfferMissionActor",
title: "Interview-to-Offer Accelerator",
shortTitle: "Interview-to-Offer",
status: "active",
progressPercent: 72,
currentStageId: "mock-interview-review",
goal: "Land a PM internship offer",
mission: { demo: true, missionId: "interview-to-offer" },
snapshot: { demo: true, progressPercent: 72 },
updatedAt: new Date(),
}).onConflictDoUpdate({
target: growActiveMissions.instanceId,
set: { status: "active", progressPercent: 72, currentStageId: "mock-interview-review", snapshot: { demo: true, progressPercent: 72 }, updatedAt: new Date() },
});
await db.insert(missionServiceSessions).values([
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "resume-fit-scan", serviceId: "resume-service", externalId: `demo-resume-${userId}`, status: "completed", metadata: { demo: true, ats: 86 }, updatedAt: new Date() },
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "mock-interview", serviceId: "interview-service", externalId: `demo-interview-${userId}`, status: "review_ready", metadata: { demo: true, overall: 82 }, updatedAt: new Date() },
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "roleplay-recovery", serviceId: "roleplay-service", externalId: `demo-roleplay-${userId}`, status: "configured", metadata: { demo: true }, updatedAt: new Date() },
]).onConflictDoNothing();
await db.insert(missionArtifacts).values([
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "resume-fit-scan", serviceId: "resume-service", externalId: `demo-resume-${userId}`, type: "resume_fit_scan", title: "Resume Fit Scan", status: "ready", summary: "ATS 86 with stronger PM keywords.", metadata: { demo: true }, updatedAt: new Date() },
{ userId, missionInstanceId: `demo-interview-to-offer-${userId}`, missionId: "interview-to-offer", stageId: "mock-interview", serviceId: "interview-service", externalId: `demo-interview-${userId}`, type: "mock_interview_review", title: "Mock Interview Review", status: "ready", summary: "Overall 82; improve story brevity and tradeoff framing.", metadata: { demo: true }, updatedAt: new Date() },
]).onConflictDoNothing();
return { inserted: rows.length, userId };
}
const entry = process.argv[1] ?? "";
if (entry.endsWith("seed-demo-home.ts") || entry.endsWith("seed-demo-home.js")) {
const userId = process.env.DEMO_USER_ID;
if (!userId) {
console.error("Set DEMO_USER_ID to seed demo home notifications.");
process.exit(1);
}
seedDemoHome(userId)
.then((result) => {
console.log(JSON.stringify(result, null, 2));
process.exit(0);
})
.catch((err) => {
console.error(err);
process.exit(1);
});
}

84
src/home/types.ts Normal file
View File

@@ -0,0 +1,84 @@
export type HomeModuleId = "suggestions" | "missions" | "social" | "pathways" | "productivity" | "rewards";
export type HomeUrgency = "now" | "today" | "soon" | "calm";
export type HomeAccent = "orange" | "blue" | "teal" | "amber" | "violet";
export type HomeSource = "resume" | "interview" | "roleplay" | "qscore" | "mission" | "social" | "pathways" | "rewards" | "system";
export type HomeNotification = {
id: string;
title: string;
subtitle: string;
tag: string;
urgency: HomeUrgency;
href: string;
source?: HomeSource;
reason?: string;
createdAt: string;
};
export type HomeModule = {
id: HomeModuleId;
label: string;
href: string;
accent: HomeAccent;
count: string;
notifications: HomeNotification[];
};
export type HomeIdentity = {
name: string;
caption: string;
qrSrc?: string;
qx: { from: number; to: number; baseline: number };
glance: { value: number; label: string }[];
};
export type HomeFeedResponse = {
generatedAt: string;
mode: "day1" | "dynamic" | "demo";
identity: HomeIdentity;
modules: HomeModule[];
};
export const MODULE_META: Record<HomeModuleId, Omit<HomeModule, "count" | "notifications">> = {
suggestions: { id: "suggestions", label: "Today's Queue", href: "/suggestions", accent: "orange" },
missions: { id: "missions", label: "Missions", href: "/missions", accent: "orange" },
social: { id: "social", label: "Social Branding", href: "/social", accent: "blue" },
pathways: { id: "pathways", label: "Pathways", href: "/pathways", accent: "teal" },
productivity: { id: "productivity", label: "Productivity", href: "/agents", accent: "orange" },
rewards: { id: "rewards", label: "Rewards", href: "/rewards", accent: "amber" },
};
export const MODULE_IDS: HomeModuleId[] = ["suggestions", "missions", "pathways", "productivity", "social", "rewards"];
export const ALLOWED_NOTIFICATION_HREFS = new Set([
"/suggestions",
"/missions",
"/missions/active",
"/missions/available",
"/social",
"/pathways",
"/productivity",
"/rewards",
"/agents/resume",
"/agents/interview",
"/agents/interview/setup",
"/agents/roleplay",
"/agents/roleplay/setup",
"/agents/qscore",
]);
export const ALLOWED_NOTIFICATION_HREF_PREFIXES = [
"/missions/active",
"/missions/available",
"/agents/resume",
"/agents/interview",
"/agents/interview/setup",
"/agents/roleplay",
"/agents/roleplay/setup",
"/agents/qscore",
] as const;
export function isAllowedNotificationHref(href: string) {
if (ALLOWED_NOTIFICATION_HREFS.has(href)) return true;
return ALLOWED_NOTIFICATION_HREF_PREFIXES.some((prefix) => href === prefix || href.startsWith(`${prefix}?`));
}

View File

@@ -10,8 +10,15 @@ import { opencodeRoutes } from "./routes/opencode.js";
import { gitRoutes } from "./routes/git.js";
import { userRoutes } from "./routes/users.js";
import { agentRoutes } from "./routes/agents.js";
import { workflowRoutes } from "./routes/workflows.js";
import { workflowRoutes, workflowRunRoutes } from "./routes/workflows.js";
import { chatRoutes } from "./routes/chat.js";
import { serviceRoutes } from "./routes/services.js";
import { conversationRoutes } from "./routes/conversations.js";
import { growRoutes } from "./routes/grow.js";
import { missionRoutes } from "./routes/missions.js";
import { eventRoutes } from "./routes/events.js";
import { homeRoutes } from "./routes/home.js";
import { startGrowEventsRedisConsumer } from "./events/redis-consumer.js";
import { db } from "./db/client.js";
import { hydratePortAllocator, reconcileOnBoot, ensureCentralGiteaReady } from "./docker/manager.js";
import { initCatalog } from "./agents/catalog.js";
@@ -33,6 +40,7 @@ async function main() {
await initCatalog();
await reconcileOnBoot();
startGrowEventsRedisConsumer().catch((err) => log.error({ err }, "failed to start grow events redis consumer"));
const app = new Hono();
@@ -73,16 +81,23 @@ async function main() {
app.route("/users", userRoutes());
app.route("/agents", agentRoutes());
app.route("/workflows", workflowRoutes());
app.route("/workflow-runs", workflowRunRoutes());
app.route("/actors", actorRoutes());
app.route("/grow", growRoutes());
app.route("/missions", missionRoutes());
app.route("/events", eventRoutes());
app.route("/home", homeRoutes());
app.route("/conversations", conversationRoutes());
app.route("/opencode", opencodeRoutes());
app.route("/git", gitRoutes());
app.route("/api/chat", chatRoutes());
app.route("/services", serviceRoutes());
if (process.env.RIVET_ENDPOINT) {
// Self-hosted: embedded engine runs at localhost:6420.
// Proxy frontend Rivet traffic to the engine instead of using registry.handler()
// (handler conflicts with startRunner — they're mutually exclusive).
app.all("/api/rivet/*", async (c) => {
const proxyRivet = async (c: any) => {
const url = new URL(c.req.url);
const target = new URL(config.rivetEndpoint);
url.protocol = target.protocol;
@@ -92,7 +107,7 @@ async function main() {
// Forward headers, stripping hop-by-hop ones
const fwdHeaders = new Headers();
for (const [k, v] of Object.entries(c.req.raw.headers)) {
for (const [k, v] of c.req.raw.headers.entries()) {
if (k.toLowerCase() === "host") continue;
if (k.toLowerCase() === "transfer-encoding") continue;
fwdHeaders.set(k, v);
@@ -122,7 +137,9 @@ async function main() {
log.error({ err, url: url.toString() }, "rivet proxy error");
return c.json({ error: "proxy_error" }, 502);
}
});
};
app.all("/api/rivet", proxyRivet);
app.all("/api/rivet/*", proxyRivet);
registry.startRunner();
} else {
// Serverless: use registry.handler() for incoming actor traffic.
@@ -134,7 +151,8 @@ async function main() {
{
port: info.port,
rivet: config.rivetEndpoint,
gitea: config.giteaUrl,
giteaPublic: config.giteaPublicUrl,
giteaInternal: config.giteaInternalUrl,
env: config.nodeEnv,
},
"growqr-backend listening",

View File

@@ -89,7 +89,7 @@ export function getSubAgentModule(id: string): SubAgentModule | undefined {
}
export function jobApplicationModuleIds(): string[] {
return ["resume", "job-search", "job-apply", "sara", "emily", "qscore"];
return ["resume", "interview", "roleplay", "qscore"];
}
// Load all prompt and agent files from disk.

View File

@@ -0,0 +1,80 @@
import type { InferSelectModel } from "drizzle-orm";
import type { missionActions } from "../db/schema.js";
export type MissionActionMode = "autonomous" | "approval_required" | "user_input_required" | "suggestion";
export type MissionActionStatus =
| "queued"
| "running"
| "waiting_approval"
| "waiting_user_input"
| "done"
| "failed"
| "dismissed"
| "snoozed";
export type MissionActionUrgency = "now" | "today" | "soon" | "calm";
export type MissionActionRow = InferSelectModel<typeof missionActions>;
export type MissionActionDto = {
id: string;
userId: string;
missionInstanceId: string;
missionId: string;
stageId?: string;
agentId: string;
agentName: string;
baseAgent?: string;
serviceId?: string;
toolName?: string;
mode: MissionActionMode;
status: MissionActionStatus;
title: string;
body: string;
prompt?: string;
payload: Record<string, unknown>;
result?: Record<string, unknown>;
error?: string;
sourceEventId?: string;
idempotencyKey?: string;
priority: number;
urgency: MissionActionUrgency;
dueAt?: string;
createdAt: string;
updatedAt: string;
resolvedAt?: string;
};
export type NewMissionActionInput = {
userId: string;
missionInstanceId: string;
missionId: string;
stageId?: string;
agentId: string;
agentName: string;
baseAgent?: string;
serviceId?: string;
toolName?: string;
mode: MissionActionMode;
status?: MissionActionStatus;
title: string;
body: string;
prompt?: string;
payload?: Record<string, unknown>;
result?: Record<string, unknown>;
error?: string;
sourceEventId?: string;
idempotencyKey?: string;
priority?: number;
urgency?: MissionActionUrgency;
dueAt?: Date | string;
};
export function defaultMissionActionStatus(mode: MissionActionMode): MissionActionStatus {
if (mode === "approval_required") return "waiting_approval";
if (mode === "user_input_required") return "waiting_user_input";
return "queued";
}
export function isOpenMissionActionStatus(status: MissionActionStatus) {
return status === "queued" || status === "running" || status === "waiting_approval" || status === "waiting_user_input" || status === "failed";
}

Some files were not shown because too many files have changed in this diff Show More