feat(FN-1217): add mission observability events and APIs

- Add mission observability types and core exports for mission health snapshots and event records
- Extend SQLite schema and MissionStore with mission_events persistence plus health and staleness query helpers
- Emit mission start and autopilot lifecycle events from MissionAutopilot for richer runtime telemetry
- Add dashboard mission observability routes and end-to-end coverage for mission events and health APIs
- Expand unit tests across core and engine and include a changeset for mission observability updates
This commit is contained in:
gsxdsm
2026-04-08 08:22:33 -07:00
parent 8e61566ad1
commit 2a923ea1a0
11 changed files with 882 additions and 15 deletions

View File

@@ -18,6 +18,8 @@ import type {
Slice,
MissionFeature,
MissionWithHierarchy,
MissionEvent,
MissionHealth,
} from "@fusion/core";
import {
__resetMissionInterviewState,
@@ -31,6 +33,7 @@ function createMockMissionStore() {
const milestones: Map<string, Milestone> = new Map();
const slices: Map<string, Slice> = new Map();
const features: Map<string, MissionFeature> = new Map();
const missionEvents: Map<string, MissionEvent[]> = new Map();
let missionCounter = 1;
let milestoneCounter = 1;
@@ -103,6 +106,40 @@ function createMockMissionStore() {
completedFeatures: 0,
})),
getMissionEvents: vi.fn((missionId: string, options?: { limit?: number; offset?: number; eventType?: string }) => {
const allEvents = missionEvents.get(missionId) ?? [];
const filtered = options?.eventType
? allEvents.filter((event) => event.eventType === options.eventType)
: allEvents;
const limit = options?.limit ?? 50;
const offset = options?.offset ?? 0;
return {
events: filtered.slice(offset, offset + limit),
total: filtered.length,
};
}),
getMissionHealth: vi.fn((missionId: string): MissionHealth | undefined => {
const mission = missions.get(missionId);
if (!mission) return undefined;
return {
missionId,
status: mission.status,
tasksCompleted: 0,
tasksFailed: 0,
tasksInFlight: 0,
totalTasks: 0,
currentSliceId: undefined,
currentMilestoneId: undefined,
estimatedCompletionPercent: 0,
lastErrorAt: undefined,
lastErrorDescription: undefined,
autopilotState: mission.autopilotState ?? "inactive",
autopilotEnabled: mission.autopilotEnabled ?? false,
lastActivityAt: mission.lastAutopilotActivityAt,
};
}),
updateMission: vi.fn((id: string, updates: Partial<Mission>) => {
const mission = missions.get(id);
if (!mission) throw new Error("Mission " + id + " not found");
@@ -406,6 +443,139 @@ describe("Mission API", () => {
});
});
describe("Mission observability endpoints", () => {
it("GET /api/missions/:missionId/events returns paginated events", async () => {
const { app, missionStore } = buildApp();
const mission = missionStore.createMission({ title: "Observable Mission" });
const mockEvents: MissionEvent[] = [
{
id: "ME-003",
missionId: mission.id,
eventType: "warning",
description: "Stale warning",
metadata: { category: "autopilot_stale" },
timestamp: "2026-04-08T12:02:00.000Z",
},
{
id: "ME-002",
missionId: mission.id,
eventType: "error",
description: "Autopilot failed",
metadata: { retryCount: 3 },
timestamp: "2026-04-08T12:01:00.000Z",
},
];
missionStore.getMissionEvents.mockReturnValue({ events: mockEvents, total: 7 });
const res = await get(app, `/api/missions/${mission.id}/events`);
expect(res.status).toBe(200);
expect(res.body).toEqual({
events: mockEvents,
total: 7,
limit: 50,
offset: 0,
});
expect(missionStore.getMissionEvents).toHaveBeenCalledWith(mission.id, {
limit: 50,
offset: 0,
eventType: undefined,
});
});
it("GET /api/missions/:missionId/events supports limit/offset query params", async () => {
const { app, missionStore } = buildApp();
const mission = missionStore.createMission({ title: "Observable Mission" });
missionStore.getMissionEvents.mockReturnValue({ events: [], total: 42 });
const res = await get(app, `/api/missions/${mission.id}/events?limit=10&offset=5`);
expect(res.status).toBe(200);
expect(res.body.limit).toBe(10);
expect(res.body.offset).toBe(5);
expect(missionStore.getMissionEvents).toHaveBeenCalledWith(mission.id, {
limit: 10,
offset: 5,
eventType: undefined,
});
});
it("GET /api/missions/:missionId/events supports eventType filtering", async () => {
const { app, missionStore } = buildApp();
const mission = missionStore.createMission({ title: "Observable Mission" });
const filteredEvents: MissionEvent[] = [
{
id: "ME-010",
missionId: mission.id,
eventType: "error",
description: "latest error",
metadata: null,
timestamp: "2026-04-08T12:10:00.000Z",
},
];
missionStore.getMissionEvents.mockReturnValue({ events: filteredEvents, total: 1 });
const res = await get(app, `/api/missions/${mission.id}/events?eventType=error`);
expect(res.status).toBe(200);
expect(res.body.events).toEqual(filteredEvents);
expect(missionStore.getMissionEvents).toHaveBeenCalledWith(mission.id, {
limit: 50,
offset: 0,
eventType: "error",
});
});
it("GET /api/missions/:missionId/events returns 404 for unknown mission", async () => {
const { app } = buildApp();
const res = await get(app, "/api/missions/M-999/events");
expect(res.status).toBe(404);
expect(res.body.error).toBe("Mission not found");
});
it("GET /api/missions/:missionId/health returns mission health", async () => {
const { app, missionStore } = buildApp();
const mission = missionStore.createMission({ title: "Healthy Mission" });
const health: MissionHealth = {
missionId: mission.id,
status: "active",
tasksCompleted: 5,
tasksFailed: 1,
tasksInFlight: 2,
totalTasks: 8,
currentSliceId: "SL-MOCK1-TST",
currentMilestoneId: "MS-MOCK1-TST",
estimatedCompletionPercent: 63,
lastErrorAt: "2026-04-08T12:00:00.000Z",
lastErrorDescription: "Most recent error",
autopilotState: "watching",
autopilotEnabled: true,
lastActivityAt: "2026-04-08T12:05:00.000Z",
};
missionStore.getMissionHealth.mockReturnValue(health);
const res = await get(app, `/api/missions/${mission.id}/health`);
expect(res.status).toBe(200);
expect(res.body).toEqual(health);
expect(missionStore.getMissionHealth).toHaveBeenCalledWith(mission.id);
});
it("GET /api/missions/:missionId/health returns 404 for unknown mission", async () => {
const { app } = buildApp();
const res = await get(app, "/api/missions/M-999/health");
expect(res.status).toBe(404);
expect(res.body.error).toBe("Mission not found");
});
});
describe("PATCH /api/missions/:missionId", () => {
it("should update mission status and auto-advance", async () => {
const { app, missionStore } = buildApp();

View File

@@ -743,6 +743,83 @@ export function createMissionRouter(
})
);
/**
* GET /api/missions/:missionId/events
* Get paginated mission event log
*/
router.get(
"/:missionId/events",
asyncHandler(async (req, res) => {
const { missionId } = req.params;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
return;
}
const mission = missionStore.getMission(missionId);
if (!mission) {
res.status(404).json({ error: "Mission not found" });
return;
}
const parseIntParam = (value: string | string[] | undefined, fallback: number): number => {
if (typeof value !== "string") return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
};
const limit = Math.min(parseIntParam(req.query.limit as string | string[] | undefined, 50), 200);
const offset = parseIntParam(req.query.offset as string | string[] | undefined, 0);
const eventType = typeof req.query.eventType === "string" && req.query.eventType.trim().length > 0
? req.query.eventType.trim()
: undefined;
const result = missionStore.getMissionEvents(missionId, {
limit,
offset,
eventType,
});
res.json({
events: result.events,
total: result.total,
limit,
offset,
});
})
);
/**
* GET /api/missions/:missionId/health
* Get computed mission health metrics
*/
router.get(
"/:missionId/health",
asyncHandler(async (req, res) => {
const { missionId } = req.params;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
return;
}
const mission = missionStore.getMission(missionId);
if (!mission) {
res.status(404).json({ error: "Mission not found" });
return;
}
const health = missionStore.getMissionHealth(missionId);
if (!health) {
res.status(404).json({ error: "Mission not found" });
return;
}
res.json(health);
})
);
// ── Interview State Endpoints (Mission) ────────────────────────────────────
/**