Compare commits
16 Commits
chore/rele
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
| 72b3f03dad | |||
| 92ab414048 | |||
|
|
9fd478c095 | ||
|
|
f0ef57f054 | ||
|
|
dd48321904 | ||
|
|
bef6d08b6b | ||
|
|
170d3583c6 | ||
|
|
aa8f2853b2 | ||
|
|
c47e6de526 | ||
|
|
5f667038d8 | ||
|
|
ef5d7bb378 | ||
|
|
d4f9b0edcb | ||
|
|
01e9cc92d4 | ||
|
|
213987a9e0 | ||
|
|
8e4fdc6adf | ||
|
|
d10ef2a882 |
@@ -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 ./
|
||||
|
||||
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.
|
||||
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.
|
||||
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");
|
||||
@@ -71,6 +71,13 @@
|
||||
"when": 1780481400000,
|
||||
"tag": "0009_mission_suggestions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1780481500000,
|
||||
"tag": "0010_mission_actions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getProjectionInsight } from "../../events/projectors/projection-agent.j
|
||||
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;
|
||||
@@ -165,20 +166,21 @@ export const userEventActor = actor({
|
||||
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.eventMessage) continue;
|
||||
|
||||
const actorHandle = missionActorHandle(client, cmd.userId, mission);
|
||||
if (!actorHandle) continue;
|
||||
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 } });
|
||||
}
|
||||
let snapshot: MissionSnapshot | undefined = reduction.stagePatches.length ? (await applyStagePatches(actorHandle, reduction.stagePatches) ?? undefined) : undefined;
|
||||
if (reduction.stagePatches.length) await applyStagePatches(actorHandle, reduction.stagePatches);
|
||||
await applyArtifactPatches({
|
||||
actorHandle,
|
||||
userId: cmd.userId,
|
||||
@@ -188,6 +190,14 @@ export const userEventActor = actor({
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -160,6 +160,66 @@ export const interviewToOfferMissionActor = actor({
|
||||
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);
|
||||
|
||||
@@ -166,6 +166,67 @@ export function createMissionActor(options: {
|
||||
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);
|
||||
|
||||
@@ -458,6 +458,52 @@ export const growQscoreProjectionState = pgTable("grow_qscore_projection_state",
|
||||
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",
|
||||
{
|
||||
@@ -544,6 +590,8 @@ export const growHomeNotifications = pgTable(
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -15,6 +15,8 @@ function nestedNumber(record: Record<string, unknown>, keys: string[]): number |
|
||||
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);
|
||||
@@ -38,8 +40,11 @@ function extractResumeSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
}
|
||||
|
||||
const signals: QscoreSignal[] = [];
|
||||
if (event.type.includes("uploaded") || event.type.includes("created") || event.type.includes("analysis")) {
|
||||
signals.push(signal("resume.uploaded", 100, { eventId: event.id }));
|
||||
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 }));
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { getConversationModel } from "../actors/conversation/agent.js";
|
||||
import { config } from "../config.js";
|
||||
import { log } from "../log.js";
|
||||
import { ALLOWED_NOTIFICATION_HREFS, MODULE_IDS, type HomeModuleId, type HomeNotification, type HomeUrgency } from "./types.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[]]),
|
||||
@@ -20,6 +20,8 @@ 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.
|
||||
@@ -39,12 +41,8 @@ Use minimal iPhone-notification copy: title <= 72 chars, subtitle <= 110 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 (ALLOWED_NOTIFICATION_HREFS.has(href)) return href;
|
||||
if (href.startsWith("/agents/resume")) return "/agents/resume";
|
||||
if (href.startsWith("/agents/interview")) return "/agents/interview";
|
||||
if (href.startsWith("/agents/roleplay")) return "/agents/roleplay";
|
||||
if (href.startsWith("/agents/qscore")) return "/agents/qscore";
|
||||
if (href.startsWith("/missions")) return "/missions";
|
||||
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";
|
||||
@@ -68,6 +66,7 @@ export async function refineHomeNotificationsWithAgent(input: {
|
||||
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,
|
||||
|
||||
@@ -8,15 +8,18 @@ import {
|
||||
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 { missionDetailHref } from "../missions/reducer-helpers.js";
|
||||
import {
|
||||
ALLOWED_NOTIFICATION_HREFS,
|
||||
isAllowedNotificationHref,
|
||||
MODULE_IDS,
|
||||
MODULE_META,
|
||||
type HomeFeedResponse,
|
||||
@@ -35,7 +38,7 @@ const SERVICE_HREFS = {
|
||||
interview: "/agents/interview",
|
||||
roleplay: "/agents/roleplay",
|
||||
qscore: "/agents/qscore",
|
||||
mission: "/missions",
|
||||
mission: "/missions/active",
|
||||
social: "/social",
|
||||
pathways: "/pathways",
|
||||
rewards: "/rewards",
|
||||
@@ -50,10 +53,13 @@ type HomeContext = {
|
||||
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> {
|
||||
@@ -64,13 +70,68 @@ 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 && ALLOWED_NOTIFICATION_HREFS.has(href)) return href;
|
||||
if (href && isAllowedNotificationHref(href)) return href;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -97,7 +158,9 @@ function hasAnyRealActivity(ctx: HomeContext) {
|
||||
ctx.activeMissions.length ||
|
||||
ctx.sessions.length ||
|
||||
ctx.artifacts.length ||
|
||||
ctx.events.length,
|
||||
ctx.events.length ||
|
||||
ctx.missionSuggestions.length ||
|
||||
profileFromPreferences(ctx.preferences).onboardingComplete,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,8 +168,8 @@ 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: "missions", title: "Explore Interview-to-Offer", subtitle: "A guided mission connects resume fit, mock practice, and readiness scoring.", tag: "Browse", urgency: "today", href: "/missions/available", 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: "/missions/available", 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 });
|
||||
@@ -119,6 +182,7 @@ function buildDayOneSeeds(): SeedNotification[] {
|
||||
|
||||
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");
|
||||
@@ -130,14 +194,58 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
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 ? missionDetailHref(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.` : `Current estimate is ${qscore || 64}. Resume + mock practice are the fastest signals.`,
|
||||
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: SERVICE_HREFS.qscore,
|
||||
href: serviceHref("qscore", ctx),
|
||||
source: "qscore",
|
||||
priority: 95,
|
||||
});
|
||||
@@ -147,10 +255,10 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "suggestions",
|
||||
title: ats >= 80 ? "ATS is demo-ready" : "Resume ATS needs one pass",
|
||||
subtitle: ats >= 80 ? `ATS ${Math.round(ats)} — review role-specific keywords before applying.` : `ATS ${Math.round(ats)} — add JD keywords and measurable bullets.`,
|
||||
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: SERVICE_HREFS.resume,
|
||||
href: serviceHref("resume", ctx),
|
||||
source: "resume",
|
||||
priority: 92,
|
||||
});
|
||||
@@ -163,7 +271,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
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: SERVICE_HREFS.mission,
|
||||
href: missionDetailHref(mission.instanceId),
|
||||
source: "mission",
|
||||
priority: 90 - mission.progressPercent,
|
||||
});
|
||||
@@ -186,7 +294,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
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 mission proof to improve your profile.",
|
||||
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,
|
||||
@@ -198,51 +306,52 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
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 role-fit scan or final polish." : "Open Resume Builder for recommendations and bullet fixes.",
|
||||
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: SERVICE_HREFS.resume,
|
||||
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 interview practice for review, next drill, or a new session.",
|
||||
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: SERVICE_HREFS.interview,
|
||||
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 mock interview", subtitle: "Generate a behavioral or role-related session from your target role.", tag: "Mock", urgency: "soon", href: SERVICE_HREFS.interview, source: "interview", priority: 72 });
|
||||
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.",
|
||||
subtitle: `Practice recruiter, manager, salary, or stakeholder conversations for ${profile.targetRole}.`,
|
||||
tag: "Roleplay",
|
||||
urgency: "soon",
|
||||
href: SERVICE_HREFS.roleplay,
|
||||
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 into one journey.", tag: "Begin", urgency: "today", href: SERVICE_HREFS.mission, source: "mission", priority: 80 });
|
||||
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): Promise<HomeContext> {
|
||||
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
|
||||
@@ -257,6 +366,27 @@ async function collectContext(userId: string): Promise<HomeContext> {
|
||||
.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)
|
||||
@@ -296,10 +426,13 @@ async function collectContext(userId: string): Promise<HomeContext> {
|
||||
: 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 ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -430,8 +563,9 @@ async function buildIdentity(ctx: HomeContext) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getHomeFeed(userId: string, opts: { refresh?: boolean } = {}): Promise<HomeFeedResponse> {
|
||||
const ctx = await collectContext(userId);
|
||||
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");
|
||||
@@ -459,6 +593,9 @@ export async function getHomeFeed(userId: string, opts: { refresh?: boolean } =
|
||||
artifacts: ctx.artifacts,
|
||||
recentEvents: ctx.events,
|
||||
serviceStates: ctx.serviceStates,
|
||||
missionSuggestions: ctx.missionSuggestions,
|
||||
userProfile: ctx.userProfile,
|
||||
preferences: ctx.preferences,
|
||||
routeRules: SERVICE_HREFS,
|
||||
},
|
||||
seeds: deterministic,
|
||||
|
||||
@@ -40,25 +40,57 @@ export type HomeFeedResponse = {
|
||||
};
|
||||
|
||||
export const MODULE_META: Record<HomeModuleId, Omit<HomeModule, "count" | "notifications">> = {
|
||||
suggestions: { id: "suggestions", label: "Suggestions", href: "/suggestions", accent: "orange" },
|
||||
suggestions: { id: "suggestions", label: "Today's Queue", href: "/suggestions", accent: "orange" },
|
||||
missions: { id: "missions", label: "Missions", href: "/missions", accent: "orange" },
|
||||
social: { id: "social", label: "Social Branding", href: "/social", accent: "blue" },
|
||||
pathways: { id: "pathways", label: "Pathways", href: "/pathways", accent: "teal" },
|
||||
productivity: { id: "productivity", label: "Productivity", href: "/productivity", accent: "orange" },
|
||||
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", "social", "pathways", "productivity", "rewards"];
|
||||
export const MODULE_IDS: HomeModuleId[] = [
|
||||
"suggestions",
|
||||
"missions",
|
||||
"social",
|
||||
"pathways",
|
||||
"productivity",
|
||||
"rewards",
|
||||
];
|
||||
|
||||
export const ALLOWED_NOTIFICATION_HREFS = new Set([
|
||||
"/suggestions",
|
||||
"/missions",
|
||||
"/missions/active",
|
||||
"/missions/available",
|
||||
"/social",
|
||||
"/pathways",
|
||||
"/productivity",
|
||||
"/rewards",
|
||||
"/agents/resume",
|
||||
"/agents/interview",
|
||||
"/agents/interview/setup",
|
||||
"/agents/roleplay",
|
||||
"/agents/roleplay/setup",
|
||||
"/agents/qscore",
|
||||
]);
|
||||
|
||||
export const ALLOWED_NOTIFICATION_HREF_PREFIXES = [
|
||||
"/missions/",
|
||||
"/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) =>
|
||||
prefix.endsWith("/")
|
||||
? href.startsWith(prefix)
|
||||
: href === prefix || href.startsWith(`${prefix}?`),
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
191
src/missions/actions.ts
Normal file
191
src/missions/actions.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { missionActions, missionSuggestions } from "../db/schema.js";
|
||||
import type { GrowActiveMission } from "../actors/missions/types.js";
|
||||
import type { MissionActionPatch } from "./reducer-types.js";
|
||||
import { defaultMissionActionStatus, type MissionActionDto, type MissionActionRow, type MissionActionStatus, type NewMissionActionInput } from "./action-types.js";
|
||||
import { missionDetailHref, serviceHref } from "./reducer-helpers.js";
|
||||
|
||||
const OPEN_STATUSES: MissionActionStatus[] = ["queued", "running", "waiting_approval", "waiting_user_input", "failed"];
|
||||
const DONE_STATUSES: MissionActionStatus[] = ["done", "dismissed", "snoozed"];
|
||||
|
||||
function toDate(value?: Date | string) {
|
||||
if (!value) return undefined;
|
||||
return value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
|
||||
export function actionToDto(row: MissionActionRow): MissionActionDto {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
missionInstanceId: row.missionInstanceId,
|
||||
missionId: row.missionId,
|
||||
stageId: row.stageId ?? undefined,
|
||||
agentId: row.agentId,
|
||||
agentName: row.agentName,
|
||||
baseAgent: row.baseAgent ?? undefined,
|
||||
serviceId: row.serviceId ?? undefined,
|
||||
toolName: row.toolName ?? undefined,
|
||||
mode: row.mode,
|
||||
status: row.status,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
prompt: row.prompt ?? undefined,
|
||||
payload: row.payload ?? {},
|
||||
result: row.result ?? undefined,
|
||||
error: row.error ?? undefined,
|
||||
sourceEventId: row.sourceEventId ?? undefined,
|
||||
idempotencyKey: row.idempotencyKey ?? undefined,
|
||||
priority: row.priority,
|
||||
urgency: row.urgency,
|
||||
dueAt: row.dueAt?.toISOString(),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
resolvedAt: row.resolvedAt?.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function ctaForAction(action: MissionActionRow | NewMissionActionInput) {
|
||||
const payload = action.payload && typeof action.payload === "object" && !Array.isArray(action.payload) ? action.payload as Record<string, unknown> : {};
|
||||
const hrefFromPayload = typeof payload.href === "string" ? payload.href : undefined;
|
||||
const serviceId = action.serviceId ?? "";
|
||||
const missionHref = missionDetailHref(action.missionInstanceId);
|
||||
const href = hrefFromPayload ??
|
||||
(serviceId.includes("interview") ? serviceHref("interview", action.missionInstanceId, action.missionId, action.stageId ?? undefined) :
|
||||
serviceId.includes("roleplay") ? serviceHref("roleplay", action.missionInstanceId, action.missionId, action.stageId ?? undefined) :
|
||||
serviceId.includes("resume") ? serviceHref("resume", action.missionInstanceId, action.missionId, action.stageId ?? undefined) : missionHref);
|
||||
|
||||
if (action.mode === "approval_required") return { ctaLabel: "Review", ctaHref: missionHref };
|
||||
if (action.mode === "user_input_required") return { ctaLabel: "Answer", ctaHref: missionHref };
|
||||
if (serviceId.includes("interview")) return { ctaLabel: "Start mock", ctaHref: href };
|
||||
if (serviceId.includes("roleplay")) return { ctaLabel: "Run drill", ctaHref: href };
|
||||
if (serviceId.includes("resume")) return { ctaLabel: "Open resume", ctaHref: href };
|
||||
return { ctaLabel: "Open", ctaHref: href };
|
||||
}
|
||||
|
||||
function suggestionTypeForAction(action: MissionActionRow | NewMissionActionInput) {
|
||||
if (action.mode === "user_input_required") return "blocked" as const;
|
||||
if (action.mode === "approval_required") return "review" as const;
|
||||
if ((action.serviceId ?? "").includes("interview") || (action.serviceId ?? "").includes("roleplay")) return "practice" as const;
|
||||
if ((action.serviceId ?? "").includes("resume")) return "artifact" as const;
|
||||
return "action" as const;
|
||||
}
|
||||
|
||||
async function refreshSuggestionForAction(row: MissionActionRow) {
|
||||
const active = OPEN_STATUSES.includes(row.status);
|
||||
const { ctaLabel, ctaHref } = ctaForAction(row);
|
||||
const status = active ? "active" : row.status === "done" ? "done" : "dismissed";
|
||||
|
||||
await db.insert(missionSuggestions).values({
|
||||
id: `suggestion:${row.id}`,
|
||||
userId: row.userId,
|
||||
missionInstanceId: row.missionInstanceId,
|
||||
missionId: row.missionId,
|
||||
stageId: row.stageId,
|
||||
role: row.agentName,
|
||||
type: suggestionTypeForAction(row),
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
reason: row.prompt,
|
||||
priority: row.priority,
|
||||
urgency: row.urgency,
|
||||
status,
|
||||
ctaLabel,
|
||||
ctaHref,
|
||||
sourceRefs: { actionId: row.id, sourceEventId: row.sourceEventId, toolName: row.toolName },
|
||||
generatedBy: "agent",
|
||||
updatedAt: new Date(),
|
||||
}).onConflictDoUpdate({
|
||||
target: missionSuggestions.id,
|
||||
set: {
|
||||
stageId: row.stageId,
|
||||
role: row.agentName,
|
||||
type: suggestionTypeForAction(row),
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
reason: row.prompt,
|
||||
priority: row.priority,
|
||||
urgency: row.urgency,
|
||||
status,
|
||||
ctaLabel,
|
||||
ctaHref,
|
||||
sourceRefs: { actionId: row.id, sourceEventId: row.sourceEventId, toolName: row.toolName },
|
||||
generatedBy: "agent",
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMissionAction(input: NewMissionActionInput) {
|
||||
const [row] = await db.insert(missionActions).values({
|
||||
...input,
|
||||
status: input.status ?? defaultMissionActionStatus(input.mode),
|
||||
priority: input.priority ?? 0,
|
||||
urgency: input.urgency ?? "calm",
|
||||
payload: input.payload ?? {},
|
||||
result: input.result,
|
||||
dueAt: toDate(input.dueAt),
|
||||
updatedAt: new Date(),
|
||||
}).onConflictDoNothing().returning();
|
||||
if (!row) return null;
|
||||
await refreshSuggestionForAction(row);
|
||||
return actionToDto(row);
|
||||
}
|
||||
|
||||
export async function createMissionActionsFromPatches(input: { userId: string; mission: GrowActiveMission; eventId: string; patches: MissionActionPatch[] }) {
|
||||
const created: MissionActionDto[] = [];
|
||||
for (const patch of input.patches) {
|
||||
const action = await createMissionAction({
|
||||
userId: input.userId,
|
||||
missionInstanceId: input.mission.instanceId,
|
||||
missionId: input.mission.missionId,
|
||||
stageId: patch.stageId,
|
||||
agentId: patch.agentId,
|
||||
agentName: patch.agentName,
|
||||
baseAgent: patch.baseAgent,
|
||||
serviceId: patch.serviceId,
|
||||
toolName: patch.toolName,
|
||||
mode: patch.mode,
|
||||
status: patch.status,
|
||||
title: patch.title,
|
||||
body: patch.body,
|
||||
prompt: patch.prompt,
|
||||
payload: patch.payload ?? {},
|
||||
sourceEventId: patch.sourceEventId ?? input.eventId,
|
||||
idempotencyKey: patch.idempotencyKey,
|
||||
priority: patch.priority,
|
||||
urgency: patch.urgency,
|
||||
dueAt: patch.dueAt,
|
||||
});
|
||||
if (action) created.push(action);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function listMissionActions(userId: string, opts: { missionInstanceId?: string; openOnly?: boolean } = {}) {
|
||||
const conditions = [eq(missionActions.userId, userId)];
|
||||
if (opts.missionInstanceId) conditions.push(eq(missionActions.missionInstanceId, opts.missionInstanceId));
|
||||
if (opts.openOnly ?? true) conditions.push(inArray(missionActions.status, OPEN_STATUSES));
|
||||
const rows = await db.select().from(missionActions).where(and(...conditions)).orderBy(desc(missionActions.priority), desc(missionActions.updatedAt));
|
||||
return rows.map(actionToDto);
|
||||
}
|
||||
|
||||
export async function getMissionAction(userId: string, actionId: string) {
|
||||
const [row] = await db.select().from(missionActions).where(and(eq(missionActions.userId, userId), eq(missionActions.id, actionId))).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function updateMissionActionStatus(userId: string, actionId: string, patch: { status: MissionActionStatus; result?: Record<string, unknown>; error?: string; payload?: Record<string, unknown> }) {
|
||||
const resolvedAt = DONE_STATUSES.includes(patch.status) ? new Date() : undefined;
|
||||
const [row] = await db.update(missionActions).set({
|
||||
status: patch.status,
|
||||
result: patch.result,
|
||||
error: patch.error,
|
||||
payload: patch.payload,
|
||||
resolvedAt,
|
||||
updatedAt: new Date(),
|
||||
}).where(and(eq(missionActions.userId, userId), eq(missionActions.id, actionId))).returning();
|
||||
if (!row) return null;
|
||||
await refreshSuggestionForAction(row);
|
||||
return actionToDto(row);
|
||||
}
|
||||
89
src/missions/career-transition/reducer.ts
Normal file
89
src/missions/career-transition/reducer.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
|
||||
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
|
||||
|
||||
export const careerTransitionReducer: MissionReducer = {
|
||||
missionId: "career-transition",
|
||||
accepts(ctx) {
|
||||
return ctx.activeMission.missionId === "career-transition" &&
|
||||
(missionExplicitlyMatches(ctx.event.mission, "career-transition") || isRelevantServiceEvent(ctx.event.source, ctx.event.type));
|
||||
},
|
||||
reduce(ctx): MissionReduction {
|
||||
const { event, activeMission } = ctx;
|
||||
const type = event.type;
|
||||
const payload = event.payload ?? {};
|
||||
const stagePatches: MissionStagePatch[] = [];
|
||||
const artifacts: MissionReduction["artifacts"] = [];
|
||||
const actions: MissionReduction["actions"] = [];
|
||||
let eventMessage = ctx.insight.summary;
|
||||
|
||||
if (!activeMission.goal && (type === "mission.started" || type.startsWith("mission."))) {
|
||||
actions.push(actionForAgent("career-transition", "planner", {
|
||||
stageId: "clarify-target",
|
||||
mode: "user_input_required",
|
||||
title: "Choose the target role for your transition",
|
||||
body: "Career transition needs a clear adjacent role before resume repositioning or practice will be useful.",
|
||||
prompt: "What role are you exploring next, and what role/background are you moving from?",
|
||||
payload: { fields: ["current_role", "target_role", "constraints"] },
|
||||
idempotencyKey: `${activeMission.instanceId}:clarify-target-role`,
|
||||
priority: 100,
|
||||
urgency: "now",
|
||||
}));
|
||||
}
|
||||
|
||||
if (isResumeEvent(event.source, type) && (type.includes("analysis") || type.includes("parsed") || type.includes("analyzed"))) {
|
||||
const signals = extractResumeSignals(payload);
|
||||
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Transferable skills mapped from resume evidence." });
|
||||
stagePatches.push({ stageId: "interview", status: "ready", progressPercent: 0, outputSummary: "Validate the transition story in an adjacent-role mock interview." });
|
||||
artifacts.push({ type: "transferable_skills_map", title: "Transferable skills map", stageId: "resume", summary: signals[0] ?? "Resume proof was mapped into transition evidence.", metadata: { sourceEventId: event.id, signals } });
|
||||
actions.push(actionForAgent("career-transition", "resume", {
|
||||
stageId: "resume",
|
||||
serviceId: "resume-service",
|
||||
toolName: "resume.create_version_prompt_draft",
|
||||
mode: "approval_required",
|
||||
title: "Draft a repositioned resume for the target role?",
|
||||
body: "Approve a Resume Agent draft that reframes your transferable skills for the career switch.",
|
||||
payload: { signals, href: serviceHref("resume", activeMission.instanceId, activeMission.missionId, "resume") },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:resume-transition-draft:${event.id}`,
|
||||
priority: 95,
|
||||
urgency: "today",
|
||||
}));
|
||||
eventMessage = "Transferable skills map created; repositioned resume action is ready.";
|
||||
}
|
||||
|
||||
if (isInterviewEvent(event.source, type) && type.includes("configured")) {
|
||||
stagePatches.push({ stageId: "interview", status: "in_progress", progressPercent: 40, outputSummary: "Adjacent-role mock interview configured." });
|
||||
eventMessage = "Adjacent-role interview practice started.";
|
||||
}
|
||||
|
||||
if (isInterviewEvent(event.source, type) && type.includes("review_completed")) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Adjacent-role credibility checked." });
|
||||
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Practice the 'why I am switching' narrative next." });
|
||||
artifacts.push({ type: "transition_interview_diagnosis", title: "Adjacent-role credibility diagnosis", stageId: "interview", summary: weakAreas.length ? `Needs work: ${weakAreas.join(", ")}` : "Interview review completed for transition credibility.", metadata: { sourceEventId: event.id, weakAreas } });
|
||||
actions.push(actionForAgent("career-transition", "roleplay", {
|
||||
stageId: "roleplay",
|
||||
serviceId: "roleplay-service",
|
||||
toolName: "roleplay.configure_practice",
|
||||
mode: "suggestion",
|
||||
title: "Practice your 'why I am switching' pitch",
|
||||
body: "Turn the adjacent-role feedback into a confident transition narrative before real conversations.",
|
||||
payload: { weakAreas, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:transition-pitch-roleplay:${event.id}`,
|
||||
priority: 92,
|
||||
urgency: "today",
|
||||
}));
|
||||
eventMessage = "Career transition interview feedback produced the next pitch-practice action.";
|
||||
}
|
||||
|
||||
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
|
||||
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Transition pitch practice reviewed." });
|
||||
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 70, outputSummary: "Transition confidence signals updated." });
|
||||
eventMessage = "Transition narrative practice completed.";
|
||||
}
|
||||
|
||||
if (ctx.qscoreSignals.length) stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: Math.min(90, 45 + ctx.qscoreSignals.length * 10), outputSummary: "Transition readiness signals updated." });
|
||||
return { stagePatches, artifacts, actions, eventMessage };
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { MissionReducer } from "./reducer-types.js";
|
||||
import { interviewToOfferReducer } from "./interview-to-offer/reducer.js";
|
||||
import { careerTransitionReducer } from "./career-transition/reducer.js";
|
||||
import { salaryNegotiationReducer } from "./salary-negotiation-war-room/reducer.js";
|
||||
import { promotionReadinessReducer } from "./promotion-readiness/reducer.js";
|
||||
import { personalBrandOpportunityReducer } from "./personal-brand-opportunity-engine/reducer.js";
|
||||
|
||||
export const missionEventReducers: MissionReducer[] = [interviewToOfferReducer];
|
||||
export const missionEventReducers: MissionReducer[] = [
|
||||
interviewToOfferReducer,
|
||||
careerTransitionReducer,
|
||||
salaryNegotiationReducer,
|
||||
promotionReadinessReducer,
|
||||
personalBrandOpportunityReducer,
|
||||
];
|
||||
|
||||
export function reducersForMission(missionId: string) {
|
||||
return missionEventReducers.filter((reducer) => reducer.missionId === missionId);
|
||||
|
||||
@@ -1,99 +1,147 @@
|
||||
import { asRecord, getNumber, getString } from "../../events/envelope.js";
|
||||
import type { MissionArtifactPatch, MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
|
||||
import { getString } from "../../events/envelope.js";
|
||||
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
|
||||
import {
|
||||
actionForAgent,
|
||||
extractOverallScore,
|
||||
extractResumeSignals,
|
||||
extractWeakAreas,
|
||||
isInterviewEvent,
|
||||
isQscoreEvent,
|
||||
isRelevantServiceEvent,
|
||||
isResumeEvent,
|
||||
isRoleplayEvent,
|
||||
missionExplicitlyMatches,
|
||||
serviceHref,
|
||||
} from "../reducer-helpers.js";
|
||||
|
||||
function eventMatchesSource(source: string, type: string) {
|
||||
return (
|
||||
source.includes("resume") ||
|
||||
source.includes("interview") ||
|
||||
source.includes("qscore") ||
|
||||
type.startsWith("resume.") ||
|
||||
type.startsWith("interview.") ||
|
||||
type.startsWith("qscore.") ||
|
||||
type.startsWith("mission.interview_to_offer")
|
||||
);
|
||||
function acceptsMission(ctx: Parameters<MissionReducer["accepts"]>[0]) {
|
||||
return ctx.activeMission.missionId === "interview-to-offer" &&
|
||||
(missionExplicitlyMatches(ctx.event.mission, "interview-to-offer") || isRelevantServiceEvent(ctx.event.source, ctx.event.type));
|
||||
}
|
||||
|
||||
function reviewSummary(payload: Record<string, unknown>) {
|
||||
const review = asRecord(payload.review ?? payload.result ?? payload);
|
||||
const overall = getNumber(review.overall_score ?? review.overallScore);
|
||||
const summary = getString(review.summary ?? review.feedback_summary ?? review.overall_feedback);
|
||||
if (overall !== undefined && summary) return `Mock interview review completed with score ${overall}. ${summary}`;
|
||||
if (overall !== undefined) return `Mock interview review completed with score ${overall}.`;
|
||||
const score = extractOverallScore(payload);
|
||||
const summary = getString(payload.summary ?? payload.feedback_summary ?? payload.overall_feedback);
|
||||
if (score !== undefined && summary) return `Mock interview review completed with score ${score}. ${summary}`;
|
||||
if (score !== undefined) return `Mock interview review completed with score ${score}.`;
|
||||
return summary ?? "Mock interview review completed.";
|
||||
}
|
||||
|
||||
export const interviewToOfferReducer: MissionReducer = {
|
||||
missionId: "interview-to-offer",
|
||||
|
||||
accepts(ctx) {
|
||||
if (ctx.activeMission.missionId !== "interview-to-offer") return false;
|
||||
const mission = asRecord(ctx.event.mission);
|
||||
const explicitMissionId = getString(mission.missionId ?? mission.mission_id);
|
||||
return explicitMissionId === "interview-to-offer" || eventMatchesSource(ctx.event.source.toLowerCase(), ctx.event.type);
|
||||
},
|
||||
accepts: acceptsMission,
|
||||
|
||||
reduce(ctx): MissionReduction {
|
||||
const type = ctx.event.type;
|
||||
const payload = ctx.event.payload ?? {};
|
||||
const { event, activeMission } = ctx;
|
||||
const type = event.type;
|
||||
const payload = event.payload ?? {};
|
||||
const stagePatches: MissionStagePatch[] = [...ctx.insight.missionStageHints.map((hint) => ({
|
||||
stageId: hint.stageId,
|
||||
status: hint.status,
|
||||
progressPercent: hint.progressPercent,
|
||||
outputSummary: hint.reason,
|
||||
}))];
|
||||
const artifacts: MissionArtifactPatch[] = [];
|
||||
const artifacts: MissionReduction["artifacts"] = [];
|
||||
const actions: MissionReduction["actions"] = [];
|
||||
let eventMessage = ctx.insight.summary;
|
||||
|
||||
if (type.startsWith("resume.") && (type.includes("analysis_completed") || type.includes("analysis.complete") || type.includes("analyzed"))) {
|
||||
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Resume fit scan completed." });
|
||||
stagePatches.push({ stageId: "interview-plan", status: "ready", progressPercent: 0 });
|
||||
if (isResumeEvent(event.source, type) && (type.includes("analysis_completed") || type.includes("analysis.complete") || type.includes("analyzed"))) {
|
||||
const signals = extractResumeSignals(payload);
|
||||
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Resume talking points and fit scan are ready." });
|
||||
stagePatches.push({ stageId: "interview", status: "ready", progressPercent: 0 });
|
||||
artifacts.push({
|
||||
type: "resume_fit_scan",
|
||||
title: "Resume Fit Scan",
|
||||
type: "resume_talking_points",
|
||||
title: "Resume-based talking points",
|
||||
stageId: "resume",
|
||||
summary: getString(payload.summary) ?? "Resume analysis completed and readiness signals were updated.",
|
||||
metadata: { sourceEventId: ctx.event.id, payload },
|
||||
summary: signals[0] ?? "Resume analysis completed and role-fit talking points are ready.",
|
||||
metadata: { sourceEventId: event.id, signals, payload },
|
||||
});
|
||||
eventMessage = "Resume fit scan completed for Interview-to-Offer.";
|
||||
actions.push(actionForAgent("interview-to-offer", "interview", {
|
||||
stageId: "interview",
|
||||
serviceId: "interview-service",
|
||||
toolName: "interview.configure_practice",
|
||||
mode: "suggestion",
|
||||
title: "Start the first mock interview",
|
||||
body: "Your resume proof is ready. Run a targeted mock interview for the scheduled role now.",
|
||||
payload: { href: serviceHref("interview", activeMission.instanceId, activeMission.missionId, "interview") },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:resume-analysis:start-interview:${event.id}`,
|
||||
priority: 92,
|
||||
urgency: "today",
|
||||
}));
|
||||
eventMessage = "Resume fit scan completed; mock interview is ready to run.";
|
||||
}
|
||||
|
||||
if (type.startsWith("interview.") && (type.includes("configured") || type.includes("created"))) {
|
||||
if (isInterviewEvent(event.source, type) && (type.includes("configured") || type.includes("created"))) {
|
||||
stagePatches.push({ stageId: "interview", status: "in_progress", progressPercent: 35, outputSummary: "Mock interview session configured." });
|
||||
eventMessage = "Mock interview session configured.";
|
||||
}
|
||||
|
||||
if (type.startsWith("interview.") && (type.includes("session_completed") || type.includes("session.completed"))) {
|
||||
if (isInterviewEvent(event.source, type) && (type.includes("session_completed") || type.includes("session.completed"))) {
|
||||
stagePatches.push({ stageId: "interview", status: "in_progress", progressPercent: 75, outputSummary: "Interview completed; review is being prepared." });
|
||||
eventMessage = "Mock interview completed; waiting for review.";
|
||||
}
|
||||
|
||||
if (type.startsWith("interview.") && (type.includes("review_completed") || type.includes("review.completed"))) {
|
||||
if (isInterviewEvent(event.source, type) && (type.includes("review_completed") || type.includes("review.completed"))) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: reviewSummary(payload) });
|
||||
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Practice the communication gaps surfaced by interview feedback." });
|
||||
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 60, outputSummary: "Readiness signals updated from interview review." });
|
||||
artifacts.push({
|
||||
type: "mock_interview_review",
|
||||
title: "Mock Interview Review",
|
||||
title: "Weakness diagnosis",
|
||||
stageId: "interview",
|
||||
summary: reviewSummary(payload),
|
||||
metadata: { sourceEventId: ctx.event.id, payload },
|
||||
metadata: { sourceEventId: event.id, weakAreas, payload },
|
||||
});
|
||||
eventMessage = "Mock interview review completed and mission readiness was updated.";
|
||||
actions.push(actionForAgent("interview-to-offer", "resume", {
|
||||
stageId: "resume",
|
||||
serviceId: "resume-service",
|
||||
toolName: "resume.create_version_prompt_draft",
|
||||
mode: "approval_required",
|
||||
title: "Create a tailored resume version from this interview feedback?",
|
||||
body: weakAreas.length
|
||||
? `The interview exposed ${weakAreas.slice(0, 3).join(", ")}. Approve a Resume Agent draft that turns this feedback into targeted bullets and talking points.`
|
||||
: "Approve a Resume Agent draft that turns the interview feedback into targeted bullets and talking points.",
|
||||
payload: { weakAreas, sourceReviewEventId: event.id, href: serviceHref("resume", activeMission.instanceId, activeMission.missionId, "resume") },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:interview-review:tailor-resume:${event.id}`,
|
||||
priority: 100,
|
||||
urgency: "now",
|
||||
}));
|
||||
if (weakAreas.some((area) => /communication|story|clarity|confidence|concise/i.test(area))) {
|
||||
actions.push(actionForAgent("interview-to-offer", "roleplay", {
|
||||
stageId: "roleplay",
|
||||
serviceId: "roleplay-service",
|
||||
toolName: "roleplay.configure_practice",
|
||||
mode: "suggestion",
|
||||
title: "Run a communication recovery drill",
|
||||
body: "Practice the exact communication weakness from the mock interview before the real interview.",
|
||||
payload: { weakAreas, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:interview-review:roleplay:${event.id}`,
|
||||
priority: 94,
|
||||
urgency: "today",
|
||||
}));
|
||||
}
|
||||
eventMessage = "Interview review completed; resume and roleplay next actions were created.";
|
||||
}
|
||||
|
||||
if (ctx.qscoreSignals.length > 0) {
|
||||
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
|
||||
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Communication drill reviewed." });
|
||||
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 75, outputSummary: "Communication readiness updated." });
|
||||
eventMessage = "Roleplay review improved interview communication readiness.";
|
||||
}
|
||||
|
||||
if (ctx.qscoreSignals.length > 0 || (isQscoreEvent(event.source, type) && type.includes("updated"))) {
|
||||
stagePatches.push({
|
||||
stageId: "qscore",
|
||||
status: "in_progress",
|
||||
progressPercent: Math.max(40, Math.min(90, ctx.qscoreSignals.length * 15)),
|
||||
outputSummary: `${ctx.qscoreSignals.length} readiness signal${ctx.qscoreSignals.length === 1 ? "" : "s"} updated.`,
|
||||
status: type.startsWith("qscore.") ? "done" : "in_progress",
|
||||
progressPercent: type.startsWith("qscore.") ? 100 : Math.max(40, Math.min(90, ctx.qscoreSignals.length * 15)),
|
||||
outputSummary: `${ctx.qscoreSignals.length || 1} readiness signal${ctx.qscoreSignals.length === 1 ? "" : "s"} updated.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (type.startsWith("qscore.") && (type.includes("snapshot") || type.includes("computed") || type.includes("updated"))) {
|
||||
stagePatches.push({ stageId: "qscore", status: "done", progressPercent: 100, outputSummary: "Readiness Q Score updated." });
|
||||
eventMessage = "Readiness Q Score updated.";
|
||||
}
|
||||
|
||||
return { stagePatches, artifacts, eventMessage };
|
||||
return { stagePatches, artifacts, actions, eventMessage };
|
||||
},
|
||||
};
|
||||
|
||||
83
src/missions/personal-brand-opportunity-engine/reducer.ts
Normal file
83
src/missions/personal-brand-opportunity-engine/reducer.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
|
||||
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionDetailHref, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
|
||||
|
||||
export const personalBrandOpportunityReducer: MissionReducer = {
|
||||
missionId: "personal-brand-opportunity-engine",
|
||||
accepts(ctx) {
|
||||
return ctx.activeMission.missionId === "personal-brand-opportunity-engine" &&
|
||||
(missionExplicitlyMatches(ctx.event.mission, "personal-brand-opportunity-engine") || isRelevantServiceEvent(ctx.event.source, ctx.event.type));
|
||||
},
|
||||
reduce(ctx): MissionReduction {
|
||||
const { event, activeMission } = ctx;
|
||||
const type = event.type;
|
||||
const payload = event.payload ?? {};
|
||||
const stagePatches: MissionStagePatch[] = [];
|
||||
const artifacts: MissionReduction["artifacts"] = [];
|
||||
const actions: MissionReduction["actions"] = [];
|
||||
let eventMessage = ctx.insight.summary;
|
||||
|
||||
if (type === "mission.started" || type.startsWith("mission.")) {
|
||||
actions.push(actionForAgent("personal-brand-opportunity-engine", "planner", {
|
||||
stageId: "positioning",
|
||||
mode: "user_input_required",
|
||||
title: "Pick the audience you want to be known by",
|
||||
body: "Personal brand only works when the target audience and credibility theme are explicit.",
|
||||
prompt: "Who should notice you, and what do you want to be known for?",
|
||||
payload: { fields: ["target_audience", "positioning_theme", "proof_points"] },
|
||||
idempotencyKey: `${activeMission.instanceId}:brand-positioning`,
|
||||
priority: 96,
|
||||
urgency: "today",
|
||||
}));
|
||||
}
|
||||
|
||||
if (isResumeEvent(event.source, type) && (type.includes("analysis") || type.includes("parsed") || type.includes("analyzed"))) {
|
||||
const signals = extractResumeSignals(payload);
|
||||
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Proof points extracted for positioning." });
|
||||
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Practice networking pitch next." });
|
||||
artifacts.push({ type: "positioning_statement", title: "Positioning statement draft", stageId: "resume", summary: signals[0] ?? "Strongest proof points were extracted from resume evidence.", metadata: { sourceEventId: event.id, signals } });
|
||||
actions.push(actionForAgent("personal-brand-opportunity-engine", "resume", {
|
||||
stageId: "resume",
|
||||
serviceId: "resume-service",
|
||||
toolName: "resume.create_version_prompt_draft",
|
||||
mode: "approval_required",
|
||||
title: "Draft your profile positioning statement?",
|
||||
body: "Approve a draft that turns your strongest resume proof into a clear LinkedIn/profile positioning statement.",
|
||||
payload: { signals, href: serviceHref("resume", activeMission.instanceId, activeMission.missionId, "resume") },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:brand-positioning-draft:${event.id}`,
|
||||
priority: 92,
|
||||
urgency: "today",
|
||||
}));
|
||||
eventMessage = "Resume proof points created a profile positioning action.";
|
||||
}
|
||||
|
||||
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Networking pitch reviewed." });
|
||||
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 70, outputSummary: "Brand voice/readiness signals updated." });
|
||||
artifacts.push({ type: "networking_scripts", title: "Networking script improvements", stageId: "roleplay", summary: weakAreas.length ? `Improve: ${weakAreas.join(", ")}` : "Networking pitch practice completed.", metadata: { sourceEventId: event.id, weakAreas } });
|
||||
actions.push(actionForAgent("personal-brand-opportunity-engine", "planner", {
|
||||
stageId: "positioning",
|
||||
mode: "suggestion",
|
||||
title: "Turn this pitch into weekly content pillars",
|
||||
body: "Use the networking practice feedback to draft 3 credibility themes for weekly posts.",
|
||||
payload: { weakAreas, href: missionDetailHref(activeMission.instanceId) },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:content-pillars:${event.id}`,
|
||||
priority: 82,
|
||||
urgency: "soon",
|
||||
}));
|
||||
eventMessage = "Networking pitch review created brand content next steps.";
|
||||
}
|
||||
|
||||
if (isInterviewEvent(event.source, type) && type.includes("review_completed")) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Credibility signals mined from interview review." });
|
||||
artifacts.push({ type: "credibility_signal_map", title: "Credibility signal map", stageId: "interview", summary: weakAreas.length ? `Recurring gaps/themes: ${weakAreas.join(", ")}` : "Interview review mined for positioning signals.", metadata: { sourceEventId: event.id, weakAreas } });
|
||||
eventMessage = "Interview feedback was mined for brand positioning signals.";
|
||||
}
|
||||
|
||||
if (ctx.qscoreSignals.length) stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: Math.min(90, 45 + ctx.qscoreSignals.length * 10), outputSummary: "Brand growth/readiness signals updated." });
|
||||
return { stagePatches, artifacts, actions, eventMessage };
|
||||
},
|
||||
};
|
||||
86
src/missions/promotion-readiness/reducer.ts
Normal file
86
src/missions/promotion-readiness/reducer.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
|
||||
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
|
||||
|
||||
export const promotionReadinessReducer: MissionReducer = {
|
||||
missionId: "promotion-readiness",
|
||||
accepts(ctx) {
|
||||
return ctx.activeMission.missionId === "promotion-readiness" &&
|
||||
(missionExplicitlyMatches(ctx.event.mission, "promotion-readiness") || isRelevantServiceEvent(ctx.event.source, ctx.event.type));
|
||||
},
|
||||
reduce(ctx): MissionReduction {
|
||||
const { event, activeMission } = ctx;
|
||||
const type = event.type;
|
||||
const payload = event.payload ?? {};
|
||||
const stagePatches: MissionStagePatch[] = [];
|
||||
const artifacts: MissionReduction["artifacts"] = [];
|
||||
const actions: MissionReduction["actions"] = [];
|
||||
let eventMessage = ctx.insight.summary;
|
||||
|
||||
if (type === "mission.started" || type.startsWith("mission.")) {
|
||||
actions.push(actionForAgent("promotion-readiness", "planner", {
|
||||
stageId: "promotion-context",
|
||||
mode: "user_input_required",
|
||||
title: "Clarify your promotion target",
|
||||
body: "Promotion readiness needs the desired level, timeline, manager context, and stakeholder map.",
|
||||
prompt: "What role/level are you targeting, by when, and what does your manager care about most?",
|
||||
payload: { fields: ["current_role", "desired_role", "timeline", "manager_context", "stakeholders"] },
|
||||
idempotencyKey: `${activeMission.instanceId}:promotion-context`,
|
||||
priority: 100,
|
||||
urgency: "today",
|
||||
}));
|
||||
}
|
||||
|
||||
if (isResumeEvent(event.source, type) && (type.includes("analysis") || type.includes("parsed") || type.includes("analyzed"))) {
|
||||
const signals = extractResumeSignals(payload);
|
||||
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Promotion evidence extracted from achievement history." });
|
||||
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Manager conversation practice is ready." });
|
||||
artifacts.push({ type: "promotion_evidence_packet", title: "Promotion evidence packet", stageId: "resume", summary: signals[0] ?? "Achievement evidence extracted for promotion case.", metadata: { sourceEventId: event.id, signals } });
|
||||
actions.push(actionForAgent("promotion-readiness", "roleplay", {
|
||||
stageId: "roleplay",
|
||||
serviceId: "roleplay-service",
|
||||
toolName: "roleplay.configure_practice",
|
||||
mode: "suggestion",
|
||||
title: "Practice the manager promotion conversation",
|
||||
body: "Use your achievement evidence in a realistic manager conversation drill.",
|
||||
payload: { signals, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:promotion-manager-roleplay:${event.id}`,
|
||||
priority: 94,
|
||||
urgency: "today",
|
||||
}));
|
||||
eventMessage = "Promotion evidence packet is ready; manager conversation practice is next.";
|
||||
}
|
||||
|
||||
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Manager conversation drill reviewed." });
|
||||
stagePatches.push({ stageId: "interview", status: "ready", progressPercent: 0, outputSummary: "Practice leadership narratives next if gaps remain." });
|
||||
artifacts.push({ type: "manager_conversation_script", title: "Manager conversation script", stageId: "roleplay", summary: weakAreas.length ? `Follow-up focus: ${weakAreas.join(", ")}` : "Manager conversation review completed.", metadata: { sourceEventId: event.id, weakAreas } });
|
||||
actions.push(actionForAgent("promotion-readiness", "interview", {
|
||||
stageId: "interview",
|
||||
serviceId: "interview-service",
|
||||
toolName: "interview.configure_practice",
|
||||
mode: "suggestion",
|
||||
title: "Practice leadership stories",
|
||||
body: "Run a leadership-style mock interview to tighten the promotion case.",
|
||||
payload: { weakAreas, href: serviceHref("interview", activeMission.instanceId, activeMission.missionId, "interview") },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:promotion-leadership-interview:${event.id}`,
|
||||
priority: 86,
|
||||
urgency: "soon",
|
||||
}));
|
||||
eventMessage = "Manager conversation review updated promotion readiness.";
|
||||
}
|
||||
|
||||
if (isInterviewEvent(event.source, type) && type.includes("review_completed")) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Leadership communication gap check completed." });
|
||||
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 75, outputSummary: "Leadership readiness signals updated." });
|
||||
artifacts.push({ type: "leadership_gap_map", title: "Leadership gap map", stageId: "interview", summary: weakAreas.length ? weakAreas.join(", ") : "Leadership practice review completed.", metadata: { sourceEventId: event.id, weakAreas } });
|
||||
eventMessage = "Leadership practice review updated the promotion gap map.";
|
||||
}
|
||||
|
||||
if (ctx.qscoreSignals.length) stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: Math.min(90, 45 + ctx.qscoreSignals.length * 10), outputSummary: "Promotion readiness signals updated." });
|
||||
return { stagePatches, artifacts, actions, eventMessage };
|
||||
},
|
||||
};
|
||||
147
src/missions/reducer-helpers.ts
Normal file
147
src/missions/reducer-helpers.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { asRecord, getNumber, getString } from "../events/envelope.js";
|
||||
import type { MissionActionPatch } from "./reducer-types.js";
|
||||
|
||||
export function isResumeEvent(source: string, type: string) {
|
||||
const value = source.toLowerCase();
|
||||
return value.includes("resume") || type.startsWith("resume.");
|
||||
}
|
||||
|
||||
export function isInterviewEvent(source: string, type: string) {
|
||||
const value = source.toLowerCase();
|
||||
return value.includes("interview") || type.startsWith("interview.");
|
||||
}
|
||||
|
||||
export function isRoleplayEvent(source: string, type: string) {
|
||||
const value = source.toLowerCase();
|
||||
return value.includes("roleplay") || type.startsWith("roleplay.");
|
||||
}
|
||||
|
||||
export function isQscoreEvent(source: string, type: string) {
|
||||
const value = source.toLowerCase();
|
||||
return value.includes("qscore") || type.startsWith("qscore.");
|
||||
}
|
||||
|
||||
export function reviewRecord(payload: Record<string, unknown>) {
|
||||
return asRecord(payload.review ?? payload.result ?? payload.data ?? payload);
|
||||
}
|
||||
|
||||
export function extractOverallScore(payload: Record<string, unknown>) {
|
||||
const review = reviewRecord(payload);
|
||||
return getNumber(review.overall_score ?? review.overallScore ?? review.score ?? payload.overall_score);
|
||||
}
|
||||
|
||||
export function extractWeakAreas(payload: Record<string, unknown>): string[] {
|
||||
const review = reviewRecord(payload);
|
||||
const candidates = [
|
||||
review.weak_areas,
|
||||
review.weakAreas,
|
||||
review.improvement_areas,
|
||||
review.improvementAreas,
|
||||
review.recommendations,
|
||||
review.gaps,
|
||||
];
|
||||
const areas: string[] = [];
|
||||
for (const candidate of candidates) {
|
||||
if (Array.isArray(candidate)) {
|
||||
for (const item of candidate) {
|
||||
if (typeof item === "string" && item.trim()) areas.push(item.trim());
|
||||
else if (item && typeof item === "object" && !Array.isArray(item)) {
|
||||
const row = item as Record<string, unknown>;
|
||||
const text = getString(row.title ?? row.area ?? row.name ?? row.label ?? row.summary);
|
||||
if (text) areas.push(text);
|
||||
}
|
||||
}
|
||||
} else if (typeof candidate === "string" && candidate.trim()) {
|
||||
areas.push(...candidate.split(/[;,]/).map((part) => part.trim()).filter(Boolean));
|
||||
}
|
||||
}
|
||||
const summary = getString(review.summary ?? review.feedback_summary ?? review.overall_feedback);
|
||||
if (!areas.length && summary) {
|
||||
const lower = summary.toLowerCase();
|
||||
if (lower.includes("communication") || lower.includes("clarity") || lower.includes("story")) areas.push("communication clarity");
|
||||
if (lower.includes("technical") || lower.includes("role")) areas.push("role-fit depth");
|
||||
if (lower.includes("confidence") || lower.includes("concise")) areas.push("confidence and concision");
|
||||
}
|
||||
return Array.from(new Set(areas)).slice(0, 5);
|
||||
}
|
||||
|
||||
export function extractResumeSignals(payload: Record<string, unknown>): string[] {
|
||||
const analysis = asRecord(payload.analysis ?? payload.result ?? payload.data ?? payload);
|
||||
const signals: string[] = [];
|
||||
const summary = getString(analysis.summary ?? analysis.overall_feedback ?? payload.summary);
|
||||
if (summary) signals.push(summary);
|
||||
for (const key of ["strengths", "gaps", "recommendations", "missing_keywords", "keyword_gaps"]) {
|
||||
const value = analysis[key];
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value.slice(0, 4)) if (typeof item === "string") signals.push(item);
|
||||
}
|
||||
}
|
||||
return signals.slice(0, 8);
|
||||
}
|
||||
|
||||
export function missionExplicitlyMatches(eventMission: unknown, missionId: string) {
|
||||
const mission = asRecord(eventMission);
|
||||
const explicit = getString(mission.missionId ?? mission.mission_id);
|
||||
return explicit === missionId;
|
||||
}
|
||||
|
||||
export function isRelevantServiceEvent(source: string, type: string) {
|
||||
return isResumeEvent(source, type) || isInterviewEvent(source, type) || isRoleplayEvent(source, type) || isQscoreEvent(source, type);
|
||||
}
|
||||
|
||||
const AGENT_NAMES: Record<string, Record<string, { agentId: string; baseAgent: string; agentName: string }>> = {
|
||||
"interview-to-offer": {
|
||||
planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Offer Strategist" },
|
||||
resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Resume Fit Agent" },
|
||||
interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Mock Interviewer" },
|
||||
roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Communication Coach" },
|
||||
qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Readiness Analyst" },
|
||||
},
|
||||
"career-transition": {
|
||||
planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Transition Strategist" },
|
||||
resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Transferable Skills Agent" },
|
||||
interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Adjacent Role Interviewer" },
|
||||
roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Transition Pitch Coach" },
|
||||
qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Transition Readiness Analyst" },
|
||||
},
|
||||
"salary-negotiation-war-room": {
|
||||
planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Negotiation Strategist" },
|
||||
resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Value Evidence Agent" },
|
||||
interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Confidence Coach" },
|
||||
roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Negotiation Drill Coach" },
|
||||
qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Confidence Analyst" },
|
||||
},
|
||||
"promotion-readiness": {
|
||||
planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Promotion Strategist" },
|
||||
resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Achievement Evidence Agent" },
|
||||
interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Leadership Interview Coach" },
|
||||
roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Manager Conversation Coach" },
|
||||
qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Leadership Readiness Analyst" },
|
||||
},
|
||||
"personal-brand-opportunity-engine": {
|
||||
planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Brand Strategist" },
|
||||
resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Proof Point Agent" },
|
||||
interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Credibility Coach" },
|
||||
roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Networking Pitch Coach" },
|
||||
qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Visibility Analyst" },
|
||||
},
|
||||
};
|
||||
|
||||
export function actionForAgent(missionId: string, agent: "planner" | "resume" | "interview" | "roleplay" | "qscore", patch: Omit<MissionActionPatch, "agentId" | "agentName" | "baseAgent">): MissionActionPatch {
|
||||
const fallback = AGENT_NAMES["interview-to-offer"]?.[agent] ?? { agentId: agent, baseAgent: agent, agentName: agent };
|
||||
const spec = AGENT_NAMES[missionId]?.[agent] ?? fallback;
|
||||
return { ...spec, ...patch };
|
||||
}
|
||||
|
||||
export function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", missionInstanceId: string, missionId: string, stageId?: string) {
|
||||
const params = new URLSearchParams({ source: "mission", missionInstanceId, missionId });
|
||||
if (stageId) params.set("stageId", stageId);
|
||||
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()}`;
|
||||
}
|
||||
|
||||
export function missionDetailHref(missionInstanceId: string) {
|
||||
return `/missions/${encodeURIComponent(missionInstanceId)}`;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { GrowEventRow } from "../db/schema.js";
|
||||
import type { ProjectionInsight } from "../events/projectors/projection-agent.js";
|
||||
import type { QscoreSignal } from "../events/envelope.js";
|
||||
import type { GrowActiveMission, MissionStageStatus } from "../actors/missions/types.js";
|
||||
import type { MissionActionMode, MissionActionStatus, MissionActionUrgency } from "./action-types.js";
|
||||
|
||||
export type MissionReducerContext = {
|
||||
userId: string;
|
||||
@@ -27,9 +28,30 @@ export type MissionStagePatch = {
|
||||
outputSummary?: string;
|
||||
};
|
||||
|
||||
export type MissionActionPatch = {
|
||||
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>;
|
||||
sourceEventId?: string;
|
||||
idempotencyKey?: string;
|
||||
priority?: number;
|
||||
urgency?: MissionActionUrgency;
|
||||
dueAt?: string;
|
||||
};
|
||||
|
||||
export type MissionReduction = {
|
||||
stagePatches: MissionStagePatch[];
|
||||
artifacts: MissionArtifactPatch[];
|
||||
actions: MissionActionPatch[];
|
||||
eventMessage?: string;
|
||||
};
|
||||
|
||||
|
||||
88
src/missions/salary-negotiation-war-room/reducer.ts
Normal file
88
src/missions/salary-negotiation-war-room/reducer.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
|
||||
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
|
||||
|
||||
export const salaryNegotiationReducer: MissionReducer = {
|
||||
missionId: "salary-negotiation-war-room",
|
||||
accepts(ctx) {
|
||||
return ctx.activeMission.missionId === "salary-negotiation-war-room" &&
|
||||
(missionExplicitlyMatches(ctx.event.mission, "salary-negotiation-war-room") || isRelevantServiceEvent(ctx.event.source, ctx.event.type));
|
||||
},
|
||||
reduce(ctx): MissionReduction {
|
||||
const { event, activeMission } = ctx;
|
||||
const type = event.type;
|
||||
const payload = event.payload ?? {};
|
||||
const stagePatches: MissionStagePatch[] = [];
|
||||
const artifacts: MissionReduction["artifacts"] = [];
|
||||
const actions: MissionReduction["actions"] = [];
|
||||
let eventMessage = ctx.insight.summary;
|
||||
|
||||
if (type === "mission.started" || type.startsWith("mission.")) {
|
||||
actions.push(actionForAgent("salary-negotiation-war-room", "planner", {
|
||||
stageId: "offer-context",
|
||||
mode: "user_input_required",
|
||||
title: "Add your offer and negotiation constraints",
|
||||
body: "The war room needs your current offer, target range, deadline, and leverage before scripts or drills are accurate.",
|
||||
prompt: "What is the current offer/raise, your target, deadline, and any competing leverage?",
|
||||
payload: { fields: ["current_offer", "target_range", "deadline", "competing_offers", "constraints"] },
|
||||
idempotencyKey: `${activeMission.instanceId}:offer-context`,
|
||||
priority: 100,
|
||||
urgency: "now",
|
||||
}));
|
||||
}
|
||||
|
||||
if (isResumeEvent(event.source, type) && (type.includes("analysis") || type.includes("parsed") || type.includes("analyzed"))) {
|
||||
const signals = extractResumeSignals(payload);
|
||||
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Value evidence extracted from resume proof." });
|
||||
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Use value evidence in negotiation roleplay." });
|
||||
artifacts.push({ type: "value_evidence_map", title: "Value evidence map", stageId: "resume", summary: signals[0] ?? "Impact proof extracted for negotiation leverage.", metadata: { sourceEventId: event.id, signals } });
|
||||
actions.push(actionForAgent("salary-negotiation-war-room", "roleplay", {
|
||||
stageId: "roleplay",
|
||||
serviceId: "roleplay-service",
|
||||
toolName: "roleplay.configure_practice",
|
||||
mode: "suggestion",
|
||||
title: "Run the first counteroffer drill",
|
||||
body: "Practice anchoring your number and defending value with the evidence now extracted.",
|
||||
payload: { signals, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:value-evidence-roleplay:${event.id}`,
|
||||
priority: 96,
|
||||
urgency: "today",
|
||||
}));
|
||||
eventMessage = "Value evidence is ready for negotiation practice.";
|
||||
}
|
||||
|
||||
if (isRoleplayEvent(event.source, type) && type.includes("configured")) {
|
||||
stagePatches.push({ stageId: "roleplay", status: "in_progress", progressPercent: 45, outputSummary: "Negotiation drill configured." });
|
||||
eventMessage = "Negotiation drill started.";
|
||||
}
|
||||
|
||||
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
|
||||
const weakAreas = extractWeakAreas(payload);
|
||||
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Negotiation drill reviewed." });
|
||||
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 70, outputSummary: "Confidence signals updated." });
|
||||
artifacts.push({ type: "negotiation_objection_map", title: "Objection handling map", stageId: "roleplay", summary: weakAreas.length ? `Practice objections: ${weakAreas.join(", ")}` : "Negotiation practice review completed.", metadata: { sourceEventId: event.id, weakAreas } });
|
||||
actions.push(actionForAgent("salary-negotiation-war-room", "roleplay", {
|
||||
stageId: "roleplay",
|
||||
serviceId: "roleplay-service",
|
||||
toolName: "roleplay.configure_practice",
|
||||
mode: "approval_required",
|
||||
title: "Run one more objection-handling drill?",
|
||||
body: weakAreas.length ? `Recommended focus: ${weakAreas.slice(0, 3).join(", ")}. Approve another drill before the live negotiation.` : "Approve one more objection-handling drill before the live negotiation.",
|
||||
payload: { weakAreas, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") },
|
||||
sourceEventId: event.id,
|
||||
idempotencyKey: `${activeMission.instanceId}:negotiation-followup:${event.id}`,
|
||||
priority: 94,
|
||||
urgency: "today",
|
||||
}));
|
||||
eventMessage = "Negotiation drill review created the next objection-handling action.";
|
||||
}
|
||||
|
||||
if (isInterviewEvent(event.source, type) && type.includes("review_completed")) {
|
||||
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Communication confidence signal captured from interview review." });
|
||||
eventMessage = "Interview feedback updated negotiation confidence signals.";
|
||||
}
|
||||
|
||||
if (ctx.qscoreSignals.length) stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: Math.min(90, 45 + ctx.qscoreSignals.length * 10), outputSummary: "Negotiation confidence signals updated." });
|
||||
return { stagePatches, artifacts, actions, eventMessage };
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MissionSnapshot, MissionStage } from "../actors/missions/types.js";
|
||||
import { missionDetailHref } from "./reducer-helpers.js";
|
||||
|
||||
export type MissionSuggestionType = "action" | "practice" | "review" | "artifact" | "blocked" | "insight";
|
||||
export type MissionSuggestionUrgency = "now" | "today" | "soon" | "calm";
|
||||
@@ -103,7 +104,7 @@ function ctaFor(stage: MissionStage, snapshot: MissionSnapshot, context?: Missio
|
||||
return { label: "Open resume", href: `/agents/resume?${params.toString()}` };
|
||||
}
|
||||
if (role === "Q Score") return { label: "View Q Score", href: `/agents/qscore?${params.toString()}` };
|
||||
return { label: "Continue", href: `/missions/active?${params.toString()}` };
|
||||
return { label: "Continue", href: `${missionDetailHref(snapshot.instanceId)}?${params.toString()}` };
|
||||
}
|
||||
|
||||
function suggestionId(snapshot: MissionSnapshot, stage: MissionStage, suffix: string) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getMissionDefinition, isActorBackedMission, listMissionDefinitions } fr
|
||||
import type { GrowActiveMission, MissionActorType, MissionSnapshot } from "../actors/missions/types.js";
|
||||
import { getSubAgentModules } from "../lib/prompt-loader.js";
|
||||
import { addMessagePg, createConversationPg, ensureConversation, getConversationPg, listActiveMissionsPg, listConversationsPg, listMessagesPg, resetConversationPg, touchConversationPg, upsertActiveMissionPg } from "../grow/persistence.js";
|
||||
import { getMissionAction, listMissionActions, updateMissionActionStatus } from "../missions/actions.js";
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
function getClient(): Client<Registry> {
|
||||
@@ -443,6 +444,49 @@ function buildConversationTools(userId: string) {
|
||||
},
|
||||
}),
|
||||
|
||||
listMissionActions: tool({
|
||||
description: "List open mission queue actions/approvals/questions for the user. Use for: what should I do today, what are my agents doing, why is my mission blocked, show approvals.",
|
||||
inputSchema: z.object({ missionInstanceId: z.string().optional() }),
|
||||
execute: async ({ missionInstanceId }) => ({
|
||||
kind: "mission-actions",
|
||||
actions: await listMissionActions(userId, { missionInstanceId }),
|
||||
}),
|
||||
}),
|
||||
|
||||
approveMissionAction: tool({
|
||||
description: "Approve a mission action by id. Only use when the user explicitly asks to approve a specific action.",
|
||||
inputSchema: z.object({ actionId: z.string() }),
|
||||
execute: async ({ actionId }) => {
|
||||
const action = await getMissionAction(userId, actionId);
|
||||
if (!action) return { kind: "mission-action-approved", actionId, error: "Action not found" };
|
||||
return { kind: "mission-action-approved", action: await updateMissionActionStatus(userId, actionId, { status: "queued", result: { approvedAt: new Date().toISOString() } }) };
|
||||
},
|
||||
}),
|
||||
|
||||
rejectMissionAction: tool({
|
||||
description: "Reject/dismiss a mission action by id. Only use when the user explicitly asks to reject or dismiss a specific action.",
|
||||
inputSchema: z.object({ actionId: z.string() }),
|
||||
execute: async ({ actionId }) => ({
|
||||
kind: "mission-action-rejected",
|
||||
action: await updateMissionActionStatus(userId, actionId, { status: "dismissed", result: { rejectedAt: new Date().toISOString() } }),
|
||||
}),
|
||||
}),
|
||||
|
||||
explainMissionProgress: tool({
|
||||
description: "Explain active mission progress using snapshots and open actions.",
|
||||
inputSchema: z.object({ missionInstanceId: z.string().optional() }),
|
||||
execute: async ({ missionInstanceId }) => {
|
||||
const persisted = await listActiveMissionsPg(userId);
|
||||
const selected = missionInstanceId ? persisted.filter((item) => item.mission.instanceId === missionInstanceId) : persisted;
|
||||
return {
|
||||
kind: "mission-progress",
|
||||
missions: selected.map((item) => item.mission),
|
||||
snapshots: selected.map((item) => item.snapshot).filter(Boolean),
|
||||
actions: await listMissionActions(userId, { missionInstanceId }),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
listMemory: tool({
|
||||
description: "List memory files for this user by path prefix.",
|
||||
inputSchema: z.object({ prefix: z.string().optional() }),
|
||||
|
||||
@@ -8,13 +8,29 @@ function canSeedDemo(userId: string) {
|
||||
return config.nodeEnv !== "production" || config.adminUserIds.includes(userId);
|
||||
}
|
||||
|
||||
async function getUserServiceProfile(req: Request): Promise<{ userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> }> {
|
||||
const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, ""));
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("cookie");
|
||||
const res = await fetch(target, { method: "GET", headers });
|
||||
if (!res.ok) return {};
|
||||
const userProfile = await res.json().catch(() => null) as Record<string, unknown> | null;
|
||||
const preferences = userProfile?.preferences;
|
||||
return {
|
||||
userProfile: userProfile ?? undefined,
|
||||
preferences: preferences && typeof preferences === "object" && !Array.isArray(preferences) ? preferences as Record<string, unknown> : {},
|
||||
};
|
||||
}
|
||||
|
||||
export function homeRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
|
||||
app.get("/feed", async (c) => {
|
||||
const refresh = c.req.query("refresh") === "1" || c.req.query("refresh") === "true";
|
||||
return c.json(await getHomeFeed(c.get("userId"), { refresh }));
|
||||
const profile = await getUserServiceProfile(c.req.raw);
|
||||
return c.json(await getHomeFeed(c.get("userId"), { refresh, ...profile }));
|
||||
});
|
||||
|
||||
app.post("/notifications/:id/dismiss", async (c) => {
|
||||
|
||||
@@ -9,6 +9,10 @@ import { isActorBackedMission } from "../missions/registry.js";
|
||||
import { getPersistedMissionDefinition, listPersistedMissionDefinitions } from "../missions/postgres-registry.js";
|
||||
import { completeMissionCoachRunPg, createMissionCoachRunPg, getActiveMissionPg, listActiveMissionsPg, listMissionSuggestionsPg, replaceMissionSuggestionsPg, upsertActiveMissionPg } from "../grow/persistence.js";
|
||||
import { buildDeterministicMissionSuggestions } from "../missions/suggestions.js";
|
||||
import { createMissionAction, getMissionAction, listMissionActions, updateMissionActionStatus } from "../missions/actions.js";
|
||||
import { recordGrowEvent } from "../events/record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
|
||||
import { missionDetailHref } from "../missions/reducer-helpers.js";
|
||||
|
||||
let _client: Client<Registry> | null = null;
|
||||
function getClient(): Client<Registry> {
|
||||
@@ -55,6 +59,15 @@ const addArtifactSchema = z.object({
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const answerActionSchema = z.object({
|
||||
input: z.record(z.unknown()).optional(),
|
||||
answer: z.string().optional(),
|
||||
});
|
||||
|
||||
const snoozeActionSchema = z.object({
|
||||
until: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
const createInstanceId = (missionId: string) =>
|
||||
`${missionId}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
@@ -126,10 +139,15 @@ export function missionRoutes() {
|
||||
await Promise.all(persisted.map(async (item) => {
|
||||
suggestionsByMission[item.mission.instanceId] = await listMissionSuggestionsPg(userId, item.mission.instanceId);
|
||||
}));
|
||||
const actionsByMission: Record<string, unknown[]> = {};
|
||||
await Promise.all(persisted.map(async (item) => {
|
||||
actionsByMission[item.mission.instanceId] = await listMissionActions(userId, { missionInstanceId: item.mission.instanceId });
|
||||
}));
|
||||
return c.json({
|
||||
missions: persisted.map((item) => item.mission),
|
||||
snapshots: persisted.map((item) => item.snapshot).filter((snapshot): snapshot is MissionSnapshot => Boolean(snapshot)),
|
||||
suggestionsByMission,
|
||||
actionsByMission,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,7 +156,24 @@ export function missionRoutes() {
|
||||
const active = await getActiveMissionPg(userId, c.req.param("instanceId"));
|
||||
if (!active) return c.json({ error: "mission_not_found" }, 404);
|
||||
const suggestions = await listMissionSuggestionsPg(userId, active.mission.instanceId);
|
||||
return c.json({ mission: active.mission, snapshot: active.snapshot, suggestions });
|
||||
const actions = await listMissionActions(userId, { missionInstanceId: active.mission.instanceId });
|
||||
return c.json({ mission: active.mission, snapshot: active.snapshot, suggestions, actions });
|
||||
});
|
||||
|
||||
app.get("/active/:instanceId/actions", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const active = await getActiveMissionPg(userId, c.req.param("instanceId"));
|
||||
if (!active) return c.json({ error: "mission_not_found" }, 404);
|
||||
return c.json({ actions: await listMissionActions(userId, { missionInstanceId: active.mission.instanceId }) });
|
||||
});
|
||||
|
||||
app.post("/active/:instanceId/scrum/run", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const active = await getActiveMissionPg(userId, c.req.param("instanceId"));
|
||||
if (!active?.mission.actorType) return c.json({ error: "mission_not_found" }, 404);
|
||||
const result = await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).runDailyScrum({ trigger: "manual" });
|
||||
if (result.snapshot) await upsertActiveMissionPg(userId, activeMissionFromSnapshot(result.snapshot), result.snapshot);
|
||||
return c.json({ summary: result.summary, snapshot: result.snapshot });
|
||||
});
|
||||
|
||||
app.post("/active/:instanceId/coach/run", async (c) => {
|
||||
@@ -190,6 +225,77 @@ export function missionRoutes() {
|
||||
return c.json({ coachRunId: run.id, summary, suggestions });
|
||||
});
|
||||
|
||||
app.post("/actions/:actionId/approve", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const action = await getMissionAction(userId, c.req.param("actionId"));
|
||||
if (!action) return c.json({ error: "action_not_found" }, 404);
|
||||
const updated = await updateMissionActionStatus(userId, action.id, {
|
||||
status: "queued",
|
||||
result: { approvedAt: new Date().toISOString() },
|
||||
payload: { ...(action.payload ?? {}), approved: true },
|
||||
});
|
||||
const active = await getActiveMissionPg(userId, action.missionInstanceId);
|
||||
if (active?.mission.actorType) {
|
||||
await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).resolveHitl({ actionId: action.id, resolution: "approved" }).catch(() => undefined);
|
||||
}
|
||||
return c.json({ action: updated });
|
||||
});
|
||||
|
||||
app.post("/actions/:actionId/reject", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const action = await updateMissionActionStatus(userId, c.req.param("actionId"), { status: "dismissed", result: { rejectedAt: new Date().toISOString() } });
|
||||
if (!action) return c.json({ error: "action_not_found" }, 404);
|
||||
return c.json({ action });
|
||||
});
|
||||
|
||||
app.post("/actions/:actionId/run", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const existing = await getMissionAction(userId, c.req.param("actionId"));
|
||||
if (!existing) return c.json({ error: "action_not_found" }, 404);
|
||||
const active = await getActiveMissionPg(userId, existing.missionInstanceId);
|
||||
if (active?.mission.actorType) {
|
||||
await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).runAction({ actionId: existing.id }).catch(() => undefined);
|
||||
}
|
||||
const href = typeof existing.payload?.href === "string" ? existing.payload.href : missionDetailHref(existing.missionInstanceId);
|
||||
const action = await updateMissionActionStatus(userId, existing.id, {
|
||||
status: "done",
|
||||
result: {
|
||||
ranAt: new Date().toISOString(),
|
||||
message: existing.toolName?.startsWith("resume.")
|
||||
? "Resume Agent prepared the draft brief. Open Resume Builder to apply it."
|
||||
: "Action marked complete. Continue from the linked GrowQR surface.",
|
||||
href,
|
||||
},
|
||||
});
|
||||
return c.json({ action });
|
||||
});
|
||||
|
||||
app.post("/actions/:actionId/answer", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const existing = await getMissionAction(userId, c.req.param("actionId"));
|
||||
if (!existing) return c.json({ error: "action_not_found" }, 404);
|
||||
const body = answerActionSchema.parse(await c.req.json().catch(() => ({})));
|
||||
const input = body.input ?? (body.answer ? { answer: body.answer } : {});
|
||||
const action = await updateMissionActionStatus(userId, existing.id, {
|
||||
status: "done",
|
||||
result: { answeredAt: new Date().toISOString(), input },
|
||||
payload: { ...(existing.payload ?? {}), userInput: input },
|
||||
});
|
||||
const active = await getActiveMissionPg(userId, existing.missionInstanceId);
|
||||
if (active?.mission.actorType) {
|
||||
await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).resolveHitl({ actionId: existing.id, resolution: "answered", input }).catch(() => undefined);
|
||||
}
|
||||
return c.json({ action });
|
||||
});
|
||||
|
||||
app.post("/actions/:actionId/snooze", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = snoozeActionSchema.parse(await c.req.json().catch(() => ({})));
|
||||
const action = await updateMissionActionStatus(userId, c.req.param("actionId"), { status: "snoozed", result: { snoozedAt: new Date().toISOString(), until: body.until } });
|
||||
if (!action) return c.json({ error: "action_not_found" }, 404);
|
||||
return c.json({ action });
|
||||
});
|
||||
|
||||
app.post("/:missionId/start", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const missionId = c.req.param("missionId");
|
||||
@@ -215,6 +321,16 @@ export function missionRoutes() {
|
||||
await upsertActiveMissionPg(userId, activeMission, snapshot);
|
||||
const grow = growFor(userId);
|
||||
grow.setup({ userId }).then(() => grow.registerActiveMission(activeMission)).catch((err) => console.warn("growActor mission mirror failed", err));
|
||||
recordGrowEvent({
|
||||
source: "growqr-backend:missions",
|
||||
type: "mission.started",
|
||||
category: "mission",
|
||||
userId,
|
||||
occurredAt: new Date().toISOString(),
|
||||
mission: { instanceId, missionId, stageId: snapshot.currentStageId },
|
||||
payload: { goal: body.goal, input: body.input ?? {}, title: snapshot.title },
|
||||
dedupeKey: `mission-started:${instanceId}`,
|
||||
}).then((event) => routeGrowEventToUserActor(event)).catch((err) => console.warn("mission start event routing failed", err));
|
||||
return c.json({ mission: activeMission, snapshot }, 201);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,18 +8,13 @@ import { db } from "../db/client.js";
|
||||
import { events, growQscoreLatest, growQscoreProjectionState } from "../db/schema.js";
|
||||
import { recordGrowEvent } from "../events/record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
|
||||
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
|
||||
import { log } from "../log.js";
|
||||
|
||||
const LANDING_AGENTS = [
|
||||
{ id: "resume", title: "The Resume Expert", agent: "AI Resume Expert", description: "Writes resumes and cover letters that get past ATS and into human hands.", route: "/agents/resume" },
|
||||
{ id: "interview", title: "The Interviewer", agent: "AI Interviewer", description: "Puts you through real interviews before the real one. Brutal feedback included.", route: "/agents/interview" },
|
||||
{ id: "roleplay", title: "The Roleplay Coach", agent: "AI Roleplay Coach", description: "Practises the hard conversations with you — so you're never caught off guard.", route: "/agents/roleplay" },
|
||||
{ id: "assessments", title: "The Skill Trainer", agent: "AI Skill Trainer", description: "Finds exactly what's holding you back — then builds a plan to close it.", route: "/agents/assessments" },
|
||||
{ id: "social-branding", title: "The Brand Voice", agent: "AI Brand Voice", description: "Makes you impossible to ignore online — posts, profiles, and positioning sorted.", route: "/agents/social-branding" },
|
||||
{ id: "pathways-report", title: "The Pathway Guide", agent: "AI Pathway Guide", description: "Maps career options, pivots, and what-fits-next at every stage of your journey.", route: "/agents/pathways-report" },
|
||||
{ id: "courses", title: "The Learning Curator", agent: "AI Learning Curator", description: "Picks only what's worth your time — courses and paths that move you forward.", route: "/agents/courses" },
|
||||
{ id: "jobs", title: "The Opportunity Finder", agent: "AI Job Finder", description: "Daily, agent-sourced shortlist of roles matched to your QX and goals.", route: "/agents/jobs" },
|
||||
{ id: "presence", title: "The Presence Coach", agent: "AI Presence Coach", description: "Tells you what to wear, how to speak, and how to carry yourself in every room.", route: "/agents/presence" },
|
||||
{ id: "resume", title: "Resume", agent: "Resume Strategist", description: "Resume proof, versions, parsing, analysis, and mission artifacts.", route: "/agents/resume" },
|
||||
{ id: "interview", title: "Interview", agent: "Interview Coach", description: "Mock interviews, reviews, weakness diagnosis, and readiness signals.", route: "/agents/interview" },
|
||||
{ id: "roleplay", title: "Roleplay", agent: "Roleplay Coach", description: "Negotiation, promotion, transition, and communication drills.", route: "/agents/roleplay" },
|
||||
] as const;
|
||||
|
||||
const DEFAULT_QSCORE = {
|
||||
@@ -48,6 +43,31 @@ function missionFromBody(body: JsonObject): Record<string, unknown> | undefined
|
||||
return mission && typeof mission === "object" && !Array.isArray(mission) ? (mission as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
function missionFromRequest(req: Request, body?: JsonObject): Record<string, unknown> | undefined {
|
||||
const fromBody = body ? missionFromBody(body) : undefined;
|
||||
if (fromBody) return fromBody;
|
||||
|
||||
const url = new URL(req.url);
|
||||
const instanceId = getString(url.searchParams.get("missionInstanceId"));
|
||||
const missionId = getString(url.searchParams.get("missionId"));
|
||||
const stageId = getString(url.searchParams.get("stageId"));
|
||||
const source = getString(url.searchParams.get("source"));
|
||||
|
||||
if (!instanceId && !missionId && !stageId) return undefined;
|
||||
return {
|
||||
instanceId,
|
||||
missionId,
|
||||
stageId,
|
||||
source: source ?? "mission",
|
||||
};
|
||||
}
|
||||
|
||||
function stripMissionFromBody(body: JsonObject): JsonObject {
|
||||
if (!("mission" in body)) return body;
|
||||
const { mission: _mission, ...rest } = body;
|
||||
return rest;
|
||||
}
|
||||
|
||||
async function recordGatewayEvent(input: {
|
||||
userId: string;
|
||||
source: string;
|
||||
@@ -84,6 +104,26 @@ function eventTypeForReview(prefix: "interview" | "roleplay", result: Record<str
|
||||
return `${prefix}.review_processing`;
|
||||
}
|
||||
|
||||
function resumeEventTypeForRest(method: string, rest: string, ok: boolean) {
|
||||
if (!ok) return "resume.request_failed";
|
||||
if (method === "POST" && /^resumes\/upload/.test(rest)) return "resume.uploaded";
|
||||
if (method === "POST" && /^parse\/resume\/[^/]+\/parse/.test(rest)) return "resume.parsed";
|
||||
if (method === "POST" && /^ai\/analyze\//.test(rest)) return "resume.analysis_completed";
|
||||
if ((method === "POST" || method === "PUT" || method === "PATCH") && /versions?/.test(rest)) return "resume.version_created";
|
||||
if (method !== "GET") return "resume.updated";
|
||||
return "resume.loaded";
|
||||
}
|
||||
|
||||
function parseJsonBody(body: ArrayBuffer | undefined, headers: Headers): JsonObject {
|
||||
if (!body || !headers.get("content-type")?.includes("application/json")) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.from(body).toString("utf8"));
|
||||
return isRecord(parsed) ? parsed as JsonObject : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
const incoming = new URL(req.url);
|
||||
const normalizedRest = rest
|
||||
@@ -92,8 +132,13 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
.replace(/^resumes\/([^/]+)\/analyze$/, "ai/analyze/$1")
|
||||
.replace(/^resumes\/([^/]+)\/suggestions$/, "ai/suggestions/$1")
|
||||
.replace(/^resumes\/([^/]+)\/preview$/, "export/resumes/$1/preview");
|
||||
const forwardedQuery = new URLSearchParams(incoming.searchParams);
|
||||
forwardedQuery.delete("missionInstanceId");
|
||||
forwardedQuery.delete("missionId");
|
||||
forwardedQuery.delete("stageId");
|
||||
forwardedQuery.delete("source");
|
||||
const target = new URL(
|
||||
`/api/v1/${normalizedRest}${incoming.search}`,
|
||||
`/api/v1/${normalizedRest}${forwardedQuery.toString() ? `?${forwardedQuery.toString()}` : ""}`,
|
||||
config.resumeServiceUrl.replace(/\/$/, ""),
|
||||
);
|
||||
|
||||
@@ -105,19 +150,262 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
|
||||
const method = req.method.toUpperCase();
|
||||
const body = ["GET", "HEAD"].includes(method) ? undefined : await req.arrayBuffer();
|
||||
const requestJson = parseJsonBody(body, headers);
|
||||
const mission = missionFromRequest(req, requestJson);
|
||||
const forwardBody =
|
||||
body && headers.get("content-type")?.includes("application/json")
|
||||
? Buffer.from(JSON.stringify(stripMissionFromBody(requestJson)))
|
||||
: body;
|
||||
if (forwardBody !== body) headers.delete("content-length");
|
||||
const res = await fetch(target, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
body: forwardBody,
|
||||
});
|
||||
|
||||
return new Response(res.body, {
|
||||
if (method === "GET" || method === "HEAD") {
|
||||
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: res.headers });
|
||||
}
|
||||
|
||||
const responseBuffer = await res.arrayBuffer();
|
||||
const responseText = Buffer.from(responseBuffer).toString("utf8");
|
||||
let responseJson: unknown;
|
||||
try { responseJson = responseText ? JSON.parse(responseText) : undefined; } catch { responseJson = undefined; }
|
||||
const responseObj = isRecord(responseJson) ? responseJson : { body: responseText.slice(0, 2000) };
|
||||
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
source: "resume-builder",
|
||||
type: resumeEventTypeForRest(method, normalizedRest, res.ok),
|
||||
payload: { request: requestJson, result: responseObj, status: res.status, path: normalizedRest },
|
||||
correlation: {
|
||||
resumeId: getString(responseObj.resume_id ?? responseObj.resumeId ?? responseObj.id) ?? getString(requestJson.resume_id ?? requestJson.resumeId),
|
||||
externalId: getString(responseObj.resume_id ?? responseObj.resumeId ?? responseObj.id) ?? getString(requestJson.resume_id ?? requestJson.resumeId),
|
||||
},
|
||||
mission,
|
||||
}).catch((err) => log.warn({ err, path: normalizedRest }, "failed to record resume gateway event"));
|
||||
|
||||
return new Response(responseBuffer, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: res.headers,
|
||||
});
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map((item) => String(item).trim()).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
async function getUserServiceProfile(req: Request): Promise<{ userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> }> {
|
||||
const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, ""));
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("cookie");
|
||||
const res = await fetch(target, { method: "GET", headers });
|
||||
if (!res.ok) return {};
|
||||
const userProfile = await res.json().catch(() => null) as Record<string, unknown> | null;
|
||||
const preferences = userProfile?.preferences;
|
||||
return {
|
||||
userProfile: userProfile ?? undefined,
|
||||
preferences: isRecord(preferences) ? preferences : {},
|
||||
};
|
||||
}
|
||||
|
||||
async function getUserServicePreferences(req: Request): Promise<Record<string, unknown> | undefined> {
|
||||
return (await getUserServiceProfile(req)).preferences;
|
||||
}
|
||||
|
||||
async function getServiceState(baseUrl: string, path: string): Promise<Record<string, unknown> | undefined> {
|
||||
const target = new URL(path, baseUrl.replace(/\/$/, ""));
|
||||
const res = await fetch(target, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
...(config.a2aAllowedKey ? { authorization: `Bearer ${config.a2aAllowedKey}` } : {}),
|
||||
},
|
||||
}).catch(() => null);
|
||||
if (!res?.ok) return undefined;
|
||||
const json = await res.json().catch(() => null);
|
||||
return isRecord(json) ? json : undefined;
|
||||
}
|
||||
|
||||
function mergeUniqueSkills(existing: unknown, incoming: unknown): string[] {
|
||||
const seen = new Set<string>();
|
||||
const merged: string[] = [];
|
||||
for (const skill of [...stringArray(existing), ...stringArray(incoming)]) {
|
||||
const key = skill.toLowerCase();
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
merged.push(skill);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function resolveGrowUserContext(req: Request, userId: string): Promise<Record<string, unknown>> {
|
||||
const { userProfile } = await getUserServiceProfile(req);
|
||||
const userContext: Record<string, unknown> = { ...(userProfile ?? {}) };
|
||||
userContext.clerk_id = String(userContext.clerk_id ?? userId);
|
||||
|
||||
const clerkId = String(userContext.clerk_id || userId);
|
||||
const [resumeState, socialState] = await Promise.all([
|
||||
getServiceState(config.resumeServiceUrl, `/api/state/${encodeURIComponent(clerkId)}`),
|
||||
getServiceState(config.socialBrandingServiceUrl, `/api/state/${encodeURIComponent(clerkId)}`),
|
||||
]);
|
||||
|
||||
if (resumeState) {
|
||||
const resumeSkills = resumeState.skills ?? [
|
||||
...stringArray(resumeState.technical_skills),
|
||||
...stringArray(resumeState.soft_skills),
|
||||
];
|
||||
userContext.skills = mergeUniqueSkills(userContext.skills, resumeSkills);
|
||||
if (resumeState.current_role && !userContext.current_role) userContext.current_role = resumeState.current_role;
|
||||
if (resumeState.current_company && !userContext.current_company) userContext.current_company = resumeState.current_company;
|
||||
if (Array.isArray(resumeState.experience_history)) userContext.experience_history = resumeState.experience_history;
|
||||
if (Array.isArray(resumeState.education)) userContext.education = resumeState.education;
|
||||
}
|
||||
|
||||
if (socialState) {
|
||||
userContext.skills = mergeUniqueSkills(userContext.skills, socialState.skills ?? socialState.linkedin_skills);
|
||||
if (socialState.headline) userContext.linkedin_headline = socialState.headline;
|
||||
if (socialState.summary) userContext.linkedin_summary = socialState.summary;
|
||||
if (Array.isArray(socialState.experience)) userContext.linkedin_experience = socialState.experience;
|
||||
userContext.linkedin_connected = Boolean(socialState.linkedin_connected);
|
||||
}
|
||||
|
||||
return userContext;
|
||||
}
|
||||
|
||||
function hasResumeContext(userContext: Record<string, unknown>): boolean {
|
||||
return Boolean(
|
||||
(Array.isArray(userContext.experience_history) && userContext.experience_history.length > 0) ||
|
||||
(Array.isArray(userContext.skills) && userContext.skills.length > 0) ||
|
||||
getString(userContext.current_role)
|
||||
);
|
||||
}
|
||||
|
||||
function hasLinkedInContext(userContext: Record<string, unknown>): boolean {
|
||||
return Boolean(
|
||||
userContext.linkedin_connected ||
|
||||
getString(userContext.linkedin_headline) ||
|
||||
getString(userContext.linkedin_summary) ||
|
||||
(Array.isArray(userContext.linkedin_experience) && userContext.linkedin_experience.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function composeCandidateProfile(userContext: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
const currentRole = getString(userContext.current_role);
|
||||
const headline = getString(userContext.linkedin_headline);
|
||||
if (currentRole) lines.push(`Current role: ${currentRole}`);
|
||||
if (headline && headline.toLowerCase() !== currentRole?.toLowerCase()) lines.push(`LinkedIn headline: ${headline}`);
|
||||
|
||||
const skills = stringArray(userContext.skills);
|
||||
if (skills.length) lines.push(`Key skills: ${skills.slice(0, 12).join(", ")}`);
|
||||
|
||||
const roles: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addRole = (title?: unknown, company?: unknown) => {
|
||||
const roleTitle = String(title ?? "").trim();
|
||||
const roleCompany = String(company ?? "").trim();
|
||||
if (!roleTitle && !roleCompany) return;
|
||||
const key = `${roleTitle.toLowerCase()}|${roleCompany.toLowerCase()}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
roles.push(roleTitle && roleCompany ? `${roleTitle} at ${roleCompany}` : roleTitle || roleCompany);
|
||||
};
|
||||
for (const item of Array.isArray(userContext.experience_history) ? userContext.experience_history : []) {
|
||||
if (isRecord(item)) addRole(item.position ?? item.title, item.company);
|
||||
}
|
||||
for (const item of Array.isArray(userContext.linkedin_experience) ? userContext.linkedin_experience : []) {
|
||||
if (isRecord(item)) addRole(item.title ?? item.position, item.company);
|
||||
}
|
||||
if (roles.length) lines.push(`Experience: ${roles.slice(0, 6).join("; ")}`);
|
||||
|
||||
const education: string[] = [];
|
||||
for (const item of Array.isArray(userContext.education) ? userContext.education : []) {
|
||||
if (!isRecord(item)) continue;
|
||||
const degree = getString(item.degree);
|
||||
const field = getString(item.field_of_study);
|
||||
const institution = getString(item.institution ?? item.school);
|
||||
let piece = [degree, field].filter(Boolean).join(" ").trim();
|
||||
if (institution) piece = piece ? `${piece} — ${institution}` : institution;
|
||||
if (piece) education.push(piece);
|
||||
}
|
||||
if (education.length) lines.push(`Education: ${education.slice(0, 3).join("; ")}`);
|
||||
|
||||
const summary = getString(userContext.linkedin_summary);
|
||||
if (summary) lines.push(`Profile summary: ${summary.length > 300 ? `${summary.slice(0, 300).trimEnd()}…` : summary}`);
|
||||
|
||||
const text = lines.join("\n");
|
||||
return text.length > 1200 ? `${text.slice(0, 1200).trimEnd()}…` : text;
|
||||
}
|
||||
|
||||
async function buildPersonalizedConfigurePayload(req: Request, body: JsonObject, userId: string): Promise<JsonObject> {
|
||||
const { mission: _mission, ...rest } = body;
|
||||
const userContext = await resolveGrowUserContext(req, userId).catch((err) => {
|
||||
log.warn({ err, userId }, "failed to resolve Grow user context for interview configure");
|
||||
return {} as Record<string, unknown>;
|
||||
});
|
||||
const incomingContext = isRecord(rest.context) ? rest.context : {};
|
||||
const context: Record<string, unknown> = {
|
||||
...incomingContext,
|
||||
candidate_name: getString(incomingContext.candidate_name) ?? getString(userContext.first_name) ?? "",
|
||||
target_role: getString(incomingContext.target_role) ?? "",
|
||||
company_name: getString(incomingContext.company_name) ?? "",
|
||||
job_description: getString(incomingContext.job_description) ?? "",
|
||||
difficulty: getString(incomingContext.difficulty) ?? "medium",
|
||||
};
|
||||
|
||||
if (incomingContext.personalize) {
|
||||
const candidateProfile = composeCandidateProfile(userContext);
|
||||
if (candidateProfile) context.candidate_profile = candidateProfile;
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
user_id: String(rest.user_id ?? userId),
|
||||
org_id: String(rest.org_id ?? "growqr"),
|
||||
context,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildPersonalizedRoleplayConfigurePayload(req: Request, body: JsonObject, userId: string): Promise<JsonObject> {
|
||||
const { mission: _mission, ...rest } = body;
|
||||
const userContext = await resolveGrowUserContext(req, userId).catch((err) => {
|
||||
log.warn({ err, userId }, "failed to resolve Grow user context for roleplay configure");
|
||||
return {} as Record<string, unknown>;
|
||||
});
|
||||
const incomingMetadata = isRecord(rest.metadata) ? rest.metadata : {};
|
||||
const metadata: Record<string, unknown> = {
|
||||
...incomingMetadata,
|
||||
candidate_name: getString(incomingMetadata.candidate_name) ?? getString(userContext.first_name) ?? "",
|
||||
target_role: getString(incomingMetadata.target_role) ?? getString(userContext.current_role) ?? "General",
|
||||
candidate_role: getString(incomingMetadata.candidate_role) ?? getString(incomingMetadata.target_role) ?? getString(userContext.current_role) ?? "General",
|
||||
difficulty: getString(incomingMetadata.difficulty) ?? "medium",
|
||||
};
|
||||
|
||||
// Match the production orchestrator behavior: roleplay-service receives the
|
||||
// same enriched user context the websocket orchestrator attached to A2A tasks,
|
||||
// plus prompt-safe metadata that can personalize scenario planning/review.
|
||||
const candidateProfile = composeCandidateProfile(userContext);
|
||||
if (candidateProfile && (incomingMetadata.personalize ?? true)) {
|
||||
metadata.candidate_profile = candidateProfile;
|
||||
metadata.context_notes = getString(incomingMetadata.context_notes) ?? candidateProfile;
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
user_id: String(rest.user_id ?? userId),
|
||||
org_id: String(rest.org_id ?? "growqr"),
|
||||
metadata,
|
||||
qscore: (rest.qscore as JsonObject | undefined) ?? (isRecord(userContext.qscore) ? userContext.qscore : DEFAULT_QSCORE),
|
||||
user_context: userContext,
|
||||
};
|
||||
}
|
||||
|
||||
async function proxySocialRequest(req: Request, rest: string, userId: string) {
|
||||
const incoming = new URL(req.url);
|
||||
const normalizedRest = rest.replace(/^\/+/, "");
|
||||
@@ -194,6 +482,12 @@ export function serviceRoutes() {
|
||||
|
||||
app.get("/qscore/current", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
try {
|
||||
await ensureOnboardingBaselineQscore(userId, await getUserServicePreferences(c.req.raw));
|
||||
} catch (err) {
|
||||
log.warn({ err, userId }, "failed to seed onboarding Q Score baseline before current Q Score read");
|
||||
}
|
||||
|
||||
const [projection] = await db
|
||||
.select()
|
||||
.from(growQscoreProjectionState)
|
||||
@@ -252,15 +546,26 @@ export function serviceRoutes() {
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/interview/page-state", async (c) => c.json(await interviewService.pageState(c.get("userId"))));
|
||||
app.get("/interview/page-state", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const [state, userContext] = await Promise.all([
|
||||
interviewService.pageState(userId),
|
||||
resolveGrowUserContext(c.req.raw, userId).catch((err) => {
|
||||
log.warn({ err, userId }, "failed to resolve Grow user context for interview page-state");
|
||||
return {} as Record<string, unknown>;
|
||||
}),
|
||||
]);
|
||||
return c.json({
|
||||
...state,
|
||||
resume_available: hasResumeContext(userContext),
|
||||
linkedin_available: hasLinkedInContext(userContext),
|
||||
});
|
||||
});
|
||||
app.post("/interview/configure", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = await c.req.json<JsonObject>();
|
||||
const payload = {
|
||||
...body,
|
||||
user_id: String(body.user_id ?? userId),
|
||||
org_id: String(body.org_id ?? "growqr"),
|
||||
} satisfies JsonObject;
|
||||
const mission = missionFromRequest(c.req.raw, body);
|
||||
const payload = await buildPersonalizedConfigurePayload(c.req.raw, body, userId);
|
||||
const result = await interviewService.configure(payload);
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
await recordGatewayEvent({
|
||||
@@ -269,7 +574,7 @@ export function serviceRoutes() {
|
||||
type: "interview.configured",
|
||||
payload: { request: payload, result: resultObj },
|
||||
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id) },
|
||||
mission: missionFromBody(body),
|
||||
mission,
|
||||
}).catch((err) => log.warn({ err }, "failed to record interview configured event"));
|
||||
return c.json(result);
|
||||
});
|
||||
@@ -299,19 +604,29 @@ export function serviceRoutes() {
|
||||
});
|
||||
app.get("/interview/leaderboard", async (c) => c.json(await interviewService.leaderboard()));
|
||||
app.get("/interview/artifacts/:sessionId/:artifactType", async (c) => c.json(await interviewService.artifact(c.req.param("sessionId"), c.req.param("artifactType"))));
|
||||
app.post("/interview/sessions/:sessionId/video/upload-url", async (c) => c.json(await interviewService.createVideoUploadUrl(c.req.param("sessionId"), await c.req.json<JsonObject>())));
|
||||
app.post("/interview/sessions/:sessionId/video/uploaded", async (c) => c.json(await interviewService.markVideoUploaded(c.req.param("sessionId"), await c.req.json<JsonObject>())));
|
||||
app.post("/interview/sessions/:sessionId/video/upload-url", async (c) => c.json(await interviewService.createVideoUploadUrl(c.req.param("sessionId"))));
|
||||
app.post("/interview/sessions/:sessionId/video/uploaded", async (c) => c.json(await interviewService.markVideoUploaded(c.req.param("sessionId"))));
|
||||
|
||||
app.get("/roleplay/page-state", async (c) => c.json(await roleplayService.pageState(c.get("userId"))));
|
||||
app.get("/roleplay/page-state", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const [state, userContext] = await Promise.all([
|
||||
roleplayService.pageState(userId),
|
||||
resolveGrowUserContext(c.req.raw, userId).catch((err) => {
|
||||
log.warn({ err, userId }, "failed to resolve Grow user context for roleplay page-state");
|
||||
return {} as Record<string, unknown>;
|
||||
}),
|
||||
]);
|
||||
return c.json({
|
||||
...state,
|
||||
resume_available: hasResumeContext(userContext),
|
||||
linkedin_available: hasLinkedInContext(userContext),
|
||||
});
|
||||
});
|
||||
app.post("/roleplay/configure", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = await c.req.json<JsonObject>();
|
||||
const payload = {
|
||||
...body,
|
||||
user_id: String(body.user_id ?? userId),
|
||||
org_id: String(body.org_id ?? "growqr"),
|
||||
qscore: (body.qscore as JsonObject | undefined) ?? DEFAULT_QSCORE,
|
||||
} satisfies JsonObject;
|
||||
const mission = missionFromRequest(c.req.raw, body);
|
||||
const payload = await buildPersonalizedRoleplayConfigurePayload(c.req.raw, body, userId);
|
||||
const result = await roleplayService.configure(payload);
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
await recordGatewayEvent({
|
||||
@@ -320,7 +635,7 @@ export function serviceRoutes() {
|
||||
type: "roleplay.configured",
|
||||
payload: { request: payload, result: resultObj },
|
||||
correlation: { sessionId: getSessionId(resultObj), requestId: getString(body.request_id) },
|
||||
mission: missionFromBody(body),
|
||||
mission,
|
||||
}).catch((err) => log.warn({ err }, "failed to record roleplay configured event"));
|
||||
return c.json(result);
|
||||
});
|
||||
@@ -350,8 +665,8 @@ export function serviceRoutes() {
|
||||
});
|
||||
app.get("/roleplay/leaderboard", async (c) => c.json(await roleplayService.leaderboard()));
|
||||
app.get("/roleplay/artifacts/:sessionId/:artifactType", async (c) => c.json(await roleplayService.artifact(c.req.param("sessionId"), c.req.param("artifactType"))));
|
||||
app.post("/roleplay/sessions/:sessionId/video/upload-url", async (c) => c.json(await roleplayService.createVideoUploadUrl(c.req.param("sessionId"), await c.req.json<JsonObject>())));
|
||||
app.post("/roleplay/sessions/:sessionId/video/uploaded", async (c) => c.json(await roleplayService.markVideoUploaded(c.req.param("sessionId"), await c.req.json<JsonObject>())));
|
||||
app.post("/roleplay/sessions/:sessionId/video/upload-url", async (c) => c.json(await roleplayService.createVideoUploadUrl(c.req.param("sessionId"))));
|
||||
app.post("/roleplay/sessions/:sessionId/video/uploaded", async (c) => c.json(await roleplayService.markVideoUploaded(c.req.param("sessionId"))));
|
||||
|
||||
app.get("/resume/state/:clerkId", async (c) => c.json(await resumeService.state(c.req.param("clerkId"))));
|
||||
app.post("/resume/tasks", async (c) => {
|
||||
@@ -364,7 +679,9 @@ export function serviceRoutes() {
|
||||
// resumes, templates, uploads, parsing, exports, cover letters, versions,
|
||||
// restore, primary selection, and AI endpoints.
|
||||
app.all("/resume/*", async (c) => {
|
||||
const rest = c.req.path.split("/resume/")[1] ?? "";
|
||||
const marker = "/resume/";
|
||||
const markerIndex = c.req.path.indexOf(marker);
|
||||
const rest = markerIndex >= 0 ? c.req.path.slice(markerIndex + marker.length) : "";
|
||||
return proxyResumeRequest(c.req.raw, rest, c.get("userId"));
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { eq } from "drizzle-orm";
|
||||
import { provisionUserStack } from "../docker/manager.js";
|
||||
import { log } from "../log.js";
|
||||
import { config } from "../config.js";
|
||||
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
|
||||
|
||||
function publicStack(stack: UserStack | null | undefined) {
|
||||
if (!stack) return stack;
|
||||
@@ -17,7 +18,7 @@ function userServiceTarget(path: string, search = "") {
|
||||
return new URL(`/api/v1/users${path}${search}`, config.userServiceUrl.replace(/\/$/, ""));
|
||||
}
|
||||
|
||||
async function proxyUserService(req: Request, path: string) {
|
||||
async function fetchUserService(req: Request, path: string) {
|
||||
const incoming = new URL(req.url);
|
||||
const target = userServiceTarget(path, incoming.search);
|
||||
const headers = new Headers(req.headers);
|
||||
@@ -26,7 +27,11 @@ async function proxyUserService(req: Request, path: string) {
|
||||
|
||||
const method = req.method.toUpperCase();
|
||||
const body = ["GET", "HEAD"].includes(method) ? undefined : await req.arrayBuffer();
|
||||
const res = await fetch(target, { method, headers, body });
|
||||
return fetch(target, { method, headers, body });
|
||||
}
|
||||
|
||||
async function proxyUserService(req: Request, path: string) {
|
||||
const res = await fetchUserService(req, path);
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
@@ -89,7 +94,31 @@ export function userRoutes() {
|
||||
});
|
||||
|
||||
app.get("/me", async (c) => proxyUserService(c.req.raw, "/me"));
|
||||
app.patch("/me", async (c) => proxyUserService(c.req.raw, "/me"));
|
||||
app.patch("/me", async (c) => {
|
||||
const res = await fetchUserService(c.req.raw, "/me");
|
||||
const text = await res.text();
|
||||
|
||||
if (res.ok) {
|
||||
try {
|
||||
const userProfile = JSON.parse(text) as Record<string, unknown>;
|
||||
const preferences = userProfile.preferences;
|
||||
await ensureOnboardingBaselineQscore(
|
||||
c.get("userId"),
|
||||
preferences && typeof preferences === "object" && !Array.isArray(preferences)
|
||||
? (preferences as Record<string, unknown>)
|
||||
: undefined,
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn({ err, userId: c.get("userId") }, "failed to seed onboarding Q Score baseline after user update");
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(text, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: res.headers,
|
||||
});
|
||||
});
|
||||
app.get("/me/plan", async (c) => proxyUserService(c.req.raw, "/me/plan"));
|
||||
app.post("/me/photo", async (c) => proxyUserService(c.req.raw, "/me/photo"));
|
||||
app.delete("/me/photo", async (c) => proxyUserService(c.req.raw, "/me/photo"));
|
||||
|
||||
@@ -6,8 +6,12 @@ export type ServiceCallOptions = {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_SERVICE_TIMEOUT_MS ?? 3500);
|
||||
const INTERACTIVE_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS ?? 120000);
|
||||
|
||||
async function serviceJson<T = JsonObject>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
@@ -21,6 +25,7 @@ async function serviceJson<T = JsonObject>(
|
||||
...(opts.headers ?? {}),
|
||||
},
|
||||
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
||||
signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_SERVICE_TIMEOUT_MS),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`${path} returned HTTP ${res.status}: ${text}`);
|
||||
@@ -30,12 +35,12 @@ async function serviceJson<T = JsonObject>(
|
||||
export const interviewService = {
|
||||
health: () => serviceJson(config.interviewServiceUrl, "/health"),
|
||||
pageState: (userId: string) => serviceJson(config.interviewServiceUrl, `/api/v1/interviews/page-state?${new URLSearchParams({ user_id: userId })}`),
|
||||
configure: (payload: JsonObject) => serviceJson(config.interviewServiceUrl, "/api/v1/configure", { body: payload }),
|
||||
preview: (payload: JsonObject) => serviceJson(config.interviewServiceUrl, "/api/v1/configure/preview", { body: payload }),
|
||||
configure: (payload: JsonObject) => serviceJson(config.interviewServiceUrl, "/api/v1/configure", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
preview: (payload: JsonObject) => serviceJson(config.interviewServiceUrl, "/api/v1/configure/preview", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
editQuestions: (payload: { session_id: string; questions: Array<JsonObject | string> }) =>
|
||||
serviceJson(config.interviewServiceUrl, "/api/v1/configure/questions", { body: payload }),
|
||||
serviceJson(config.interviewServiceUrl, "/api/v1/configure/questions", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
approve: (sessionId: string) =>
|
||||
serviceJson(config.interviewServiceUrl, "/api/v1/configure/approve", { body: { session_id: sessionId } }),
|
||||
serviceJson(config.interviewServiceUrl, "/api/v1/configure/approve", { body: { session_id: sessionId }, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
createAssignments: (payload: { organization_id: string; role: string; round: "warm_up" | "behavioral" | "technical"; assignee_emails: string[] }) =>
|
||||
serviceJson(config.interviewServiceUrl, "/api/v1/interviews/assignments", { body: payload }),
|
||||
listAssignments: (email: string, status = "pending", limit = 20) =>
|
||||
@@ -48,21 +53,27 @@ export const interviewService = {
|
||||
leaderboard: () => serviceJson(config.interviewServiceUrl, "/api/v1/leaderboard"),
|
||||
artifact: (sessionId: string, artifactType: string) =>
|
||||
serviceJson(config.interviewServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`),
|
||||
createVideoUploadUrl: (sessionId: string, payload: JsonObject) =>
|
||||
serviceJson(config.interviewServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/upload-url`, { body: payload }),
|
||||
markVideoUploaded: (sessionId: string, payload: JsonObject) =>
|
||||
serviceJson(config.interviewServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, { body: payload }),
|
||||
createVideoUploadUrl: (sessionId: string, payload?: JsonObject) =>
|
||||
serviceJson(config.interviewServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/upload-url`, {
|
||||
method: "POST",
|
||||
...(payload === undefined ? {} : { body: payload }),
|
||||
}),
|
||||
markVideoUploaded: (sessionId: string, payload?: JsonObject) =>
|
||||
serviceJson(config.interviewServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, {
|
||||
method: "POST",
|
||||
...(payload === undefined ? {} : { body: payload }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const roleplayService = {
|
||||
health: () => serviceJson(config.roleplayServiceUrl, "/health"),
|
||||
pageState: (userId: string) => serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/page-state?${new URLSearchParams({ user_id: userId })}`),
|
||||
configure: (payload: JsonObject) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure", { body: payload }),
|
||||
preview: (payload: JsonObject) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/preview", { body: payload }),
|
||||
configure: (payload: JsonObject) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
preview: (payload: JsonObject) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/preview", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
editQuestions: (payload: { session_id: string; questions: Array<JsonObject | string> }) =>
|
||||
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/questions", { body: payload }),
|
||||
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/questions", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
approve: (sessionId: string) =>
|
||||
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/approve", { body: { session_id: sessionId } }),
|
||||
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/approve", { body: { session_id: sessionId }, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
createAssignments: (payload: { organization_id: string; name: string; scenario: string; assignee_emails: string[] }) =>
|
||||
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/assignments", { body: payload }),
|
||||
listAssignments: (email: string, status = "pending", limit = 20) =>
|
||||
@@ -75,10 +86,16 @@ export const roleplayService = {
|
||||
leaderboard: () => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/leaderboard"),
|
||||
artifact: (sessionId: string, artifactType: string) =>
|
||||
serviceJson(config.roleplayServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`),
|
||||
createVideoUploadUrl: (sessionId: string, payload: JsonObject) =>
|
||||
serviceJson(config.roleplayServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/upload-url`, { body: payload }),
|
||||
markVideoUploaded: (sessionId: string, payload: JsonObject) =>
|
||||
serviceJson(config.roleplayServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, { body: payload }),
|
||||
createVideoUploadUrl: (sessionId: string, payload?: JsonObject) =>
|
||||
serviceJson(config.roleplayServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/upload-url`, {
|
||||
method: "POST",
|
||||
...(payload === undefined ? {} : { body: payload }),
|
||||
}),
|
||||
markVideoUploaded: (sessionId: string, payload?: JsonObject) =>
|
||||
serviceJson(config.roleplayServiceUrl, `/api/v1/sessions/${encodeURIComponent(sessionId)}/video/uploaded`, {
|
||||
method: "POST",
|
||||
...(payload === undefined ? {} : { body: payload }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const resumeService = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { WorkflowDefinition } from "./types.js";
|
||||
import { displayLabelForExecution, displayLabelForService } from "../features/registry.js";
|
||||
|
||||
const serviceLabel = (serviceId: string) => displayLabelForService(serviceId) ?? serviceId;
|
||||
const planningLabel = displayLabelForExecution("opencode") ?? "Mission Planning";
|
||||
const planningLabel = displayLabelForExecution("manual") ?? "Mission Planner";
|
||||
|
||||
const commonInputs = [
|
||||
{ id: "goal", label: "Target outcome", type: "text", required: true },
|
||||
@@ -11,53 +11,151 @@ const commonInputs = [
|
||||
|
||||
export const workflowDefinitions: WorkflowDefinition[] = [
|
||||
{
|
||||
id: "interview-to-offer", version: "1.0.0", title: "Interview to Offer", shortTitle: "Interview to Offer",
|
||||
promise: "Turn a scheduled interview into a focused prep plan, practice sessions, and a readiness report.", segment: ["job-seekers", "interviewing"], urgency: "high", estimatedDuration: "2-5 days", priceTier: "starter", sku: "workflow_interview_to_offer", isPurchasable: true, isFreePreview: true,
|
||||
visual: { icon: "briefcase-business", color: "emerald", mascotAgentIds: ["interview", "roleplay", "qscore"] }, requiredInputs: commonInputs,
|
||||
id: "interview-to-offer",
|
||||
version: "2.0.0",
|
||||
title: "Interview-to-Offer Accelerator",
|
||||
shortTitle: "Interview to Offer",
|
||||
promise: "Prepare me for this specific interview and help me convert it into an offer.",
|
||||
segment: ["job-seekers", "interviewing", "urgent"],
|
||||
urgency: "high",
|
||||
estimatedDuration: "2-5 days",
|
||||
priceTier: "starter",
|
||||
sku: "workflow_interview_to_offer",
|
||||
isPurchasable: true,
|
||||
isFreePreview: true,
|
||||
visual: { icon: "briefcase-business", color: "emerald", mascotAgentIds: ["interview", "roleplay", "resume", "qscore"] },
|
||||
requiredInputs: commonInputs,
|
||||
modules: [
|
||||
{ id: "resume", title: "Resume fit scan", role: serviceLabel("resume-service"), description: "Analyze resume readiness for the target role.", execution: "service", service: "resume-service" },
|
||||
{ id: "interview-plan", title: "Interview prep plan", role: planningLabel, description: "Generate a prep plan and likely questions artifact.", execution: "opencode", promptPath: "prompts/workflows/interview-to-offer/interview-plan.md", artifactTypes: ["interview_plan"], approvalGateAfter: "review-plan" },
|
||||
{ id: "interview", title: "Mock interview", role: serviceLabel("interview-service"), description: "Create a real interview practice session.", execution: "service", service: "interview-service" },
|
||||
{ id: "roleplay", title: "Communication roleplay", role: serviceLabel("roleplay-service"), description: "Create a realistic roleplay session.", execution: "service", service: "roleplay-service" },
|
||||
{ id: "branding", title: "Social branding", role: serviceLabel("social-branding-service"), description: "Optimize LinkedIn and professional presence.", execution: "service", service: "social-branding-service" },
|
||||
{ id: "matchmaking", title: "Matchmaking", role: serviceLabel("matchmaking-service"), description: "Connect with mentors and opportunities.", execution: "service", service: "matchmaking-service" },
|
||||
{ id: "qscore", title: "Readiness Q Score", role: serviceLabel("qscore-service"), description: "Compute readiness score.", execution: "service", service: "qscore-service" },
|
||||
{ id: "plan", title: "Interview prep plan", role: planningLabel, description: "Clarify target role, company context, likely questions, and prep priorities.", execution: "manual" },
|
||||
{ id: "resume", title: "Resume-based talking points", role: serviceLabel("resume-service"), description: "Extract role-fit proof, gaps, and tailored talking points from the resume.", execution: "service", service: "resume-service" },
|
||||
{ id: "interview", title: "Mock interview sessions", role: serviceLabel("interview-service"), description: "Run interview practice and collect weakness diagnosis.", execution: "service", service: "interview-service" },
|
||||
{ id: "roleplay", title: "Behavioral/story bank practice", role: serviceLabel("roleplay-service"), description: "Practice concise stories, objections, and communication recovery.", execution: "service", service: "roleplay-service" },
|
||||
{ id: "qscore", title: "Final readiness score", role: serviceLabel("qscore-service"), description: "Continuously score readiness from resume, interview, and roleplay signals.", execution: "service", service: "qscore-service" },
|
||||
],
|
||||
outputs: [{ id: "interview_plan", type: "markdown", title: "Interview prep plan", path: "artifacts/interview-to-offer/interview-plan.md" }], qscoreDimensions: ["clarity", "communication", "role_fit"], approvalGates: [{ id: "review-plan", title: "Review prep plan", description: "User reviews generated plan before practice.", required: false }],
|
||||
outputs: [
|
||||
{ id: "interview_prep_plan", type: "markdown", title: "Interview prep plan" },
|
||||
{ id: "story_bank", type: "markdown", title: "Behavioral/story bank" },
|
||||
{ id: "readiness_report", type: "scorecard", title: "Final readiness score" },
|
||||
],
|
||||
qscoreDimensions: ["role_fit", "communication", "confidence"],
|
||||
approvalGates: [],
|
||||
},
|
||||
{
|
||||
id: "career-transition", version: "1.0.0", title: "Switch Careers", shortTitle: "Switch Careers", promise: "Map transferable abilities and produce a transition narrative.", segment: ["career-changers"], urgency: "medium", estimatedDuration: "1-2 weeks", priceTier: "starter", sku: "workflow_career_transition", isPurchasable: true, isFreePreview: true, visual: { icon: "route", color: "blue", mascotAgentIds: ["resume", "qscore"] }, requiredInputs: commonInputs,
|
||||
id: "career-transition",
|
||||
version: "2.0.0",
|
||||
title: "Career Transition Accelerator",
|
||||
shortTitle: "Career Transition",
|
||||
promise: "Help me reposition from my current career into a better-fit role.",
|
||||
segment: ["career-changers"],
|
||||
urgency: "medium",
|
||||
estimatedDuration: "1-2 weeks",
|
||||
priceTier: "starter",
|
||||
sku: "workflow_career_transition",
|
||||
isPurchasable: true,
|
||||
isFreePreview: true,
|
||||
visual: { icon: "route", color: "blue", mascotAgentIds: ["resume", "interview", "roleplay", "qscore"] },
|
||||
requiredInputs: commonInputs,
|
||||
modules: [
|
||||
{ id: "transition-map", title: "Transition map", role: planningLabel, description: "Generate skills map and positioning narrative.", execution: "opencode", promptPath: "prompts/workflows/career-transition/orchestrator.md", artifactTypes: ["transition_map"] },
|
||||
{ id: "resume", title: "Resume fit scan", role: serviceLabel("resume-service"), description: "Analyze resume for target path.", execution: "service", service: "resume-service" },
|
||||
{ id: "branding", title: "Social branding", role: serviceLabel("social-branding-service"), description: "Build transition narrative on LinkedIn.", execution: "service", service: "social-branding-service" },
|
||||
{ id: "matchmaking", title: "Matchmaking", role: serviceLabel("matchmaking-service"), description: "Connect with mentors in the target field.", execution: "service", service: "matchmaking-service" },
|
||||
], outputs: [{ id: "transition_map", type: "markdown", title: "Transition map" }], qscoreDimensions: ["positioning", "skills", "confidence"], approvalGates: [],
|
||||
{ id: "clarify-target", title: "Target role recommendation", role: planningLabel, description: "Clarify current role, target role, constraints, and transition thesis.", execution: "manual" },
|
||||
{ id: "resume", title: "Transferable skills map", role: serviceLabel("resume-service"), description: "Map transferable skills and reposition resume proof for the target lane.", execution: "service", service: "resume-service" },
|
||||
{ id: "interview", title: "Adjacent-role interview narrative", role: serviceLabel("interview-service"), description: "Validate credibility for the new role through mock interview practice.", execution: "service", service: "interview-service" },
|
||||
{ id: "roleplay", title: "Why I am switching practice", role: serviceLabel("roleplay-service"), description: "Practice the transition pitch and hard follow-up questions.", execution: "service", service: "roleplay-service" },
|
||||
{ id: "qscore", title: "Transition readiness delta", role: serviceLabel("qscore-service"), description: "Track readiness gains and the next missing proof.", execution: "service", service: "qscore-service" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "target_role_recommendation", type: "markdown", title: "Target role recommendation" },
|
||||
{ id: "transition_plan", type: "markdown", title: "30/60/90 transition plan" },
|
||||
],
|
||||
qscoreDimensions: ["positioning", "transferable_skills", "confidence"],
|
||||
approvalGates: [],
|
||||
},
|
||||
{
|
||||
id: "salary-negotiation-war-room", version: "1.0.0", title: "Negotiate Salary", shortTitle: "Negotiate Salary", promise: "Prepare scripts, ranges, and roleplay for an offer conversation.", segment: ["offer-stage"], urgency: "high", estimatedDuration: "24-72 hours", priceTier: "premium", sku: "workflow_salary_negotiation", isPurchasable: true, isFreePreview: false, visual: { icon: "badge-dollar-sign", color: "amber", mascotAgentIds: ["roleplay"] }, requiredInputs: commonInputs,
|
||||
id: "salary-negotiation-war-room",
|
||||
version: "2.0.0",
|
||||
title: "Salary / Offer Negotiation War Room",
|
||||
shortTitle: "Negotiation War Room",
|
||||
promise: "Help me negotiate my offer, raise, or promotion conversation.",
|
||||
segment: ["offer-stage", "employed"],
|
||||
urgency: "high",
|
||||
estimatedDuration: "24-72 hours",
|
||||
priceTier: "premium",
|
||||
sku: "workflow_salary_negotiation",
|
||||
isPurchasable: true,
|
||||
isFreePreview: false,
|
||||
visual: { icon: "badge-dollar-sign", color: "amber", mascotAgentIds: ["roleplay", "resume", "qscore"] },
|
||||
requiredInputs: commonInputs,
|
||||
modules: [
|
||||
{ id: "negotiation-script", title: "Negotiation script", role: planningLabel, description: "Generate negotiation strategy and scripts.", execution: "opencode", promptPath: "prompts/workflows/salary-negotiation-war-room/orchestrator.md", artifactTypes: ["negotiation_script"] },
|
||||
{ id: "roleplay", title: "Negotiation roleplay", role: serviceLabel("roleplay-service"), description: "Create offer negotiation roleplay.", execution: "service", service: "roleplay-service" },
|
||||
{ id: "matchmaking", title: "Matchmaking", role: serviceLabel("matchmaking-service"), description: "Connect with mentors who navigated similar negotiations.", execution: "service", service: "matchmaking-service" },
|
||||
], outputs: [{ id: "negotiation_script", type: "markdown", title: "Negotiation script" }], qscoreDimensions: ["voice", "confidence", "strategy"], approvalGates: [],
|
||||
{ id: "offer-context", title: "Offer analysis", role: planningLabel, description: "Capture offer, target range, leverage, constraints, and deadline.", execution: "manual" },
|
||||
{ id: "resume", title: "Value evidence map", role: serviceLabel("resume-service"), description: "Extract measurable impact and proof points from resume history.", execution: "service", service: "resume-service" },
|
||||
{ id: "roleplay", title: "Live-call negotiation practice", role: serviceLabel("roleplay-service"), description: "Practice counteroffer, objection handling, and calm assertiveness.", execution: "service", service: "roleplay-service" },
|
||||
{ id: "interview", title: "Confidence signal check", role: serviceLabel("interview-service"), description: "Use interview signals when available to assess communication confidence.", execution: "service", service: "interview-service" },
|
||||
{ id: "qscore", title: "Confidence score", role: serviceLabel("qscore-service"), description: "Track negotiation confidence and communication readiness.", execution: "service", service: "qscore-service" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "counteroffer_script", type: "markdown", title: "Counteroffer script" },
|
||||
{ id: "objection_map", type: "markdown", title: "Objection handling map" },
|
||||
],
|
||||
qscoreDimensions: ["confidence", "value_evidence", "communication"],
|
||||
approvalGates: [],
|
||||
},
|
||||
{
|
||||
id: "promotion-readiness", version: "1.0.0", title: "Get Promoted", shortTitle: "Get Promoted", promise: "Build an evidence packet and manager conversation plan.", segment: ["employed"], urgency: "medium", estimatedDuration: "1 week", priceTier: "starter", sku: "workflow_promotion_readiness", isPurchasable: true, isFreePreview: true, visual: { icon: "trending-up", color: "purple", mascotAgentIds: ["roleplay", "qscore"] }, requiredInputs: commonInputs,
|
||||
id: "promotion-readiness",
|
||||
version: "2.0.0",
|
||||
title: "Promotion & Leadership Readiness System",
|
||||
shortTitle: "Promotion Readiness",
|
||||
promise: "Help me become promotion-ready and make a strong case for my next level.",
|
||||
segment: ["employed"],
|
||||
urgency: "medium",
|
||||
estimatedDuration: "1 week",
|
||||
priceTier: "starter",
|
||||
sku: "workflow_promotion_readiness",
|
||||
isPurchasable: true,
|
||||
isFreePreview: true,
|
||||
visual: { icon: "trending-up", color: "purple", mascotAgentIds: ["roleplay", "resume", "interview", "qscore"] },
|
||||
requiredInputs: commonInputs,
|
||||
modules: [
|
||||
{ id: "evidence-packet", title: "Evidence packet", role: planningLabel, description: "Generate promotion evidence packet.", execution: "opencode", promptPath: "prompts/workflows/promotion-readiness/orchestrator.md", artifactTypes: ["promotion_packet"] },
|
||||
{ id: "roleplay", title: "Manager conversation roleplay", role: serviceLabel("roleplay-service"), description: "Practice the promotion conversation.", execution: "service", service: "roleplay-service" },
|
||||
{ id: "branding", title: "Social branding", role: serviceLabel("social-branding-service"), description: "Showcase promotion-worthy impact on LinkedIn.", execution: "service", service: "social-branding-service" },
|
||||
{ id: "matchmaking", title: "Matchmaking", role: serviceLabel("matchmaking-service"), description: "Connect with senior leaders for sponsorship.", execution: "service", service: "matchmaking-service" },
|
||||
], outputs: [{ id: "promotion_packet", type: "markdown", title: "Promotion evidence packet" }], qscoreDimensions: ["impact", "leadership", "communication"], approvalGates: [],
|
||||
{ id: "promotion-context", title: "Promotion target", role: planningLabel, description: "Clarify desired level, timeline, stakeholders, and manager context.", execution: "manual" },
|
||||
{ id: "resume", title: "Achievement evidence packet", role: serviceLabel("resume-service"), description: "Extract impact bullets and leadership proof from resume history.", execution: "service", service: "resume-service" },
|
||||
{ id: "roleplay", title: "Manager conversation script", role: serviceLabel("roleplay-service"), description: "Practice the promotion conversation with objections and follow-ups.", execution: "service", service: "roleplay-service" },
|
||||
{ id: "interview", title: "Leadership gap practice", role: serviceLabel("interview-service"), description: "Practice leadership narratives and detect communication gaps.", execution: "service", service: "interview-service" },
|
||||
{ id: "qscore", title: "Leadership readiness score", role: serviceLabel("qscore-service"), description: "Track readiness and confidence trend.", execution: "service", service: "qscore-service" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "promotion_packet", type: "markdown", title: "Promotion evidence packet" },
|
||||
{ id: "manager_script", type: "markdown", title: "Manager conversation script" },
|
||||
],
|
||||
qscoreDimensions: ["impact", "leadership", "communication"],
|
||||
approvalGates: [],
|
||||
},
|
||||
{
|
||||
id: "personal-brand-opportunity-engine", version: "1.0.0", title: "Build Your Brand", shortTitle: "Build Your Brand", promise: "Draft profile positioning and a weekly opportunity/content plan.", segment: ["networking", "creators"], urgency: "low", estimatedDuration: "1 week", priceTier: "starter", sku: "workflow_brand_engine", isPurchasable: true, isFreePreview: true, visual: { icon: "sparkles", color: "pink", mascotAgentIds: ["qscore"] }, requiredInputs: commonInputs,
|
||||
id: "personal-brand-opportunity-engine",
|
||||
version: "2.0.0",
|
||||
title: "Personal Brand & Opportunity Engine",
|
||||
shortTitle: "Brand Engine",
|
||||
promise: "Make me visible and credible so better opportunities come to me.",
|
||||
segment: ["networking", "creators", "job-seekers"],
|
||||
urgency: "low",
|
||||
estimatedDuration: "1 week",
|
||||
priceTier: "starter",
|
||||
sku: "workflow_brand_engine",
|
||||
isPurchasable: true,
|
||||
isFreePreview: true,
|
||||
visual: { icon: "sparkles", color: "pink", mascotAgentIds: ["resume", "roleplay", "qscore"] },
|
||||
requiredInputs: commonInputs,
|
||||
modules: [
|
||||
{ id: "profile-rewrite", title: "Profile rewrite", role: planningLabel, description: "Generate LinkedIn/profile rewrite draft.", execution: "opencode", promptPath: "prompts/workflows/personal-brand-opportunity-engine/orchestrator.md", artifactTypes: ["profile_rewrite", "content_plan"] },
|
||||
{ id: "branding", title: "Social branding", role: serviceLabel("social-branding-service"), description: "Optimize profile and content strategy.", execution: "service", service: "social-branding-service" },
|
||||
{ id: "matchmaking", title: "Matchmaking", role: serviceLabel("matchmaking-service"), description: "Connect with collaborators and brand amplifiers.", execution: "service", service: "matchmaking-service" },
|
||||
], outputs: [{ id: "profile_rewrite", type: "markdown", title: "Profile rewrite" }, { id: "content_plan", type: "markdown", title: "Weekly content plan" }], qscoreDimensions: ["visibility", "network", "voice"], approvalGates: [],
|
||||
{ id: "positioning", title: "Positioning statement", role: planningLabel, description: "Clarify target audience, positioning, and credibility theme.", execution: "manual" },
|
||||
{ id: "resume", title: "Proof point extraction", role: serviceLabel("resume-service"), description: "Turn resume proof into brand themes and profile claims.", execution: "service", service: "resume-service" },
|
||||
{ id: "roleplay", title: "Networking scripts", role: serviceLabel("roleplay-service"), description: "Practice intros, outreach, and opportunity conversations.", execution: "service", service: "roleplay-service" },
|
||||
{ id: "interview", title: "Credibility signal mining", role: serviceLabel("interview-service"), description: "Use interview strengths and gaps as brand signal when available.", execution: "service", service: "interview-service" },
|
||||
{ id: "qscore", title: "Brand growth score", role: serviceLabel("qscore-service"), description: "Track visibility/readiness signals from practice and profile proof.", execution: "service", service: "qscore-service" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "profile_rewrite", type: "markdown", title: "LinkedIn/profile rewrite" },
|
||||
{ id: "content_pillars", type: "markdown", title: "Content pillars" },
|
||||
{ id: "weekly_post_drafts", type: "markdown", title: "Weekly post drafts" },
|
||||
],
|
||||
qscoreDimensions: ["visibility", "credibility", "voice"],
|
||||
approvalGates: [],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user