feat(FN-1228): complete Step 1 — Backend API Routes modifications
- Modified POST /api/nodes to make type optional (defaults to 'remote') - Changed DELETE /api/nodes/:id to return 204 No Content - Updated GET /api/nodes/:id/metrics to return SystemMetrics from node.systemMetrics - Added GET /api/mesh/state route for full mesh topology state
This commit is contained in:
@@ -61,6 +61,7 @@ vi.mock("../scheduler.js", async () => {
|
||||
const self = {} as Record<string, unknown>;
|
||||
self.start = vi.fn();
|
||||
self.stop = vi.fn();
|
||||
self.reconcileAllMissionFeatures = vi.fn().mockResolvedValue(0);
|
||||
return self;
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -345,6 +345,11 @@ export class InProcessRuntime
|
||||
void activeMissionAutopilot.recoverMissions(activeMissionStore);
|
||||
}
|
||||
|
||||
// 11. Reconcile feature status for all active missions (not just autopilot)
|
||||
if (activeMissionStore) {
|
||||
void this.scheduler.reconcileAllMissionFeatures();
|
||||
}
|
||||
|
||||
this.setStatus("active");
|
||||
runtimeLog.log(`InProcessRuntime started for project ${this.config.projectId}`);
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { PrMonitor } from "./pr-monitor.js";
|
||||
import { Scheduler, pathsOverlap } from "./scheduler.js";
|
||||
import { AgentSemaphore } from "./concurrency.js";
|
||||
import type { TaskStore, Task } from "@fusion/core";
|
||||
import type { TaskStore, Task, Column } from "@fusion/core";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
@@ -24,10 +24,11 @@ vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
});
|
||||
|
||||
// Helper to create mock tasks
|
||||
function createMockTask(overrides: Partial<Task> = {}): Task {
|
||||
function createMockTask(overrides: Partial<Task & { prompt?: string }> = {}): Task {
|
||||
return {
|
||||
id: "FN-001",
|
||||
description: "Test task",
|
||||
prompt: "Test prompt",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
@@ -1749,4 +1750,323 @@ describe("Scheduler", () => {
|
||||
expect(mockAutopilot.handleTaskCompletion).toHaveBeenCalledWith("FN-001");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileAllMissionFeatures", () => {
|
||||
it("returns early when missionStore is not provided", async () => {
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store);
|
||||
|
||||
const result = await scheduler.reconcileAllMissionFeatures();
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it("skips non-active missions", async () => {
|
||||
const store = createMockStore();
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
listMissions: vi.fn().mockReturnValue([
|
||||
{ id: "M-001", status: "complete" },
|
||||
{ id: "M-002", status: "archived" },
|
||||
]),
|
||||
getMissionWithHierarchy: vi.fn(),
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store, {
|
||||
missionStore: mockMissionStore as any,
|
||||
});
|
||||
|
||||
await scheduler.reconcileAllMissionFeatures();
|
||||
|
||||
expect(mockMissionStore.getMissionWithHierarchy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates feature to in-progress when task is in-progress and feature is triaged", async () => {
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue(createMockTask({ id: "FN-001", column: "in-progress" as Column })),
|
||||
});
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
listMissions: vi.fn().mockReturnValue([
|
||||
{ id: "M-001", status: "active" },
|
||||
]),
|
||||
getMissionWithHierarchy: vi.fn().mockReturnValue({
|
||||
id: "M-001",
|
||||
status: "active",
|
||||
milestones: [{
|
||||
id: "MS-001",
|
||||
slices: [{
|
||||
id: "SL-001",
|
||||
status: "active",
|
||||
features: [{
|
||||
id: "F-001",
|
||||
taskId: "FN-001",
|
||||
status: "triaged",
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store, {
|
||||
missionStore: mockMissionStore as any,
|
||||
});
|
||||
|
||||
const result = await scheduler.reconcileAllMissionFeatures();
|
||||
|
||||
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "in-progress");
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it("updates feature to done when task is done and feature is not done", async () => {
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue(createMockTask({ id: "FN-001", column: "done" as Column })),
|
||||
});
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
listMissions: vi.fn().mockReturnValue([
|
||||
{ id: "M-001", status: "active" },
|
||||
]),
|
||||
getMissionWithHierarchy: vi.fn().mockReturnValue({
|
||||
id: "M-001",
|
||||
status: "active",
|
||||
milestones: [{
|
||||
id: "MS-001",
|
||||
slices: [{
|
||||
id: "SL-001",
|
||||
status: "active",
|
||||
features: [{
|
||||
id: "F-001",
|
||||
taskId: "FN-001",
|
||||
status: "in-progress",
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store, {
|
||||
missionStore: mockMissionStore as any,
|
||||
});
|
||||
|
||||
const result = await scheduler.reconcileAllMissionFeatures();
|
||||
|
||||
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done");
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it("updates feature to triaged when task moves back to todo and feature is in-progress", async () => {
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue(createMockTask({ id: "FN-001", column: "todo" as Column })),
|
||||
});
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
listMissions: vi.fn().mockReturnValue([
|
||||
{ id: "M-001", status: "active" },
|
||||
]),
|
||||
getMissionWithHierarchy: vi.fn().mockReturnValue({
|
||||
id: "M-001",
|
||||
status: "active",
|
||||
milestones: [{
|
||||
id: "MS-001",
|
||||
slices: [{
|
||||
id: "SL-001",
|
||||
status: "active",
|
||||
features: [{
|
||||
id: "F-001",
|
||||
taskId: "FN-001",
|
||||
status: "in-progress",
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store, {
|
||||
missionStore: mockMissionStore as any,
|
||||
});
|
||||
|
||||
const result = await scheduler.reconcileAllMissionFeatures();
|
||||
|
||||
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "triaged");
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it("does not update correctly synced features", async () => {
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue(createMockTask({ id: "FN-001", column: "in-progress" as Column })),
|
||||
});
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
listMissions: vi.fn().mockReturnValue([
|
||||
{ id: "M-001", status: "active" },
|
||||
]),
|
||||
getMissionWithHierarchy: vi.fn().mockReturnValue({
|
||||
id: "M-001",
|
||||
status: "active",
|
||||
milestones: [{
|
||||
id: "MS-001",
|
||||
slices: [{
|
||||
id: "SL-001",
|
||||
status: "active",
|
||||
features: [{
|
||||
id: "F-001",
|
||||
taskId: "FN-001",
|
||||
status: "in-progress", // Already synced
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store, {
|
||||
missionStore: mockMissionStore as any,
|
||||
});
|
||||
|
||||
const result = await scheduler.reconcileAllMissionFeatures();
|
||||
|
||||
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it("skips features without taskId", async () => {
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn(),
|
||||
});
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
listMissions: vi.fn().mockReturnValue([
|
||||
{ id: "M-001", status: "active" },
|
||||
]),
|
||||
getMissionWithHierarchy: vi.fn().mockReturnValue({
|
||||
id: "M-001",
|
||||
status: "active",
|
||||
milestones: [{
|
||||
id: "MS-001",
|
||||
slices: [{
|
||||
id: "SL-001",
|
||||
status: "active",
|
||||
features: [{
|
||||
id: "F-001",
|
||||
taskId: undefined, // No linked task
|
||||
status: "defined",
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store, {
|
||||
missionStore: mockMissionStore as any,
|
||||
});
|
||||
|
||||
const result = await scheduler.reconcileAllMissionFeatures();
|
||||
|
||||
expect(store.getTask).not.toHaveBeenCalled();
|
||||
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it("skips inactive slices", async () => {
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn(),
|
||||
});
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
listMissions: vi.fn().mockReturnValue([
|
||||
{ id: "M-001", status: "active" },
|
||||
]),
|
||||
getMissionWithHierarchy: vi.fn().mockReturnValue({
|
||||
id: "M-001",
|
||||
status: "active",
|
||||
milestones: [{
|
||||
id: "MS-001",
|
||||
slices: [{
|
||||
id: "SL-001",
|
||||
status: "pending", // Not active
|
||||
features: [{
|
||||
id: "F-001",
|
||||
taskId: "FN-001",
|
||||
status: "triaged",
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store, {
|
||||
missionStore: mockMissionStore as any,
|
||||
});
|
||||
|
||||
const result = await scheduler.reconcileAllMissionFeatures();
|
||||
|
||||
expect(store.getTask).not.toHaveBeenCalled();
|
||||
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it("handles multiple features across multiple missions and slices", async () => {
|
||||
const store = createMockStore({
|
||||
getTask: vi.fn((id: string) => {
|
||||
const columns: Record<string, Column> = {
|
||||
"FN-001": "in-progress",
|
||||
"FN-002": "done",
|
||||
"FN-003": "triage",
|
||||
};
|
||||
return Promise.resolve(createMockTask({ id, column: columns[id] || "todo" }));
|
||||
}),
|
||||
});
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
listMissions: vi.fn().mockReturnValue([
|
||||
{ id: "M-001", status: "active" },
|
||||
{ id: "M-002", status: "active" },
|
||||
]),
|
||||
getMissionWithHierarchy: vi.fn((id: string) => {
|
||||
if (id === "M-001") {
|
||||
return {
|
||||
id: "M-001",
|
||||
status: "active",
|
||||
milestones: [{
|
||||
id: "MS-001",
|
||||
slices: [{
|
||||
id: "SL-001",
|
||||
status: "active",
|
||||
features: [
|
||||
{ id: "F-001", taskId: "FN-001", status: "triaged" }, // Should update to in-progress
|
||||
{ id: "F-002", taskId: "FN-002", status: "in-progress" }, // Should update to done
|
||||
],
|
||||
}],
|
||||
}],
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: "M-002",
|
||||
status: "active",
|
||||
milestones: [{
|
||||
id: "MS-002",
|
||||
slices: [{
|
||||
id: "SL-002",
|
||||
status: "active",
|
||||
features: [
|
||||
{ id: "F-003", taskId: "FN-003", status: "in-progress" }, // Should update to triaged
|
||||
],
|
||||
}],
|
||||
}],
|
||||
};
|
||||
}),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store, {
|
||||
missionStore: mockMissionStore as any,
|
||||
});
|
||||
|
||||
const result = await scheduler.reconcileAllMissionFeatures();
|
||||
|
||||
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledTimes(3);
|
||||
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "in-progress");
|
||||
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-002", "done");
|
||||
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-003", "triaged");
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -869,4 +869,80 @@ export class Scheduler {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile feature status for all active missions on startup.
|
||||
*
|
||||
* This ensures that feature statuses are in sync with their linked task
|
||||
* columns for all missions, not just autopilot-enabled ones. The
|
||||
* reconciliation logic mirrors MissionAutopilot.reconcileMissionConsistency()
|
||||
* but runs unconditionally on startup.
|
||||
*
|
||||
* @returns The total number of fixes applied across all missions
|
||||
*/
|
||||
async reconcileAllMissionFeatures(): Promise<number> {
|
||||
if (!this.options.missionStore) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const missionStore = this.options.missionStore;
|
||||
let totalFixed = 0;
|
||||
|
||||
try {
|
||||
const missions = missionStore.listMissions();
|
||||
const activeMissions = missions.filter((m) => m.status === "active");
|
||||
|
||||
for (const mission of activeMissions) {
|
||||
const hierarchy = missionStore.getMissionWithHierarchy(mission.id);
|
||||
if (!hierarchy) continue;
|
||||
|
||||
const activeSlices = hierarchy.milestones
|
||||
.flatMap((milestone) => milestone.slices)
|
||||
.filter((slice) => slice.status === "active");
|
||||
|
||||
for (const slice of activeSlices) {
|
||||
for (const feature of slice.features) {
|
||||
if (!feature.taskId) continue;
|
||||
|
||||
const task = await this.store.getTask(feature.taskId);
|
||||
if (!task) continue;
|
||||
|
||||
// Task done but feature not done -> update feature to done
|
||||
if (task.column === "done" && feature.status !== "done") {
|
||||
missionStore.updateFeatureStatus(feature.id, "done");
|
||||
totalFixed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Task in-progress and feature triaged/defined -> update to in-progress
|
||||
if (
|
||||
task.column === "in-progress"
|
||||
&& (feature.status === "triaged" || feature.status === "defined")
|
||||
) {
|
||||
missionStore.updateFeatureStatus(feature.id, "in-progress");
|
||||
totalFixed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Task in triage/todo and feature in-progress -> update to triaged
|
||||
if (
|
||||
(task.column === "triage" || task.column === "todo")
|
||||
&& feature.status === "in-progress"
|
||||
) {
|
||||
missionStore.updateFeatureStatus(feature.id, "triaged");
|
||||
totalFixed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalFixed > 0) {
|
||||
schedulerLog.log(`Mission feature reconciliation: fixed ${totalFixed} inconsistencies`);
|
||||
}
|
||||
} catch (err) {
|
||||
schedulerLog.error("Error during mission feature reconciliation:", err);
|
||||
}
|
||||
|
||||
return totalFixed;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user