Intelligence
Artifacts
Browse the repository, read documents, and manage the governance folders. Source, runtime, and infrastructure are read-only.
Repository
repositories/aaf-holdings/hq01/lib/missions/manager.ts
7.7 KB
import fs from "node:fs";
import path from "node:path";
import { listSessions } from "@/lib/sessions/manager";
import { ORGANIZATION } from "./config";
import { allowedNextStates, canTransition } from "./lifecycle";
import { missionCompletionBlocked } from "./objectives";
import {
appendEvent,
ensureMissionDirs,
isValidMissionId,
listMissionIds,
listReports,
nextMissionId,
readHistory,
readMission,
reportStored,
storeReport,
writeMission,
} from "./store";
import type {
CreateMissionInput,
Mission,
MissionEvent,
MissionEventType,
MissionStatus,
MissionView,
UpdateMissionInput,
} from "./types";
/**
* The Mission Registry manager — the one place that creates, reads, updates, and
* archives missions, and that maintains their append-only history. It also
* collects execution reports from completed dispatched sessions into the owning
* mission (PASS M0 report storage), reading sessions through the Session Manager
* without reaching into its internals.
*/
export class MissionError extends Error {
constructor(
message: string,
readonly status = 400,
) {
super(message);
this.name = "MissionError";
}
}
function nowIso(): string {
return new Date().toISOString();
}
function event(
type: MissionEventType,
extra: Partial<MissionEvent> = {},
): MissionEvent {
return { type, at: nowIso(), ...extra };
}
/** Append a history event to a mission. */
export function recordEvent(id: string, ev: MissionEvent): void {
appendEvent(id, ev);
}
export function createMission(input: CreateMissionInput): Mission {
const title = input.title?.trim();
if (!title) throw new MissionError("Title is required.");
const description = input.description?.trim();
if (!description) throw new MissionError("Description is required.");
const product = input.product?.trim();
if (!product) throw new MissionError("Product is required.");
const repository = input.repository?.trim();
if (!repository) throw new MissionError("Repository is required.");
const owner = input.executive_owner?.trim();
if (!owner) throw new MissionError("Executive owner is required.");
if (!input.priority) throw new MissionError("Priority is required.");
const id = nextMissionId();
const created = nowIso();
const mission: Mission = {
id,
title,
description,
organization: input.organization?.trim() || ORGANIZATION,
company: input.company?.trim() || null,
product,
repository,
executive_owner: owner,
status: "Draft",
priority: input.priority,
created_at: created,
updated_at: created,
};
ensureMissionDirs(id);
writeMission(mission);
recordEvent(id, event("mission_created", { detail: title }));
recordEvent(id, event("executive_assigned", { detail: owner }));
return mission;
}
export function getMission(id: string): Mission | null {
if (!isValidMissionId(id)) return null;
return readMission(id);
}
export function listMissions(): Mission[] {
return listMissionIds()
.map((id) => readMission(id))
.filter((m): m is Mission => Boolean(m))
.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
}
export function updateMission(id: string, patch: UpdateMissionInput): Mission {
const mission = getMission(id);
if (!mission) throw new MissionError("Mission not found.", 404);
const prevOwner = mission.executive_owner;
const next: Mission = { ...mission };
if (patch.title !== undefined) next.title = patch.title.trim();
if (patch.description !== undefined) next.description = patch.description.trim();
if (patch.product !== undefined) next.product = patch.product.trim();
if (patch.repository !== undefined) next.repository = patch.repository.trim();
if (patch.company !== undefined) next.company = patch.company?.trim() || null;
if (patch.priority !== undefined) next.priority = patch.priority;
// Status is governed by transitionMission, never edited directly here.
if (patch.executive_owner !== undefined) {
next.executive_owner = patch.executive_owner?.trim() || null;
}
next.updated_at = nowIso();
writeMission(next);
recordEvent(id, event("mission_updated"));
if (patch.executive_owner !== undefined && next.executive_owner !== prevOwner) {
recordEvent(id, event("executive_assigned", { detail: next.executive_owner ?? "(unassigned)" }));
}
return next;
}
/**
* Transition a mission to a new state, enforcing the lifecycle state machine.
* Invalid transitions are rejected. Records an append-only state_changed event.
*/
export function transitionMission(
id: string,
to: MissionStatus,
opts: { actor?: string; reason?: string } = {},
): Mission {
const mission = getMission(id);
if (!mission) throw new MissionError("Mission not found.", 404);
const from = mission.status;
if (!canTransition(from, to)) {
const allowed = allowedNextStates(from);
throw new MissionError(
`Invalid transition: ${from} → ${to}. Allowed from ${from}: ${
allowed.length ? allowed.join(", ") : "(none — terminal)"
}.`,
409,
);
}
// Completion is gated by objectives (PASS M2): every objective must be done.
if (to === "Completed" && missionCompletionBlocked(id)) {
throw new MissionError(
"Mission completion blocked. Incomplete Objectives remain.",
409,
);
}
const next: Mission = { ...mission, status: to, updated_at: nowIso() };
writeMission(next);
recordEvent(
id,
event("state_changed", {
previous_state: from,
new_state: to,
actor: opts.actor ?? "CEO",
reason: opts.reason,
detail: `${from} → ${to}`,
}),
);
return next;
}
/** Archive a mission (governed: only valid from Completed or Cancelled). */
export function archiveMission(id: string, actor = "CEO"): Mission {
return transitionMission(id, "Archived", { actor });
}
/** Record that a dispatch began under this mission. */
export function recordDispatchStarted(
id: string,
opts: { sessionId: string; executiveId: string; instruction: string },
): void {
recordEvent(
id,
event("dispatch_started", {
session_id: opts.sessionId,
by: opts.executiveId,
detail: opts.instruction.slice(0, 140),
}),
);
}
/**
* Collect reports from completed dispatched sessions belonging to this mission.
* Idempotent: a session's report is stored once. Appends report_added and
* dispatch_completed history the first time each report lands.
*/
export function collectReports(id: string): number {
const mission = getMission(id);
if (!mission) return 0;
let collected = 0;
const sessions = listSessions().filter((s) => s.mission_id === id);
for (const s of sessions) {
if (s.status !== "completed") continue;
if (reportStored(id, s.id)) continue;
const root = s.runtime_write_root || s.working_directory;
const candidates = [
path.join(root, "report.md"),
path.join(root, "outputs", "report.md"),
];
const found = candidates.find((p) => {
try {
return fs.statSync(p).isFile();
} catch {
return false;
}
});
if (!found) continue;
let content: string;
try {
content = fs.readFileSync(found, "utf8");
} catch {
continue;
}
storeReport(id, s.id, content);
recordEvent(id, event("report_added", { session_id: s.id, report_id: s.id }));
recordEvent(id, event("dispatch_completed", { session_id: s.id, by: s.executive }));
collected += 1;
}
return collected;
}
/** Mission plus history and reports, collecting any newly-finished reports. */
export function getMissionView(id: string): MissionView | null {
const mission = getMission(id);
if (!mission) return null;
collectReports(id);
return {
mission,
history: readHistory(id).sort((a, b) => b.at.localeCompare(a.at)),
reports: listReports(id),
};
}
root · /srv/aaf