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:
@@ -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
BIN
kb.db/fusion.db
Normal file
Binary file not shown.
BIN
kb.db/fusion.db-shm
Normal file
BIN
kb.db/fusion.db-shm
Normal file
Binary file not shown.
0
kb.db/fusion.db-wal
Normal file
0
kb.db/fusion.db-wal
Normal 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");
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()} />);
|
||||||
|
|||||||
@@ -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;
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user