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:
gsxdsm
2026-04-09 11:51:03 -07:00
parent 26ff7eb900
commit ea8d32d4bf
13 changed files with 497 additions and 8 deletions

View File

@@ -73,3 +73,10 @@
- When checking if a CSS value is inside a `@media` block, don't just search backwards for the nearest `@media` — track brace depth to confirm the line is actually between the block's opening `{` and closing `}`. Many component styles are defined globally (not in media queries) even though they visually only appear on mobile. - When checking if a CSS value is inside a `@media` block, don't just search backwards for the nearest `@media` — track brace depth to confirm the line is actually between the block's opening `{` and closing `}`. Many component styles are defined globally (not in media queries) even though they visually only appear on mobile.
- Regex tests using `[\s\S]*` (greedy match across lines) to check CSS rules inside `@media` blocks are unreliable — they can match across block boundaries. Use non-greedy `[^}]*` scoped to a single rule block instead. - Regex tests using `[\s\S]*` (greedy match across lines) to check CSS rules inside `@media` blocks are unreliable — they can match across block boundaries. Use non-greedy `[^}]*` scoped to a single rule block instead.
- Touch target sizing in `styles.css` mobile media queries uses 36px (reduced from the original 44px). The `.touch-target` opt-in utility class remains at 44px. Comments mentioning "44px" in the mobile sections have been updated to reflect the actual values. - Touch target sizing in `styles.css` mobile media queries uses 36px (reduced from the original 44px). The `.touch-target` opt-in utility class remains at 44px. Comments mentioning "44px" in the mobile sections have been updated to reflect the actual values.
## TUI Package Testing
- The `@fusion/tui` package uses `ink`'s `render` function for testing, not `@testing-library/react`. Use `setTimeout(resolve, ms)` to wait for async operations in tests.
- When mocking `useFusion` in TUI tests, use `vi.mock("../fusion-context.js", ...)` to intercept the import.
- For EventEmitter mocking in TUI tests, create mock objects with `Object.create(EventEmitter.prototype)` and add methods like `listTasks` or `getActivityLog`.
- Ink's render function captures errors but doesn't throw them — use `expect(() => instance.unmount()).not.toThrow()` pattern for error-handling tests.

BIN
kb.db/fusion.db Normal file

Binary file not shown.

BIN
kb.db/fusion.db-shm Normal file

Binary file not shown.

0
kb.db/fusion.db-wal Normal file
View File

View File

@@ -367,6 +367,7 @@ CREATE TABLE IF NOT EXISTS mission_events (
description TEXT NOT NULL, description TEXT NOT NULL,
metadata TEXT, metadata TEXT,
timestamp TEXT NOT NULL, timestamp TEXT NOT NULL,
seq INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (missionId) REFERENCES missions(id) ON DELETE CASCADE FOREIGN KEY (missionId) REFERENCES missions(id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idxMissionEventsMissionId ON mission_events(missionId); CREATE INDEX IF NOT EXISTS idxMissionEventsMissionId ON mission_events(missionId);
@@ -850,6 +851,17 @@ export class Database {
this.db.exec(`CREATE INDEX IF NOT EXISTS idxChatMessagesCreatedAt ON chat_messages(createdAt)`); this.db.exec(`CREATE INDEX IF NOT EXISTS idxChatMessagesCreatedAt ON chat_messages(createdAt)`);
}); });
} }
if (version < 23) {
this.applyMigration(23, () => {
this.addColumnIfMissing("milestones", "planningNotes", "TEXT");
this.addColumnIfMissing("milestones", "verification", "TEXT");
this.addColumnIfMissing("slices", "planningNotes", "TEXT");
this.addColumnIfMissing("slices", "verification", "TEXT");
this.addColumnIfMissing("slices", "planState", "TEXT NOT NULL DEFAULT 'not_started'");
this.addColumnIfMissing("mission_events", "seq", "INTEGER NOT NULL DEFAULT 0");
});
}
} }
/** /**

View File

@@ -103,8 +103,15 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
) { ) {
super(); super();
this.setMaxListeners(100); this.setMaxListeners(100);
// Initialize sequence counter from existing events to ensure uniqueness across restarts
const lastEvent = this.db.prepare(`
SELECT seq FROM mission_events ORDER BY seq DESC LIMIT 1
`).get() as { seq?: number } | undefined;
this._eventSeq = lastEvent?.seq ?? 0;
} }
private _eventSeq = 0;
// ── Row-to-Object Converters ─────────────────────────────────────── // ── Row-to-Object Converters ───────────────────────────────────────
/** /**
@@ -189,6 +196,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
description: row.description, description: row.description,
metadata: fromJson<Record<string, unknown>>(row.metadata) ?? null, metadata: fromJson<Record<string, unknown>>(row.metadata) ?? null,
timestamp: row.timestamp, timestamp: row.timestamp,
seq: row.seq ?? 0,
}; };
} }
@@ -469,7 +477,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
SELECT missionId, timestamp, description SELECT missionId, timestamp, description
FROM mission_events FROM mission_events
WHERE eventType = 'error' WHERE eventType = 'error'
ORDER BY timestamp DESC, id DESC ORDER BY seq DESC, id DESC
`).all() as Array<{ missionId: string; timestamp: string; description: string }>; `).all() as Array<{ missionId: string; timestamp: string; description: string }>;
// Only keep the first (latest) error per missionId // Only keep the first (latest) error per missionId
const lastErrorByMission = new Map<string, { timestamp: string; description: string }>(); const lastErrorByMission = new Map<string, { timestamp: string; description: string }>();
@@ -603,11 +611,12 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
description, description,
metadata: metadata ?? null, metadata: metadata ?? null,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
seq: ++this._eventSeq,
}; };
this.db.prepare(` this.db.prepare(`
INSERT INTO mission_events (id, missionId, eventType, description, metadata, timestamp) INSERT INTO mission_events (id, missionId, eventType, description, metadata, timestamp, seq)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
event.id, event.id,
event.missionId, event.missionId,
@@ -615,6 +624,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
event.description, event.description,
toJsonNullable(event.metadata), toJsonNullable(event.metadata),
event.timestamp, event.timestamp,
event.seq,
); );
this.db.bumpLastModified(); this.db.bumpLastModified();
@@ -726,7 +736,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
SELECT timestamp, description SELECT timestamp, description
FROM mission_events FROM mission_events
WHERE missionId = ? AND eventType = 'error' WHERE missionId = ? AND eventType = 'error'
ORDER BY timestamp DESC, id DESC ORDER BY seq DESC, id DESC
LIMIT 1 LIMIT 1
`).get(missionId) as { timestamp: string; description: string } | undefined; `).get(missionId) as { timestamp: string; description: string } | undefined;

View File

@@ -73,6 +73,8 @@ export interface MissionEvent {
description: string; description: string;
metadata: Record<string, unknown> | null; metadata: Record<string, unknown> | null;
timestamp: string; timestamp: string;
/** Monotonically increasing sequence number for ordering events with identical timestamps */
seq: number;
} }
/** Computed mission health snapshot used by observability APIs. */ /** Computed mission health snapshot used by observability APIs. */

View File

@@ -588,6 +588,14 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
void loadMissionHealth(missions); void loadMissionHealth(missions);
}; };
const handleFeatureUpdated = () => {
refreshHealth();
// Reload the selected mission detail to reflect updated feature status
if (selectedMission) {
void loadMissionDetail(selectedMission.id);
}
};
const handleMissionEvent = (rawEvent: Event) => { const handleMissionEvent = (rawEvent: Event) => {
refreshHealth(); refreshHealth();
@@ -634,13 +642,13 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
eventSource.addEventListener("mission:updated", refreshHealth); eventSource.addEventListener("mission:updated", refreshHealth);
eventSource.addEventListener("slice:updated", refreshHealth); eventSource.addEventListener("slice:updated", refreshHealth);
eventSource.addEventListener("feature:updated", refreshHealth); eventSource.addEventListener("feature:updated", handleFeatureUpdated);
eventSource.addEventListener("mission:event", handleMissionEvent); eventSource.addEventListener("mission:event", handleMissionEvent);
return () => { return () => {
eventSource.removeEventListener("mission:updated", refreshHealth); eventSource.removeEventListener("mission:updated", refreshHealth);
eventSource.removeEventListener("slice:updated", refreshHealth); eventSource.removeEventListener("slice:updated", refreshHealth);
eventSource.removeEventListener("feature:updated", refreshHealth); eventSource.removeEventListener("feature:updated", handleFeatureUpdated);
eventSource.removeEventListener("mission:event", handleMissionEvent); eventSource.removeEventListener("mission:event", handleMissionEvent);
eventSource.close(); eventSource.close();
}; };
@@ -649,6 +657,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
eventsFilter, eventsFilter,
isActive, isActive,
isActivityScrolledNearBottom, isActivityScrolledNearBottom,
loadMissionDetail,
loadMissionHealth, loadMissionHealth,
missions, missions,
projectId, projectId,

View File

@@ -762,6 +762,53 @@ describe("MissionManager", () => {
}); });
}); });
it("reloads selected mission detail when feature:updated SSE event arrives", async () => {
const fetchMock = createDetailFetchMock(mockMissionEvents);
globalThis.fetch = fetchMock;
globalThis.EventSource = MockEventSource as unknown as typeof globalThis.EventSource;
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeDefined();
});
// Click on the mission to open detail view
fireEvent.click(screen.getByText("Build Auth System"));
await waitFor(() => {
// Back button should appear in detail view
expect(screen.getByTestId("mission-back-btn")).toBeDefined();
});
// Record initial fetch calls for mission detail
const initialFetchCount = fetchMock.mock.calls.filter(
(call) => typeof call[0] === "string" && call[0].includes("/api/missions/M-001")
).length;
expect(initialFetchCount).toBeGreaterThan(0);
// Emit a feature:updated SSE event
await act(async () => {
for (const source of MockEventSource.instances) {
source.emit("feature:updated", {
featureId: "F-001",
missionId: "M-001",
sliceId: "SL-001",
previousStatus: "triaged",
newStatus: "in-progress",
});
}
});
// Verify mission detail was reloaded (fetch was called again for the mission)
await waitFor(() => {
const updatedFetchCount = fetchMock.mock.calls.filter(
(call) => typeof call[0] === "string" && call[0].includes("/api/missions/M-001")
).length;
expect(updatedFetchCount).toBeGreaterThan(initialFetchCount);
});
});
it("shows empty state when no missions exist", async () => { it("shows empty state when no missions exist", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(mockApiResponse([])); globalThis.fetch = vi.fn().mockResolvedValue(mockApiResponse([]));
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />); render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);

View File

@@ -61,6 +61,7 @@ vi.mock("../scheduler.js", async () => {
const self = {} as Record<string, unknown>; const self = {} as Record<string, unknown>;
self.start = vi.fn(); self.start = vi.fn();
self.stop = vi.fn(); self.stop = vi.fn();
self.reconcileAllMissionFeatures = vi.fn().mockResolvedValue(0);
return self; return self;
}), }),
}; };

View File

@@ -345,6 +345,11 @@ export class InProcessRuntime
void activeMissionAutopilot.recoverMissions(activeMissionStore); void activeMissionAutopilot.recoverMissions(activeMissionStore);
} }
// 11. Reconcile feature status for all active missions (not just autopilot)
if (activeMissionStore) {
void this.scheduler.reconcileAllMissionFeatures();
}
this.setStatus("active"); this.setStatus("active");
runtimeLog.log(`InProcessRuntime started for project ${this.config.projectId}`); runtimeLog.log(`InProcessRuntime started for project ${this.config.projectId}`);
} catch (error) { } catch (error) {

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import type { PrMonitor } from "./pr-monitor.js"; import type { PrMonitor } from "./pr-monitor.js";
import { Scheduler, pathsOverlap } from "./scheduler.js"; import { Scheduler, pathsOverlap } from "./scheduler.js";
import { AgentSemaphore } from "./concurrency.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 { existsSync } from "node:fs";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
@@ -24,10 +24,11 @@ vi.mock("node:fs/promises", async (importOriginal) => {
}); });
// Helper to create mock tasks // Helper to create mock tasks
function createMockTask(overrides: Partial<Task> = {}): Task { function createMockTask(overrides: Partial<Task & { prompt?: string }> = {}): Task {
return { return {
id: "FN-001", id: "FN-001",
description: "Test task", description: "Test task",
prompt: "Test prompt",
column: "todo", column: "todo",
dependencies: [], dependencies: [],
steps: [], steps: [],
@@ -1749,4 +1750,323 @@ describe("Scheduler", () => {
expect(mockAutopilot.handleTaskCompletion).toHaveBeenCalledWith("FN-001"); 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);
});
});
}); });

View File

@@ -869,4 +869,80 @@ export class Scheduler {
return null; 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;
}
} }