Compare commits
39 Commits
feat/integ
...
sai/refact
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fd478c095 | ||
|
|
f0ef57f054 | ||
|
|
dd48321904 | ||
|
|
bef6d08b6b | ||
|
|
170d3583c6 | ||
|
|
aa8f2853b2 | ||
|
|
c47e6de526 | ||
|
|
5f667038d8 | ||
|
|
ef5d7bb378 | ||
|
|
d4f9b0edcb | ||
|
|
01e9cc92d4 | ||
|
|
213987a9e0 | ||
|
|
8e4fdc6adf | ||
|
|
d10ef2a882 | ||
|
|
e478db9334 | ||
|
|
9e96912942 | ||
|
|
1d3cfbcff7 | ||
|
|
f03de1ea58 | ||
|
|
1f7b2ae958 | ||
|
|
821788558e | ||
|
|
b7d61944b4 | ||
|
|
5c480ce90f | ||
|
|
a1654d23b4 | ||
|
|
289f6f7844 | ||
|
|
f3fe3c4748 | ||
|
|
c4217eb18c | ||
|
|
a937bcf09e | ||
|
|
068b57c553 | ||
|
|
86ec1fa603 | ||
|
|
5839d91d97 | ||
|
|
a84f323cd5 | ||
|
|
3663fb91b0 | ||
| 488fc1aeca | |||
|
|
d0b0efca74 | ||
|
|
f9f69653e3 | ||
|
|
4a4a03ebb9 | ||
|
|
370c45c002 | ||
|
|
ef87cf80e5 | ||
|
|
be486e12e3 |
14
.env.example
14
.env.example
@@ -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
|
||||
|
||||
@@ -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 ./
|
||||
|
||||
@@ -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 10–20s 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
|
||||
|
||||
@@ -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
185
agents/interview.md
Normal 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 candidate’s 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 user’s 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.
|
||||
@@ -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.
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
id: job-search
|
||||
name: Job Search Agent
|
||||
role: Opportunity Scout
|
||||
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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
77
agents/roleplay.md
Normal 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.
|
||||
@@ -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
962
bun.lock
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
84
docs/backend-dead-code.md
Normal 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
|
||||
```
|
||||
179
docs/backend-organization-audit.md
Normal file
179
docs/backend-organization-audit.md
Normal 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
148
docs/environment-matrix.md
Normal 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.
|
||||
34
docs/opencode-lifecycle-followups.md
Normal file
34
docs/opencode-lifecycle-followups.md
Normal 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.
|
||||
284
docs/retry-idempotency-dlq-plan.md
Normal file
284
docs/retry-idempotency-dlq-plan.md
Normal 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.
|
||||
53
drizzle/0002_workflow_runs.sql
Normal file
53
drizzle/0002_workflow_runs.sql
Normal 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
|
||||
);
|
||||
14
drizzle/0003_workflow_phase2.sql
Normal file
14
drizzle/0003_workflow_phase2.sql
Normal 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");
|
||||
11
drizzle/0004_qscore_snapshots.sql
Normal file
11
drizzle/0004_qscore_snapshots.sql
Normal 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");
|
||||
15
drizzle/0005_mission_registry.sql
Normal file
15
drizzle/0005_mission_registry.sql
Normal 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");
|
||||
49
drizzle/0006_conversations_active_missions.sql
Normal file
49
drizzle/0006_conversations_active_missions.sql
Normal 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);
|
||||
123
drizzle/0007_grow_event_backbone.sql
Normal file
123
drizzle/0007_grow_event_backbone.sql
Normal 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()
|
||||
);
|
||||
36
drizzle/0008_home_notifications.sql
Normal file
36
drizzle/0008_home_notifications.sql
Normal 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);
|
||||
47
drizzle/0009_mission_suggestions.sql
Normal file
47
drizzle/0009_mission_suggestions.sql
Normal 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");
|
||||
34
drizzle/0010_mission_actions.sql
Normal file
34
drizzle/0010_mission_actions.sql
Normal 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");
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
12
package.json
12
package.json
@@ -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
4251
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
pnpm-workspace.yaml
Normal file
9
pnpm-workspace.yaml
Normal 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
|
||||
@@ -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.
|
||||
|
||||
7
prompts/workflows/career-transition/orchestrator.md
Normal file
7
prompts/workflows/career-transition/orchestrator.md
Normal 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.
|
||||
@@ -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.
|
||||
7
prompts/workflows/interview-to-offer/interview-plan.md
Normal file
7
prompts/workflows/interview-to-offer/interview-plan.md
Normal 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.
|
||||
7
prompts/workflows/interview-to-offer/orchestrator.md
Normal file
7
prompts/workflows/interview-to-offer/orchestrator.md
Normal 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.
|
||||
7
prompts/workflows/interview-to-offer/resume-analysis.md
Normal file
7
prompts/workflows/interview-to-offer/resume-analysis.md
Normal 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.
|
||||
7
prompts/workflows/interview-to-offer/story-bank.md
Normal file
7
prompts/workflows/interview-to-offer/story-bank.md
Normal 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.
|
||||
@@ -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.
|
||||
7
prompts/workflows/promotion-readiness/orchestrator.md
Normal file
7
prompts/workflows/promotion-readiness/orchestrator.md
Normal 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.
|
||||
@@ -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
179
scripts/rivet-actors.ts
Normal 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);
|
||||
});
|
||||
39
src/actors/conversation/README.md
Normal file
39
src/actors/conversation/README.md
Normal 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.
|
||||
76
src/actors/conversation/agent.ts
Normal file
76
src/actors/conversation/agent.ts
Normal 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",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
233
src/actors/conversation/conversation-actor.ts
Normal file
233
src/actors/conversation/conversation-actor.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
3
src/actors/conversation/index.ts
Normal file
3
src/actors/conversation/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { conversationActor } from "./conversation-actor.js";
|
||||
export * from "./schema.js";
|
||||
export * from "./types.js";
|
||||
42
src/actors/conversation/migrations.ts
Normal file
42
src/actors/conversation/migrations.ts
Normal 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
|
||||
);
|
||||
`);
|
||||
}
|
||||
89
src/actors/conversation/schema.ts
Normal file
89
src/actors/conversation/schema.ts
Normal 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;
|
||||
39
src/actors/conversation/types.ts
Normal file
39
src/actors/conversation/types.ts
Normal 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;
|
||||
};
|
||||
1
src/actors/events/index.ts
Normal file
1
src/actors/events/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { userEventActor } from "./user-event-actor.js";
|
||||
224
src/actors/events/user-event-actor.ts
Normal file
224
src/actors/events/user-event-actor.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
}),
|
||||
});
|
||||
193
src/actors/grow/grow-actor.ts
Normal file
193
src/actors/grow/grow-actor.ts
Normal 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
2
src/actors/grow/index.ts
Normal 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
25
src/actors/grow/types.ts
Normal 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;
|
||||
};
|
||||
64
src/actors/memory/README.md
Normal file
64
src/actors/memory/README.md
Normal 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.
|
||||
3
src/actors/memory/index.ts
Normal file
3
src/actors/memory/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { memoryActor } from "./memory-actor.js";
|
||||
export * from "./schema.js";
|
||||
export * from "./types.js";
|
||||
292
src/actors/memory/memory-actor.ts
Normal file
292
src/actors/memory/memory-actor.ts
Normal 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);
|
||||
}
|
||||
28
src/actors/memory/migrations.ts
Normal file
28
src/actors/memory/migrations.ts
Normal 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);
|
||||
`);
|
||||
}
|
||||
52
src/actors/memory/schema.ts
Normal file
52
src/actors/memory/schema.ts
Normal 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;
|
||||
38
src/actors/memory/types.ts
Normal file
38
src/actors/memory/types.ts
Normal 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;
|
||||
};
|
||||
7
src/actors/missions/career-transition-actor.ts
Normal file
7
src/actors/missions/career-transition-actor.ts
Normal 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",
|
||||
});
|
||||
7
src/actors/missions/career-transition/SKILL.md
Normal file
7
src/actors/missions/career-transition/SKILL.md
Normal 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
|
||||
6
src/actors/missions/index.ts
Normal file
6
src/actors/missions/index.ts
Normal 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";
|
||||
302
src/actors/missions/interview-to-offer-actor.ts
Normal file
302
src/actors/missions/interview-to-offer-actor.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
41
src/actors/missions/interview-to-offer/SKILL.md
Normal file
41
src/actors/missions/interview-to-offer/SKILL.md
Normal 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.
|
||||
310
src/actors/missions/mission-actor-factory.ts
Normal file
310
src/actors/missions/mission-actor-factory.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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
|
||||
7
src/actors/missions/promotion-readiness-actor.ts
Normal file
7
src/actors/missions/promotion-readiness-actor.ts
Normal 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",
|
||||
});
|
||||
7
src/actors/missions/promotion-readiness/SKILL.md
Normal file
7
src/actors/missions/promotion-readiness/SKILL.md
Normal 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
|
||||
7
src/actors/missions/salary-negotiation-war-room-actor.ts
Normal file
7
src/actors/missions/salary-negotiation-war-room-actor.ts
Normal 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",
|
||||
});
|
||||
7
src/actors/missions/salary-negotiation-war-room/SKILL.md
Normal file
7
src/actors/missions/salary-negotiation-war-room/SKILL.md
Normal 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
|
||||
109
src/actors/missions/types.ts
Normal file
109
src/actors/missions/types.ts
Normal 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;
|
||||
};
|
||||
70
src/actors/product-service-actors.ts
Normal file
70
src/actors/product-service-actors.ts
Normal 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();
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getSubAgentModules,
|
||||
} from "../lib/prompt-loader.js";
|
||||
import {
|
||||
buildServiceSessionUrl,
|
||||
runServiceAgentProbe,
|
||||
type ServiceAgentResult,
|
||||
} from "../services/service-agents.js";
|
||||
@@ -23,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 ──
|
||||
|
||||
@@ -35,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;
|
||||
@@ -78,6 +88,7 @@ type UserActorState = {
|
||||
workflowId: string;
|
||||
workflowStatus: WorkflowStatus;
|
||||
workflowGoal: string;
|
||||
workflowRunId?: string;
|
||||
modules: WorkflowModuleState[];
|
||||
timeline: WorkflowEvent[];
|
||||
createdAt: string;
|
||||
@@ -164,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"],
|
||||
},
|
||||
@@ -178,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" } },
|
||||
@@ -190,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" } },
|
||||
@@ -202,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: {},
|
||||
@@ -242,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: {
|
||||
@@ -318,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(
|
||||
@@ -410,7 +417,7 @@ export const userActor = actor({
|
||||
prompt: stack.promptVersion,
|
||||
},
|
||||
});
|
||||
return stack;
|
||||
return publicStack(stack);
|
||||
},
|
||||
|
||||
shutdown: async (c) => {
|
||||
@@ -532,11 +539,9 @@ export const userActor = actor({
|
||||
moduleName: m.name,
|
||||
status: m.status,
|
||||
sessionId: detail?.session_id as string | undefined,
|
||||
sessionUrl: m.service === "interview-service"
|
||||
? `http://localhost:8007/api/v1/demo?session_id=${detail?.session_id ?? ""}`
|
||||
: m.service === "roleplay-service"
|
||||
? `http://localhost:8008/api/v1/demo?session_id=${detail?.session_id ?? ""}`
|
||||
: undefined,
|
||||
sessionUrl: typeof detail?.ui_session_url === "string"
|
||||
? detail.ui_session_url
|
||||
: buildServiceSessionUrl(m.service, detail, c.state.workflowGoal),
|
||||
summary: m.lastResult?.summary,
|
||||
};
|
||||
}),
|
||||
@@ -545,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();
|
||||
|
||||
@@ -558,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,
|
||||
@@ -586,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}`);
|
||||
|
||||
@@ -594,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 });
|
||||
@@ -614,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;
|
||||
@@ -706,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 [];
|
||||
@@ -746,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));
|
||||
@@ -756,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 });
|
||||
@@ -851,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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
198
src/actors/workflow-run-actor.ts
Normal file
198
src/actors/workflow-run-actor.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,15 +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 ?? "",
|
||||
@@ -68,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",
|
||||
@@ -82,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),
|
||||
|
||||
425
src/db/schema.ts
425
src/db/schema.ts
@@ -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;
|
||||
|
||||
@@ -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
93
src/events/envelope.ts
Normal 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
79
src/events/normalize.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
152
src/events/onboarding-qscore.ts
Normal file
152
src/events/onboarding-qscore.ts
Normal 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;
|
||||
}
|
||||
104
src/events/projectors/projection-agent.ts
Normal file
104
src/events/projectors/projection-agent.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
194
src/events/projectors/qscore-projector.ts
Normal file
194
src/events/projectors/qscore-projector.ts
Normal 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 };
|
||||
}
|
||||
76
src/events/projectors/service-session-projector.ts
Normal file
76
src/events/projectors/service-session-projector.ts
Normal 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;
|
||||
}
|
||||
114
src/events/record-grow-event.ts
Normal file
114
src/events/record-grow-event.ts
Normal 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));
|
||||
}
|
||||
324
src/events/redis-consumer.ts
Normal file
324
src/events/redis-consumer.ts
Normal 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)));
|
||||
}
|
||||
15
src/events/route-to-user-actor.ts
Normal file
15
src/events/route-to-user-actor.ts
Normal 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
117
src/features/registry.ts
Normal 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
268
src/grow/persistence.ts
Normal 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));
|
||||
}
|
||||
90
src/home/home-feed-agent.ts
Normal file
90
src/home/home-feed-agent.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
640
src/home/home-feed.ts
Normal file
640
src/home/home-feed.ts
Normal file
@@ -0,0 +1,640 @@
|
||||
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 === "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: "Turn proof into LinkedIn updates",
|
||||
subtitle: ctx.artifacts.length ? `${ctx.artifacts.length} artifact${ctx.artifacts.length === 1 ? "" : "s"} can feed headline, featured, or post ideas.` : `Connect LinkedIn and use ${profile.targetRole} proof to improve your profile.`,
|
||||
tag: ctx.artifacts.length ? "Proof" : "Setup",
|
||||
urgency: ctx.artifacts.length ? "today" : "soon",
|
||||
href: SERVICE_HREFS.social,
|
||||
source: "social",
|
||||
priority: 70,
|
||||
});
|
||||
|
||||
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 mode === "day1" ? "Soon" : "Locked";
|
||||
if (moduleId === "rewards") return mode === "day1" ? "0" : "Demo";
|
||||
if (moduleId === "social") return mode === "day1" ? "Setup" : `${notifications.length} updates`;
|
||||
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;
|
||||
|
||||
if (persisted.length && (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
165
src/home/seed-demo-home.ts
Normal 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
84
src/home/types.ts
Normal 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: "Interview · Roleplay · Resume", href: "/agents", accent: "orange" },
|
||||
rewards: { id: "rewards", label: "Rewards", href: "/rewards", accent: "amber" },
|
||||
};
|
||||
|
||||
export const MODULE_IDS: HomeModuleId[] = ["suggestions", "missions", "productivity"];
|
||||
|
||||
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}?`));
|
||||
}
|
||||
28
src/index.ts
28
src/index.ts
@@ -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",
|
||||
|
||||
@@ -9,7 +9,7 @@ export type SubAgentModule = {
|
||||
name: string;
|
||||
role: string;
|
||||
description: string;
|
||||
service?: "interview-service" | "roleplay-service" | "qscore-service" | "resume-service";
|
||||
service?: "interview-service" | "roleplay-service" | "qscore-service" | "resume-service" | "matchmaking-service";
|
||||
toolNames: string[];
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
@@ -122,7 +122,8 @@ export async function loadPromptsFromDisk(): Promise<void> {
|
||||
service !== "interview-service" &&
|
||||
service !== "roleplay-service" &&
|
||||
service !== "qscore-service" &&
|
||||
service !== "resume-service"
|
||||
service !== "resume-service" &&
|
||||
service !== "matchmaking-service"
|
||||
) {
|
||||
log.warn({ file: filename, service }, "unknown service value — treating as no service");
|
||||
}
|
||||
@@ -133,7 +134,7 @@ export async function loadPromptsFromDisk(): Promise<void> {
|
||||
role: data.role ?? data.name,
|
||||
description: body || `Agent module: ${data.name}`,
|
||||
service: service &&
|
||||
["interview-service", "roleplay-service", "qscore-service", "resume-service"].includes(service)
|
||||
["interview-service", "roleplay-service", "qscore-service", "resume-service", "matchmaking-service"].includes(service)
|
||||
? (service as SubAgentModule["service"])
|
||||
: undefined,
|
||||
toolNames: data.tools ?? [],
|
||||
|
||||
80
src/missions/action-types.ts
Normal file
80
src/missions/action-types.ts
Normal 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
Reference in New Issue
Block a user