feat(FN-1348): export HeartbeatMonitor and HeartbeatTriggerScheduler from @fusion/engine
This commit is contained in:
5
.changeset/optimize-mission-loading.md
Normal file
5
.changeset/optimize-mission-loading.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Optimize mission loading performance by eliminating N+1 query patterns and adding batched API endpoints. The GET /api/missions list endpoint now uses batched queries instead of firing getMissionSummary() per mission. Added new GET /api/missions/health batch endpoint for fetching all mission health metrics in a single request. The frontend now makes 1 request instead of N parallel requests when loading mission health data. Added database indexes on mission hierarchy FK columns (milestones.missionId, slices.milestoneId, mission_features.sliceId, mission_features.taskId) to improve query performance.
|
||||||
5
.factory/settings.json
Normal file
5
.factory/settings.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"core@factory-plugins": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -281,6 +281,295 @@ describe("MissionStore", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Batched Summary Tests ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("listMissionsWithSummaries", () => {
|
||||||
|
it("returns empty array when no missions exist", () => {
|
||||||
|
const result = store.listMissionsWithSummaries();
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct summaries for multiple missions", () => {
|
||||||
|
// Mission 1: 2 milestones, 1 complete, 2 features, 1 done
|
||||||
|
const m1 = store.createMission({ title: "Mission 1" });
|
||||||
|
const ms1a = store.addMilestone(m1.id, { title: "MS1a" });
|
||||||
|
const ms1b = store.addMilestone(m1.id, { title: "MS1b" });
|
||||||
|
store.updateMilestone(ms1b.id, { status: "complete" });
|
||||||
|
const sl1 = store.addSlice(ms1a.id, { title: "SL1" });
|
||||||
|
const f1 = store.addFeature(sl1.id, { title: "F1" });
|
||||||
|
store.updateFeature(f1.id, { status: "done" });
|
||||||
|
const f2 = store.addFeature(sl1.id, { title: "F2" });
|
||||||
|
// f2 not done
|
||||||
|
|
||||||
|
// Mission 2: 1 milestone, 0 features
|
||||||
|
const m2 = store.createMission({ title: "Mission 2" });
|
||||||
|
store.addMilestone(m2.id, { title: "MS2" });
|
||||||
|
|
||||||
|
// Mission 3: 0 milestones
|
||||||
|
store.createMission({ title: "Mission 3" });
|
||||||
|
|
||||||
|
const result = store.listMissionsWithSummaries();
|
||||||
|
|
||||||
|
// Should be sorted by createdAt DESC (m3, m2, m1 based on creation order)
|
||||||
|
expect(result.length).toBe(3);
|
||||||
|
|
||||||
|
// Mission 3: 0 milestones, 0 features → 0%
|
||||||
|
const mission3 = result.find((m) => m.title === "Mission 3")!;
|
||||||
|
expect(mission3.summary).toEqual({
|
||||||
|
totalMilestones: 0,
|
||||||
|
completedMilestones: 0,
|
||||||
|
totalFeatures: 0,
|
||||||
|
completedFeatures: 0,
|
||||||
|
progressPercent: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mission 2: 1 milestone, 0 features → 0%
|
||||||
|
const mission2 = result.find((m) => m.title === "Mission 2")!;
|
||||||
|
expect(mission2.summary).toEqual({
|
||||||
|
totalMilestones: 1,
|
||||||
|
completedMilestones: 0,
|
||||||
|
totalFeatures: 0,
|
||||||
|
completedFeatures: 0,
|
||||||
|
progressPercent: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mission 1: 2 milestones (1 complete), 2 features (1 done) → 50%
|
||||||
|
const mission1 = result.find((m) => m.title === "Mission 1")!;
|
||||||
|
expect(mission1.summary).toEqual({
|
||||||
|
totalMilestones: 2,
|
||||||
|
completedMilestones: 1,
|
||||||
|
totalFeatures: 2,
|
||||||
|
completedFeatures: 1,
|
||||||
|
progressPercent: 50,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("progress percent matches getMissionSummary behavior", () => {
|
||||||
|
const mission = store.createMission({ title: "Compare test" });
|
||||||
|
const milestone = store.addMilestone(mission.id, { title: "M1" });
|
||||||
|
const slice = store.addSlice(milestone.id, { title: "S1" });
|
||||||
|
const f1 = store.addFeature(slice.id, { title: "F1" });
|
||||||
|
store.updateFeature(f1.id, { status: "done" });
|
||||||
|
const f2 = store.addFeature(slice.id, { title: "F2" });
|
||||||
|
store.updateFeature(f2.id, { status: "done" });
|
||||||
|
const f3 = store.addFeature(slice.id, { title: "F3" });
|
||||||
|
// f3 not done
|
||||||
|
|
||||||
|
const singleSummary = store.getMissionSummary(mission.id);
|
||||||
|
const batchedResult = store.listMissionsWithSummaries().find((m) => m.id === mission.id)!;
|
||||||
|
|
||||||
|
expect(batchedResult.summary.totalMilestones).toBe(singleSummary.totalMilestones);
|
||||||
|
expect(batchedResult.summary.completedMilestones).toBe(singleSummary.completedMilestones);
|
||||||
|
expect(batchedResult.summary.totalFeatures).toBe(singleSummary.totalFeatures);
|
||||||
|
expect(batchedResult.summary.completedFeatures).toBe(singleSummary.completedFeatures);
|
||||||
|
expect(batchedResult.summary.progressPercent).toBe(singleSummary.progressPercent);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Batched Health Tests ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("listMissionsHealth", () => {
|
||||||
|
it("returns empty map when no missions exist", () => {
|
||||||
|
const result = store.listMissionsHealth();
|
||||||
|
expect(result).toBeInstanceOf(Map);
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns correct health for a single empty mission", () => {
|
||||||
|
const mission = store.createMission({ title: "Empty mission" });
|
||||||
|
store.updateMission(mission.id, {
|
||||||
|
autopilotEnabled: true,
|
||||||
|
autopilotState: "watching",
|
||||||
|
lastAutopilotActivityAt: "2026-01-01T10:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = store.listMissionsHealth();
|
||||||
|
|
||||||
|
expect(result.size).toBe(1);
|
||||||
|
expect(result.get(mission.id)).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: "watching",
|
||||||
|
autopilotEnabled: true,
|
||||||
|
lastActivityAt: "2026-01-01T10:00:00.000Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes correct health for multiple missions with varying states", async () => {
|
||||||
|
// Mission 1: 1 milestone (active), 1 slice (active), 4 features (1 done, 2 in-flight, 1 failed)
|
||||||
|
const m1 = store.createMission({ title: "Mission 1" });
|
||||||
|
store.updateMission(m1.id, { status: "active" });
|
||||||
|
const ms1 = store.addMilestone(m1.id, { title: "MS1" });
|
||||||
|
store.updateMilestone(ms1.id, { status: "active" });
|
||||||
|
const sl1 = store.addSlice(ms1.id, { title: "SL1" });
|
||||||
|
store.updateSlice(sl1.id, { status: "active" });
|
||||||
|
|
||||||
|
const f1Done = store.addFeature(sl1.id, { title: "F1-done" });
|
||||||
|
store.updateFeature(f1Done.id, { status: "done" });
|
||||||
|
|
||||||
|
const f1Triaged = store.addFeature(sl1.id, { title: "F1-triaged" });
|
||||||
|
store.updateFeature(f1Triaged.id, { status: "triaged" });
|
||||||
|
|
||||||
|
const f1Progress = store.addFeature(sl1.id, { title: "F1-progress" });
|
||||||
|
store.updateFeature(f1Progress.id, { status: "in-progress" });
|
||||||
|
|
||||||
|
createTaskInDb(db, "FN-FAILED-1", "Failed task", "failed");
|
||||||
|
const f1Failed = store.addFeature(sl1.id, { title: "F1-failed" });
|
||||||
|
store.linkFeatureToTask(f1Failed.id, "FN-FAILED-1");
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
// Mission 2: 2 milestones (1 complete, 1 active), 0 features
|
||||||
|
const m2 = store.createMission({ title: "Mission 2" });
|
||||||
|
store.updateMission(m2.id, { status: "active" });
|
||||||
|
const ms2a = store.addMilestone(m2.id, { title: "MS2a" });
|
||||||
|
store.updateMilestone(ms2a.id, { status: "complete" });
|
||||||
|
const ms2b = store.addMilestone(m2.id, { title: "MS2b" });
|
||||||
|
store.updateMilestone(ms2b.id, { status: "active" });
|
||||||
|
const sl2 = store.addSlice(ms2b.id, { title: "SL2" });
|
||||||
|
store.updateSlice(sl2.id, { status: "active" });
|
||||||
|
|
||||||
|
store.logMissionEvent(m1.id, "error", "Error on mission 1");
|
||||||
|
|
||||||
|
const result = store.listMissionsHealth();
|
||||||
|
|
||||||
|
expect(result.size).toBe(2);
|
||||||
|
|
||||||
|
// Mission 1 health
|
||||||
|
const health1 = result.get(m1.id)!;
|
||||||
|
expect(health1).toEqual({
|
||||||
|
missionId: m1.id,
|
||||||
|
status: "active",
|
||||||
|
tasksCompleted: 1,
|
||||||
|
tasksFailed: 1,
|
||||||
|
tasksInFlight: 3,
|
||||||
|
totalTasks: 4,
|
||||||
|
currentSliceId: sl1.id,
|
||||||
|
currentMilestoneId: ms1.id,
|
||||||
|
estimatedCompletionPercent: 25,
|
||||||
|
lastErrorAt: expect.any(String),
|
||||||
|
lastErrorDescription: "Error on mission 1",
|
||||||
|
autopilotState: "inactive",
|
||||||
|
autopilotEnabled: false,
|
||||||
|
lastActivityAt: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mission 2 health: no features, 1/2 milestones complete → 50%
|
||||||
|
const health2 = result.get(m2.id)!;
|
||||||
|
expect(health2).toEqual({
|
||||||
|
missionId: m2.id,
|
||||||
|
status: "active",
|
||||||
|
tasksCompleted: 0,
|
||||||
|
tasksFailed: 0,
|
||||||
|
tasksInFlight: 0,
|
||||||
|
totalTasks: 0,
|
||||||
|
currentSliceId: sl2.id,
|
||||||
|
currentMilestoneId: ms2b.id,
|
||||||
|
estimatedCompletionPercent: 50,
|
||||||
|
lastErrorAt: undefined,
|
||||||
|
lastErrorDescription: undefined,
|
||||||
|
autopilotState: "inactive",
|
||||||
|
autopilotEnabled: false,
|
||||||
|
lastActivityAt: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts failed tasks across missions correctly", () => {
|
||||||
|
const m1 = store.createMission({ title: "Mission 1" });
|
||||||
|
const ms1 = store.addMilestone(m1.id, { title: "MS1" });
|
||||||
|
const sl1 = store.addSlice(ms1.id, { title: "SL1" });
|
||||||
|
|
||||||
|
const m2 = store.createMission({ title: "Mission 2" });
|
||||||
|
const ms2 = store.addMilestone(m2.id, { title: "MS2" });
|
||||||
|
const sl2 = store.addSlice(ms2.id, { title: "SL2" });
|
||||||
|
|
||||||
|
createTaskInDb(db, "FN-FAIL-A", "Task A", "failed");
|
||||||
|
createTaskInDb(db, "FN-FAIL-B", "Task B", "failed");
|
||||||
|
createTaskInDb(db, "FN-OK-C", "Task C", "done");
|
||||||
|
|
||||||
|
const f1 = store.addFeature(sl1.id, { title: "F1" });
|
||||||
|
store.linkFeatureToTask(f1.id, "FN-FAIL-A");
|
||||||
|
|
||||||
|
const f2 = store.addFeature(sl2.id, { title: "F2" });
|
||||||
|
store.linkFeatureToTask(f2.id, "FN-FAIL-B");
|
||||||
|
|
||||||
|
const f3 = store.addFeature(sl2.id, { title: "F3" });
|
||||||
|
store.linkFeatureToTask(f3.id, "FN-OK-C");
|
||||||
|
|
||||||
|
const result = store.listMissionsHealth();
|
||||||
|
|
||||||
|
expect(result.get(m1.id)!.tasksFailed).toBe(1);
|
||||||
|
expect(result.get(m2.id)!.tasksFailed).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects last error per mission independently", () => {
|
||||||
|
const m1 = store.createMission({ title: "Mission 1" });
|
||||||
|
const m2 = store.createMission({ title: "Mission 2" });
|
||||||
|
|
||||||
|
store.logMissionEvent(m1.id, "error", "Old error on M1");
|
||||||
|
store.logMissionEvent(m2.id, "error", "Only error on M2");
|
||||||
|
store.logMissionEvent(m1.id, "error", "Latest error on M1");
|
||||||
|
|
||||||
|
const result = store.listMissionsHealth();
|
||||||
|
|
||||||
|
expect(result.get(m1.id)!.lastErrorDescription).toBe("Latest error on M1");
|
||||||
|
expect(result.get(m2.id)!.lastErrorDescription).toBe("Only error on M2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces results consistent with getMissionHealth", () => {
|
||||||
|
const mission = store.createMission({ title: "Consistency test" });
|
||||||
|
store.updateMission(mission.id, {
|
||||||
|
status: "active",
|
||||||
|
autopilotEnabled: true,
|
||||||
|
autopilotState: "watching",
|
||||||
|
lastAutopilotActivityAt: "2026-01-01T10:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const milestone = store.addMilestone(mission.id, { title: "M1" });
|
||||||
|
store.updateMilestone(milestone.id, { status: "active" });
|
||||||
|
const slice = store.addSlice(milestone.id, { title: "S1" });
|
||||||
|
store.updateSlice(slice.id, { status: "active" });
|
||||||
|
|
||||||
|
const f1 = store.addFeature(slice.id, { title: "F1" });
|
||||||
|
store.updateFeature(f1.id, { status: "done" });
|
||||||
|
|
||||||
|
const f2 = store.addFeature(slice.id, { title: "F2" });
|
||||||
|
store.updateFeature(f2.id, { status: "triaged" });
|
||||||
|
|
||||||
|
createTaskInDb(db, "FN-FAILED-X", "Failed task", "failed");
|
||||||
|
const f3 = store.addFeature(slice.id, { title: "F3" });
|
||||||
|
store.linkFeatureToTask(f3.id, "FN-FAILED-X");
|
||||||
|
|
||||||
|
store.logMissionEvent(mission.id, "error", "Test error");
|
||||||
|
|
||||||
|
const singleHealth = store.getMissionHealth(mission.id);
|
||||||
|
const batchedHealth = store.listMissionsHealth().get(mission.id)!;
|
||||||
|
|
||||||
|
// Compare all fields except lastErrorAt (may differ by ms due to separate queries)
|
||||||
|
expect(batchedHealth.missionId).toBe(singleHealth!.missionId);
|
||||||
|
expect(batchedHealth.status).toBe(singleHealth!.status);
|
||||||
|
expect(batchedHealth.tasksCompleted).toBe(singleHealth!.tasksCompleted);
|
||||||
|
expect(batchedHealth.tasksFailed).toBe(singleHealth!.tasksFailed);
|
||||||
|
expect(batchedHealth.tasksInFlight).toBe(singleHealth!.tasksInFlight);
|
||||||
|
expect(batchedHealth.totalTasks).toBe(singleHealth!.totalTasks);
|
||||||
|
expect(batchedHealth.currentSliceId).toBe(singleHealth!.currentSliceId);
|
||||||
|
expect(batchedHealth.currentMilestoneId).toBe(singleHealth!.currentMilestoneId);
|
||||||
|
expect(batchedHealth.estimatedCompletionPercent).toBe(singleHealth!.estimatedCompletionPercent);
|
||||||
|
expect(batchedHealth.lastErrorDescription).toBe(singleHealth!.lastErrorDescription);
|
||||||
|
expect(batchedHealth.autopilotState).toBe(singleHealth!.autopilotState);
|
||||||
|
expect(batchedHealth.autopilotEnabled).toBe(singleHealth!.autopilotEnabled);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Mission Observability Tests ───────────────────────────────────────
|
// ── Mission Observability Tests ───────────────────────────────────────
|
||||||
|
|
||||||
describe("Mission observability", () => {
|
describe("Mission observability", () => {
|
||||||
|
|||||||
@@ -335,6 +335,253 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all missions with computed summaries in a single batch of queries.
|
||||||
|
*
|
||||||
|
* Instead of N×(1 + M×(1 + S×1)) queries (one per mission, then per-milestone,
|
||||||
|
* per-slice, per-feature), this method fires 4 batch queries total and groups
|
||||||
|
* the data in-memory for summary computation.
|
||||||
|
*
|
||||||
|
* @returns Array of missions with summary, sorted by createdAt DESC
|
||||||
|
*/
|
||||||
|
listMissionsWithSummaries(): Array<Mission & { summary: MissionSummary }> {
|
||||||
|
// 1. Fetch all missions
|
||||||
|
const missions = this.listMissions();
|
||||||
|
if (missions.length === 0) return [];
|
||||||
|
|
||||||
|
// 2. Batch query all milestones
|
||||||
|
const milestoneRows = this.db.prepare(
|
||||||
|
"SELECT * FROM milestones ORDER BY orderIndex ASC"
|
||||||
|
).all() as any[];
|
||||||
|
const allMilestones = milestoneRows.map((row) => this.rowToMilestone(row));
|
||||||
|
|
||||||
|
// 3. Batch query all slices
|
||||||
|
const sliceRows = this.db.prepare(
|
||||||
|
"SELECT * FROM slices ORDER BY orderIndex ASC"
|
||||||
|
).all() as any[];
|
||||||
|
const allSlices = sliceRows.map((row) => this.rowToSlice(row));
|
||||||
|
|
||||||
|
// 4. Batch query all features
|
||||||
|
const featureRows = this.db.prepare(
|
||||||
|
"SELECT * FROM mission_features ORDER BY createdAt ASC"
|
||||||
|
).all() as any[];
|
||||||
|
const allFeatures = featureRows.map((row) => this.rowToFeature(row));
|
||||||
|
|
||||||
|
// 5. Group in-memory: slices by milestoneId, features by sliceId
|
||||||
|
const slicesByMilestoneId = new Map<string, Slice[]>();
|
||||||
|
for (const slice of allSlices) {
|
||||||
|
const list = slicesByMilestoneId.get(slice.milestoneId) || [];
|
||||||
|
list.push(slice);
|
||||||
|
slicesByMilestoneId.set(slice.milestoneId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const featuresBySliceId = new Map<string, MissionFeature[]>();
|
||||||
|
for (const feature of allFeatures) {
|
||||||
|
const list = featuresBySliceId.get(feature.sliceId) || [];
|
||||||
|
list.push(feature);
|
||||||
|
featuresBySliceId.set(feature.sliceId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Group milestones by missionId
|
||||||
|
const milestonesByMissionId = new Map<string, Milestone[]>();
|
||||||
|
for (const milestone of allMilestones) {
|
||||||
|
const list = milestonesByMissionId.get(milestone.missionId) || [];
|
||||||
|
list.push(milestone);
|
||||||
|
milestonesByMissionId.set(milestone.missionId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Compute summary for each mission using grouped data
|
||||||
|
return missions.map((mission) => {
|
||||||
|
const milestones = milestonesByMissionId.get(mission.id) || [];
|
||||||
|
const totalMilestones = milestones.length;
|
||||||
|
const completedMilestones = milestones.filter((m) => m.status === "complete").length;
|
||||||
|
|
||||||
|
let totalFeatures = 0;
|
||||||
|
let completedFeatures = 0;
|
||||||
|
|
||||||
|
for (const milestone of milestones) {
|
||||||
|
const slices = slicesByMilestoneId.get(milestone.id) || [];
|
||||||
|
for (const slice of slices) {
|
||||||
|
const features = featuresBySliceId.get(slice.id) || [];
|
||||||
|
totalFeatures += features.length;
|
||||||
|
completedFeatures += features.filter((f) => f.status === "done").length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let progressPercent = 0;
|
||||||
|
if (totalFeatures > 0) {
|
||||||
|
progressPercent = Math.round((completedFeatures / totalFeatures) * 100);
|
||||||
|
} else if (totalMilestones > 0) {
|
||||||
|
progressPercent = Math.round((completedMilestones / totalMilestones) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mission,
|
||||||
|
summary: {
|
||||||
|
totalMilestones,
|
||||||
|
completedMilestones,
|
||||||
|
totalFeatures,
|
||||||
|
completedFeatures,
|
||||||
|
progressPercent,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute health for ALL missions in a single batch of queries.
|
||||||
|
*
|
||||||
|
* Instead of N × (1 + M + S + F + failedTasks + lastError) individual queries,
|
||||||
|
* this method fires a fixed number of batch queries and groups in-memory.
|
||||||
|
*
|
||||||
|
* @returns Map of mission ID → MissionHealth
|
||||||
|
*/
|
||||||
|
listMissionsHealth(): Map<string, MissionHealth> {
|
||||||
|
const missions = this.listMissions();
|
||||||
|
if (missions.length === 0) return new Map();
|
||||||
|
|
||||||
|
// 1. Batch query all milestones
|
||||||
|
const milestoneRows = this.db.prepare(
|
||||||
|
"SELECT * FROM milestones ORDER BY orderIndex ASC"
|
||||||
|
).all() as any[];
|
||||||
|
const allMilestones = milestoneRows.map((row) => this.rowToMilestone(row));
|
||||||
|
|
||||||
|
// 2. Batch query all slices
|
||||||
|
const sliceRows = this.db.prepare(
|
||||||
|
"SELECT * FROM slices ORDER BY orderIndex ASC"
|
||||||
|
).all() as any[];
|
||||||
|
const allSlices = sliceRows.map((row) => this.rowToSlice(row));
|
||||||
|
|
||||||
|
// 3. Batch query all features
|
||||||
|
const featureRows = this.db.prepare(
|
||||||
|
"SELECT * FROM mission_features ORDER BY createdAt ASC"
|
||||||
|
).all() as any[];
|
||||||
|
const allFeatures = featureRows.map((row) => this.rowToFeature(row));
|
||||||
|
|
||||||
|
// 4. Batch query all failed task IDs
|
||||||
|
const failedTaskRows = this.db.prepare(
|
||||||
|
"SELECT id FROM tasks WHERE status = 'failed'"
|
||||||
|
).all() as Array<{ id: string }>;
|
||||||
|
const failedTaskIds = new Set(failedTaskRows.map((row) => row.id));
|
||||||
|
|
||||||
|
// 5. Batch query last error event per mission
|
||||||
|
const lastErrorRows = this.db.prepare(`
|
||||||
|
SELECT missionId, timestamp, description
|
||||||
|
FROM mission_events
|
||||||
|
WHERE eventType = 'error'
|
||||||
|
ORDER BY timestamp DESC, id DESC
|
||||||
|
`).all() as Array<{ missionId: string; timestamp: string; description: string }>;
|
||||||
|
// Only keep the first (latest) error per missionId
|
||||||
|
const lastErrorByMission = new Map<string, { timestamp: string; description: string }>();
|
||||||
|
for (const row of lastErrorRows) {
|
||||||
|
if (!lastErrorByMission.has(row.missionId)) {
|
||||||
|
lastErrorByMission.set(row.missionId, { timestamp: row.timestamp, description: row.description });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Group hierarchy in-memory
|
||||||
|
const milestonesByMissionId = new Map<string, Milestone[]>();
|
||||||
|
for (const milestone of allMilestones) {
|
||||||
|
const list = milestonesByMissionId.get(milestone.missionId) || [];
|
||||||
|
list.push(milestone);
|
||||||
|
milestonesByMissionId.set(milestone.missionId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const slicesByMilestoneId = new Map<string, Slice[]>();
|
||||||
|
for (const slice of allSlices) {
|
||||||
|
const list = slicesByMilestoneId.get(slice.milestoneId) || [];
|
||||||
|
list.push(slice);
|
||||||
|
slicesByMilestoneId.set(slice.milestoneId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const featuresBySliceId = new Map<string, MissionFeature[]>();
|
||||||
|
for (const feature of allFeatures) {
|
||||||
|
const list = featuresBySliceId.get(feature.sliceId) || [];
|
||||||
|
list.push(feature);
|
||||||
|
featuresBySliceId.set(feature.sliceId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Compute health for each mission
|
||||||
|
const result = new Map<string, MissionHealth>();
|
||||||
|
|
||||||
|
for (const mission of missions) {
|
||||||
|
const milestones = milestonesByMissionId.get(mission.id) || [];
|
||||||
|
|
||||||
|
let totalTasks = 0;
|
||||||
|
let tasksCompleted = 0;
|
||||||
|
let tasksInFlight = 0;
|
||||||
|
let tasksFailed = 0;
|
||||||
|
let currentSliceId: string | undefined;
|
||||||
|
let currentMilestoneId: string | undefined;
|
||||||
|
|
||||||
|
let totalMilestones = milestones.length;
|
||||||
|
let completedMilestones = 0;
|
||||||
|
let totalFeatures = 0;
|
||||||
|
let completedFeatures = 0;
|
||||||
|
|
||||||
|
for (const milestone of milestones) {
|
||||||
|
if (milestone.status === "complete") {
|
||||||
|
completedMilestones++;
|
||||||
|
}
|
||||||
|
if (!currentMilestoneId && milestone.status === "active") {
|
||||||
|
currentMilestoneId = milestone.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slices = slicesByMilestoneId.get(milestone.id) || [];
|
||||||
|
for (const slice of slices) {
|
||||||
|
if (!currentSliceId && slice.status === "active") {
|
||||||
|
currentSliceId = slice.id;
|
||||||
|
currentMilestoneId ??= milestone.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const features = featuresBySliceId.get(slice.id) || [];
|
||||||
|
for (const feature of features) {
|
||||||
|
totalFeatures++;
|
||||||
|
totalTasks += 1;
|
||||||
|
if (feature.status === "done") {
|
||||||
|
tasksCompleted += 1;
|
||||||
|
completedFeatures++;
|
||||||
|
}
|
||||||
|
if (feature.status === "triaged" || feature.status === "in-progress") {
|
||||||
|
tasksInFlight += 1;
|
||||||
|
}
|
||||||
|
if (feature.taskId && failedTaskIds.has(feature.taskId)) {
|
||||||
|
tasksFailed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let progressPercent = 0;
|
||||||
|
if (totalFeatures > 0) {
|
||||||
|
progressPercent = Math.round((completedFeatures / totalFeatures) * 100);
|
||||||
|
} else if (totalMilestones > 0) {
|
||||||
|
progressPercent = Math.round((completedMilestones / totalMilestones) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastError = lastErrorByMission.get(mission.id);
|
||||||
|
|
||||||
|
result.set(mission.id, {
|
||||||
|
missionId: mission.id,
|
||||||
|
status: mission.status,
|
||||||
|
tasksCompleted,
|
||||||
|
tasksFailed,
|
||||||
|
tasksInFlight,
|
||||||
|
totalTasks,
|
||||||
|
currentSliceId,
|
||||||
|
currentMilestoneId,
|
||||||
|
estimatedCompletionPercent: progressPercent,
|
||||||
|
lastErrorAt: lastError?.timestamp,
|
||||||
|
lastErrorDescription: lastError?.description,
|
||||||
|
autopilotState: mission.autopilotState ?? "inactive",
|
||||||
|
autopilotEnabled: mission.autopilotEnabled ?? false,
|
||||||
|
lastActivityAt: mission.lastAutopilotActivityAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist a mission lifecycle event for observability and auditing.
|
* Persist a mission lifecycle event for observability and auditing.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3074,6 +3074,11 @@ export function fetchMissionHealth(missionId: string, projectId?: string): Promi
|
|||||||
return api<MissionHealth>(withProjectId(`/missions/${encodeURIComponent(missionId)}/health`, projectId));
|
return api<MissionHealth>(withProjectId(`/missions/${encodeURIComponent(missionId)}/health`, projectId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetch health metrics for all missions in a single batched request. */
|
||||||
|
export function fetchMissionsHealth(projectId?: string): Promise<Record<string, MissionHealth>> {
|
||||||
|
return api<Record<string, MissionHealth>>(withProjectId("/missions/health", projectId));
|
||||||
|
}
|
||||||
|
|
||||||
/** Add milestone to mission */
|
/** Add milestone to mission */
|
||||||
export function createMilestone(
|
export function createMilestone(
|
||||||
missionId: string,
|
missionId: string,
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ import {
|
|||||||
startMissionAutopilot,
|
startMissionAutopilot,
|
||||||
stopMissionAutopilot,
|
stopMissionAutopilot,
|
||||||
fetchMissionHealth,
|
fetchMissionHealth,
|
||||||
|
fetchMissionsHealth,
|
||||||
fetchMissionEvents,
|
fetchMissionEvents,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import type { AutopilotStatus as AutopilotStatusType, AutopilotState } from "./mission-types";
|
import type { AutopilotStatus as AutopilotStatusType, AutopilotState } from "./mission-types";
|
||||||
@@ -429,21 +430,14 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const healthResults = await Promise.allSettled(
|
// Use batched endpoint for optimal performance (1 request instead of N)
|
||||||
missionList.map(async (mission) => {
|
const healthRecord = await fetchMissionsHealth(projectId);
|
||||||
const health = await fetchMissionHealth(mission.id, projectId);
|
|
||||||
return [mission.id, health] as const;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
setMissionHealthById((prev) => {
|
setMissionHealthById((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
for (const result of healthResults) {
|
for (const [missionId, health] of Object.entries(healthRecord)) {
|
||||||
if (result.status === "fulfilled") {
|
if (isMissionHealth(health)) {
|
||||||
const [missionId, health] = result.value;
|
next.set(missionId, health);
|
||||||
if (isMissionHealth(health)) {
|
|
||||||
next.set(missionId, health);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
|
|||||||
@@ -251,22 +251,34 @@ export function createMissionRouter(
|
|||||||
/**
|
/**
|
||||||
* GET /api/missions
|
* GET /api/missions
|
||||||
* List all missions ordered by createdAt desc, with status summary
|
* List all missions ordered by createdAt desc, with status summary
|
||||||
|
* Uses batched query for optimal performance.
|
||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/",
|
"/",
|
||||||
catchTypedHandler(async (_req, res) => {
|
catchTypedHandler(async (_req, res) => {
|
||||||
const missions = missionStore.listMissions();
|
const missionsWithSummary = missionStore.listMissionsWithSummaries();
|
||||||
// Sort by createdAt desc
|
|
||||||
missions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
|
||||||
// Attach status summary to each mission
|
|
||||||
const missionsWithSummary = missions.map((mission) => ({
|
|
||||||
...mission,
|
|
||||||
summary: missionStore.getMissionSummary(mission.id),
|
|
||||||
}));
|
|
||||||
res.json(missionsWithSummary);
|
res.json(missionsWithSummary);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/missions/health
|
||||||
|
* Get health metrics for all missions in a single batched request.
|
||||||
|
* Returns a map of mission ID → health object.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/health",
|
||||||
|
catchTypedHandler(async (_req, res) => {
|
||||||
|
const healthMap = missionStore.listMissionsHealth();
|
||||||
|
// Convert Map to Record for JSON serialization
|
||||||
|
const result: Record<string, ReturnType<typeof healthMap.get>> = {};
|
||||||
|
for (const [missionId, health] of healthMap) {
|
||||||
|
result[missionId] = health;
|
||||||
|
}
|
||||||
|
res.json(result);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/missions
|
* POST /api/missions
|
||||||
* Create a new mission
|
* Create a new mission
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export { PrCommentHandler } from "./pr-comment-handler.js";
|
|||||||
export { NtfyNotifier, type NtfyNotifierOptions } from "./notifier.js";
|
export { NtfyNotifier, type NtfyNotifierOptions } from "./notifier.js";
|
||||||
export { CronRunner, type CronRunnerOptions, type AiPromptExecutor, createAiPromptExecutor } from "./cron-runner.js";
|
export { CronRunner, type CronRunnerOptions, type AiPromptExecutor, createAiPromptExecutor } from "./cron-runner.js";
|
||||||
export { StuckTaskDetector, type StuckTaskDetectorOptions, type DisposableSession } from "./stuck-task-detector.js";
|
export { StuckTaskDetector, type StuckTaskDetectorOptions, type DisposableSession } from "./stuck-task-detector.js";
|
||||||
|
export { HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext } from "./agent-heartbeat.js";
|
||||||
export { TokenCapDetector, type TokenCapCheckResult } from "./token-cap-detector.js";
|
export { TokenCapDetector, type TokenCapCheckResult } from "./token-cap-detector.js";
|
||||||
export { SelfHealingManager, type SelfHealingOptions } from "./self-healing.js";
|
export { SelfHealingManager, type SelfHealingOptions } from "./self-healing.js";
|
||||||
export { ProjectManager } from "./project-manager.js";
|
export { ProjectManager } from "./project-manager.js";
|
||||||
|
|||||||
Reference in New Issue
Block a user