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:
@@ -69,6 +69,7 @@ describe("Database", () => {
|
||||
expect(tableNames).toContain("milestones");
|
||||
expect(tableNames).toContain("slices");
|
||||
expect(tableNames).toContain("mission_features");
|
||||
expect(tableNames).toContain("mission_events");
|
||||
expect(tableNames).toContain("ai_sessions");
|
||||
expect(tableNames).toContain("messages");
|
||||
expect(tableNames).toContain("agentRatings");
|
||||
@@ -93,10 +94,13 @@ describe("Database", () => {
|
||||
expect(indexNames).toContain("idxMessagesTo");
|
||||
expect(indexNames).toContain("idxAgentRatingsAgentId");
|
||||
expect(indexNames).toContain("idxAgentRatingsCreatedAt");
|
||||
expect(indexNames).toContain("idxMissionEventsMissionId");
|
||||
expect(indexNames).toContain("idxMissionEventsTimestamp");
|
||||
expect(indexNames).toContain("idxMissionEventsType");
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(16);
|
||||
expect(db.getSchemaVersion()).toBe(17);
|
||||
});
|
||||
|
||||
it("seeds lastModified", () => {
|
||||
@@ -119,7 +123,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(16);
|
||||
expect(db.getSchemaVersion()).toBe(17);
|
||||
});
|
||||
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
@@ -726,7 +730,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 5 (includes v1→v2, v2→v3, v3→v4, and v4→v5 migrations)
|
||||
expect(db.getSchemaVersion()).toBe(16);
|
||||
expect(db.getSchemaVersion()).toBe(17);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -751,11 +755,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(16);
|
||||
expect(db.getSchemaVersion()).toBe(17);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(16);
|
||||
expect(db.getSchemaVersion()).toBe(17);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -771,7 +775,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(16);
|
||||
expect(db.getSchemaVersion()).toBe(17);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||
@@ -784,6 +788,31 @@ describe("schema migrations", () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("migrates a v16 database by creating mission_events table and indexes", () => {
|
||||
tmpDir = makeTmpDir();
|
||||
const kbDir = join(tmpDir, ".fusion");
|
||||
|
||||
const db = new Database(kbDir);
|
||||
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
|
||||
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '16')");
|
||||
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(17);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||
|
||||
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name = 'mission_events' ORDER BY name").all() as Array<{ name: string }>;
|
||||
const indexNames = indexes.map((index) => index.name);
|
||||
expect(indexNames).toContain("idxMissionEventsMissionId");
|
||||
expect(indexNames).toContain("idxMissionEventsTimestamp");
|
||||
expect(indexNames).toContain("idxMissionEventsType");
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("migrates a v2 database by adding missionId and sliceId columns", () => {
|
||||
tmpDir = makeTmpDir();
|
||||
const kbDir = join(tmpDir, ".fusion");
|
||||
@@ -874,7 +903,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 5
|
||||
expect(db.getSchemaVersion()).toBe(16);
|
||||
expect(db.getSchemaVersion()).toBe(17);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1084,7 +1113,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(16);
|
||||
expect(db.getSchemaVersion()).toBe(17);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 16;
|
||||
const SCHEMA_VERSION = 17;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -329,6 +329,20 @@ CREATE TABLE IF NOT EXISTS mission_features (
|
||||
FOREIGN KEY (sliceId) REFERENCES slices(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (taskId) REFERENCES tasks(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Mission event log for lifecycle observability
|
||||
CREATE TABLE IF NOT EXISTS mission_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
missionId TEXT NOT NULL,
|
||||
eventType TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
metadata TEXT,
|
||||
timestamp TEXT NOT NULL,
|
||||
FOREIGN KEY (missionId) REFERENCES missions(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idxMissionEventsMissionId ON mission_events(missionId);
|
||||
CREATE INDEX IF NOT EXISTS idxMissionEventsTimestamp ON mission_events(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idxMissionEventsType ON mission_events(eventType);
|
||||
`;
|
||||
|
||||
// ── Database Class ───────────────────────────────────────────────────
|
||||
@@ -635,6 +649,25 @@ export class Database {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 17) {
|
||||
this.applyMigration(17, () => {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS mission_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
missionId TEXT NOT NULL,
|
||||
eventType TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
metadata TEXT,
|
||||
timestamp TEXT NOT NULL,
|
||||
FOREIGN KEY (missionId) REFERENCES missions(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxMissionEventsMissionId ON mission_events(missionId)`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxMissionEventsTimestamp ON mission_events(timestamp)`);
|
||||
this.db.exec(`CREATE INDEX IF NOT EXISTS idxMissionEventsType ON mission_events(eventType)`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -97,6 +97,7 @@ export {
|
||||
FEATURE_STATUSES,
|
||||
INTERVIEW_STATES,
|
||||
AUTOPILOT_STATES,
|
||||
MISSION_EVENT_TYPES,
|
||||
} from "./mission-types.js";
|
||||
export type {
|
||||
MissionStatus,
|
||||
@@ -105,11 +106,14 @@ export type {
|
||||
FeatureStatus,
|
||||
InterviewState,
|
||||
AutopilotState,
|
||||
MissionEventType,
|
||||
AutopilotStatus,
|
||||
Mission,
|
||||
Milestone,
|
||||
Slice,
|
||||
MissionFeature,
|
||||
MissionEvent,
|
||||
MissionHealth,
|
||||
MissionCreateInput,
|
||||
MilestoneCreateInput,
|
||||
SliceCreateInput,
|
||||
|
||||
@@ -15,11 +15,12 @@ function createTaskInDb(
|
||||
database: Database,
|
||||
taskId: string,
|
||||
description = "Test task",
|
||||
status?: string,
|
||||
): void {
|
||||
const now = new Date().toISOString();
|
||||
database.prepare(
|
||||
`INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)`
|
||||
).run(taskId, description, "triage", now, now);
|
||||
`INSERT INTO tasks (id, description, "column", status, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
).run(taskId, description, "triage", status ?? null, now, now);
|
||||
}
|
||||
|
||||
describe("MissionStore", () => {
|
||||
@@ -280,6 +281,139 @@ describe("MissionStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Mission Observability Tests ───────────────────────────────────────
|
||||
|
||||
describe("Mission observability", () => {
|
||||
it("logMissionEvent persists the event and emits mission:event", () => {
|
||||
const mission = store.createMission({ title: "Observable mission" });
|
||||
const eventHandler = vi.fn();
|
||||
store.on("mission:event", eventHandler);
|
||||
|
||||
const event = store.logMissionEvent(
|
||||
mission.id,
|
||||
"mission_started",
|
||||
"Mission was started",
|
||||
{ source: "test" },
|
||||
);
|
||||
|
||||
expect(event.id).toMatch(/^ME-/);
|
||||
expect(event.missionId).toBe(mission.id);
|
||||
expect(event.eventType).toBe("mission_started");
|
||||
expect(event.description).toBe("Mission was started");
|
||||
expect(event.metadata).toEqual({ source: "test" });
|
||||
expect(eventHandler).toHaveBeenCalledWith(event);
|
||||
|
||||
const events = store.getMissionEvents(mission.id);
|
||||
expect(events.total).toBe(1);
|
||||
expect(events.events[0]).toEqual(event);
|
||||
});
|
||||
|
||||
it("getMissionEvents supports pagination, filtering, and newest-first ordering", async () => {
|
||||
const mission = store.createMission({ title: "Events mission" });
|
||||
|
||||
const first = store.logMissionEvent(mission.id, "mission_started", "first");
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
const second = store.logMissionEvent(mission.id, "warning", "second warning");
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
const third = store.logMissionEvent(mission.id, "error", "third error");
|
||||
|
||||
const pageOne = store.getMissionEvents(mission.id, { limit: 2, offset: 0 });
|
||||
expect(pageOne.total).toBe(3);
|
||||
expect(pageOne.events).toHaveLength(2);
|
||||
expect(pageOne.events.map((event) => event.id)).toEqual([third.id, second.id]);
|
||||
|
||||
const pageTwo = store.getMissionEvents(mission.id, { limit: 2, offset: 2 });
|
||||
expect(pageTwo.total).toBe(3);
|
||||
expect(pageTwo.events).toHaveLength(1);
|
||||
expect(pageTwo.events[0].id).toBe(first.id);
|
||||
|
||||
const filtered = store.getMissionEvents(mission.id, { eventType: "error" });
|
||||
expect(filtered.total).toBe(1);
|
||||
expect(filtered.events).toHaveLength(1);
|
||||
expect(filtered.events[0].eventType).toBe("error");
|
||||
expect(filtered.events[0].id).toBe(third.id);
|
||||
});
|
||||
|
||||
it("getMissionHealth computes mission metrics and latest error context", () => {
|
||||
const mission = store.createMission({ title: "Health mission" });
|
||||
store.updateMission(mission.id, {
|
||||
status: "active",
|
||||
autopilotEnabled: true,
|
||||
autopilotState: "watching",
|
||||
lastAutopilotActivityAt: "2026-01-01T10:00:00.000Z",
|
||||
});
|
||||
|
||||
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
|
||||
const slice = store.addSlice(milestone.id, { title: "Slice" });
|
||||
store.updateMilestone(milestone.id, { status: "active" });
|
||||
store.updateSlice(slice.id, { status: "active" });
|
||||
|
||||
const doneFeature = store.addFeature(slice.id, { title: "Done feature" });
|
||||
store.updateFeature(doneFeature.id, { status: "done" });
|
||||
|
||||
const triagedFeature = store.addFeature(slice.id, { title: "Triaged feature" });
|
||||
store.updateFeature(triagedFeature.id, { status: "triaged" });
|
||||
|
||||
const inProgressFeature = store.addFeature(slice.id, { title: "In progress feature" });
|
||||
store.updateFeature(inProgressFeature.id, { status: "in-progress" });
|
||||
|
||||
createTaskInDb(db, "FN-FAILED", "Failed task", "failed");
|
||||
const failedFeature = store.addFeature(slice.id, { title: "Failed feature" });
|
||||
store.linkFeatureToTask(failedFeature.id, "FN-FAILED");
|
||||
// Keep failed feature out of in-flight count for deterministic assertions.
|
||||
store.updateFeature(failedFeature.id, { status: "defined" });
|
||||
|
||||
store.logMissionEvent(mission.id, "error", "Old error", { at: "old" });
|
||||
const latestError = store.logMissionEvent(mission.id, "error", "Latest error", { at: "latest" });
|
||||
|
||||
const health = store.getMissionHealth(mission.id);
|
||||
|
||||
expect(health).toEqual({
|
||||
missionId: mission.id,
|
||||
status: "active",
|
||||
tasksCompleted: 1,
|
||||
tasksFailed: 1,
|
||||
tasksInFlight: 2,
|
||||
totalTasks: 4,
|
||||
currentSliceId: slice.id,
|
||||
currentMilestoneId: milestone.id,
|
||||
estimatedCompletionPercent: 25,
|
||||
lastErrorAt: latestError.timestamp,
|
||||
lastErrorDescription: "Latest error",
|
||||
autopilotState: "watching",
|
||||
autopilotEnabled: true,
|
||||
lastActivityAt: "2026-01-01T10:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("getMissionHealth returns undefined for non-existent mission", () => {
|
||||
expect(store.getMissionHealth("M-NONEXISTENT")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("getMissionHealth handles an empty mission", () => {
|
||||
const mission = store.createMission({ title: "Empty health mission" });
|
||||
|
||||
const health = store.getMissionHealth(mission.id);
|
||||
|
||||
expect(health).toEqual({
|
||||
missionId: mission.id,
|
||||
status: "planning",
|
||||
tasksCompleted: 0,
|
||||
tasksFailed: 0,
|
||||
tasksInFlight: 0,
|
||||
totalTasks: 0,
|
||||
currentSliceId: undefined,
|
||||
currentMilestoneId: undefined,
|
||||
estimatedCompletionPercent: 0,
|
||||
lastErrorAt: undefined,
|
||||
lastErrorDescription: undefined,
|
||||
autopilotState: "inactive",
|
||||
autopilotEnabled: false,
|
||||
lastActivityAt: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Milestone CRUD Tests ──────────────────────────────────────────────
|
||||
|
||||
describe("Milestone CRUD", () => {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Database } from "./db.js";
|
||||
import { fromJson, toJson } from "./db.js";
|
||||
import { fromJson, toJson, toJsonNullable } from "./db.js";
|
||||
import type {
|
||||
Mission,
|
||||
Milestone,
|
||||
@@ -30,6 +30,9 @@ import type {
|
||||
FeatureStatus,
|
||||
InterviewState,
|
||||
AutopilotState,
|
||||
MissionEvent,
|
||||
MissionEventType,
|
||||
MissionHealth,
|
||||
} from "./mission-types.js";
|
||||
|
||||
// ── Mission Summary Type ─────────────────────────────────────────────
|
||||
@@ -79,6 +82,8 @@ export interface MissionStoreEvents {
|
||||
"feature:deleted": [string];
|
||||
/** Emitted when a feature is linked to a task */
|
||||
"feature:linked": [{ feature: MissionFeature; taskId: string }];
|
||||
/** Emitted when a mission lifecycle event is persisted */
|
||||
"mission:event": [MissionEvent];
|
||||
}
|
||||
|
||||
// ── MissionStore Class ──────────────────────────────────────────────
|
||||
@@ -173,6 +178,20 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a database row to a MissionEvent object.
|
||||
*/
|
||||
private rowToMissionEvent(row: any): MissionEvent {
|
||||
return {
|
||||
id: row.id,
|
||||
missionId: row.missionId,
|
||||
eventType: row.eventType as MissionEventType,
|
||||
description: row.description,
|
||||
metadata: fromJson<Record<string, unknown>>(row.metadata) ?? null,
|
||||
timestamp: row.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Mission CRUD Operations ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -316,6 +335,172 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a mission lifecycle event for observability and auditing.
|
||||
*/
|
||||
logMissionEvent(
|
||||
missionId: string,
|
||||
eventType: MissionEventType,
|
||||
description: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): MissionEvent {
|
||||
const mission = this.getMission(missionId);
|
||||
if (!mission) {
|
||||
throw new Error(`Mission ${missionId} not found`);
|
||||
}
|
||||
|
||||
const event: MissionEvent = {
|
||||
id: this.generateMissionEventId(),
|
||||
missionId,
|
||||
eventType,
|
||||
description,
|
||||
metadata: metadata ?? null,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO mission_events (id, missionId, eventType, description, metadata, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
event.id,
|
||||
event.missionId,
|
||||
event.eventType,
|
||||
event.description,
|
||||
toJsonNullable(event.metadata),
|
||||
event.timestamp,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("mission:event", event);
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* List mission lifecycle events with pagination/filtering.
|
||||
*/
|
||||
getMissionEvents(
|
||||
missionId: string,
|
||||
options?: { limit?: number; offset?: number; eventType?: string },
|
||||
): { events: MissionEvent[]; total: number } {
|
||||
const limit = Math.max(0, options?.limit ?? 50);
|
||||
const offset = Math.max(0, options?.offset ?? 0);
|
||||
const eventType = options?.eventType;
|
||||
|
||||
const whereClauses = ["missionId = ?"];
|
||||
const params: string[] = [missionId];
|
||||
|
||||
if (eventType) {
|
||||
whereClauses.push("eventType = ?");
|
||||
params.push(eventType);
|
||||
}
|
||||
|
||||
const whereSql = whereClauses.join(" AND ");
|
||||
const totalRow = this.db.prepare(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM mission_events
|
||||
WHERE ${whereSql}
|
||||
`).get(...params) as { count: number };
|
||||
|
||||
const rows = this.db.prepare(`
|
||||
SELECT *
|
||||
FROM mission_events
|
||||
WHERE ${whereSql}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(...params, limit, offset) as any[];
|
||||
|
||||
return {
|
||||
events: rows.map((row) => this.rowToMissionEvent(row)),
|
||||
total: totalRow?.count ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a mission health snapshot for observability endpoints.
|
||||
*/
|
||||
getMissionHealth(missionId: string): MissionHealth | undefined {
|
||||
const mission = this.getMission(missionId);
|
||||
if (!mission) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const milestones = this.listMilestones(missionId);
|
||||
const summary = this.getMissionSummary(missionId);
|
||||
|
||||
let totalTasks = 0;
|
||||
let tasksCompleted = 0;
|
||||
let tasksInFlight = 0;
|
||||
let currentSliceId: string | undefined;
|
||||
let currentMilestoneId: string | undefined;
|
||||
const featureTaskIds: string[] = [];
|
||||
|
||||
for (const milestone of milestones) {
|
||||
if (!currentMilestoneId && milestone.status === "active") {
|
||||
currentMilestoneId = milestone.id;
|
||||
}
|
||||
|
||||
const slices = this.listSlices(milestone.id);
|
||||
for (const slice of slices) {
|
||||
if (!currentSliceId && slice.status === "active") {
|
||||
currentSliceId = slice.id;
|
||||
currentMilestoneId ??= milestone.id;
|
||||
}
|
||||
|
||||
const features = this.listFeatures(slice.id);
|
||||
for (const feature of features) {
|
||||
totalTasks += 1;
|
||||
if (feature.status === "done") {
|
||||
tasksCompleted += 1;
|
||||
}
|
||||
if (feature.status === "triaged" || feature.status === "in-progress") {
|
||||
tasksInFlight += 1;
|
||||
}
|
||||
if (feature.taskId) {
|
||||
featureTaskIds.push(feature.taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tasksFailed = 0;
|
||||
if (featureTaskIds.length > 0) {
|
||||
const uniqueTaskIds = [...new Set(featureTaskIds)];
|
||||
const placeholders = uniqueTaskIds.map(() => "?").join(", ");
|
||||
const failedTaskRows = this.db.prepare(`
|
||||
SELECT id
|
||||
FROM tasks
|
||||
WHERE status = 'failed' AND id IN (${placeholders})
|
||||
`).all(...uniqueTaskIds) as Array<{ id: string }>;
|
||||
const failedTaskIds = new Set(failedTaskRows.map((row) => row.id));
|
||||
tasksFailed = featureTaskIds.filter((taskId) => failedTaskIds.has(taskId)).length;
|
||||
}
|
||||
|
||||
const lastErrorRow = this.db.prepare(`
|
||||
SELECT timestamp, description
|
||||
FROM mission_events
|
||||
WHERE missionId = ? AND eventType = 'error'
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1
|
||||
`).get(missionId) as { timestamp: string; description: string } | undefined;
|
||||
|
||||
return {
|
||||
missionId,
|
||||
status: mission.status,
|
||||
tasksCompleted,
|
||||
tasksFailed,
|
||||
tasksInFlight,
|
||||
totalTasks,
|
||||
currentSliceId,
|
||||
currentMilestoneId,
|
||||
estimatedCompletionPercent: summary.progressPercent,
|
||||
lastErrorAt: lastErrorRow?.timestamp,
|
||||
lastErrorDescription: lastErrorRow?.description,
|
||||
autopilotState: mission.autopilotState ?? "inactive",
|
||||
autopilotEnabled: mission.autopilotEnabled ?? false,
|
||||
lastActivityAt: mission.lastAutopilotActivityAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a mission.
|
||||
*
|
||||
@@ -1411,4 +1596,10 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `F-${timestamp.toString(36).toUpperCase()}-${random}`;
|
||||
}
|
||||
|
||||
private generateMissionEventId(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `ME-${timestamp.toString(36).toUpperCase()}-${random}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,27 @@ export type InterviewState = (typeof INTERVIEW_STATES)[number];
|
||||
export const AUTOPILOT_STATES = ["inactive", "watching", "activating", "completing"] as const;
|
||||
export type AutopilotState = (typeof AUTOPILOT_STATES)[number];
|
||||
|
||||
/** Persisted mission lifecycle event categories for observability/audit trails. */
|
||||
export const MISSION_EVENT_TYPES = [
|
||||
"slice_activated",
|
||||
"feature_triaged",
|
||||
"feature_completed",
|
||||
"slice_completed",
|
||||
"milestone_completed",
|
||||
"mission_completed",
|
||||
"mission_started",
|
||||
"mission_paused",
|
||||
"mission_resumed",
|
||||
"autopilot_enabled",
|
||||
"autopilot_disabled",
|
||||
"autopilot_state_changed",
|
||||
"autopilot_retry",
|
||||
"autopilot_stale",
|
||||
"error",
|
||||
"warning",
|
||||
] as const;
|
||||
export type MissionEventType = (typeof MISSION_EVENT_TYPES)[number];
|
||||
|
||||
/** Autopilot status for a mission */
|
||||
export interface AutopilotStatus {
|
||||
enabled: boolean;
|
||||
@@ -44,6 +65,34 @@ export interface AutopilotStatus {
|
||||
nextScheduledCheck?: string;
|
||||
}
|
||||
|
||||
/** Persisted audit event describing a mission lifecycle transition or warning. */
|
||||
export interface MissionEvent {
|
||||
id: string;
|
||||
missionId: string;
|
||||
eventType: MissionEventType;
|
||||
description: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/** Computed mission health snapshot used by observability APIs. */
|
||||
export interface MissionHealth {
|
||||
missionId: string;
|
||||
status: MissionStatus;
|
||||
tasksCompleted: number;
|
||||
tasksFailed: number;
|
||||
tasksInFlight: number;
|
||||
totalTasks: number;
|
||||
currentSliceId?: string;
|
||||
currentMilestoneId?: string;
|
||||
estimatedCompletionPercent: number;
|
||||
lastErrorAt?: string;
|
||||
lastErrorDescription?: string;
|
||||
autopilotState: AutopilotState;
|
||||
autopilotEnabled: boolean;
|
||||
lastActivityAt?: string;
|
||||
}
|
||||
|
||||
// ── Core Entity Types ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -78,6 +78,14 @@ function createMockMissionStore(missions: Mission[] = []) {
|
||||
missionMap.set(id, updated);
|
||||
return updated;
|
||||
}),
|
||||
logMissionEvent: vi.fn((missionId: string, eventType: string, description: string, metadata?: Record<string, unknown>) => ({
|
||||
id: `ME-${Date.now()}`,
|
||||
missionId,
|
||||
eventType,
|
||||
description,
|
||||
metadata: metadata ?? null,
|
||||
timestamp: new Date().toISOString(),
|
||||
})),
|
||||
getMilestone: vi.fn(),
|
||||
listMilestones: vi.fn(),
|
||||
getSlice: vi.fn(),
|
||||
@@ -168,6 +176,18 @@ describe("MissionAutopilot", () => {
|
||||
"M-TEST1",
|
||||
expect.objectContaining({ autopilotState: "watching" }),
|
||||
);
|
||||
expect(missionStore.logMissionEvent).toHaveBeenCalledWith(
|
||||
"M-TEST1",
|
||||
"autopilot_enabled",
|
||||
expect.stringContaining("Autopilot enabled"),
|
||||
expect.objectContaining({ source: "watchMission" }),
|
||||
);
|
||||
expect(missionStore.logMissionEvent).toHaveBeenCalledWith(
|
||||
"M-TEST1",
|
||||
"autopilot_state_changed",
|
||||
expect.stringContaining("inactive to watching"),
|
||||
expect.objectContaining({ fromState: "inactive", toState: "watching" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not watch a mission without autopilot enabled", () => {
|
||||
@@ -201,6 +221,12 @@ describe("MissionAutopilot", () => {
|
||||
"M-TEST1",
|
||||
expect.objectContaining({ autopilotState: "inactive" }),
|
||||
);
|
||||
expect(missionStore.logMissionEvent).toHaveBeenCalledWith(
|
||||
"M-TEST1",
|
||||
"autopilot_disabled",
|
||||
expect.stringContaining("Autopilot disabled"),
|
||||
expect.objectContaining({ source: "unwatchMission" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should be a no-op for non-watched mission", () => {
|
||||
@@ -366,6 +392,20 @@ describe("MissionAutopilot", () => {
|
||||
await autopilot.advanceToNextSlice("M-TEST1");
|
||||
expect(scheduler.activateNextPendingSlice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs retry events when slice activation fails", async () => {
|
||||
scheduler.activateNextPendingSlice.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
autopilot.watchMission("M-TEST1");
|
||||
await autopilot.advanceToNextSlice("M-TEST1");
|
||||
|
||||
expect(missionStore.logMissionEvent).toHaveBeenCalledWith(
|
||||
"M-TEST1",
|
||||
"autopilot_retry",
|
||||
expect.stringContaining("Retrying slice activation"),
|
||||
expect.objectContaining({ retryCount: 1, maxRetries: 3 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Check and Start Mission ──────────────────────────────────────
|
||||
@@ -385,6 +425,12 @@ describe("MissionAutopilot", () => {
|
||||
"M-TEST1",
|
||||
expect.objectContaining({ status: "active" }),
|
||||
);
|
||||
expect(store.logMissionEvent).toHaveBeenCalledWith(
|
||||
"M-TEST1",
|
||||
"mission_started",
|
||||
expect.stringContaining("started by autopilot"),
|
||||
expect.objectContaining({ source: "checkAndStartMission" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not transition active mission", async () => {
|
||||
@@ -424,6 +470,12 @@ describe("MissionAutopilot", () => {
|
||||
"M-TEST1",
|
||||
expect.objectContaining({ status: "complete" }),
|
||||
);
|
||||
expect(missionStore.logMissionEvent).toHaveBeenCalledWith(
|
||||
"M-TEST1",
|
||||
"mission_completed",
|
||||
expect.stringContaining("marked complete"),
|
||||
expect.objectContaining({ milestoneCount: 1 }),
|
||||
);
|
||||
expect(autopilot.isWatching("M-TEST1")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -448,6 +500,33 @@ describe("MissionAutopilot", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Poll / stale detection ──────────────────────────────────────
|
||||
|
||||
describe("poll stale detection", () => {
|
||||
it("logs warning events for stale watched missions", () => {
|
||||
const staleMission = createMockMission({
|
||||
lastAutopilotActivityAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
||||
});
|
||||
const store = createMockMissionStore([staleMission]);
|
||||
const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler });
|
||||
|
||||
ap.start();
|
||||
ap.watchMission("M-TEST1");
|
||||
store.logMissionEvent.mockClear();
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
expect(store.logMissionEvent).toHaveBeenCalledWith(
|
||||
"M-TEST1",
|
||||
"warning",
|
||||
expect.stringContaining("stale"),
|
||||
expect.objectContaining({ category: "autopilot_stale" }),
|
||||
);
|
||||
|
||||
ap.stop();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Stop cleanup ─────────────────────────────────────────────────
|
||||
|
||||
describe("stop cleanup", () => {
|
||||
|
||||
@@ -19,7 +19,15 @@
|
||||
* - `completing` → `inactive`: Mission complete
|
||||
*/
|
||||
|
||||
import type { TaskStore, MissionStore, Mission, AutopilotState, AutopilotStatus, Slice } from "@fusion/core";
|
||||
import type {
|
||||
TaskStore,
|
||||
MissionStore,
|
||||
Mission,
|
||||
AutopilotState,
|
||||
AutopilotStatus,
|
||||
Slice,
|
||||
MissionEventType,
|
||||
} from "@fusion/core";
|
||||
import { autopilotLog } from "./logger.js";
|
||||
|
||||
/** Maximum retry attempts for slice activation failures. */
|
||||
@@ -143,6 +151,16 @@ export class MissionAutopilot {
|
||||
|
||||
this.watchedMissions.set(missionId, { missionId, retryCount: 0 });
|
||||
this.setAutopilotState(missionId, "watching");
|
||||
this.logMissionEventSafe(
|
||||
missionId,
|
||||
"autopilot_enabled",
|
||||
`Autopilot enabled for mission ${mission.title}`,
|
||||
{
|
||||
source: "watchMission",
|
||||
missionStatus: mission.status,
|
||||
autoAdvance: mission.autoAdvance ?? false,
|
||||
},
|
||||
);
|
||||
autopilotLog.log(`Watching mission ${missionId} (${mission.title})`);
|
||||
}
|
||||
|
||||
@@ -163,6 +181,12 @@ export class MissionAutopilot {
|
||||
} catch {
|
||||
// Mission may have been deleted
|
||||
}
|
||||
this.logMissionEventSafe(
|
||||
missionId,
|
||||
"autopilot_disabled",
|
||||
`Autopilot disabled for mission ${missionId}`,
|
||||
{ source: "unwatchMission" },
|
||||
);
|
||||
autopilotLog.log(`Unwatched mission ${missionId}`);
|
||||
}
|
||||
|
||||
@@ -287,6 +311,12 @@ export class MissionAutopilot {
|
||||
state.retryCount++;
|
||||
if (state.retryCount <= MAX_RETRY_ATTEMPTS) {
|
||||
const delay = RETRY_BASE_DELAY_MS * Math.pow(3, state.retryCount - 1);
|
||||
this.logMissionEventSafe(
|
||||
missionId,
|
||||
"autopilot_retry",
|
||||
`Retrying slice activation after error (attempt ${state.retryCount}/${MAX_RETRY_ATTEMPTS})`,
|
||||
{ retryCount: state.retryCount, maxRetries: MAX_RETRY_ATTEMPTS, delayMs: delay },
|
||||
);
|
||||
autopilotLog.log(`Retrying slice activation for mission ${missionId} (attempt ${state.retryCount}/${MAX_RETRY_ATTEMPTS}, delay ${delay}ms)`);
|
||||
setTimeout(() => {
|
||||
if (this.isWatching(missionId)) {
|
||||
@@ -294,6 +324,12 @@ export class MissionAutopilot {
|
||||
}
|
||||
}, delay);
|
||||
} else {
|
||||
this.logMissionEventSafe(
|
||||
missionId,
|
||||
"error",
|
||||
`Autopilot exceeded max slice-activation retries (${MAX_RETRY_ATTEMPTS})`,
|
||||
{ retryCount: state.retryCount, maxRetries: MAX_RETRY_ATTEMPTS },
|
||||
);
|
||||
autopilotLog.error(`Max retries exceeded for mission ${missionId} — pausing autopilot`);
|
||||
this.setAutopilotState(missionId, "watching");
|
||||
state.retryCount = 0;
|
||||
@@ -316,6 +352,12 @@ export class MissionAutopilot {
|
||||
autopilotLog.log(`Starting mission ${missionId} (transitioning from planning to active)`);
|
||||
|
||||
this.missionStore.updateMission(missionId, { status: "active" });
|
||||
this.logMissionEventSafe(
|
||||
missionId,
|
||||
"mission_started",
|
||||
`Mission ${mission.title} started by autopilot`,
|
||||
{ source: "checkAndStartMission" },
|
||||
);
|
||||
this.updateActivity(missionId);
|
||||
|
||||
// Activate first pending slice
|
||||
@@ -347,6 +389,12 @@ export class MissionAutopilot {
|
||||
autopilotLog.log(`Mission ${missionId} is complete!`);
|
||||
this.setAutopilotState(missionId, "completing");
|
||||
this.missionStore.updateMission(missionId, { status: "complete" });
|
||||
this.logMissionEventSafe(
|
||||
missionId,
|
||||
"mission_completed",
|
||||
`Mission ${mission.title} marked complete`,
|
||||
{ milestoneCount: milestones.length },
|
||||
);
|
||||
this.updateActivity(missionId);
|
||||
this.setAutopilotState(missionId, "inactive");
|
||||
this.watchedMissions.delete(missionId);
|
||||
@@ -396,7 +444,20 @@ export class MissionAutopilot {
|
||||
if (mission.lastAutopilotActivityAt) {
|
||||
const lastActivity = new Date(mission.lastAutopilotActivityAt).getTime();
|
||||
if (now - lastActivity > STALE_THRESHOLD_MS) {
|
||||
autopilotLog.warn(`Mission ${missionId} is stale (no activity for ${Math.round((now - lastActivity) / 60_000)} minutes)`);
|
||||
const staleMinutes = Math.round((now - lastActivity) / 60_000);
|
||||
this.logMissionEventSafe(
|
||||
missionId,
|
||||
"warning",
|
||||
`Mission autopilot appears stale (no activity for ${staleMinutes} minutes)`,
|
||||
{
|
||||
staleMinutes,
|
||||
staleThresholdMs: STALE_THRESHOLD_MS,
|
||||
lastActivityAt: mission.lastAutopilotActivityAt,
|
||||
retryCount: state.retryCount,
|
||||
category: "autopilot_stale",
|
||||
},
|
||||
);
|
||||
autopilotLog.warn(`Mission ${missionId} is stale (no activity for ${staleMinutes} minutes)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -407,14 +468,44 @@ export class MissionAutopilot {
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Best-effort mission event logging that must never break autopilot control flow.
|
||||
*/
|
||||
private logMissionEventSafe(
|
||||
missionId: string,
|
||||
eventType: MissionEventType,
|
||||
description: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): void {
|
||||
try {
|
||||
this.missionStore.logMissionEvent(missionId, eventType, description, metadata);
|
||||
} catch (err) {
|
||||
autopilotLog.error(
|
||||
`Failed to persist mission event (${eventType}) for ${missionId}:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the `autopilotState` on a mission in the store.
|
||||
*/
|
||||
private setAutopilotState(missionId: string, state: AutopilotState): void {
|
||||
try {
|
||||
const mission = this.missionStore.getMission(missionId);
|
||||
if (mission && mission.autopilotState !== state) {
|
||||
if (!mission) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousState = mission.autopilotState ?? "inactive";
|
||||
if (previousState !== state) {
|
||||
this.missionStore.updateMission(missionId, { autopilotState: state });
|
||||
this.logMissionEventSafe(
|
||||
missionId,
|
||||
"autopilot_state_changed",
|
||||
`Autopilot state changed from ${previousState} to ${state}`,
|
||||
{ fromState: previousState, toState: state },
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
autopilotLog.error(`Error setting autopilot state for mission ${missionId}:`, err);
|
||||
|
||||
Reference in New Issue
Block a user