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 ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user