feat: add mission action queue runtime

This commit is contained in:
-Puter
2026-06-06 03:25:29 +05:30
parent 170d3583c6
commit bef6d08b6b
21 changed files with 1459 additions and 106 deletions

View File

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

View File

@@ -71,6 +71,13 @@
"when": 1780481400000,
"tag": "0009_mission_suggestions",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1780481500000,
"tag": "0010_mission_actions",
"breakpoints": true
}
]
}
}

View File

@@ -11,6 +11,7 @@ import { getProjectionInsight } from "../../events/projectors/projection-agent.j
import { markGrowEventFailed, markGrowEventProcessed, markGrowEventProcessing } from "../../events/record-grow-event.js";
import { reducersForMission } from "../../missions/event-reducers.js";
import type { MissionArtifactPatch, MissionStagePatch } from "../../missions/reducer-types.js";
import { createMissionActionsFromPatches } from "../../missions/actions.js";
export type UserEventCommand = {
userId: string;
@@ -165,20 +166,21 @@ export const userEventActor = actor({
const client = loopCtx.client<any>();
for (const active of activeRows) {
const mission = active.mission;
const actorHandle = missionActorHandle(client, cmd.userId, mission);
if (!actorHandle) continue;
await actorHandle.ingestEvent({ eventId: row.id }).catch(() => undefined);
const reducers = reducersForMission(mission.missionId);
if (!reducers.length) continue;
for (const reducer of reducers) {
const reduceCtx = { userId: cmd.userId, activeMission: mission, event: row, qscoreSignals: qscoreResult.signals, insight };
if (!reducer.accepts(reduceCtx)) continue;
const reduction = reducer.reduce(reduceCtx);
if (!reduction.stagePatches.length && !reduction.artifacts.length && !reduction.eventMessage) continue;
const actorHandle = missionActorHandle(client, cmd.userId, mission);
if (!actorHandle) continue;
if (!reduction.stagePatches.length && !reduction.artifacts.length && !reduction.actions.length && !reduction.eventMessage) continue;
if (reduction.eventMessage) {
await actorHandle.recordEvent({ type: row.type, message: reduction.eventMessage, payload: { sourceEventId: row.id } });
}
let snapshot: MissionSnapshot | undefined = reduction.stagePatches.length ? (await applyStagePatches(actorHandle, reduction.stagePatches) ?? undefined) : undefined;
if (reduction.stagePatches.length) await applyStagePatches(actorHandle, reduction.stagePatches);
await applyArtifactPatches({
actorHandle,
userId: cmd.userId,
@@ -188,6 +190,14 @@ export const userEventActor = actor({
externalId: typeof row.correlation?.sessionId === "string" ? row.correlation.sessionId : undefined,
patches: reduction.artifacts,
});
if (reduction.actions.length) {
await createMissionActionsFromPatches({
userId: cmd.userId,
mission,
eventId: row.id,
patches: reduction.actions,
});
}
const finalSnapshot = (await actorHandle.getState()) as MissionSnapshot;
await upsertActiveMissionPg(cmd.userId, summarizeMissionSnapshot(finalSnapshot), finalSnapshot);
}

View File

@@ -160,6 +160,66 @@ export const interviewToOfferMissionActor = actor({
return entry;
},
ingestEvent: (c, input: { eventId: string }) => {
ensureInitialized(c.state);
const entry: MissionEvent = {
id: eventId(),
type: "mission.event_ingested",
message: `Event ${input.eventId} ingested by mission runtime.`,
payload: { eventId: input.eventId },
createdAt: nowIso(),
};
c.state.events.unshift(entry);
c.state.updatedAt = entry.createdAt;
c.broadcast("eventAdded", entry);
c.broadcast("updated", c.state);
return c.state;
},
planNextActions: (c, input: { reason?: string } = {}) => {
ensureInitialized(c.state);
const active = c.state.stages.find((stage) => stage.status === "ready" || stage.status === "in_progress" || stage.status === "blocked");
return {
missionInstanceId: c.state.instanceId,
missionId: c.state.missionId,
currentStageId: active?.id,
reason: input.reason ?? "manual",
recommendation: active ? `Focus next on ${active.title}.` : "No open stage requires action right now.",
};
},
runDailyScrum: (c, input: { trigger?: "manual" | "nightly" } = {}) => {
ensureInitialized(c.state);
const active = c.state.stages.find((stage) => stage.status === "ready" || stage.status === "in_progress" || stage.status === "blocked");
const entry: MissionEvent = {
id: eventId(),
type: "mission.daily_scrum.completed",
message: active ? `Daily scrum: next focus is ${active.title}.` : "Daily scrum: mission has no blocked action right now.",
payload: { trigger: input.trigger ?? "manual", currentStageId: active?.id },
createdAt: nowIso(),
};
c.state.events.unshift(entry);
c.state.updatedAt = entry.createdAt;
c.broadcast("eventAdded", entry);
c.broadcast("updated", c.state);
return { snapshot: c.state, summary: entry.message };
},
queueAction: (c, input: { actionId: string; title?: string }) => {
ensureInitialized(c.state);
return { queued: true, actionId: input.actionId, missionInstanceId: c.state.instanceId, title: input.title };
},
runAction: (c, input: { actionId: string }) => {
ensureInitialized(c.state);
return { started: true, actionId: input.actionId, missionInstanceId: c.state.instanceId };
},
resolveHitl: (c, input: { actionId: string; resolution: string; input?: Record<string, unknown> }) => {
ensureInitialized(c.state);
return { resolved: true, actionId: input.actionId, resolution: input.resolution, missionInstanceId: c.state.instanceId };
},
updateStage: (c, input: { stageId: string; status?: MissionStage["status"]; progressPercent?: number; outputSummary?: string }) => {
ensureInitialized(c.state);
const stage = c.state.stages.find((item) => item.id === input.stageId);

View File

@@ -166,6 +166,67 @@ export function createMissionActor(options: {
return entry;
},
ingestEvent: (c, input: { eventId: string }) => {
ensureInitialized(c.state);
const entry: MissionEvent = {
id: eventId(),
type: "mission.event_ingested",
message: `Event ${input.eventId} ingested by mission runtime.`,
payload: { eventId: input.eventId },
createdAt: nowIso(),
};
c.state.events.unshift(entry);
c.state.updatedAt = entry.createdAt;
c.broadcast("eventAdded", entry);
c.broadcast("updated", c.state);
return c.state;
},
planNextActions: (c, input: { reason?: string } = {}) => {
ensureInitialized(c.state);
const blocked = c.state.stages.find((stage) => stage.status === "blocked");
const active = blocked ?? c.state.stages.find((stage) => stage.status === "ready" || stage.status === "in_progress");
return {
missionInstanceId: c.state.instanceId,
missionId: c.state.missionId,
currentStageId: active?.id,
reason: input.reason ?? "manual",
recommendation: active ? `Focus next on ${active.title}.` : "No open stage requires action right now.",
};
},
runDailyScrum: (c, input: { trigger?: "manual" | "nightly" } = {}) => {
ensureInitialized(c.state);
const recommendation = c.state.stages.find((stage) => stage.status === "ready" || stage.status === "in_progress" || stage.status === "blocked");
const entry: MissionEvent = {
id: eventId(),
type: "mission.daily_scrum.completed",
message: recommendation ? `Daily scrum: next focus is ${recommendation.title}.` : "Daily scrum: mission has no blocked action right now.",
payload: { trigger: input.trigger ?? "manual", currentStageId: recommendation?.id },
createdAt: nowIso(),
};
c.state.events.unshift(entry);
c.state.updatedAt = entry.createdAt;
c.broadcast("eventAdded", entry);
c.broadcast("updated", c.state);
return { snapshot: c.state, summary: entry.message };
},
queueAction: (c, input: { actionId: string; title?: string }) => {
ensureInitialized(c.state);
return { queued: true, actionId: input.actionId, missionInstanceId: c.state.instanceId, title: input.title };
},
runAction: (c, input: { actionId: string }) => {
ensureInitialized(c.state);
return { started: true, actionId: input.actionId, missionInstanceId: c.state.instanceId };
},
resolveHitl: (c, input: { actionId: string; resolution: string; input?: Record<string, unknown> }) => {
ensureInitialized(c.state);
return { resolved: true, actionId: input.actionId, resolution: input.resolution, missionInstanceId: c.state.instanceId };
},
updateStage: (c, input: { stageId: string; status?: MissionStage["status"]; progressPercent?: number; outputSummary?: string }) => {
ensureInitialized(c.state);
const stage = c.state.stages.find((item) => item.id === input.stageId);

View File

@@ -458,6 +458,52 @@ export const growQscoreProjectionState = pgTable("grow_qscore_projection_state",
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export const missionActions = pgTable(
"mission_actions",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
missionInstanceId: text("mission_instance_id").notNull().references(() => growActiveMissions.instanceId, { onDelete: "cascade" }),
missionId: text("mission_id").notNull(),
stageId: text("stage_id"),
agentId: text("agent_id").notNull(),
agentName: text("agent_name").notNull(),
baseAgent: text("base_agent"),
serviceId: text("service_id"),
toolName: text("tool_name"),
mode: text("mode", { enum: ["autonomous", "approval_required", "user_input_required", "suggestion"] }).notNull(),
status: text("status", {
enum: ["queued", "running", "waiting_approval", "waiting_user_input", "done", "failed", "dismissed", "snoozed"],
}).notNull().default("queued"),
title: text("title").notNull(),
body: text("body").notNull(),
prompt: text("prompt"),
payload: jsonb("payload").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
result: jsonb("result").$type<Record<string, unknown>>(),
error: text("error"),
sourceEventId: text("source_event_id").references(() => growEvents.id, { onDelete: "set null" }),
idempotencyKey: text("idempotency_key"),
priority: integer("priority").notNull().default(0),
urgency: text("urgency", { enum: ["now", "today", "soon", "calm"] }).notNull().default("calm"),
dueAt: timestamp("due_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
},
(t) => ({
missionIdx: index("mission_actions_mission_idx").on(t.userId, t.missionInstanceId, t.status, t.priority),
userIdx: index("mission_actions_user_idx").on(t.userId, t.status, t.updatedAt),
sourceIdx: index("mission_actions_source_idx").on(t.sourceEventId),
dueIdx: index("mission_actions_due_idx").on(t.dueAt),
idempotencyIdx: uniqueIndex("mission_actions_idempotency_idx").on(t.idempotencyKey),
}),
);
export const missionSuggestions = pgTable(
"mission_suggestions",
{
@@ -544,6 +590,8 @@ export const growHomeNotifications = pgTable(
export type GrowEventRow = typeof growEvents.$inferSelect;
export type NewGrowEvent = typeof growEvents.$inferInsert;
export type MissionActionRow = typeof missionActions.$inferSelect;
export type NewMissionAction = typeof missionActions.$inferInsert;
export type MissionSuggestionRow = typeof missionSuggestions.$inferSelect;
export type NewMissionSuggestion = typeof missionSuggestions.$inferInsert;
export type MissionCoachRunRow = typeof missionCoachRuns.$inferSelect;

View File

@@ -40,15 +40,15 @@ export type HomeFeedResponse = {
};
export const MODULE_META: Record<HomeModuleId, Omit<HomeModule, "count" | "notifications">> = {
suggestions: { id: "suggestions", label: "Suggestions", href: "/suggestions", accent: "orange" },
suggestions: { id: "suggestions", label: "Today's Queue", href: "/suggestions", accent: "orange" },
missions: { id: "missions", label: "Missions", href: "/missions", accent: "orange" },
social: { id: "social", label: "Social Branding", href: "/social", accent: "blue" },
pathways: { id: "pathways", label: "Pathways", href: "/pathways", accent: "teal" },
productivity: { id: "productivity", label: "Productivity", href: "/productivity", accent: "orange" },
productivity: { id: "productivity", label: "Interview · Roleplay · Resume", href: "/agents", accent: "orange" },
rewards: { id: "rewards", label: "Rewards", href: "/rewards", accent: "amber" },
};
export const MODULE_IDS: HomeModuleId[] = ["suggestions", "missions", "social", "pathways", "productivity", "rewards"];
export const MODULE_IDS: HomeModuleId[] = ["suggestions", "missions", "productivity"];
export const ALLOWED_NOTIFICATION_HREFS = new Set([
"/suggestions",

View File

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

190
src/missions/actions.ts Normal file
View File

@@ -0,0 +1,190 @@
import { and, desc, eq, inArray } from "drizzle-orm";
import { db } from "../db/client.js";
import { missionActions, missionSuggestions } from "../db/schema.js";
import type { GrowActiveMission } from "../actors/missions/types.js";
import type { MissionActionPatch } from "./reducer-types.js";
import { defaultMissionActionStatus, type MissionActionDto, type MissionActionRow, type MissionActionStatus, type NewMissionActionInput } from "./action-types.js";
const OPEN_STATUSES: MissionActionStatus[] = ["queued", "running", "waiting_approval", "waiting_user_input", "failed"];
const DONE_STATUSES: MissionActionStatus[] = ["done", "dismissed", "snoozed"];
function toDate(value?: Date | string) {
if (!value) return undefined;
return value instanceof Date ? value : new Date(value);
}
export function actionToDto(row: MissionActionRow): MissionActionDto {
return {
id: row.id,
userId: row.userId,
missionInstanceId: row.missionInstanceId,
missionId: row.missionId,
stageId: row.stageId ?? undefined,
agentId: row.agentId,
agentName: row.agentName,
baseAgent: row.baseAgent ?? undefined,
serviceId: row.serviceId ?? undefined,
toolName: row.toolName ?? undefined,
mode: row.mode,
status: row.status,
title: row.title,
body: row.body,
prompt: row.prompt ?? undefined,
payload: row.payload ?? {},
result: row.result ?? undefined,
error: row.error ?? undefined,
sourceEventId: row.sourceEventId ?? undefined,
idempotencyKey: row.idempotencyKey ?? undefined,
priority: row.priority,
urgency: row.urgency,
dueAt: row.dueAt?.toISOString(),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
resolvedAt: row.resolvedAt?.toISOString(),
};
}
function ctaForAction(action: MissionActionRow | NewMissionActionInput) {
const payload = action.payload && typeof action.payload === "object" && !Array.isArray(action.payload) ? action.payload as Record<string, unknown> : {};
const hrefFromPayload = typeof payload.href === "string" ? payload.href : undefined;
const serviceId = action.serviceId ?? "";
const missionHref = `/missions/active?missionInstanceId=${encodeURIComponent(action.missionInstanceId)}`;
const href = hrefFromPayload ??
(serviceId.includes("interview") ? `/agents/interview/setup?source=mission&missionInstanceId=${encodeURIComponent(action.missionInstanceId)}&missionId=${encodeURIComponent(action.missionId)}${action.stageId ? `&stageId=${encodeURIComponent(action.stageId)}` : ""}` :
serviceId.includes("roleplay") ? `/agents/roleplay/setup?source=mission&missionInstanceId=${encodeURIComponent(action.missionInstanceId)}&missionId=${encodeURIComponent(action.missionId)}${action.stageId ? `&stageId=${encodeURIComponent(action.stageId)}` : ""}` :
serviceId.includes("resume") ? `/agents/resume?source=mission&missionInstanceId=${encodeURIComponent(action.missionInstanceId)}&missionId=${encodeURIComponent(action.missionId)}${action.stageId ? `&stageId=${encodeURIComponent(action.stageId)}` : ""}` : missionHref);
if (action.mode === "approval_required") return { ctaLabel: "Review", ctaHref: missionHref };
if (action.mode === "user_input_required") return { ctaLabel: "Answer", ctaHref: missionHref };
if (serviceId.includes("interview")) return { ctaLabel: "Start mock", ctaHref: href };
if (serviceId.includes("roleplay")) return { ctaLabel: "Run drill", ctaHref: href };
if (serviceId.includes("resume")) return { ctaLabel: "Open resume", ctaHref: href };
return { ctaLabel: "Open", ctaHref: href };
}
function suggestionTypeForAction(action: MissionActionRow | NewMissionActionInput) {
if (action.mode === "user_input_required") return "blocked" as const;
if (action.mode === "approval_required") return "review" as const;
if ((action.serviceId ?? "").includes("interview") || (action.serviceId ?? "").includes("roleplay")) return "practice" as const;
if ((action.serviceId ?? "").includes("resume")) return "artifact" as const;
return "action" as const;
}
async function refreshSuggestionForAction(row: MissionActionRow) {
const active = OPEN_STATUSES.includes(row.status);
const { ctaLabel, ctaHref } = ctaForAction(row);
const status = active ? "active" : row.status === "done" ? "done" : "dismissed";
await db.insert(missionSuggestions).values({
id: `suggestion:${row.id}`,
userId: row.userId,
missionInstanceId: row.missionInstanceId,
missionId: row.missionId,
stageId: row.stageId,
role: row.agentName,
type: suggestionTypeForAction(row),
title: row.title,
body: row.body,
reason: row.prompt,
priority: row.priority,
urgency: row.urgency,
status,
ctaLabel,
ctaHref,
sourceRefs: { actionId: row.id, sourceEventId: row.sourceEventId, toolName: row.toolName },
generatedBy: "agent",
updatedAt: new Date(),
}).onConflictDoUpdate({
target: missionSuggestions.id,
set: {
stageId: row.stageId,
role: row.agentName,
type: suggestionTypeForAction(row),
title: row.title,
body: row.body,
reason: row.prompt,
priority: row.priority,
urgency: row.urgency,
status,
ctaLabel,
ctaHref,
sourceRefs: { actionId: row.id, sourceEventId: row.sourceEventId, toolName: row.toolName },
generatedBy: "agent",
updatedAt: new Date(),
},
});
}
export async function createMissionAction(input: NewMissionActionInput) {
const [row] = await db.insert(missionActions).values({
...input,
status: input.status ?? defaultMissionActionStatus(input.mode),
priority: input.priority ?? 0,
urgency: input.urgency ?? "calm",
payload: input.payload ?? {},
result: input.result,
dueAt: toDate(input.dueAt),
updatedAt: new Date(),
}).onConflictDoNothing().returning();
if (!row) return null;
await refreshSuggestionForAction(row);
return actionToDto(row);
}
export async function createMissionActionsFromPatches(input: { userId: string; mission: GrowActiveMission; eventId: string; patches: MissionActionPatch[] }) {
const created: MissionActionDto[] = [];
for (const patch of input.patches) {
const action = await createMissionAction({
userId: input.userId,
missionInstanceId: input.mission.instanceId,
missionId: input.mission.missionId,
stageId: patch.stageId,
agentId: patch.agentId,
agentName: patch.agentName,
baseAgent: patch.baseAgent,
serviceId: patch.serviceId,
toolName: patch.toolName,
mode: patch.mode,
status: patch.status,
title: patch.title,
body: patch.body,
prompt: patch.prompt,
payload: patch.payload ?? {},
sourceEventId: patch.sourceEventId ?? input.eventId,
idempotencyKey: patch.idempotencyKey,
priority: patch.priority,
urgency: patch.urgency,
dueAt: patch.dueAt,
});
if (action) created.push(action);
}
return created;
}
export async function listMissionActions(userId: string, opts: { missionInstanceId?: string; openOnly?: boolean } = {}) {
const conditions = [eq(missionActions.userId, userId)];
if (opts.missionInstanceId) conditions.push(eq(missionActions.missionInstanceId, opts.missionInstanceId));
if (opts.openOnly ?? true) conditions.push(inArray(missionActions.status, OPEN_STATUSES));
const rows = await db.select().from(missionActions).where(and(...conditions)).orderBy(desc(missionActions.priority), desc(missionActions.updatedAt));
return rows.map(actionToDto);
}
export async function getMissionAction(userId: string, actionId: string) {
const [row] = await db.select().from(missionActions).where(and(eq(missionActions.userId, userId), eq(missionActions.id, actionId))).limit(1);
return row ?? null;
}
export async function updateMissionActionStatus(userId: string, actionId: string, patch: { status: MissionActionStatus; result?: Record<string, unknown>; error?: string; payload?: Record<string, unknown> }) {
const resolvedAt = DONE_STATUSES.includes(patch.status) ? new Date() : undefined;
const [row] = await db.update(missionActions).set({
status: patch.status,
result: patch.result,
error: patch.error,
payload: patch.payload,
resolvedAt,
updatedAt: new Date(),
}).where(and(eq(missionActions.userId, userId), eq(missionActions.id, actionId))).returning();
if (!row) return null;
await refreshSuggestionForAction(row);
return actionToDto(row);
}

View File

@@ -0,0 +1,89 @@
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
export const careerTransitionReducer: MissionReducer = {
missionId: "career-transition",
accepts(ctx) {
return ctx.activeMission.missionId === "career-transition" &&
(missionExplicitlyMatches(ctx.event.mission, "career-transition") || isRelevantServiceEvent(ctx.event.source, ctx.event.type));
},
reduce(ctx): MissionReduction {
const { event, activeMission } = ctx;
const type = event.type;
const payload = event.payload ?? {};
const stagePatches: MissionStagePatch[] = [];
const artifacts: MissionReduction["artifacts"] = [];
const actions: MissionReduction["actions"] = [];
let eventMessage = ctx.insight.summary;
if (!activeMission.goal && (type === "mission.started" || type.startsWith("mission."))) {
actions.push(actionForAgent("career-transition", "planner", {
stageId: "clarify-target",
mode: "user_input_required",
title: "Choose the target role for your transition",
body: "Career transition needs a clear adjacent role before resume repositioning or practice will be useful.",
prompt: "What role are you exploring next, and what role/background are you moving from?",
payload: { fields: ["current_role", "target_role", "constraints"] },
idempotencyKey: `${activeMission.instanceId}:clarify-target-role`,
priority: 100,
urgency: "now",
}));
}
if (isResumeEvent(event.source, type) && (type.includes("analysis") || type.includes("parsed") || type.includes("analyzed"))) {
const signals = extractResumeSignals(payload);
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Transferable skills mapped from resume evidence." });
stagePatches.push({ stageId: "interview", status: "ready", progressPercent: 0, outputSummary: "Validate the transition story in an adjacent-role mock interview." });
artifacts.push({ type: "transferable_skills_map", title: "Transferable skills map", stageId: "resume", summary: signals[0] ?? "Resume proof was mapped into transition evidence.", metadata: { sourceEventId: event.id, signals } });
actions.push(actionForAgent("career-transition", "resume", {
stageId: "resume",
serviceId: "resume-service",
toolName: "resume.create_version_prompt_draft",
mode: "approval_required",
title: "Draft a repositioned resume for the target role?",
body: "Approve a Resume Agent draft that reframes your transferable skills for the career switch.",
payload: { signals, href: serviceHref("resume", activeMission.instanceId, activeMission.missionId, "resume") },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:resume-transition-draft:${event.id}`,
priority: 95,
urgency: "today",
}));
eventMessage = "Transferable skills map created; repositioned resume action is ready.";
}
if (isInterviewEvent(event.source, type) && type.includes("configured")) {
stagePatches.push({ stageId: "interview", status: "in_progress", progressPercent: 40, outputSummary: "Adjacent-role mock interview configured." });
eventMessage = "Adjacent-role interview practice started.";
}
if (isInterviewEvent(event.source, type) && type.includes("review_completed")) {
const weakAreas = extractWeakAreas(payload);
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Adjacent-role credibility checked." });
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Practice the 'why I am switching' narrative next." });
artifacts.push({ type: "transition_interview_diagnosis", title: "Adjacent-role credibility diagnosis", stageId: "interview", summary: weakAreas.length ? `Needs work: ${weakAreas.join(", ")}` : "Interview review completed for transition credibility.", metadata: { sourceEventId: event.id, weakAreas } });
actions.push(actionForAgent("career-transition", "roleplay", {
stageId: "roleplay",
serviceId: "roleplay-service",
toolName: "roleplay.configure_practice",
mode: "suggestion",
title: "Practice your 'why I am switching' pitch",
body: "Turn the adjacent-role feedback into a confident transition narrative before real conversations.",
payload: { weakAreas, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:transition-pitch-roleplay:${event.id}`,
priority: 92,
urgency: "today",
}));
eventMessage = "Career transition interview feedback produced the next pitch-practice action.";
}
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Transition pitch practice reviewed." });
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 70, outputSummary: "Transition confidence signals updated." });
eventMessage = "Transition narrative practice completed.";
}
if (ctx.qscoreSignals.length) stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: Math.min(90, 45 + ctx.qscoreSignals.length * 10), outputSummary: "Transition readiness signals updated." });
return { stagePatches, artifacts, actions, eventMessage };
},
};

View File

@@ -1,7 +1,17 @@
import type { MissionReducer } from "./reducer-types.js";
import { interviewToOfferReducer } from "./interview-to-offer/reducer.js";
import { careerTransitionReducer } from "./career-transition/reducer.js";
import { salaryNegotiationReducer } from "./salary-negotiation-war-room/reducer.js";
import { promotionReadinessReducer } from "./promotion-readiness/reducer.js";
import { personalBrandOpportunityReducer } from "./personal-brand-opportunity-engine/reducer.js";
export const missionEventReducers: MissionReducer[] = [interviewToOfferReducer];
export const missionEventReducers: MissionReducer[] = [
interviewToOfferReducer,
careerTransitionReducer,
salaryNegotiationReducer,
promotionReadinessReducer,
personalBrandOpportunityReducer,
];
export function reducersForMission(missionId: string) {
return missionEventReducers.filter((reducer) => reducer.missionId === missionId);

View File

@@ -1,99 +1,147 @@
import { asRecord, getNumber, getString } from "../../events/envelope.js";
import type { MissionArtifactPatch, MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
import { getString } from "../../events/envelope.js";
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
import {
actionForAgent,
extractOverallScore,
extractResumeSignals,
extractWeakAreas,
isInterviewEvent,
isQscoreEvent,
isRelevantServiceEvent,
isResumeEvent,
isRoleplayEvent,
missionExplicitlyMatches,
serviceHref,
} from "../reducer-helpers.js";
function eventMatchesSource(source: string, type: string) {
return (
source.includes("resume") ||
source.includes("interview") ||
source.includes("qscore") ||
type.startsWith("resume.") ||
type.startsWith("interview.") ||
type.startsWith("qscore.") ||
type.startsWith("mission.interview_to_offer")
);
function acceptsMission(ctx: Parameters<MissionReducer["accepts"]>[0]) {
return ctx.activeMission.missionId === "interview-to-offer" &&
(missionExplicitlyMatches(ctx.event.mission, "interview-to-offer") || isRelevantServiceEvent(ctx.event.source, ctx.event.type));
}
function reviewSummary(payload: Record<string, unknown>) {
const review = asRecord(payload.review ?? payload.result ?? payload);
const overall = getNumber(review.overall_score ?? review.overallScore);
const summary = getString(review.summary ?? review.feedback_summary ?? review.overall_feedback);
if (overall !== undefined && summary) return `Mock interview review completed with score ${overall}. ${summary}`;
if (overall !== undefined) return `Mock interview review completed with score ${overall}.`;
const score = extractOverallScore(payload);
const summary = getString(payload.summary ?? payload.feedback_summary ?? payload.overall_feedback);
if (score !== undefined && summary) return `Mock interview review completed with score ${score}. ${summary}`;
if (score !== undefined) return `Mock interview review completed with score ${score}.`;
return summary ?? "Mock interview review completed.";
}
export const interviewToOfferReducer: MissionReducer = {
missionId: "interview-to-offer",
accepts(ctx) {
if (ctx.activeMission.missionId !== "interview-to-offer") return false;
const mission = asRecord(ctx.event.mission);
const explicitMissionId = getString(mission.missionId ?? mission.mission_id);
return explicitMissionId === "interview-to-offer" || eventMatchesSource(ctx.event.source.toLowerCase(), ctx.event.type);
},
accepts: acceptsMission,
reduce(ctx): MissionReduction {
const type = ctx.event.type;
const payload = ctx.event.payload ?? {};
const { event, activeMission } = ctx;
const type = event.type;
const payload = event.payload ?? {};
const stagePatches: MissionStagePatch[] = [...ctx.insight.missionStageHints.map((hint) => ({
stageId: hint.stageId,
status: hint.status,
progressPercent: hint.progressPercent,
outputSummary: hint.reason,
}))];
const artifacts: MissionArtifactPatch[] = [];
const artifacts: MissionReduction["artifacts"] = [];
const actions: MissionReduction["actions"] = [];
let eventMessage = ctx.insight.summary;
if (type.startsWith("resume.") && (type.includes("analysis_completed") || type.includes("analysis.complete") || type.includes("analyzed"))) {
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Resume fit scan completed." });
stagePatches.push({ stageId: "interview-plan", status: "ready", progressPercent: 0 });
if (isResumeEvent(event.source, type) && (type.includes("analysis_completed") || type.includes("analysis.complete") || type.includes("analyzed"))) {
const signals = extractResumeSignals(payload);
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Resume talking points and fit scan are ready." });
stagePatches.push({ stageId: "interview", status: "ready", progressPercent: 0 });
artifacts.push({
type: "resume_fit_scan",
title: "Resume Fit Scan",
type: "resume_talking_points",
title: "Resume-based talking points",
stageId: "resume",
summary: getString(payload.summary) ?? "Resume analysis completed and readiness signals were updated.",
metadata: { sourceEventId: ctx.event.id, payload },
summary: signals[0] ?? "Resume analysis completed and role-fit talking points are ready.",
metadata: { sourceEventId: event.id, signals, payload },
});
eventMessage = "Resume fit scan completed for Interview-to-Offer.";
actions.push(actionForAgent("interview-to-offer", "interview", {
stageId: "interview",
serviceId: "interview-service",
toolName: "interview.configure_practice",
mode: "suggestion",
title: "Start the first mock interview",
body: "Your resume proof is ready. Run a targeted mock interview for the scheduled role now.",
payload: { href: serviceHref("interview", activeMission.instanceId, activeMission.missionId, "interview") },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:resume-analysis:start-interview:${event.id}`,
priority: 92,
urgency: "today",
}));
eventMessage = "Resume fit scan completed; mock interview is ready to run.";
}
if (type.startsWith("interview.") && (type.includes("configured") || type.includes("created"))) {
if (isInterviewEvent(event.source, type) && (type.includes("configured") || type.includes("created"))) {
stagePatches.push({ stageId: "interview", status: "in_progress", progressPercent: 35, outputSummary: "Mock interview session configured." });
eventMessage = "Mock interview session configured.";
}
if (type.startsWith("interview.") && (type.includes("session_completed") || type.includes("session.completed"))) {
if (isInterviewEvent(event.source, type) && (type.includes("session_completed") || type.includes("session.completed"))) {
stagePatches.push({ stageId: "interview", status: "in_progress", progressPercent: 75, outputSummary: "Interview completed; review is being prepared." });
eventMessage = "Mock interview completed; waiting for review.";
}
if (type.startsWith("interview.") && (type.includes("review_completed") || type.includes("review.completed"))) {
if (isInterviewEvent(event.source, type) && (type.includes("review_completed") || type.includes("review.completed"))) {
const weakAreas = extractWeakAreas(payload);
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: reviewSummary(payload) });
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Practice the communication gaps surfaced by interview feedback." });
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 60, outputSummary: "Readiness signals updated from interview review." });
artifacts.push({
type: "mock_interview_review",
title: "Mock Interview Review",
title: "Weakness diagnosis",
stageId: "interview",
summary: reviewSummary(payload),
metadata: { sourceEventId: ctx.event.id, payload },
metadata: { sourceEventId: event.id, weakAreas, payload },
});
eventMessage = "Mock interview review completed and mission readiness was updated.";
actions.push(actionForAgent("interview-to-offer", "resume", {
stageId: "resume",
serviceId: "resume-service",
toolName: "resume.create_version_prompt_draft",
mode: "approval_required",
title: "Create a tailored resume version from this interview feedback?",
body: weakAreas.length
? `The interview exposed ${weakAreas.slice(0, 3).join(", ")}. Approve a Resume Agent draft that turns this feedback into targeted bullets and talking points.`
: "Approve a Resume Agent draft that turns the interview feedback into targeted bullets and talking points.",
payload: { weakAreas, sourceReviewEventId: event.id, href: serviceHref("resume", activeMission.instanceId, activeMission.missionId, "resume") },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:interview-review:tailor-resume:${event.id}`,
priority: 100,
urgency: "now",
}));
if (weakAreas.some((area) => /communication|story|clarity|confidence|concise/i.test(area))) {
actions.push(actionForAgent("interview-to-offer", "roleplay", {
stageId: "roleplay",
serviceId: "roleplay-service",
toolName: "roleplay.configure_practice",
mode: "suggestion",
title: "Run a communication recovery drill",
body: "Practice the exact communication weakness from the mock interview before the real interview.",
payload: { weakAreas, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:interview-review:roleplay:${event.id}`,
priority: 94,
urgency: "today",
}));
}
eventMessage = "Interview review completed; resume and roleplay next actions were created.";
}
if (ctx.qscoreSignals.length > 0) {
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Communication drill reviewed." });
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 75, outputSummary: "Communication readiness updated." });
eventMessage = "Roleplay review improved interview communication readiness.";
}
if (ctx.qscoreSignals.length > 0 || (isQscoreEvent(event.source, type) && type.includes("updated"))) {
stagePatches.push({
stageId: "qscore",
status: "in_progress",
progressPercent: Math.max(40, Math.min(90, ctx.qscoreSignals.length * 15)),
outputSummary: `${ctx.qscoreSignals.length} readiness signal${ctx.qscoreSignals.length === 1 ? "" : "s"} updated.`,
status: type.startsWith("qscore.") ? "done" : "in_progress",
progressPercent: type.startsWith("qscore.") ? 100 : Math.max(40, Math.min(90, ctx.qscoreSignals.length * 15)),
outputSummary: `${ctx.qscoreSignals.length || 1} readiness signal${ctx.qscoreSignals.length === 1 ? "" : "s"} updated.`,
});
}
if (type.startsWith("qscore.") && (type.includes("snapshot") || type.includes("computed") || type.includes("updated"))) {
stagePatches.push({ stageId: "qscore", status: "done", progressPercent: 100, outputSummary: "Readiness Q Score updated." });
eventMessage = "Readiness Q Score updated.";
}
return { stagePatches, artifacts, eventMessage };
return { stagePatches, artifacts, actions, eventMessage };
},
};

View File

@@ -0,0 +1,83 @@
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
export const personalBrandOpportunityReducer: MissionReducer = {
missionId: "personal-brand-opportunity-engine",
accepts(ctx) {
return ctx.activeMission.missionId === "personal-brand-opportunity-engine" &&
(missionExplicitlyMatches(ctx.event.mission, "personal-brand-opportunity-engine") || isRelevantServiceEvent(ctx.event.source, ctx.event.type));
},
reduce(ctx): MissionReduction {
const { event, activeMission } = ctx;
const type = event.type;
const payload = event.payload ?? {};
const stagePatches: MissionStagePatch[] = [];
const artifacts: MissionReduction["artifacts"] = [];
const actions: MissionReduction["actions"] = [];
let eventMessage = ctx.insight.summary;
if (type === "mission.started" || type.startsWith("mission.")) {
actions.push(actionForAgent("personal-brand-opportunity-engine", "planner", {
stageId: "positioning",
mode: "user_input_required",
title: "Pick the audience you want to be known by",
body: "Personal brand only works when the target audience and credibility theme are explicit.",
prompt: "Who should notice you, and what do you want to be known for?",
payload: { fields: ["target_audience", "positioning_theme", "proof_points"] },
idempotencyKey: `${activeMission.instanceId}:brand-positioning`,
priority: 96,
urgency: "today",
}));
}
if (isResumeEvent(event.source, type) && (type.includes("analysis") || type.includes("parsed") || type.includes("analyzed"))) {
const signals = extractResumeSignals(payload);
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Proof points extracted for positioning." });
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Practice networking pitch next." });
artifacts.push({ type: "positioning_statement", title: "Positioning statement draft", stageId: "resume", summary: signals[0] ?? "Strongest proof points were extracted from resume evidence.", metadata: { sourceEventId: event.id, signals } });
actions.push(actionForAgent("personal-brand-opportunity-engine", "resume", {
stageId: "resume",
serviceId: "resume-service",
toolName: "resume.create_version_prompt_draft",
mode: "approval_required",
title: "Draft your profile positioning statement?",
body: "Approve a draft that turns your strongest resume proof into a clear LinkedIn/profile positioning statement.",
payload: { signals, href: serviceHref("resume", activeMission.instanceId, activeMission.missionId, "resume") },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:brand-positioning-draft:${event.id}`,
priority: 92,
urgency: "today",
}));
eventMessage = "Resume proof points created a profile positioning action.";
}
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
const weakAreas = extractWeakAreas(payload);
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Networking pitch reviewed." });
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 70, outputSummary: "Brand voice/readiness signals updated." });
artifacts.push({ type: "networking_scripts", title: "Networking script improvements", stageId: "roleplay", summary: weakAreas.length ? `Improve: ${weakAreas.join(", ")}` : "Networking pitch practice completed.", metadata: { sourceEventId: event.id, weakAreas } });
actions.push(actionForAgent("personal-brand-opportunity-engine", "planner", {
stageId: "positioning",
mode: "suggestion",
title: "Turn this pitch into weekly content pillars",
body: "Use the networking practice feedback to draft 3 credibility themes for weekly posts.",
payload: { weakAreas, href: `/missions/active?missionInstanceId=${encodeURIComponent(activeMission.instanceId)}` },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:content-pillars:${event.id}`,
priority: 82,
urgency: "soon",
}));
eventMessage = "Networking pitch review created brand content next steps.";
}
if (isInterviewEvent(event.source, type) && type.includes("review_completed")) {
const weakAreas = extractWeakAreas(payload);
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Credibility signals mined from interview review." });
artifacts.push({ type: "credibility_signal_map", title: "Credibility signal map", stageId: "interview", summary: weakAreas.length ? `Recurring gaps/themes: ${weakAreas.join(", ")}` : "Interview review mined for positioning signals.", metadata: { sourceEventId: event.id, weakAreas } });
eventMessage = "Interview feedback was mined for brand positioning signals.";
}
if (ctx.qscoreSignals.length) stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: Math.min(90, 45 + ctx.qscoreSignals.length * 10), outputSummary: "Brand growth/readiness signals updated." });
return { stagePatches, artifacts, actions, eventMessage };
},
};

View File

@@ -0,0 +1,86 @@
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
export const promotionReadinessReducer: MissionReducer = {
missionId: "promotion-readiness",
accepts(ctx) {
return ctx.activeMission.missionId === "promotion-readiness" &&
(missionExplicitlyMatches(ctx.event.mission, "promotion-readiness") || isRelevantServiceEvent(ctx.event.source, ctx.event.type));
},
reduce(ctx): MissionReduction {
const { event, activeMission } = ctx;
const type = event.type;
const payload = event.payload ?? {};
const stagePatches: MissionStagePatch[] = [];
const artifacts: MissionReduction["artifacts"] = [];
const actions: MissionReduction["actions"] = [];
let eventMessage = ctx.insight.summary;
if (type === "mission.started" || type.startsWith("mission.")) {
actions.push(actionForAgent("promotion-readiness", "planner", {
stageId: "promotion-context",
mode: "user_input_required",
title: "Clarify your promotion target",
body: "Promotion readiness needs the desired level, timeline, manager context, and stakeholder map.",
prompt: "What role/level are you targeting, by when, and what does your manager care about most?",
payload: { fields: ["current_role", "desired_role", "timeline", "manager_context", "stakeholders"] },
idempotencyKey: `${activeMission.instanceId}:promotion-context`,
priority: 100,
urgency: "today",
}));
}
if (isResumeEvent(event.source, type) && (type.includes("analysis") || type.includes("parsed") || type.includes("analyzed"))) {
const signals = extractResumeSignals(payload);
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Promotion evidence extracted from achievement history." });
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Manager conversation practice is ready." });
artifacts.push({ type: "promotion_evidence_packet", title: "Promotion evidence packet", stageId: "resume", summary: signals[0] ?? "Achievement evidence extracted for promotion case.", metadata: { sourceEventId: event.id, signals } });
actions.push(actionForAgent("promotion-readiness", "roleplay", {
stageId: "roleplay",
serviceId: "roleplay-service",
toolName: "roleplay.configure_practice",
mode: "suggestion",
title: "Practice the manager promotion conversation",
body: "Use your achievement evidence in a realistic manager conversation drill.",
payload: { signals, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:promotion-manager-roleplay:${event.id}`,
priority: 94,
urgency: "today",
}));
eventMessage = "Promotion evidence packet is ready; manager conversation practice is next.";
}
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
const weakAreas = extractWeakAreas(payload);
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Manager conversation drill reviewed." });
stagePatches.push({ stageId: "interview", status: "ready", progressPercent: 0, outputSummary: "Practice leadership narratives next if gaps remain." });
artifacts.push({ type: "manager_conversation_script", title: "Manager conversation script", stageId: "roleplay", summary: weakAreas.length ? `Follow-up focus: ${weakAreas.join(", ")}` : "Manager conversation review completed.", metadata: { sourceEventId: event.id, weakAreas } });
actions.push(actionForAgent("promotion-readiness", "interview", {
stageId: "interview",
serviceId: "interview-service",
toolName: "interview.configure_practice",
mode: "suggestion",
title: "Practice leadership stories",
body: "Run a leadership-style mock interview to tighten the promotion case.",
payload: { weakAreas, href: serviceHref("interview", activeMission.instanceId, activeMission.missionId, "interview") },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:promotion-leadership-interview:${event.id}`,
priority: 86,
urgency: "soon",
}));
eventMessage = "Manager conversation review updated promotion readiness.";
}
if (isInterviewEvent(event.source, type) && type.includes("review_completed")) {
const weakAreas = extractWeakAreas(payload);
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Leadership communication gap check completed." });
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 75, outputSummary: "Leadership readiness signals updated." });
artifacts.push({ type: "leadership_gap_map", title: "Leadership gap map", stageId: "interview", summary: weakAreas.length ? weakAreas.join(", ") : "Leadership practice review completed.", metadata: { sourceEventId: event.id, weakAreas } });
eventMessage = "Leadership practice review updated the promotion gap map.";
}
if (ctx.qscoreSignals.length) stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: Math.min(90, 45 + ctx.qscoreSignals.length * 10), outputSummary: "Promotion readiness signals updated." });
return { stagePatches, artifacts, actions, eventMessage };
},
};

View File

@@ -0,0 +1,143 @@
import { asRecord, getNumber, getString } from "../events/envelope.js";
import type { MissionActionPatch } from "./reducer-types.js";
export function isResumeEvent(source: string, type: string) {
const value = source.toLowerCase();
return value.includes("resume") || type.startsWith("resume.");
}
export function isInterviewEvent(source: string, type: string) {
const value = source.toLowerCase();
return value.includes("interview") || type.startsWith("interview.");
}
export function isRoleplayEvent(source: string, type: string) {
const value = source.toLowerCase();
return value.includes("roleplay") || type.startsWith("roleplay.");
}
export function isQscoreEvent(source: string, type: string) {
const value = source.toLowerCase();
return value.includes("qscore") || type.startsWith("qscore.");
}
export function reviewRecord(payload: Record<string, unknown>) {
return asRecord(payload.review ?? payload.result ?? payload.data ?? payload);
}
export function extractOverallScore(payload: Record<string, unknown>) {
const review = reviewRecord(payload);
return getNumber(review.overall_score ?? review.overallScore ?? review.score ?? payload.overall_score);
}
export function extractWeakAreas(payload: Record<string, unknown>): string[] {
const review = reviewRecord(payload);
const candidates = [
review.weak_areas,
review.weakAreas,
review.improvement_areas,
review.improvementAreas,
review.recommendations,
review.gaps,
];
const areas: string[] = [];
for (const candidate of candidates) {
if (Array.isArray(candidate)) {
for (const item of candidate) {
if (typeof item === "string" && item.trim()) areas.push(item.trim());
else if (item && typeof item === "object" && !Array.isArray(item)) {
const row = item as Record<string, unknown>;
const text = getString(row.title ?? row.area ?? row.name ?? row.label ?? row.summary);
if (text) areas.push(text);
}
}
} else if (typeof candidate === "string" && candidate.trim()) {
areas.push(...candidate.split(/[;,]/).map((part) => part.trim()).filter(Boolean));
}
}
const summary = getString(review.summary ?? review.feedback_summary ?? review.overall_feedback);
if (!areas.length && summary) {
const lower = summary.toLowerCase();
if (lower.includes("communication") || lower.includes("clarity") || lower.includes("story")) areas.push("communication clarity");
if (lower.includes("technical") || lower.includes("role")) areas.push("role-fit depth");
if (lower.includes("confidence") || lower.includes("concise")) areas.push("confidence and concision");
}
return Array.from(new Set(areas)).slice(0, 5);
}
export function extractResumeSignals(payload: Record<string, unknown>): string[] {
const analysis = asRecord(payload.analysis ?? payload.result ?? payload.data ?? payload);
const signals: string[] = [];
const summary = getString(analysis.summary ?? analysis.overall_feedback ?? payload.summary);
if (summary) signals.push(summary);
for (const key of ["strengths", "gaps", "recommendations", "missing_keywords", "keyword_gaps"]) {
const value = analysis[key];
if (Array.isArray(value)) {
for (const item of value.slice(0, 4)) if (typeof item === "string") signals.push(item);
}
}
return signals.slice(0, 8);
}
export function missionExplicitlyMatches(eventMission: unknown, missionId: string) {
const mission = asRecord(eventMission);
const explicit = getString(mission.missionId ?? mission.mission_id);
return explicit === missionId;
}
export function isRelevantServiceEvent(source: string, type: string) {
return isResumeEvent(source, type) || isInterviewEvent(source, type) || isRoleplayEvent(source, type) || isQscoreEvent(source, type);
}
const AGENT_NAMES: Record<string, Record<string, { agentId: string; baseAgent: string; agentName: string }>> = {
"interview-to-offer": {
planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Offer Strategist" },
resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Resume Fit Agent" },
interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Mock Interviewer" },
roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Communication Coach" },
qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Readiness Analyst" },
},
"career-transition": {
planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Transition Strategist" },
resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Transferable Skills Agent" },
interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Adjacent Role Interviewer" },
roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Transition Pitch Coach" },
qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Transition Readiness Analyst" },
},
"salary-negotiation-war-room": {
planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Negotiation Strategist" },
resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Value Evidence Agent" },
interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Confidence Coach" },
roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Negotiation Drill Coach" },
qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Confidence Analyst" },
},
"promotion-readiness": {
planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Promotion Strategist" },
resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Achievement Evidence Agent" },
interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Leadership Interview Coach" },
roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Manager Conversation Coach" },
qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Leadership Readiness Analyst" },
},
"personal-brand-opportunity-engine": {
planner: { agentId: "planner", baseAgent: "mission-planner", agentName: "Brand Strategist" },
resume: { agentId: "resume", baseAgent: "resume-strategist", agentName: "Proof Point Agent" },
interview: { agentId: "interview", baseAgent: "interview-coach", agentName: "Credibility Coach" },
roleplay: { agentId: "roleplay", baseAgent: "roleplay-coach", agentName: "Networking Pitch Coach" },
qscore: { agentId: "qscore", baseAgent: "qscore-analyst", agentName: "Visibility Analyst" },
},
};
export function actionForAgent(missionId: string, agent: "planner" | "resume" | "interview" | "roleplay" | "qscore", patch: Omit<MissionActionPatch, "agentId" | "agentName" | "baseAgent">): MissionActionPatch {
const fallback = AGENT_NAMES["interview-to-offer"]?.[agent] ?? { agentId: agent, baseAgent: agent, agentName: agent };
const spec = AGENT_NAMES[missionId]?.[agent] ?? fallback;
return { ...spec, ...patch };
}
export function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", missionInstanceId: string, missionId: string, stageId?: string) {
const params = new URLSearchParams({ source: "mission", missionInstanceId, missionId });
if (stageId) params.set("stageId", stageId);
if (service === "interview") return `/agents/interview/setup?${params.toString()}`;
if (service === "roleplay") return `/agents/roleplay/setup?${params.toString()}`;
if (service === "resume") return `/agents/resume?${params.toString()}`;
return `/agents/qscore?${params.toString()}`;
}

View File

@@ -2,6 +2,7 @@ import type { GrowEventRow } from "../db/schema.js";
import type { ProjectionInsight } from "../events/projectors/projection-agent.js";
import type { QscoreSignal } from "../events/envelope.js";
import type { GrowActiveMission, MissionStageStatus } from "../actors/missions/types.js";
import type { MissionActionMode, MissionActionStatus, MissionActionUrgency } from "./action-types.js";
export type MissionReducerContext = {
userId: string;
@@ -27,9 +28,30 @@ export type MissionStagePatch = {
outputSummary?: string;
};
export type MissionActionPatch = {
stageId?: string;
agentId: string;
agentName: string;
baseAgent?: string;
serviceId?: string;
toolName?: string;
mode: MissionActionMode;
status?: MissionActionStatus;
title: string;
body: string;
prompt?: string;
payload?: Record<string, unknown>;
sourceEventId?: string;
idempotencyKey?: string;
priority?: number;
urgency?: MissionActionUrgency;
dueAt?: string;
};
export type MissionReduction = {
stagePatches: MissionStagePatch[];
artifacts: MissionArtifactPatch[];
actions: MissionActionPatch[];
eventMessage?: string;
};

View File

@@ -0,0 +1,88 @@
import type { MissionReducer, MissionReduction, MissionStagePatch } from "../reducer-types.js";
import { actionForAgent, extractResumeSignals, extractWeakAreas, isInterviewEvent, isRelevantServiceEvent, isResumeEvent, isRoleplayEvent, missionExplicitlyMatches, serviceHref } from "../reducer-helpers.js";
export const salaryNegotiationReducer: MissionReducer = {
missionId: "salary-negotiation-war-room",
accepts(ctx) {
return ctx.activeMission.missionId === "salary-negotiation-war-room" &&
(missionExplicitlyMatches(ctx.event.mission, "salary-negotiation-war-room") || isRelevantServiceEvent(ctx.event.source, ctx.event.type));
},
reduce(ctx): MissionReduction {
const { event, activeMission } = ctx;
const type = event.type;
const payload = event.payload ?? {};
const stagePatches: MissionStagePatch[] = [];
const artifacts: MissionReduction["artifacts"] = [];
const actions: MissionReduction["actions"] = [];
let eventMessage = ctx.insight.summary;
if (type === "mission.started" || type.startsWith("mission.")) {
actions.push(actionForAgent("salary-negotiation-war-room", "planner", {
stageId: "offer-context",
mode: "user_input_required",
title: "Add your offer and negotiation constraints",
body: "The war room needs your current offer, target range, deadline, and leverage before scripts or drills are accurate.",
prompt: "What is the current offer/raise, your target, deadline, and any competing leverage?",
payload: { fields: ["current_offer", "target_range", "deadline", "competing_offers", "constraints"] },
idempotencyKey: `${activeMission.instanceId}:offer-context`,
priority: 100,
urgency: "now",
}));
}
if (isResumeEvent(event.source, type) && (type.includes("analysis") || type.includes("parsed") || type.includes("analyzed"))) {
const signals = extractResumeSignals(payload);
stagePatches.push({ stageId: "resume", status: "done", progressPercent: 100, outputSummary: "Value evidence extracted from resume proof." });
stagePatches.push({ stageId: "roleplay", status: "ready", progressPercent: 0, outputSummary: "Use value evidence in negotiation roleplay." });
artifacts.push({ type: "value_evidence_map", title: "Value evidence map", stageId: "resume", summary: signals[0] ?? "Impact proof extracted for negotiation leverage.", metadata: { sourceEventId: event.id, signals } });
actions.push(actionForAgent("salary-negotiation-war-room", "roleplay", {
stageId: "roleplay",
serviceId: "roleplay-service",
toolName: "roleplay.configure_practice",
mode: "suggestion",
title: "Run the first counteroffer drill",
body: "Practice anchoring your number and defending value with the evidence now extracted.",
payload: { signals, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:value-evidence-roleplay:${event.id}`,
priority: 96,
urgency: "today",
}));
eventMessage = "Value evidence is ready for negotiation practice.";
}
if (isRoleplayEvent(event.source, type) && type.includes("configured")) {
stagePatches.push({ stageId: "roleplay", status: "in_progress", progressPercent: 45, outputSummary: "Negotiation drill configured." });
eventMessage = "Negotiation drill started.";
}
if (isRoleplayEvent(event.source, type) && type.includes("review_completed")) {
const weakAreas = extractWeakAreas(payload);
stagePatches.push({ stageId: "roleplay", status: "done", progressPercent: 100, outputSummary: "Negotiation drill reviewed." });
stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: 70, outputSummary: "Confidence signals updated." });
artifacts.push({ type: "negotiation_objection_map", title: "Objection handling map", stageId: "roleplay", summary: weakAreas.length ? `Practice objections: ${weakAreas.join(", ")}` : "Negotiation practice review completed.", metadata: { sourceEventId: event.id, weakAreas } });
actions.push(actionForAgent("salary-negotiation-war-room", "roleplay", {
stageId: "roleplay",
serviceId: "roleplay-service",
toolName: "roleplay.configure_practice",
mode: "approval_required",
title: "Run one more objection-handling drill?",
body: weakAreas.length ? `Recommended focus: ${weakAreas.slice(0, 3).join(", ")}. Approve another drill before the live negotiation.` : "Approve one more objection-handling drill before the live negotiation.",
payload: { weakAreas, href: serviceHref("roleplay", activeMission.instanceId, activeMission.missionId, "roleplay") },
sourceEventId: event.id,
idempotencyKey: `${activeMission.instanceId}:negotiation-followup:${event.id}`,
priority: 94,
urgency: "today",
}));
eventMessage = "Negotiation drill review created the next objection-handling action.";
}
if (isInterviewEvent(event.source, type) && type.includes("review_completed")) {
stagePatches.push({ stageId: "interview", status: "done", progressPercent: 100, outputSummary: "Communication confidence signal captured from interview review." });
eventMessage = "Interview feedback updated negotiation confidence signals.";
}
if (ctx.qscoreSignals.length) stagePatches.push({ stageId: "qscore", status: "in_progress", progressPercent: Math.min(90, 45 + ctx.qscoreSignals.length * 10), outputSummary: "Negotiation confidence signals updated." });
return { stagePatches, artifacts, actions, eventMessage };
},
};

View File

@@ -10,6 +10,7 @@ import { getMissionDefinition, isActorBackedMission, listMissionDefinitions } fr
import type { GrowActiveMission, MissionActorType, MissionSnapshot } from "../actors/missions/types.js";
import { getSubAgentModules } from "../lib/prompt-loader.js";
import { addMessagePg, createConversationPg, ensureConversation, getConversationPg, listActiveMissionsPg, listConversationsPg, listMessagesPg, resetConversationPg, touchConversationPg, upsertActiveMissionPg } from "../grow/persistence.js";
import { getMissionAction, listMissionActions, updateMissionActionStatus } from "../missions/actions.js";
let _client: Client<Registry> | null = null;
function getClient(): Client<Registry> {
@@ -443,6 +444,49 @@ function buildConversationTools(userId: string) {
},
}),
listMissionActions: tool({
description: "List open mission queue actions/approvals/questions for the user. Use for: what should I do today, what are my agents doing, why is my mission blocked, show approvals.",
inputSchema: z.object({ missionInstanceId: z.string().optional() }),
execute: async ({ missionInstanceId }) => ({
kind: "mission-actions",
actions: await listMissionActions(userId, { missionInstanceId }),
}),
}),
approveMissionAction: tool({
description: "Approve a mission action by id. Only use when the user explicitly asks to approve a specific action.",
inputSchema: z.object({ actionId: z.string() }),
execute: async ({ actionId }) => {
const action = await getMissionAction(userId, actionId);
if (!action) return { kind: "mission-action-approved", actionId, error: "Action not found" };
return { kind: "mission-action-approved", action: await updateMissionActionStatus(userId, actionId, { status: "queued", result: { approvedAt: new Date().toISOString() } }) };
},
}),
rejectMissionAction: tool({
description: "Reject/dismiss a mission action by id. Only use when the user explicitly asks to reject or dismiss a specific action.",
inputSchema: z.object({ actionId: z.string() }),
execute: async ({ actionId }) => ({
kind: "mission-action-rejected",
action: await updateMissionActionStatus(userId, actionId, { status: "dismissed", result: { rejectedAt: new Date().toISOString() } }),
}),
}),
explainMissionProgress: tool({
description: "Explain active mission progress using snapshots and open actions.",
inputSchema: z.object({ missionInstanceId: z.string().optional() }),
execute: async ({ missionInstanceId }) => {
const persisted = await listActiveMissionsPg(userId);
const selected = missionInstanceId ? persisted.filter((item) => item.mission.instanceId === missionInstanceId) : persisted;
return {
kind: "mission-progress",
missions: selected.map((item) => item.mission),
snapshots: selected.map((item) => item.snapshot).filter(Boolean),
actions: await listMissionActions(userId, { missionInstanceId }),
};
},
}),
listMemory: tool({
description: "List memory files for this user by path prefix.",
inputSchema: z.object({ prefix: z.string().optional() }),

View File

@@ -9,6 +9,9 @@ import { isActorBackedMission } from "../missions/registry.js";
import { getPersistedMissionDefinition, listPersistedMissionDefinitions } from "../missions/postgres-registry.js";
import { completeMissionCoachRunPg, createMissionCoachRunPg, getActiveMissionPg, listActiveMissionsPg, listMissionSuggestionsPg, replaceMissionSuggestionsPg, upsertActiveMissionPg } from "../grow/persistence.js";
import { buildDeterministicMissionSuggestions } from "../missions/suggestions.js";
import { createMissionAction, getMissionAction, listMissionActions, updateMissionActionStatus } from "../missions/actions.js";
import { recordGrowEvent } from "../events/record-grow-event.js";
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
let _client: Client<Registry> | null = null;
function getClient(): Client<Registry> {
@@ -55,6 +58,15 @@ const addArtifactSchema = z.object({
metadata: z.record(z.unknown()).optional(),
});
const answerActionSchema = z.object({
input: z.record(z.unknown()).optional(),
answer: z.string().optional(),
});
const snoozeActionSchema = z.object({
until: z.string().datetime().optional(),
});
const createInstanceId = (missionId: string) =>
`${missionId}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
@@ -126,10 +138,15 @@ export function missionRoutes() {
await Promise.all(persisted.map(async (item) => {
suggestionsByMission[item.mission.instanceId] = await listMissionSuggestionsPg(userId, item.mission.instanceId);
}));
const actionsByMission: Record<string, unknown[]> = {};
await Promise.all(persisted.map(async (item) => {
actionsByMission[item.mission.instanceId] = await listMissionActions(userId, { missionInstanceId: item.mission.instanceId });
}));
return c.json({
missions: persisted.map((item) => item.mission),
snapshots: persisted.map((item) => item.snapshot).filter((snapshot): snapshot is MissionSnapshot => Boolean(snapshot)),
suggestionsByMission,
actionsByMission,
});
});
@@ -138,7 +155,24 @@ export function missionRoutes() {
const active = await getActiveMissionPg(userId, c.req.param("instanceId"));
if (!active) return c.json({ error: "mission_not_found" }, 404);
const suggestions = await listMissionSuggestionsPg(userId, active.mission.instanceId);
return c.json({ mission: active.mission, snapshot: active.snapshot, suggestions });
const actions = await listMissionActions(userId, { missionInstanceId: active.mission.instanceId });
return c.json({ mission: active.mission, snapshot: active.snapshot, suggestions, actions });
});
app.get("/active/:instanceId/actions", async (c) => {
const userId = c.get("userId");
const active = await getActiveMissionPg(userId, c.req.param("instanceId"));
if (!active) return c.json({ error: "mission_not_found" }, 404);
return c.json({ actions: await listMissionActions(userId, { missionInstanceId: active.mission.instanceId }) });
});
app.post("/active/:instanceId/scrum/run", async (c) => {
const userId = c.get("userId");
const active = await getActiveMissionPg(userId, c.req.param("instanceId"));
if (!active?.mission.actorType) return c.json({ error: "mission_not_found" }, 404);
const result = await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).runDailyScrum({ trigger: "manual" });
if (result.snapshot) await upsertActiveMissionPg(userId, activeMissionFromSnapshot(result.snapshot), result.snapshot);
return c.json({ summary: result.summary, snapshot: result.snapshot });
});
app.post("/active/:instanceId/coach/run", async (c) => {
@@ -190,6 +224,77 @@ export function missionRoutes() {
return c.json({ coachRunId: run.id, summary, suggestions });
});
app.post("/actions/:actionId/approve", async (c) => {
const userId = c.get("userId");
const action = await getMissionAction(userId, c.req.param("actionId"));
if (!action) return c.json({ error: "action_not_found" }, 404);
const updated = await updateMissionActionStatus(userId, action.id, {
status: "queued",
result: { approvedAt: new Date().toISOString() },
payload: { ...(action.payload ?? {}), approved: true },
});
const active = await getActiveMissionPg(userId, action.missionInstanceId);
if (active?.mission.actorType) {
await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).resolveHitl({ actionId: action.id, resolution: "approved" }).catch(() => undefined);
}
return c.json({ action: updated });
});
app.post("/actions/:actionId/reject", async (c) => {
const userId = c.get("userId");
const action = await updateMissionActionStatus(userId, c.req.param("actionId"), { status: "dismissed", result: { rejectedAt: new Date().toISOString() } });
if (!action) return c.json({ error: "action_not_found" }, 404);
return c.json({ action });
});
app.post("/actions/:actionId/run", async (c) => {
const userId = c.get("userId");
const existing = await getMissionAction(userId, c.req.param("actionId"));
if (!existing) return c.json({ error: "action_not_found" }, 404);
const active = await getActiveMissionPg(userId, existing.missionInstanceId);
if (active?.mission.actorType) {
await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).runAction({ actionId: existing.id }).catch(() => undefined);
}
const href = typeof existing.payload?.href === "string" ? existing.payload.href : `/missions/active?missionInstanceId=${encodeURIComponent(existing.missionInstanceId)}`;
const action = await updateMissionActionStatus(userId, existing.id, {
status: "done",
result: {
ranAt: new Date().toISOString(),
message: existing.toolName?.startsWith("resume.")
? "Resume Agent prepared the draft brief. Open Resume Builder to apply it."
: "Action marked complete. Continue from the linked GrowQR surface.",
href,
},
});
return c.json({ action });
});
app.post("/actions/:actionId/answer", async (c) => {
const userId = c.get("userId");
const existing = await getMissionAction(userId, c.req.param("actionId"));
if (!existing) return c.json({ error: "action_not_found" }, 404);
const body = answerActionSchema.parse(await c.req.json().catch(() => ({})));
const input = body.input ?? (body.answer ? { answer: body.answer } : {});
const action = await updateMissionActionStatus(userId, existing.id, {
status: "done",
result: { answeredAt: new Date().toISOString(), input },
payload: { ...(existing.payload ?? {}), userInput: input },
});
const active = await getActiveMissionPg(userId, existing.missionInstanceId);
if (active?.mission.actorType) {
await missionActorFor(userId, active.mission.instanceId, active.mission.actorType).resolveHitl({ actionId: existing.id, resolution: "answered", input }).catch(() => undefined);
}
return c.json({ action });
});
app.post("/actions/:actionId/snooze", async (c) => {
const userId = c.get("userId");
const body = snoozeActionSchema.parse(await c.req.json().catch(() => ({})));
const action = await updateMissionActionStatus(userId, c.req.param("actionId"), { status: "snoozed", result: { snoozedAt: new Date().toISOString(), until: body.until } });
if (!action) return c.json({ error: "action_not_found" }, 404);
return c.json({ action });
});
app.post("/:missionId/start", async (c) => {
const userId = c.get("userId");
const missionId = c.req.param("missionId");
@@ -215,6 +320,16 @@ export function missionRoutes() {
await upsertActiveMissionPg(userId, activeMission, snapshot);
const grow = growFor(userId);
grow.setup({ userId }).then(() => grow.registerActiveMission(activeMission)).catch((err) => console.warn("growActor mission mirror failed", err));
recordGrowEvent({
source: "growqr-backend:missions",
type: "mission.started",
category: "mission",
userId,
occurredAt: new Date().toISOString(),
mission: { instanceId, missionId, stageId: snapshot.currentStageId },
payload: { goal: body.goal, input: body.input ?? {}, title: snapshot.title },
dedupeKey: `mission-started:${instanceId}`,
}).then((event) => routeGrowEventToUserActor(event)).catch((err) => console.warn("mission start event routing failed", err));
return c.json({ mission: activeMission, snapshot }, 201);
});

View File

@@ -12,15 +12,9 @@ import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
import { log } from "../log.js";
const LANDING_AGENTS = [
{ id: "resume", title: "The Resume Expert", agent: "AI Resume Expert", description: "Writes resumes and cover letters that get past ATS and into human hands.", route: "/agents/resume" },
{ id: "interview", title: "The Interviewer", agent: "AI Interviewer", description: "Puts you through real interviews before the real one. Brutal feedback included.", route: "/agents/interview" },
{ id: "roleplay", title: "The Roleplay Coach", agent: "AI Roleplay Coach", description: "Practises the hard conversations with you — so you're never caught off guard.", route: "/agents/roleplay" },
{ id: "assessments", title: "The Skill Trainer", agent: "AI Skill Trainer", description: "Finds exactly what's holding you back — then builds a plan to close it.", route: "/agents/assessments" },
{ id: "social-branding", title: "The Brand Voice", agent: "AI Brand Voice", description: "Makes you impossible to ignore online — posts, profiles, and positioning sorted.", route: "/agents/social-branding" },
{ id: "pathways-report", title: "The Pathway Guide", agent: "AI Pathway Guide", description: "Maps career options, pivots, and what-fits-next at every stage of your journey.", route: "/agents/pathways-report" },
{ id: "courses", title: "The Learning Curator", agent: "AI Learning Curator", description: "Picks only what's worth your time — courses and paths that move you forward.", route: "/agents/courses" },
{ id: "jobs", title: "The Opportunity Finder", agent: "AI Job Finder", description: "Daily, agent-sourced shortlist of roles matched to your QX and goals.", route: "/agents/jobs" },
{ id: "presence", title: "The Presence Coach", agent: "AI Presence Coach", description: "Tells you what to wear, how to speak, and how to carry yourself in every room.", route: "/agents/presence" },
{ id: "resume", title: "Resume", agent: "Resume Strategist", description: "Resume proof, versions, parsing, analysis, and mission artifacts.", route: "/agents/resume" },
{ id: "interview", title: "Interview", agent: "Interview Coach", description: "Mock interviews, reviews, weakness diagnosis, and readiness signals.", route: "/agents/interview" },
{ id: "roleplay", title: "Roleplay", agent: "Roleplay Coach", description: "Negotiation, promotion, transition, and communication drills.", route: "/agents/roleplay" },
] as const;
const DEFAULT_QSCORE = {
@@ -85,6 +79,26 @@ function eventTypeForReview(prefix: "interview" | "roleplay", result: Record<str
return `${prefix}.review_processing`;
}
function resumeEventTypeForRest(method: string, rest: string, ok: boolean) {
if (!ok) return "resume.request_failed";
if (method === "POST" && /^resumes\/upload/.test(rest)) return "resume.uploaded";
if (method === "POST" && /^parse\/resume\/[^/]+\/parse/.test(rest)) return "resume.parsed";
if (method === "POST" && /^ai\/analyze\//.test(rest)) return "resume.analysis_completed";
if ((method === "POST" || method === "PUT" || method === "PATCH") && /versions?/.test(rest)) return "resume.version_created";
if (method !== "GET") return "resume.updated";
return "resume.loaded";
}
function parseJsonBody(body: ArrayBuffer | undefined, headers: Headers): JsonObject {
if (!body || !headers.get("content-type")?.includes("application/json")) return {};
try {
const parsed = JSON.parse(Buffer.from(body).toString("utf8"));
return isRecord(parsed) ? parsed as JsonObject : {};
} catch {
return {};
}
}
async function proxyResumeRequest(req: Request, rest: string, userId: string) {
const incoming = new URL(req.url);
const normalizedRest = rest
@@ -106,13 +120,36 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
const method = req.method.toUpperCase();
const body = ["GET", "HEAD"].includes(method) ? undefined : await req.arrayBuffer();
const requestJson = parseJsonBody(body, headers);
const res = await fetch(target, {
method,
headers,
body,
});
return new Response(res.body, {
if (method === "GET" || method === "HEAD") {
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: res.headers });
}
const responseBuffer = await res.arrayBuffer();
const responseText = Buffer.from(responseBuffer).toString("utf8");
let responseJson: unknown;
try { responseJson = responseText ? JSON.parse(responseText) : undefined; } catch { responseJson = undefined; }
const responseObj = isRecord(responseJson) ? responseJson : { body: responseText.slice(0, 2000) };
await recordGatewayEvent({
userId,
source: "resume-builder",
type: resumeEventTypeForRest(method, normalizedRest, res.ok),
payload: { request: requestJson, result: responseObj, status: res.status, path: normalizedRest },
correlation: {
resumeId: getString(responseObj.resume_id ?? responseObj.resumeId ?? responseObj.id) ?? getString(requestJson.resume_id ?? requestJson.resumeId),
externalId: getString(responseObj.resume_id ?? responseObj.resumeId ?? responseObj.id) ?? getString(requestJson.resume_id ?? requestJson.resumeId),
},
mission: missionFromBody(requestJson),
}).catch((err) => log.warn({ err, path: normalizedRest }, "failed to record resume gateway event"));
return new Response(responseBuffer, {
status: res.status,
statusText: res.statusText,
headers: res.headers,

View File

@@ -2,7 +2,7 @@ import type { WorkflowDefinition } from "./types.js";
import { displayLabelForExecution, displayLabelForService } from "../features/registry.js";
const serviceLabel = (serviceId: string) => displayLabelForService(serviceId) ?? serviceId;
const planningLabel = displayLabelForExecution("opencode") ?? "Mission Planning";
const planningLabel = displayLabelForExecution("manual") ?? "Mission Planner";
const commonInputs = [
{ id: "goal", label: "Target outcome", type: "text", required: true },
@@ -11,53 +11,151 @@ const commonInputs = [
export const workflowDefinitions: WorkflowDefinition[] = [
{
id: "interview-to-offer", version: "1.0.0", title: "Interview to Offer", shortTitle: "Interview to Offer",
promise: "Turn a scheduled interview into a focused prep plan, practice sessions, and a readiness report.", segment: ["job-seekers", "interviewing"], urgency: "high", estimatedDuration: "2-5 days", priceTier: "starter", sku: "workflow_interview_to_offer", isPurchasable: true, isFreePreview: true,
visual: { icon: "briefcase-business", color: "emerald", mascotAgentIds: ["interview", "roleplay", "qscore"] }, requiredInputs: commonInputs,
id: "interview-to-offer",
version: "2.0.0",
title: "Interview-to-Offer Accelerator",
shortTitle: "Interview to Offer",
promise: "Prepare me for this specific interview and help me convert it into an offer.",
segment: ["job-seekers", "interviewing", "urgent"],
urgency: "high",
estimatedDuration: "2-5 days",
priceTier: "starter",
sku: "workflow_interview_to_offer",
isPurchasable: true,
isFreePreview: true,
visual: { icon: "briefcase-business", color: "emerald", mascotAgentIds: ["interview", "roleplay", "resume", "qscore"] },
requiredInputs: commonInputs,
modules: [
{ id: "resume", title: "Resume fit scan", role: serviceLabel("resume-service"), description: "Analyze resume readiness for the target role.", execution: "service", service: "resume-service" },
{ id: "interview-plan", title: "Interview prep plan", role: planningLabel, description: "Generate a prep plan and likely questions artifact.", execution: "opencode", promptPath: "prompts/workflows/interview-to-offer/interview-plan.md", artifactTypes: ["interview_plan"], approvalGateAfter: "review-plan" },
{ id: "interview", title: "Mock interview", role: serviceLabel("interview-service"), description: "Create a real interview practice session.", execution: "service", service: "interview-service" },
{ id: "roleplay", title: "Communication roleplay", role: serviceLabel("roleplay-service"), description: "Create a realistic roleplay session.", execution: "service", service: "roleplay-service" },
{ id: "branding", title: "Social branding", role: serviceLabel("social-branding-service"), description: "Optimize LinkedIn and professional presence.", execution: "service", service: "social-branding-service" },
{ id: "matchmaking", title: "Matchmaking", role: serviceLabel("matchmaking-service"), description: "Connect with mentors and opportunities.", execution: "service", service: "matchmaking-service" },
{ id: "qscore", title: "Readiness Q Score", role: serviceLabel("qscore-service"), description: "Compute readiness score.", execution: "service", service: "qscore-service" },
{ id: "plan", title: "Interview prep plan", role: planningLabel, description: "Clarify target role, company context, likely questions, and prep priorities.", execution: "manual" },
{ id: "resume", title: "Resume-based talking points", role: serviceLabel("resume-service"), description: "Extract role-fit proof, gaps, and tailored talking points from the resume.", execution: "service", service: "resume-service" },
{ id: "interview", title: "Mock interview sessions", role: serviceLabel("interview-service"), description: "Run interview practice and collect weakness diagnosis.", execution: "service", service: "interview-service" },
{ id: "roleplay", title: "Behavioral/story bank practice", role: serviceLabel("roleplay-service"), description: "Practice concise stories, objections, and communication recovery.", execution: "service", service: "roleplay-service" },
{ id: "qscore", title: "Final readiness score", role: serviceLabel("qscore-service"), description: "Continuously score readiness from resume, interview, and roleplay signals.", execution: "service", service: "qscore-service" },
],
outputs: [{ id: "interview_plan", type: "markdown", title: "Interview prep plan", path: "artifacts/interview-to-offer/interview-plan.md" }], qscoreDimensions: ["clarity", "communication", "role_fit"], approvalGates: [{ id: "review-plan", title: "Review prep plan", description: "User reviews generated plan before practice.", required: false }],
outputs: [
{ id: "interview_prep_plan", type: "markdown", title: "Interview prep plan" },
{ id: "story_bank", type: "markdown", title: "Behavioral/story bank" },
{ id: "readiness_report", type: "scorecard", title: "Final readiness score" },
],
qscoreDimensions: ["role_fit", "communication", "confidence"],
approvalGates: [],
},
{
id: "career-transition", version: "1.0.0", title: "Switch Careers", shortTitle: "Switch Careers", promise: "Map transferable abilities and produce a transition narrative.", segment: ["career-changers"], urgency: "medium", estimatedDuration: "1-2 weeks", priceTier: "starter", sku: "workflow_career_transition", isPurchasable: true, isFreePreview: true, visual: { icon: "route", color: "blue", mascotAgentIds: ["resume", "qscore"] }, requiredInputs: commonInputs,
id: "career-transition",
version: "2.0.0",
title: "Career Transition Accelerator",
shortTitle: "Career Transition",
promise: "Help me reposition from my current career into a better-fit role.",
segment: ["career-changers"],
urgency: "medium",
estimatedDuration: "1-2 weeks",
priceTier: "starter",
sku: "workflow_career_transition",
isPurchasable: true,
isFreePreview: true,
visual: { icon: "route", color: "blue", mascotAgentIds: ["resume", "interview", "roleplay", "qscore"] },
requiredInputs: commonInputs,
modules: [
{ id: "transition-map", title: "Transition map", role: planningLabel, description: "Generate skills map and positioning narrative.", execution: "opencode", promptPath: "prompts/workflows/career-transition/orchestrator.md", artifactTypes: ["transition_map"] },
{ id: "resume", title: "Resume fit scan", role: serviceLabel("resume-service"), description: "Analyze resume for target path.", execution: "service", service: "resume-service" },
{ id: "branding", title: "Social branding", role: serviceLabel("social-branding-service"), description: "Build transition narrative on LinkedIn.", execution: "service", service: "social-branding-service" },
{ id: "matchmaking", title: "Matchmaking", role: serviceLabel("matchmaking-service"), description: "Connect with mentors in the target field.", execution: "service", service: "matchmaking-service" },
], outputs: [{ id: "transition_map", type: "markdown", title: "Transition map" }], qscoreDimensions: ["positioning", "skills", "confidence"], approvalGates: [],
{ id: "clarify-target", title: "Target role recommendation", role: planningLabel, description: "Clarify current role, target role, constraints, and transition thesis.", execution: "manual" },
{ id: "resume", title: "Transferable skills map", role: serviceLabel("resume-service"), description: "Map transferable skills and reposition resume proof for the target lane.", execution: "service", service: "resume-service" },
{ id: "interview", title: "Adjacent-role interview narrative", role: serviceLabel("interview-service"), description: "Validate credibility for the new role through mock interview practice.", execution: "service", service: "interview-service" },
{ id: "roleplay", title: "Why I am switching practice", role: serviceLabel("roleplay-service"), description: "Practice the transition pitch and hard follow-up questions.", execution: "service", service: "roleplay-service" },
{ id: "qscore", title: "Transition readiness delta", role: serviceLabel("qscore-service"), description: "Track readiness gains and the next missing proof.", execution: "service", service: "qscore-service" },
],
outputs: [
{ id: "target_role_recommendation", type: "markdown", title: "Target role recommendation" },
{ id: "transition_plan", type: "markdown", title: "30/60/90 transition plan" },
],
qscoreDimensions: ["positioning", "transferable_skills", "confidence"],
approvalGates: [],
},
{
id: "salary-negotiation-war-room", version: "1.0.0", title: "Negotiate Salary", shortTitle: "Negotiate Salary", promise: "Prepare scripts, ranges, and roleplay for an offer conversation.", segment: ["offer-stage"], urgency: "high", estimatedDuration: "24-72 hours", priceTier: "premium", sku: "workflow_salary_negotiation", isPurchasable: true, isFreePreview: false, visual: { icon: "badge-dollar-sign", color: "amber", mascotAgentIds: ["roleplay"] }, requiredInputs: commonInputs,
id: "salary-negotiation-war-room",
version: "2.0.0",
title: "Salary / Offer Negotiation War Room",
shortTitle: "Negotiation War Room",
promise: "Help me negotiate my offer, raise, or promotion conversation.",
segment: ["offer-stage", "employed"],
urgency: "high",
estimatedDuration: "24-72 hours",
priceTier: "premium",
sku: "workflow_salary_negotiation",
isPurchasable: true,
isFreePreview: false,
visual: { icon: "badge-dollar-sign", color: "amber", mascotAgentIds: ["roleplay", "resume", "qscore"] },
requiredInputs: commonInputs,
modules: [
{ id: "negotiation-script", title: "Negotiation script", role: planningLabel, description: "Generate negotiation strategy and scripts.", execution: "opencode", promptPath: "prompts/workflows/salary-negotiation-war-room/orchestrator.md", artifactTypes: ["negotiation_script"] },
{ id: "roleplay", title: "Negotiation roleplay", role: serviceLabel("roleplay-service"), description: "Create offer negotiation roleplay.", execution: "service", service: "roleplay-service" },
{ id: "matchmaking", title: "Matchmaking", role: serviceLabel("matchmaking-service"), description: "Connect with mentors who navigated similar negotiations.", execution: "service", service: "matchmaking-service" },
], outputs: [{ id: "negotiation_script", type: "markdown", title: "Negotiation script" }], qscoreDimensions: ["voice", "confidence", "strategy"], approvalGates: [],
{ id: "offer-context", title: "Offer analysis", role: planningLabel, description: "Capture offer, target range, leverage, constraints, and deadline.", execution: "manual" },
{ id: "resume", title: "Value evidence map", role: serviceLabel("resume-service"), description: "Extract measurable impact and proof points from resume history.", execution: "service", service: "resume-service" },
{ id: "roleplay", title: "Live-call negotiation practice", role: serviceLabel("roleplay-service"), description: "Practice counteroffer, objection handling, and calm assertiveness.", execution: "service", service: "roleplay-service" },
{ id: "interview", title: "Confidence signal check", role: serviceLabel("interview-service"), description: "Use interview signals when available to assess communication confidence.", execution: "service", service: "interview-service" },
{ id: "qscore", title: "Confidence score", role: serviceLabel("qscore-service"), description: "Track negotiation confidence and communication readiness.", execution: "service", service: "qscore-service" },
],
outputs: [
{ id: "counteroffer_script", type: "markdown", title: "Counteroffer script" },
{ id: "objection_map", type: "markdown", title: "Objection handling map" },
],
qscoreDimensions: ["confidence", "value_evidence", "communication"],
approvalGates: [],
},
{
id: "promotion-readiness", version: "1.0.0", title: "Get Promoted", shortTitle: "Get Promoted", promise: "Build an evidence packet and manager conversation plan.", segment: ["employed"], urgency: "medium", estimatedDuration: "1 week", priceTier: "starter", sku: "workflow_promotion_readiness", isPurchasable: true, isFreePreview: true, visual: { icon: "trending-up", color: "purple", mascotAgentIds: ["roleplay", "qscore"] }, requiredInputs: commonInputs,
id: "promotion-readiness",
version: "2.0.0",
title: "Promotion & Leadership Readiness System",
shortTitle: "Promotion Readiness",
promise: "Help me become promotion-ready and make a strong case for my next level.",
segment: ["employed"],
urgency: "medium",
estimatedDuration: "1 week",
priceTier: "starter",
sku: "workflow_promotion_readiness",
isPurchasable: true,
isFreePreview: true,
visual: { icon: "trending-up", color: "purple", mascotAgentIds: ["roleplay", "resume", "interview", "qscore"] },
requiredInputs: commonInputs,
modules: [
{ id: "evidence-packet", title: "Evidence packet", role: planningLabel, description: "Generate promotion evidence packet.", execution: "opencode", promptPath: "prompts/workflows/promotion-readiness/orchestrator.md", artifactTypes: ["promotion_packet"] },
{ id: "roleplay", title: "Manager conversation roleplay", role: serviceLabel("roleplay-service"), description: "Practice the promotion conversation.", execution: "service", service: "roleplay-service" },
{ id: "branding", title: "Social branding", role: serviceLabel("social-branding-service"), description: "Showcase promotion-worthy impact on LinkedIn.", execution: "service", service: "social-branding-service" },
{ id: "matchmaking", title: "Matchmaking", role: serviceLabel("matchmaking-service"), description: "Connect with senior leaders for sponsorship.", execution: "service", service: "matchmaking-service" },
], outputs: [{ id: "promotion_packet", type: "markdown", title: "Promotion evidence packet" }], qscoreDimensions: ["impact", "leadership", "communication"], approvalGates: [],
{ id: "promotion-context", title: "Promotion target", role: planningLabel, description: "Clarify desired level, timeline, stakeholders, and manager context.", execution: "manual" },
{ id: "resume", title: "Achievement evidence packet", role: serviceLabel("resume-service"), description: "Extract impact bullets and leadership proof from resume history.", execution: "service", service: "resume-service" },
{ id: "roleplay", title: "Manager conversation script", role: serviceLabel("roleplay-service"), description: "Practice the promotion conversation with objections and follow-ups.", execution: "service", service: "roleplay-service" },
{ id: "interview", title: "Leadership gap practice", role: serviceLabel("interview-service"), description: "Practice leadership narratives and detect communication gaps.", execution: "service", service: "interview-service" },
{ id: "qscore", title: "Leadership readiness score", role: serviceLabel("qscore-service"), description: "Track readiness and confidence trend.", execution: "service", service: "qscore-service" },
],
outputs: [
{ id: "promotion_packet", type: "markdown", title: "Promotion evidence packet" },
{ id: "manager_script", type: "markdown", title: "Manager conversation script" },
],
qscoreDimensions: ["impact", "leadership", "communication"],
approvalGates: [],
},
{
id: "personal-brand-opportunity-engine", version: "1.0.0", title: "Build Your Brand", shortTitle: "Build Your Brand", promise: "Draft profile positioning and a weekly opportunity/content plan.", segment: ["networking", "creators"], urgency: "low", estimatedDuration: "1 week", priceTier: "starter", sku: "workflow_brand_engine", isPurchasable: true, isFreePreview: true, visual: { icon: "sparkles", color: "pink", mascotAgentIds: ["qscore"] }, requiredInputs: commonInputs,
id: "personal-brand-opportunity-engine",
version: "2.0.0",
title: "Personal Brand & Opportunity Engine",
shortTitle: "Brand Engine",
promise: "Make me visible and credible so better opportunities come to me.",
segment: ["networking", "creators", "job-seekers"],
urgency: "low",
estimatedDuration: "1 week",
priceTier: "starter",
sku: "workflow_brand_engine",
isPurchasable: true,
isFreePreview: true,
visual: { icon: "sparkles", color: "pink", mascotAgentIds: ["resume", "roleplay", "qscore"] },
requiredInputs: commonInputs,
modules: [
{ id: "profile-rewrite", title: "Profile rewrite", role: planningLabel, description: "Generate LinkedIn/profile rewrite draft.", execution: "opencode", promptPath: "prompts/workflows/personal-brand-opportunity-engine/orchestrator.md", artifactTypes: ["profile_rewrite", "content_plan"] },
{ id: "branding", title: "Social branding", role: serviceLabel("social-branding-service"), description: "Optimize profile and content strategy.", execution: "service", service: "social-branding-service" },
{ id: "matchmaking", title: "Matchmaking", role: serviceLabel("matchmaking-service"), description: "Connect with collaborators and brand amplifiers.", execution: "service", service: "matchmaking-service" },
], outputs: [{ id: "profile_rewrite", type: "markdown", title: "Profile rewrite" }, { id: "content_plan", type: "markdown", title: "Weekly content plan" }], qscoreDimensions: ["visibility", "network", "voice"], approvalGates: [],
{ id: "positioning", title: "Positioning statement", role: planningLabel, description: "Clarify target audience, positioning, and credibility theme.", execution: "manual" },
{ id: "resume", title: "Proof point extraction", role: serviceLabel("resume-service"), description: "Turn resume proof into brand themes and profile claims.", execution: "service", service: "resume-service" },
{ id: "roleplay", title: "Networking scripts", role: serviceLabel("roleplay-service"), description: "Practice intros, outreach, and opportunity conversations.", execution: "service", service: "roleplay-service" },
{ id: "interview", title: "Credibility signal mining", role: serviceLabel("interview-service"), description: "Use interview strengths and gaps as brand signal when available.", execution: "service", service: "interview-service" },
{ id: "qscore", title: "Brand growth score", role: serviceLabel("qscore-service"), description: "Track visibility/readiness signals from practice and profile proof.", execution: "service", service: "qscore-service" },
],
outputs: [
{ id: "profile_rewrite", type: "markdown", title: "LinkedIn/profile rewrite" },
{ id: "content_pillars", type: "markdown", title: "Content pillars" },
{ id: "weekly_post_drafts", type: "markdown", title: "Weekly post drafts" },
],
qscoreDimensions: ["visibility", "credibility", "voice"],
approvalGates: [],
},
];