Files
growqr-backend/src/actors/missions/mission-actor-factory.ts
2026-06-06 03:25:29 +05:30

311 lines
11 KiB
TypeScript

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