311 lines
11 KiB
TypeScript
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;
|
|
},
|
|
},
|
|
});
|
|
}
|