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:
@@ -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();
|
||||
|
||||
@@ -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) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user