feat: add missions actor, routes, features, workflow registry updates, and DB schema migration
This commit is contained in:
15
drizzle/0005_mission_registry.sql
Normal file
15
drizzle/0005_mission_registry.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS "mission_registry" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"version" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"short_title" text NOT NULL,
|
||||
"actor_type" text,
|
||||
"actor_backed" boolean DEFAULT false NOT NULL,
|
||||
"skill_path" text NOT NULL,
|
||||
"display_order" integer NOT NULL,
|
||||
"definition" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "mission_registry_display_idx" ON "mission_registry" ("display_order");
|
||||
@@ -36,6 +36,13 @@
|
||||
"when": 1780306900000,
|
||||
"tag": "0004_qscore_snapshots",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1780307000000,
|
||||
"tag": "0005_mission_registry",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
7
src/actors/missions/career-transition-actor.ts
Normal file
7
src/actors/missions/career-transition-actor.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createMissionActor } from "./mission-actor-factory.js";
|
||||
|
||||
export const careerTransitionMissionActor = createMissionActor({
|
||||
missionId: "career-transition",
|
||||
name: "Career Transition Mission",
|
||||
icon: "route",
|
||||
});
|
||||
7
src/actors/missions/career-transition/SKILL.md
Normal file
7
src/actors/missions/career-transition/SKILL.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Career Transition Sprint Mission
|
||||
|
||||
Durable state holder for the `career-transition` mission. This actor is intentionally non-agentic: the Grow conversation layer drives coaching and calls actor actions to update state, stages, artifacts, and events.
|
||||
|
||||
## Stages
|
||||
- Transition map
|
||||
- Resume fit scan
|
||||
6
src/actors/missions/index.ts
Normal file
6
src/actors/missions/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { interviewToOfferMissionActor } from "./interview-to-offer-actor.js";
|
||||
export { careerTransitionMissionActor } from "./career-transition-actor.js";
|
||||
export { salaryNegotiationWarRoomMissionActor } from "./salary-negotiation-war-room-actor.js";
|
||||
export { promotionReadinessMissionActor } from "./promotion-readiness-actor.js";
|
||||
export { personalBrandOpportunityEngineMissionActor } from "./personal-brand-opportunity-engine-actor.js";
|
||||
export type * from "./types.js";
|
||||
242
src/actors/missions/interview-to-offer-actor.ts
Normal file
242
src/actors/missions/interview-to-offer-actor.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
41
src/actors/missions/interview-to-offer/SKILL.md
Normal file
41
src/actors/missions/interview-to-offer/SKILL.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: interview-to-offer
|
||||
displayName: Interview-to-Offer Accelerator
|
||||
version: 1.0.0
|
||||
actor: interviewToOfferMissionActor
|
||||
---
|
||||
|
||||
# Interview-to-Offer Accelerator
|
||||
|
||||
## Promise
|
||||
Prepare the user for a specific interview and help convert it into an offer.
|
||||
|
||||
## Mission Contract
|
||||
This mission is not an autonomous agent. The conversation layer (Grow) owns the coaching interaction. This actor owns only durable mission state: stages, progress, artifacts, and event history.
|
||||
|
||||
## Inputs
|
||||
- Target company and role
|
||||
- Interview date or urgency window
|
||||
- Resume/profile context
|
||||
- Biggest concern or blocker
|
||||
|
||||
## Stages
|
||||
1. Resume fit scan
|
||||
2. Interview prep plan
|
||||
3. Mock interview session
|
||||
4. Communication roleplay
|
||||
5. Final readiness Q Score
|
||||
|
||||
## Artifacts
|
||||
- Interview prep plan
|
||||
- Likely questions
|
||||
- Behavioral/story bank
|
||||
- Resume-based talking points
|
||||
- Weakness diagnosis
|
||||
- Final readiness score
|
||||
|
||||
## Conversation Handoff Rules
|
||||
- Grow can read the mission state before answering next-step questions.
|
||||
- Grow can update stages only when a user action, specialist output, or artifact justifies it.
|
||||
- Grow should ask for approval before marking a major artifact approved.
|
||||
- Grow should keep the tone warm and practical.
|
||||
249
src/actors/missions/mission-actor-factory.ts
Normal file
249
src/actors/missions/mission-actor-factory.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createMissionActor } from "./mission-actor-factory.js";
|
||||
|
||||
export const personalBrandOpportunityEngineMissionActor = createMissionActor({
|
||||
missionId: "personal-brand-opportunity-engine",
|
||||
name: "Personal Brand Opportunity Engine Mission",
|
||||
icon: "sparkles",
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
# Personal Brand Opportunity Engine Mission
|
||||
|
||||
Durable state holder for the `personal-brand-opportunity-engine` mission. This actor is intentionally non-agentic: the Grow conversation layer drives coaching and calls actor actions to update state, stages, artifacts, and events.
|
||||
|
||||
## Stages
|
||||
- Profile rewrite
|
||||
7
src/actors/missions/promotion-readiness-actor.ts
Normal file
7
src/actors/missions/promotion-readiness-actor.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createMissionActor } from "./mission-actor-factory.js";
|
||||
|
||||
export const promotionReadinessMissionActor = createMissionActor({
|
||||
missionId: "promotion-readiness",
|
||||
name: "Promotion Readiness Mission",
|
||||
icon: "trending-up",
|
||||
});
|
||||
7
src/actors/missions/promotion-readiness/SKILL.md
Normal file
7
src/actors/missions/promotion-readiness/SKILL.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Promotion Readiness Packet Mission
|
||||
|
||||
Durable state holder for the `promotion-readiness` mission. This actor is intentionally non-agentic: the Grow conversation layer drives coaching and calls actor actions to update state, stages, artifacts, and events.
|
||||
|
||||
## Stages
|
||||
- Evidence packet
|
||||
- Manager conversation roleplay
|
||||
7
src/actors/missions/salary-negotiation-war-room-actor.ts
Normal file
7
src/actors/missions/salary-negotiation-war-room-actor.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createMissionActor } from "./mission-actor-factory.js";
|
||||
|
||||
export const salaryNegotiationWarRoomMissionActor = createMissionActor({
|
||||
missionId: "salary-negotiation-war-room",
|
||||
name: "Salary Negotiation War Room Mission",
|
||||
icon: "badge-dollar-sign",
|
||||
});
|
||||
7
src/actors/missions/salary-negotiation-war-room/SKILL.md
Normal file
7
src/actors/missions/salary-negotiation-war-room/SKILL.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Salary Negotiation War Room Mission
|
||||
|
||||
Durable state holder for the `salary-negotiation-war-room` mission. This actor is intentionally non-agentic: the Grow conversation layer drives coaching and calls actor actions to update state, stages, artifacts, and events.
|
||||
|
||||
## Stages
|
||||
- Negotiation script
|
||||
- Negotiation roleplay
|
||||
109
src/actors/missions/types.ts
Normal file
109
src/actors/missions/types.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { WorkflowDefinition } from "../../workflows/types.js";
|
||||
|
||||
export type MissionId =
|
||||
| "interview-to-offer"
|
||||
| "career-transition"
|
||||
| "salary-negotiation-war-room"
|
||||
| "promotion-readiness"
|
||||
| "personal-brand-opportunity-engine";
|
||||
|
||||
export type MissionInstanceStatus = "draft" | "active" | "paused" | "completed" | "cancelled";
|
||||
export type MissionStageStatus = "locked" | "ready" | "in_progress" | "blocked" | "done";
|
||||
export type MissionArtifactStatus = "draft" | "ready" | "approved" | "archived";
|
||||
|
||||
export type MissionStage = {
|
||||
id: string;
|
||||
title: string;
|
||||
role: string;
|
||||
description: string;
|
||||
status: MissionStageStatus;
|
||||
progressPercent: number;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
outputSummary?: string;
|
||||
};
|
||||
|
||||
export type MissionArtifact = {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
status: MissionArtifactStatus;
|
||||
summary?: string;
|
||||
contentMd?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type MissionEvent = {
|
||||
id: string;
|
||||
type: string;
|
||||
message: string;
|
||||
payload?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type MissionSnapshot = {
|
||||
instanceId: string;
|
||||
missionId: MissionId;
|
||||
workflowId: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
shortTitle: string;
|
||||
promise: string;
|
||||
status: MissionInstanceStatus;
|
||||
goal?: string;
|
||||
input: Record<string, unknown>;
|
||||
progressPercent: number;
|
||||
currentStageId?: string;
|
||||
stages: MissionStage[];
|
||||
artifacts: MissionArtifact[];
|
||||
events: MissionEvent[];
|
||||
qscoreBefore?: Record<string, unknown>;
|
||||
qscoreAfter?: Record<string, unknown>;
|
||||
skillVersion: string;
|
||||
workflowVersion: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
};
|
||||
|
||||
export type MissionStartInput = {
|
||||
userId: string;
|
||||
instanceId: string;
|
||||
missionId: MissionId;
|
||||
goal?: string;
|
||||
input?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type MissionActorType =
|
||||
| "interviewToOfferMissionActor"
|
||||
| "careerTransitionMissionActor"
|
||||
| "salaryNegotiationWarRoomMissionActor"
|
||||
| "promotionReadinessMissionActor"
|
||||
| "personalBrandOpportunityEngineMissionActor";
|
||||
|
||||
export type MissionRegistryEntry = WorkflowDefinition & {
|
||||
missionId: MissionId;
|
||||
kind: "mission";
|
||||
actorType?: MissionActorType;
|
||||
actorBacked: boolean;
|
||||
skillVersion: string;
|
||||
skillPath: string;
|
||||
displayOrder: number;
|
||||
};
|
||||
|
||||
export type GrowActiveMission = {
|
||||
instanceId: string;
|
||||
missionId: MissionId;
|
||||
workflowId: string;
|
||||
title: string;
|
||||
shortTitle: string;
|
||||
status: MissionInstanceStatus;
|
||||
progressPercent: number;
|
||||
currentStageId?: string;
|
||||
goal?: string;
|
||||
actorType?: MissionActorType;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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
95
src/features/registry.ts
Normal 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;
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
56
src/missions/postgres-registry.ts
Normal file
56
src/missions/postgres-registry.ts
Normal 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
52
src/missions/registry.ts
Normal 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]);
|
||||
}
|
||||
@@ -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
216
src/routes/missions.ts
Normal 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;
|
||||
}
|
||||
@@ -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: [],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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"],
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user