feat: add missions actor, routes, features, workflow registry updates, and DB schema migration

This commit is contained in:
-Puter
2026-06-03 17:52:48 +05:30
parent a1654d23b4
commit 5c480ce90f
27 changed files with 1412 additions and 40 deletions

View File

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

View File

@@ -36,6 +36,13 @@
"when": 1780306900000,
"tag": "0004_qscore_snapshots",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1780307000000,
"tag": "0005_mission_registry",
"breakpoints": true
}
]
}

View File

@@ -1,5 +1,6 @@
import { actor, event } from "rivetkit";
import type { CreateConversationInput, GrowActorState, GrowConversation, SetupGrowInput } from "./types.js";
import type { GrowActiveMission } from "../missions/types.js";
const buildId = (prefix: string) =>
`${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
@@ -14,6 +15,14 @@ function ensureInitialized(state: GrowActorState) {
if (!state.userId) throw new Error("Grow actor is not initialized");
}
function normalizeState(state: GrowActorState) {
state.conversations ??= [];
state.activeConversationId ??= null;
state.activeMissions ??= [];
state.createdAt ??= now();
state.updatedAt ??= now();
}
function createConversationRecord(state: GrowActorState, input: CreateConversationInput = {}): GrowConversation {
const timestamp = now();
return {
@@ -29,6 +38,7 @@ export const growActor = actor({
userId: "",
conversations: [],
activeConversationId: null,
activeMissions: [],
createdAt: Date.now(),
updatedAt: Date.now(),
} as GrowActorState,
@@ -37,6 +47,8 @@ export const growActor = actor({
ready: event<{ userId: string; conversationId: string }>(),
conversationCreated: event<GrowConversation>(),
conversationReset: event<GrowConversation>(),
missionActivated: event<GrowActiveMission>(),
missionUpdated: event<GrowActiveMission>(),
},
actions: {
@@ -45,6 +57,7 @@ export const growActor = actor({
throw new Error("Grow actor already bound to a different user");
}
normalizeState(c.state);
c.state.userId = input.userId;
if (!c.state.conversations.length) {
const conversation = createConversationRecord(c.state, { title: "Talk to Me" });
@@ -68,19 +81,23 @@ export const growActor = actor({
userId: c.state.userId,
activeConversationId: c.state.activeConversationId,
conversations: c.state.conversations,
activeMissions: c.state.activeMissions,
};
},
getState: async (c) => {
normalizeState(c.state);
ensureInitialized(c.state);
return {
userId: c.state.userId,
activeConversationId: c.state.activeConversationId,
conversations: c.state.conversations,
activeMissions: c.state.activeMissions,
};
},
createConversation: async (c, input: CreateConversationInput = {}) => {
normalizeState(c.state);
ensureInitialized(c.state);
const conversation = createConversationRecord(c.state, input);
c.state.conversations.unshift(conversation);
@@ -91,6 +108,7 @@ export const growActor = actor({
},
listConversations: async (c) => {
normalizeState(c.state);
ensureInitialized(c.state);
return c.state.conversations;
},
@@ -132,5 +150,44 @@ export const growActor = actor({
c.broadcast("conversationReset", conversation);
return conversation;
},
registerActiveMission: async (c, input: GrowActiveMission) => {
normalizeState(c.state);
ensureInitialized(c.state);
const existingIndex = c.state.activeMissions.findIndex((mission) => mission.instanceId === input.instanceId);
const record = { ...input, updatedAt: now() };
if (existingIndex >= 0) {
c.state.activeMissions[existingIndex] = record;
c.broadcast("missionUpdated", record);
} else {
c.state.activeMissions.unshift(record);
c.broadcast("missionActivated", record);
}
c.state.updatedAt = record.updatedAt;
return record;
},
updateActiveMission: async (c, input: Pick<GrowActiveMission, "instanceId"> & Partial<GrowActiveMission>) => {
normalizeState(c.state);
ensureInitialized(c.state);
const mission = c.state.activeMissions.find((item) => item.instanceId === input.instanceId);
if (!mission) throw new Error(`Unknown active mission: ${input.instanceId}`);
Object.assign(mission, input, { updatedAt: now() });
c.state.updatedAt = mission.updatedAt;
c.broadcast("missionUpdated", mission);
return mission;
},
listActiveMissions: async (c) => {
normalizeState(c.state);
ensureInitialized(c.state);
return c.state.activeMissions;
},
getActiveMission: async (c, input: { instanceId: string }) => {
normalizeState(c.state);
ensureInitialized(c.state);
return c.state.activeMissions.find((mission) => mission.instanceId === input.instanceId) ?? null;
},
},
});

View File

@@ -1,3 +1,5 @@
import type { GrowActiveMission } from "../missions/types.js";
export type GrowConversation = {
id: string;
title: string;
@@ -9,6 +11,7 @@ export type GrowActorState = {
userId: string;
conversations: GrowConversation[];
activeConversationId: string | null;
activeMissions: GrowActiveMission[];
createdAt: number;
updatedAt: number;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,12 +5,24 @@ import { interviewServiceActor, resumeServiceActor, roleplayServiceActor } from
import { conversationActor } from "./conversation/index.js";
import { memoryActor } from "./memory/index.js";
import { growActor } from "./grow/index.js";
import {
careerTransitionMissionActor,
interviewToOfferMissionActor,
personalBrandOpportunityEngineMissionActor,
promotionReadinessMissionActor,
salaryNegotiationWarRoomMissionActor,
} from "./missions/index.js";
export const registry = setup({
use: {
growActor,
conversationActor,
memoryActor,
interviewToOfferMissionActor,
careerTransitionMissionActor,
salaryNegotiationWarRoomMissionActor,
promotionReadinessMissionActor,
personalBrandOpportunityEngineMissionActor,
userActor,
workflowRunActor,
interviewServiceActor,

View File

@@ -4,6 +4,7 @@ import {
text,
timestamp,
integer,
boolean,
jsonb,
uniqueIndex,
index,
@@ -171,6 +172,27 @@ export type UserStack = typeof userStacks.$inferSelect;
export type NewUserStack = typeof userStacks.$inferInsert;
export type ActorRow = typeof actors.$inferSelect;
export type RepoRow = typeof repos.$inferSelect;
export const missionRegistry = pgTable(
"mission_registry",
{
id: text("id").primaryKey(),
version: text("version").notNull(),
title: text("title").notNull(),
shortTitle: text("short_title").notNull(),
actorType: text("actor_type"),
actorBacked: boolean("actor_backed").notNull().default(false),
skillPath: text("skill_path").notNull(),
displayOrder: integer("display_order").notNull(),
definition: jsonb("definition").$type<Record<string, unknown>>().notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({ displayIdx: index("mission_registry_display_idx").on(t.displayOrder) }),
);
export type MissionRegistryRow = typeof missionRegistry.$inferSelect;
export const workflowRuns = pgTable(
"workflow_runs",
{

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

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

View File

@@ -15,6 +15,7 @@ import { chatRoutes } from "./routes/chat.js";
import { serviceRoutes } from "./routes/services.js";
import { conversationRoutes } from "./routes/conversations.js";
import { growRoutes } from "./routes/grow.js";
import { missionRoutes } from "./routes/missions.js";
import { db } from "./db/client.js";
import { hydratePortAllocator, reconcileOnBoot, ensureCentralGiteaReady } from "./docker/manager.js";
import { initCatalog } from "./agents/catalog.js";
@@ -79,6 +80,7 @@ async function main() {
app.route("/workflow-runs", workflowRunRoutes());
app.route("/actors", actorRoutes());
app.route("/grow", growRoutes());
app.route("/missions", missionRoutes());
app.route("/conversations", conversationRoutes());
app.route("/opencode", opencodeRoutes());
app.route("/git", gitRoutes());

View File

@@ -0,0 +1,56 @@
import { asc, eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { missionRegistry } from "../db/schema.js";
import type { MissionRegistryEntry } from "../actors/missions/types.js";
import { getMissionDefinition, listMissionDefinitions } from "./registry.js";
function toRow(mission: MissionRegistryEntry) {
return {
id: mission.missionId,
version: mission.version,
title: mission.title,
shortTitle: mission.shortTitle,
actorType: mission.actorType,
actorBacked: mission.actorBacked,
skillPath: mission.skillPath,
displayOrder: mission.displayOrder,
definition: mission as unknown as Record<string, unknown>,
updatedAt: new Date(),
};
}
export async function seedMissionRegistry() {
const missions = listMissionDefinitions();
for (const mission of missions) {
const row = toRow(mission);
await db
.insert(missionRegistry)
.values(row)
.onConflictDoUpdate({
target: missionRegistry.id,
set: {
version: row.version,
title: row.title,
shortTitle: row.shortTitle,
actorType: row.actorType,
actorBacked: row.actorBacked,
skillPath: row.skillPath,
displayOrder: row.displayOrder,
definition: row.definition,
updatedAt: row.updatedAt,
},
});
}
}
export async function listPersistedMissionDefinitions(): Promise<MissionRegistryEntry[]> {
await seedMissionRegistry();
const rows = await db.select().from(missionRegistry).orderBy(asc(missionRegistry.displayOrder));
return rows.map((row) => row.definition as unknown as MissionRegistryEntry);
}
export async function getPersistedMissionDefinition(missionId: string): Promise<MissionRegistryEntry | undefined> {
await seedMissionRegistry();
const [row] = await db.select().from(missionRegistry).where(eq(missionRegistry.id, missionId)).limit(1);
return row ? (row.definition as unknown as MissionRegistryEntry) : getMissionDefinition(missionId);
}

52
src/missions/registry.ts Normal file
View File

@@ -0,0 +1,52 @@
import { getWorkflowDefinition, listWorkflowDefinitions } from "../workflows/registry.js";
import type { MissionActorType, MissionId, MissionRegistryEntry } from "../actors/missions/types.js";
const MISSION_ORDER: MissionId[] = [
"interview-to-offer",
"career-transition",
"salary-negotiation-war-room",
"promotion-readiness",
"personal-brand-opportunity-engine",
];
const ACTOR_BACKED_MISSIONS: Record<MissionId, MissionActorType> = {
"interview-to-offer": "interviewToOfferMissionActor",
"career-transition": "careerTransitionMissionActor",
"salary-negotiation-war-room": "salaryNegotiationWarRoomMissionActor",
"promotion-readiness": "promotionReadinessMissionActor",
"personal-brand-opportunity-engine": "personalBrandOpportunityEngineMissionActor",
};
export function listMissionDefinitions(): MissionRegistryEntry[] {
return MISSION_ORDER.map((missionId, index) => {
const workflow = getWorkflowDefinition(missionId);
if (!workflow) throw new Error(`Mission workflow definition not found: ${missionId}`);
const actorType = ACTOR_BACKED_MISSIONS[missionId];
return {
...workflow,
kind: "mission",
missionId,
actorType,
actorBacked: Boolean(actorType),
skillVersion: workflow.version,
skillPath: `src/actors/missions/${missionId}/SKILL.md`,
displayOrder: index + 1,
};
});
}
export function getMissionDefinition(missionId: string): MissionRegistryEntry | undefined {
return listMissionDefinitions().find((mission) => mission.missionId === missionId);
}
export function listAvailableMissionDefinitions(): MissionRegistryEntry[] {
const ids = new Set(MISSION_ORDER);
return listWorkflowDefinitions()
.filter((workflow) => ids.has(workflow.id as MissionId))
.map((workflow) => getMissionDefinition(workflow.id))
.filter((mission): mission is MissionRegistryEntry => Boolean(mission));
}
export function isActorBackedMission(missionId: string): missionId is MissionId {
return Boolean(ACTOR_BACKED_MISSIONS[missionId as MissionId]);
}

View File

@@ -6,7 +6,8 @@ import { config } from "../config.js";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import type { Registry } from "../actors/registry.js";
import { getConversationModel } from "../actors/conversation/agent.js";
import { listWorkflowDefinitions } from "../workflows/registry.js";
import { getMissionDefinition, isActorBackedMission, listMissionDefinitions } from "../missions/registry.js";
import type { GrowActiveMission, MissionActorType, MissionSnapshot } from "../actors/missions/types.js";
import { getSubAgentModules } from "../lib/prompt-loader.js";
let _client: Client<Registry> | null = null;
@@ -26,8 +27,20 @@ function memoryFor(userId: string) {
return getClient().memoryActor.getOrCreate([userId]);
}
function workflowFor(userId: string) {
return getClient().userActor.getOrCreate([userId]);
function missionActorFor(userId: string, instanceId: string, actorType: MissionActorType) {
const client = getClient();
switch (actorType) {
case "interviewToOfferMissionActor":
return client.interviewToOfferMissionActor.getOrCreate([userId, instanceId]);
case "careerTransitionMissionActor":
return client.careerTransitionMissionActor.getOrCreate([userId, instanceId]);
case "salaryNegotiationWarRoomMissionActor":
return client.salaryNegotiationWarRoomMissionActor.getOrCreate([userId, instanceId]);
case "promotionReadinessMissionActor":
return client.promotionReadinessMissionActor.getOrCreate([userId, instanceId]);
case "personalBrandOpportunityEngineMissionActor":
return client.personalBrandOpportunityEngineMissionActor.getOrCreate([userId, instanceId]);
}
}
const createConversationSchema = z.object({ title: z.string().optional() });
@@ -36,6 +49,38 @@ const streamSchema = z.object({
conversationId: z.string().optional(),
});
const createMissionInstanceId = (missionId: string) =>
`${missionId}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
function actorTypeForMission(missionId: string): MissionActorType | undefined {
if (missionId === "interview-to-offer") return "interviewToOfferMissionActor";
if (missionId === "career-transition") return "careerTransitionMissionActor";
if (missionId === "salary-negotiation-war-room") return "salaryNegotiationWarRoomMissionActor";
if (missionId === "promotion-readiness") return "promotionReadinessMissionActor";
if (missionId === "personal-brand-opportunity-engine") return "personalBrandOpportunityEngineMissionActor";
return undefined;
}
function activeMissionFromSnapshot(snapshot: MissionSnapshot): GrowActiveMission {
const actorType = actorTypeForMission(snapshot.missionId);
if (!actorType) throw new Error(`Mission actor not registered for: ${snapshot.missionId}`);
return {
instanceId: snapshot.instanceId,
missionId: snapshot.missionId,
workflowId: snapshot.workflowId,
title: snapshot.title,
shortTitle: snapshot.shortTitle,
status: snapshot.status,
progressPercent: snapshot.progressPercent,
currentStageId: snapshot.currentStageId,
goal: snapshot.goal,
actorType,
createdAt: new Date(snapshot.createdAt).getTime(),
updatedAt: new Date(snapshot.updatedAt).getTime(),
};
}
function textFromMessage(message: UIMessage | undefined) {
if (!message) return "";
return message.parts
@@ -45,6 +90,30 @@ function textFromMessage(message: UIMessage | undefined) {
.trim();
}
function forcedToolForPrompt(text: string) {
const lower = text.toLowerCase();
// Keep high-value dashboard UI predictable: when the user explicitly asks to
// see/discover/find workflows, always produce workflow cards instead of a
// text-only answer.
if (
/\b(discover|find|show|list|recommend|suggest|compare|what|which)\b/.test(lower) &&
/\b(workflow|workflows|plan|plans|program|programs)\b/.test(lower)
) {
return "discoverWorkflows" as const;
}
if (/\b(mission|missions|next actions|next steps|what should i do today)\b/.test(lower)) {
return "showMissions" as const;
}
if (/\b(agent registry|specialists|subagents|coaches|who can help)\b/.test(lower)) {
return "listAgentRegistry" as const;
}
return undefined;
}
function buildSystemPrompt() {
return `You are Grow, the warm, encouraging career companion inside GrowQR. You are part coach, part strategist, and part cheerleader.
@@ -68,11 +137,19 @@ How to help:
5. SPECIALISTS — Use listAgentRegistry to show available specialists, then askSubAgent to route focused questions to Sara (interview), Emily (roleplay), Quinn (Q Score), or Rhea (resume).
6. BE HONEST — If you don't know something, say so. If a tool fails, acknowledge it and try a different approach.
Visual-first product behavior:
- GrowQR users prefer visual cards and guided choices over long text. They are often scanning, not studying.
- For product objects — missions/workflows, next actions, active workflow state, specialists/coaches, memory lists/searches — prefer calling the relevant UI tool so the dashboard can render cards.
- After a tool call, add only a short human summary: one warm sentence or 1-3 bullets. Let the visual UI carry most of the information.
- Do not use tools for casual conversation, emotional support, simple explanations, or when direct text is clearly better.
Tool usage rules:
- Only call tools when they genuinely help the user. Don't call tools for every response.
- If the user asks about their memory, use searchMemory or readMemory first.
- If the user asks about missions, use discoverWorkflows or inspectWorkflowRegistry.
- If the user wants interview prep, salary negotiation, or resume help, route to the right specialist via askSubAgent.
- Use tools when they materially improve comprehension, navigation, or action-taking.
- If the user asks to see/find/discover/recommend/compare missions or workflows, call discoverWorkflows first, then summarize briefly.
- If the user asks "what should I do next", "next steps", "today", or "missions", call showMissions.
- If the user asks about their memory, use searchMemory, readMemory, listMemory, or memoryStats first.
- If the user asks about specialists, coaches, interview prep, roleplay, Q Score, or resume help, use listAgentRegistry and/or askSubAgent when helpful.
- Only start missions/workflows with startWorkflow when the user clearly agrees to start one.
- When you write memory, confirm what you saved: "Got it — I've saved that you're targeting PM roles at Stripe and Notion."
- When you start a mission, celebrate: "🎉 Let's do this! Starting your Interview-to-Offer mission now."`;
}
@@ -138,14 +215,14 @@ Return compact, bullet-heavy markdown.`,
function buildConversationTools(userId: string) {
return {
discoverWorkflows: tool({
description: "Return sellable GrowQR missions as UI-ready discovery cards.",
description: "Return sellable GrowQR missions as rich UI-ready discovery cards. Use whenever the user asks to see, find, discover, compare, choose, recommend, or understand missions/workflows.",
inputSchema: z.object({
segment: z.string().optional().describe("Optional user segment or goal to filter/rank by."),
}),
execute: async ({ segment }) => ({
kind: "workflow-discovery",
segment,
workflows: listWorkflowDefinitions().map((workflow) => ({
workflows: listMissionDefinitions().map((workflow) => ({
id: workflow.id,
title: workflow.title,
shortTitle: workflow.shortTitle,
@@ -165,12 +242,12 @@ function buildConversationTools(userId: string) {
}),
inspectWorkflowRegistry: tool({
description: "Inspect the GrowQR mission registry with module-level detail.",
description: "Inspect the GrowQR mission registry with module-level detail as UI cards. Use for deeper mission/module questions after discovery or when the user asks what a mission includes.",
inputSchema: z.object({
workflowId: z.string().optional(),
}),
execute: async ({ workflowId }) => {
const workflows = listWorkflowDefinitions()
const workflows = listMissionDefinitions()
.filter((workflow) => !workflowId || workflow.id === workflowId)
.map((workflow) => ({
id: workflow.id,
@@ -231,7 +308,7 @@ function buildConversationTools(userId: string) {
}),
showMissions: tool({
description: "Return UI-ready career missions for the user's dashboard.",
description: "Return UI-ready career mission cards for the user's dashboard. Use when the user asks what to do next, asks for priorities, asks for missions, or seems stuck.",
inputSchema: z.object({
focus: z.string().optional(),
}),
@@ -267,22 +344,47 @@ function buildConversationTools(userId: string) {
startWorkflow: tool({
description: "Start one of the GrowQR missions for this user. Only call this when the user explicitly agrees to start a mission.",
inputSchema: z.object({
workflowId: z.string().describe("Workflow id, e.g. interview-to-offer."),
workflowId: z.string().describe("Mission/workflow id, e.g. interview-to-offer."),
goal: z.string().optional().describe("Optional goal text to personalize the mission."),
}),
execute: async ({ workflowId, goal }) => {
try {
const handle = workflowFor(userId);
await handle.init({ userId });
const state = await handle.startWorkflow({ workflowId, goal });
const workflow = listWorkflowDefinitions().find((w) => w.id === workflowId);
const mission = getMissionDefinition(workflowId);
if (!mission) throw new Error(`Unknown mission: ${workflowId}`);
if (!isActorBackedMission(workflowId)) {
return {
kind: "workflow-started",
workflowId,
goal,
status: "coming_soon",
title: mission.title,
error: "This mission is in the registry but its actor is not implemented yet.",
};
}
if (!mission.actorType) throw new Error(`Mission actor not registered for: ${workflowId}`);
const instanceId = createMissionInstanceId(workflowId);
const snapshot = await missionActorFor(userId, instanceId, mission.actorType).init({
userId,
instanceId,
missionId: workflowId,
goal,
input: {},
});
const grow = growFor(userId);
await grow.setup({ userId });
const activeMission = await grow.registerActiveMission(activeMissionFromSnapshot(snapshot));
return {
kind: "workflow-started",
workflowId,
missionId: workflowId,
instanceId,
goal,
status: state.workflowStatus,
runId: state.workflowRunId,
title: workflow?.title ?? workflowId,
status: activeMission.status,
progressPercent: activeMission.progressPercent,
title: mission.title,
snapshot,
};
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to start mission";
@@ -292,20 +394,32 @@ function buildConversationTools(userId: string) {
goal,
status: "error",
error: errorMessage,
title: listWorkflowDefinitions().find((w) => w.id === workflowId)?.title ?? workflowId,
title: listMissionDefinitions().find((w) => w.id === workflowId)?.title ?? workflowId,
};
}
},
}),
getActiveWorkflow: tool({
description: "Return the active mission snapshot for this user. Use this when the user asks 'what am I working on' or 'what's my current mission'.",
description: "Return the user's active mission snapshots. Use this when the user asks 'what am I working on' or 'what's my current mission'.",
inputSchema: z.object({}),
execute: async () => {
try {
const handle = workflowFor(userId);
await handle.init({ userId });
return { kind: "active-workflow", workflow: await handle.getWorkflowStatus() };
const grow = growFor(userId);
await grow.setup({ userId });
const missions = await grow.listActiveMissions();
const snapshots = await Promise.all(
missions.map(async (mission) => {
if (!mission.actorType) return null;
return missionActorFor(userId, mission.instanceId, mission.actorType).getState();
}),
);
return {
kind: "active-workflow",
workflow: snapshots.find(Boolean) ?? null,
missions,
snapshots: snapshots.filter(Boolean),
};
} catch (err) {
return { kind: "active-workflow", workflow: null, error: err instanceof Error ? err.message : "Could not fetch workflow" };
}
@@ -432,11 +546,13 @@ export function conversationRoutes() {
await conversation.addMessage({ role: "user", content: latestUserText, sender: "User" });
}
const visualTool = forcedToolForPrompt(latestUserText);
const result = streamText({
model: getConversationModel(),
system: buildSystemPrompt(),
messages: await convertToModelMessages(body.messages),
tools: buildConversationTools(userId),
toolChoice: visualTool ? { type: "tool", toolName: visualTool } : "auto",
stopWhen: stepCountIs(5),
});

216
src/routes/missions.ts Normal file
View File

@@ -0,0 +1,216 @@
import { Hono } from "hono";
import { z } from "zod";
import { createClient, type Client } from "rivetkit/client";
import { config } from "../config.js";
import { requireUser, type AuthContext } from "../auth/clerk.js";
import type { Registry } from "../actors/registry.js";
import type { GrowActiveMission, MissionActorType, MissionSnapshot } from "../actors/missions/types.js";
import { isActorBackedMission } from "../missions/registry.js";
import { getPersistedMissionDefinition, listPersistedMissionDefinitions } from "../missions/postgres-registry.js";
let _client: Client<Registry> | null = null;
function getClient(): Client<Registry> {
return (_client ??= createClient<Registry>(config.rivetClientEndpoint));
}
function growFor(userId: string) {
return getClient().growActor.getOrCreate([userId]);
}
function missionActorFor(userId: string, instanceId: string, actorType: MissionActorType) {
const client = getClient();
switch (actorType) {
case "interviewToOfferMissionActor":
return client.interviewToOfferMissionActor.getOrCreate([userId, instanceId]);
case "careerTransitionMissionActor":
return client.careerTransitionMissionActor.getOrCreate([userId, instanceId]);
case "salaryNegotiationWarRoomMissionActor":
return client.salaryNegotiationWarRoomMissionActor.getOrCreate([userId, instanceId]);
case "promotionReadinessMissionActor":
return client.promotionReadinessMissionActor.getOrCreate([userId, instanceId]);
case "personalBrandOpportunityEngineMissionActor":
return client.personalBrandOpportunityEngineMissionActor.getOrCreate([userId, instanceId]);
}
}
const startMissionSchema = z.object({
goal: z.string().min(1).optional(),
input: z.record(z.unknown()).optional(),
});
const updateStageSchema = z.object({
status: z.enum(["locked", "ready", "in_progress", "blocked", "done"]).optional(),
progressPercent: z.number().min(0).max(100).optional(),
outputSummary: z.string().optional(),
});
const addArtifactSchema = z.object({
type: z.string().min(1),
title: z.string().min(1),
status: z.enum(["draft", "ready", "approved", "archived"]).default("ready"),
summary: z.string().optional(),
contentMd: z.string().optional(),
metadata: z.record(z.unknown()).optional(),
});
const createInstanceId = (missionId: string) =>
`${missionId}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
function activeMissionFromSnapshot(snapshot: MissionSnapshot): GrowActiveMission {
const actorType = getPersistedActorType(snapshot.missionId);
if (!actorType) throw new Error(`Mission actor not registered for: ${snapshot.missionId}`);
return {
instanceId: snapshot.instanceId,
missionId: snapshot.missionId,
workflowId: snapshot.workflowId,
title: snapshot.title,
shortTitle: snapshot.shortTitle,
status: snapshot.status,
progressPercent: snapshot.progressPercent,
currentStageId: snapshot.currentStageId,
goal: snapshot.goal,
actorType,
createdAt: new Date(snapshot.createdAt).getTime(),
updatedAt: new Date(snapshot.updatedAt).getTime(),
};
}
function getPersistedActorType(missionId: string): MissionActorType | undefined {
if (missionId === "interview-to-offer") return "interviewToOfferMissionActor";
if (missionId === "career-transition") return "careerTransitionMissionActor";
if (missionId === "salary-negotiation-war-room") return "salaryNegotiationWarRoomMissionActor";
if (missionId === "promotion-readiness") return "promotionReadinessMissionActor";
if (missionId === "personal-brand-opportunity-engine") return "personalBrandOpportunityEngineMissionActor";
return undefined;
}
async function getMissionSnapshot(userId: string, active: GrowActiveMission): Promise<MissionSnapshot | null> {
if (!active.actorType) return null;
return missionActorFor(userId, active.instanceId, active.actorType).getState();
}
export function missionRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.get("/available", async (c) => {
const missions = await listPersistedMissionDefinitions();
return c.json({
missions: missions.map((mission) => ({
...mission,
startable: mission.actorBacked,
actorStatus: mission.actorBacked ? "ready" : "coming_soon",
})),
});
});
app.get("/active", async (c) => {
const userId = c.get("userId");
const grow = growFor(userId);
await grow.setup({ userId });
const activeMissions = await grow.listActiveMissions();
const snapshots = await Promise.all(activeMissions.map((mission) => getMissionSnapshot(userId, mission)));
return c.json({
missions: activeMissions,
snapshots: snapshots.filter((snapshot): snapshot is MissionSnapshot => Boolean(snapshot)),
});
});
app.get("/active/:instanceId", async (c) => {
const userId = c.get("userId");
const grow = growFor(userId);
await grow.setup({ userId });
const active = await grow.getActiveMission({ instanceId: c.req.param("instanceId") });
if (!active) return c.json({ error: "mission_not_found" }, 404);
const snapshot = await getMissionSnapshot(userId, active);
if (!snapshot) return c.json({ error: "mission_actor_not_available" }, 404);
return c.json({ mission: active, snapshot });
});
app.post("/:missionId/start", async (c) => {
const userId = c.get("userId");
const missionId = c.req.param("missionId");
const mission = await getPersistedMissionDefinition(missionId);
if (!mission) return c.json({ error: "mission_not_found" }, 404);
if (!isActorBackedMission(missionId)) {
return c.json({ error: "mission_actor_not_implemented", mission }, 409);
}
const body = startMissionSchema.parse(await c.req.json().catch(() => ({})));
const instanceId = createInstanceId(missionId);
if (!mission.actorType) return c.json({ error: "mission_actor_not_available", mission }, 409);
const actor = missionActorFor(userId, instanceId, mission.actorType);
const snapshot = await actor.init({
userId,
instanceId,
missionId,
goal: body.goal,
input: body.input ?? {},
});
const grow = growFor(userId);
await grow.setup({ userId });
const activeMission = await grow.registerActiveMission(activeMissionFromSnapshot(snapshot));
return c.json({ mission: activeMission, snapshot }, 201);
});
app.post("/active/:instanceId/stages/:stageId", async (c) => {
const userId = c.get("userId");
const grow = growFor(userId);
await grow.setup({ userId });
const active = await grow.getActiveMission({ instanceId: c.req.param("instanceId") });
if (!active) return c.json({ error: "mission_not_found" }, 404);
if (!active.actorType) return c.json({ error: "mission_actor_not_available" }, 404);
const body = updateStageSchema.parse(await c.req.json().catch(() => ({})));
const snapshot = await missionActorFor(userId, active.instanceId, active.actorType).updateStage({
stageId: c.req.param("stageId"),
...body,
});
await grow.updateActiveMission(activeMissionFromSnapshot(snapshot));
return c.json({ snapshot });
});
app.post("/active/:instanceId/artifacts", async (c) => {
const userId = c.get("userId");
const grow = growFor(userId);
await grow.setup({ userId });
const active = await grow.getActiveMission({ instanceId: c.req.param("instanceId") });
if (!active) return c.json({ error: "mission_not_found" }, 404);
if (!active.actorType) return c.json({ error: "mission_actor_not_available" }, 404);
const body = addArtifactSchema.parse(await c.req.json());
const actor = missionActorFor(userId, active.instanceId, active.actorType);
const artifact = await actor.addArtifact(body);
const snapshot = await actor.getState();
await grow.updateActiveMission(activeMissionFromSnapshot(snapshot));
return c.json({ artifact, snapshot }, 201);
});
app.post("/active/:instanceId/pause", async (c) => {
const userId = c.get("userId");
const grow = growFor(userId);
await grow.setup({ userId });
const active = await grow.getActiveMission({ instanceId: c.req.param("instanceId") });
if (!active) return c.json({ error: "mission_not_found" }, 404);
if (!active.actorType) return c.json({ error: "mission_actor_not_available" }, 404);
const snapshot = await missionActorFor(userId, active.instanceId, active.actorType).pause();
await grow.updateActiveMission(activeMissionFromSnapshot(snapshot));
return c.json({ snapshot });
});
app.post("/active/:instanceId/resume", async (c) => {
const userId = c.get("userId");
const grow = growFor(userId);
await grow.setup({ userId });
const active = await grow.getActiveMission({ instanceId: c.req.param("instanceId") });
if (!active) return c.json({ error: "mission_not_found" }, 404);
if (!active.actorType) return c.json({ error: "mission_actor_not_available" }, 404);
const snapshot = await missionActorFor(userId, active.instanceId, active.actorType).resume();
await grow.updateActiveMission(activeMissionFromSnapshot(snapshot));
return c.json({ snapshot });
});
return app;
}

View File

@@ -1,4 +1,8 @@
import type { WorkflowDefinition } from "./types.js";
import { displayLabelForExecution, displayLabelForService } from "../features/registry.js";
const serviceSkill = (serviceId: string) => displayLabelForService(serviceId) ?? serviceId;
const planningSkill = displayLabelForExecution("opencode") ?? "Mission Planning";
const commonInputs = [
{ id: "goal", label: "Target outcome", type: "text", required: true },
@@ -11,29 +15,29 @@ export const workflowDefinitions: WorkflowDefinition[] = [
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: ["sara", "emily", "qscore"] }, requiredInputs: commonInputs,
modules: [
{ id: "resume", title: "Resume fit scan", role: "Resume Agent", description: "Analyze resume readiness for the target role.", execution: "service", service: "resume-service" },
{ id: "interview-plan", title: "Interview prep plan", role: "OpenCode", 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: "sara", title: "Mock interview", role: "Sara", description: "Create a real interview practice session.", execution: "service", service: "interview-service" },
{ id: "emily", title: "Communication roleplay", role: "Emily", description: "Create a realistic roleplay session.", execution: "service", service: "roleplay-service" },
{ id: "qscore", title: "Readiness Q Score", role: "Quinn", description: "Compute readiness score.", execution: "service", service: "qscore-service" },
{ id: "resume", title: "Resume fit scan", role: serviceSkill("resume-service"), description: "Analyze resume readiness for the target role.", execution: "service", service: "resume-service" },
{ id: "interview-plan", title: "Interview prep plan", role: planningSkill, 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: "sara", title: "Mock interview", role: serviceSkill("interview-service"), description: "Create a real interview practice session.", execution: "service", service: "interview-service" },
{ id: "emily", title: "Communication roleplay", role: serviceSkill("roleplay-service"), description: "Create a realistic roleplay session.", execution: "service", service: "roleplay-service" },
{ id: "qscore", title: "Readiness Q Score", role: serviceSkill("qscore-service"), description: "Compute readiness score.", 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 }],
},
{
id: "career-transition", version: "1.0.0", title: "Career Transition Sprint", shortTitle: "Career Transition", promise: "Map transferable skills 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,
modules: [{ id: "transition-map", title: "Transition map", role: "OpenCode", 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: "Resume Agent", description: "Analyze resume for target path.", execution: "service", service: "resume-service" }], outputs: [{ id: "transition_map", type: "markdown", title: "Transition map" }], qscoreDimensions: ["positioning", "skills", "confidence"], approvalGates: [],
modules: [{ id: "transition-map", title: "Transition map", role: planningSkill, 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: serviceSkill("resume-service"), description: "Analyze resume for target path.", execution: "service", service: "resume-service" }], outputs: [{ id: "transition_map", type: "markdown", title: "Transition map" }], qscoreDimensions: ["positioning", "skills", "confidence"], approvalGates: [],
},
{
id: "salary-negotiation-war-room", version: "1.0.0", title: "Salary Negotiation War Room", shortTitle: "Negotiation", 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: ["emily"] }, requiredInputs: commonInputs,
modules: [{ id: "negotiation-script", title: "Negotiation script", role: "OpenCode", description: "Generate negotiation strategy and scripts.", execution: "opencode", promptPath: "prompts/workflows/salary-negotiation-war-room/orchestrator.md", artifactTypes: ["negotiation_script"] }, { id: "emily", title: "Negotiation roleplay", role: "Emily", description: "Create offer negotiation roleplay.", execution: "service", service: "roleplay-service" }], outputs: [{ id: "negotiation_script", type: "markdown", title: "Negotiation script" }], qscoreDimensions: ["voice", "confidence", "strategy"], approvalGates: [],
modules: [{ id: "negotiation-script", title: "Negotiation script", role: planningSkill, description: "Generate negotiation strategy and scripts.", execution: "opencode", promptPath: "prompts/workflows/salary-negotiation-war-room/orchestrator.md", artifactTypes: ["negotiation_script"] }, { id: "emily", title: "Negotiation roleplay", role: serviceSkill("roleplay-service"), description: "Create offer negotiation roleplay.", execution: "service", service: "roleplay-service" }], outputs: [{ id: "negotiation_script", type: "markdown", title: "Negotiation script" }], qscoreDimensions: ["voice", "confidence", "strategy"], approvalGates: [],
},
{
id: "promotion-readiness", version: "1.0.0", title: "Promotion Readiness Packet", shortTitle: "Promotion", 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: ["emily", "qscore"] }, requiredInputs: commonInputs,
modules: [{ id: "evidence-packet", title: "Evidence packet", role: "OpenCode", description: "Generate promotion evidence packet.", execution: "opencode", promptPath: "prompts/workflows/promotion-readiness/orchestrator.md", artifactTypes: ["promotion_packet"] }, { id: "emily", title: "Manager conversation roleplay", role: "Emily", description: "Practice the promotion conversation.", execution: "service", service: "roleplay-service" }], outputs: [{ id: "promotion_packet", type: "markdown", title: "Promotion evidence packet" }], qscoreDimensions: ["impact", "leadership", "communication"], approvalGates: [],
modules: [{ id: "evidence-packet", title: "Evidence packet", role: planningSkill, description: "Generate promotion evidence packet.", execution: "opencode", promptPath: "prompts/workflows/promotion-readiness/orchestrator.md", artifactTypes: ["promotion_packet"] }, { id: "emily", title: "Manager conversation roleplay", role: serviceSkill("roleplay-service"), description: "Practice the promotion conversation.", execution: "service", service: "roleplay-service" }], outputs: [{ id: "promotion_packet", type: "markdown", title: "Promotion evidence packet" }], qscoreDimensions: ["impact", "leadership", "communication"], approvalGates: [],
},
{
id: "personal-brand-opportunity-engine", version: "1.0.0", title: "Personal Brand Opportunity Engine", shortTitle: "Brand Engine", 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,
modules: [{ id: "profile-rewrite", title: "Profile rewrite", role: "OpenCode", description: "Generate LinkedIn/profile rewrite draft.", execution: "opencode", promptPath: "prompts/workflows/personal-brand-opportunity-engine/orchestrator.md", artifactTypes: ["profile_rewrite", "content_plan"] }], outputs: [{ id: "profile_rewrite", type: "markdown", title: "Profile rewrite" }, { id: "content_plan", type: "markdown", title: "Weekly content plan" }], qscoreDimensions: ["visibility", "network", "voice"], approvalGates: [],
modules: [{ id: "profile-rewrite", title: "Profile rewrite", role: planningSkill, description: "Generate LinkedIn/profile rewrite draft.", execution: "opencode", promptPath: "prompts/workflows/personal-brand-opportunity-engine/orchestrator.md", artifactTypes: ["profile_rewrite", "content_plan"] }], outputs: [{ id: "profile_rewrite", type: "markdown", title: "Profile rewrite" }, { id: "content_plan", type: "markdown", title: "Weekly content plan" }], qscoreDimensions: ["visibility", "network", "voice"], approvalGates: [],
},
];

View File

@@ -1,4 +1,4 @@
import { config } from "../config.js";
import { listFeatureDefinitions, internalWorkflowSkills } from "../features/registry.js";
export type ServiceCapability = {
id: string;
@@ -7,14 +7,27 @@ export type ServiceCapability = {
internalUrl?: string;
publicUrl?: string;
operations: string[];
featureId?: string;
promptModulePath?: string;
};
export function listServiceCapabilities(): ServiceCapability[] {
return [
{ id: "resume-service", name: "Resume Agent", enabled: Boolean(config.resumeServiceUrl), internalUrl: config.resumeServiceUrl, publicUrl: config.resumePublicUrl, operations: ["resume.state", "resume.templates", "resume.a2aTask", "resume.create", "resume.update", "resume.analyze", "resume.suggestions", "resume.copilot", "resume.optimizeSummary", "resume.optimizeExperience", "resume.suggestSkills", "resume.generateSummary", "resume.versions", "resume.preview"] },
{ id: "interview-service", name: "Sara Interview", enabled: Boolean(config.interviewServiceUrl), internalUrl: config.interviewServiceUrl, publicUrl: config.interviewPublicUrl, operations: ["interview.configure", "interview.preview", "interview.questions", "interview.approve", "interview.assignments", "interview.unassign", "interview.resultsBulk", "interview.review", "interview.leaderboard", "interview.artifacts", "interview.videoUpload", "interview.practice"] },
{ id: "roleplay-service", name: "Emily Roleplay", enabled: Boolean(config.roleplayServiceUrl), internalUrl: config.roleplayServiceUrl, publicUrl: config.roleplayPublicUrl, operations: ["roleplay.configure", "roleplay.preview", "roleplay.questions", "roleplay.approve", "roleplay.assignments", "roleplay.unassign", "roleplay.resultsBulk", "roleplay.review", "roleplay.leaderboard", "roleplay.artifacts", "roleplay.videoUpload", "roleplay.practice"] },
{ id: "qscore-service", name: "Quinn Q Score", enabled: Boolean(config.qscoreServiceUrl), internalUrl: config.qscoreServiceUrl, operations: ["qscore.ingest", "qscore.compute"] },
{ id: "opencode", name: "OpenCode Artifact Executor", enabled: true, operations: ["artifact.prepare", "artifact.generate"] },
...listFeatureDefinitions().map((feature) => ({
id: feature.serviceId,
name: feature.title,
enabled: feature.enabled,
internalUrl: feature.internalUrl,
publicUrl: feature.publicUrl,
operations: feature.operations,
featureId: feature.id,
promptModulePath: feature.promptModulePath,
})),
...internalWorkflowSkills.map((skill) => ({
id: skill.id,
name: skill.title,
enabled: true,
operations: ["artifact.prepare", "artifact.generate"],
})),
];
}