feat(FN-1850): aggregate projects across connected nodes

- Add /api/projects/across-nodes to merge local projects with online remote node project lists
- Expose fetchProjectsAcrossNodes and ProjectInfoWithSource, and switch useProjects to consume cross-node data
- Update ProjectOverview and ProjectCard with node badges, node count stats, and a node filter dropdown plus responsive styles
- Add server and dashboard test coverage for cross-node aggregation, filtering, and hook behavior changes
- Include a changeset for @gsxdsm/fusion minor release and preserve stale-channel SSE reconnect guards during merge
This commit is contained in:
Fusion
2026-04-18 03:08:00 -07:00
committed by gsxdsm
parent 01aeb449d7
commit b80cae1b5a
14 changed files with 1674 additions and 55 deletions

View File

@@ -0,0 +1,439 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { get } from "../test-request.js";
// ── Mock @fusion/core for project routes ─────────────────────────────────
const mockInit = vi.fn().mockResolvedValue(undefined);
const mockClose = vi.fn().mockResolvedValue(undefined);
const mockListProjects = vi.fn();
const mockListNodes = vi.fn();
const mockReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined);
const mockChatStoreInit = vi.fn().mockResolvedValue(undefined);
const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined);
// Store original fetch for use in tests
const originalFetch = globalThis.fetch;
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
CentralCore: class MockCentralCore {
init = mockInit;
close = mockClose;
listProjects = mockListProjects;
listNodes = mockListNodes;
reconcileProjectStatuses = mockReconcileProjectStatuses;
},
ChatStore: class MockChatStore {
init = mockChatStoreInit;
},
AgentStore: class MockAgentStore {
init = mockAgentStoreInit;
},
};
});
// ── Mock Store ────────────────────────────────────────────────────────
class MockStore extends EventEmitter {
getRootDir(): string {
return "/tmp/fn-1850-test";
}
getFusionDir(): string {
return "/tmp/fn-1850-test/.fusion";
}
getDatabase() {
return {
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({
run: vi.fn().mockReturnValue({ changes: 0 }),
get: vi.fn(),
all: vi.fn().mockReturnValue([]),
}),
};
}
}
// ── Test helpers ──────────────────────────────────────────────────────
function createMockProject(overrides: Record<string, unknown> = {}) {
return {
id: "proj_local001",
name: "Local Project",
path: "/projects/local",
status: "active" as const,
isolationMode: "in-process" as const,
nodeId: undefined,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
function createMockRemoteNode(overrides: Record<string, unknown> = {}) {
return {
id: "node_remote001",
name: "Remote Node",
type: "remote" as const,
status: "online" as const,
url: "https://remote-node.example.com",
apiKey: "test-api-key-123",
maxConcurrent: 4,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
function createMockRemoteProject(overrides: Record<string, unknown> = {}) {
return {
id: "proj_remote001",
name: "Remote Project",
path: "/projects/remote",
status: "active" as const,
isolationMode: "child-process" as const,
nodeId: "node_remote001",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
// ── Test setup ─────────────────────────────────────────────────────────
describe("GET /api/projects/across-nodes", () => {
let store: MockStore;
let app: (req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) => void;
beforeEach(async () => {
vi.clearAllMocks();
store = new MockStore();
const { createServer } = await import("../server.js");
app = createServer(store as unknown as Parameters<typeof createServer>[0] extends { store: infer S } ? S : never);
});
afterEach(() => {
vi.restoreAllMocks();
// Restore original fetch
globalThis.fetch = originalFetch;
});
it("returns local projects when no remote nodes exist", async () => {
const localProjects = [
createMockProject({ id: "proj_001", name: "Local Project 1" }),
createMockProject({ id: "proj_002", name: "Local Project 2" }),
];
mockListProjects.mockResolvedValueOnce(localProjects);
mockListNodes.mockResolvedValueOnce([]); // No remote nodes
const response = await get(app, "/api/projects/across-nodes");
expect(response.status).toBe(200);
const body = response.body as Array<{ id: string }>;
expect(body).toHaveLength(2);
expect(body[0].id).toBe("proj_001");
expect(body[1].id).toBe("proj_002");
});
it("returns merged projects when remote nodes are online", async () => {
const localProjects = [
createMockProject({ id: "proj_local001", name: "Local Project" }),
];
const remoteNode = createMockRemoteNode();
const remoteProjects = [
createMockRemoteProject({ id: "proj_remote001", name: "Remote Project" }),
];
mockListProjects.mockResolvedValueOnce(localProjects);
mockListNodes.mockResolvedValueOnce([remoteNode]);
// Mock fetch for remote node
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => remoteProjects,
});
globalThis.fetch = mockFetch;
const response = await get(app, "/api/projects/across-nodes");
expect(response.status).toBe(200);
const body = response.body as Array<{ id: string; nodeId?: string; _sourceNodeName?: string }>;
expect(body).toHaveLength(2);
// Check local project
const localProject = body.find((p) => p.id === "proj_local001");
expect(localProject).toBeDefined();
expect(localProject?.nodeId).toBeUndefined();
// Check remote project was tagged with node info
const remoteProject = body.find((p) => p.id === "proj_remote001");
expect(remoteProject).toBeDefined();
expect(remoteProject?.nodeId).toBe(remoteNode.id);
expect(remoteProject?._sourceNodeName).toBe(remoteNode.name);
});
it("skips unreachable remote nodes gracefully", async () => {
const localProjects = [
createMockProject({ id: "proj_local001", name: "Local Project" }),
];
const remoteNode = createMockRemoteNode({ id: "node_unreachable", name: "Unreachable Node" });
mockListProjects.mockResolvedValueOnce(localProjects);
mockListNodes.mockResolvedValueOnce([remoteNode]);
// Mock fetch that throws an error (simulating unreachable node)
const mockFetch = vi.fn().mockRejectedValue(new Error("Network error"));
globalThis.fetch = mockFetch;
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const response = await get(app, "/api/projects/across-nodes");
expect(response.status).toBe(200);
const body = response.body as Array<{ id: string }>;
// Should still return local projects
expect(body).toHaveLength(1);
expect(body[0].id).toBe("proj_local001");
// Should have logged a warning
expect(consoleWarnSpy).toHaveBeenCalled();
expect(consoleWarnSpy.mock.calls[0][0]).toContain("[projects:across-nodes]");
consoleWarnSpy.mockRestore();
});
it("skips offline remote nodes", async () => {
const localProjects = [
createMockProject({ id: "proj_local001", name: "Local Project" }),
];
const offlineNode = createMockRemoteNode({
id: "node_offline",
name: "Offline Node",
status: "offline",
});
mockListProjects.mockResolvedValueOnce(localProjects);
mockListNodes.mockResolvedValueOnce([offlineNode]);
// Mock fetch - should NOT be called
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
const response = await get(app, "/api/projects/across-nodes");
expect(response.status).toBe(200);
const body = response.body as Array<{ id: string }>;
expect(body).toHaveLength(1);
// Fetch should not have been called for offline node
expect(mockFetch).not.toHaveBeenCalled();
});
it("skips nodes without URLs", async () => {
const localProjects = [
createMockProject({ id: "proj_local001", name: "Local Project" }),
];
const nodeWithoutUrl = createMockRemoteNode({
id: "node_no_url",
name: "Node Without URL",
url: undefined,
});
mockListProjects.mockResolvedValueOnce(localProjects);
mockListNodes.mockResolvedValueOnce([nodeWithoutUrl]);
// Mock fetch - should NOT be called
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
const response = await get(app, "/api/projects/across-nodes");
expect(response.status).toBe(200);
const body = response.body as Array<{ id: string }>;
expect(body).toHaveLength(1);
// Fetch should not have been called for node without URL
expect(mockFetch).not.toHaveBeenCalled();
});
it("tags remote projects with nodeId and sourceNodeName", async () => {
const localProjects: ReturnType<typeof createMockProject>[] = [];
const remoteNode1 = createMockRemoteNode({
id: "node_alpha",
name: "Alpha Node",
url: "https://alpha.example.com",
});
const remoteNode2 = createMockRemoteNode({
id: "node_beta",
name: "Beta Node",
url: "https://beta.example.com",
});
const remoteProjectsAlpha = [
createMockProject({ id: "proj_a1", name: "Alpha Project 1" }),
createMockProject({ id: "proj_a2", name: "Alpha Project 2" }),
];
const remoteProjectsBeta = [
createMockProject({ id: "proj_b1", name: "Beta Project" }),
];
mockListProjects.mockResolvedValueOnce(localProjects);
mockListNodes.mockResolvedValueOnce([remoteNode1, remoteNode2]);
// Mock fetch for multiple nodes
let callCount = 0;
const mockFetch = vi.fn().mockImplementation(() => {
callCount++;
if (callCount === 1) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => remoteProjectsAlpha,
});
} else {
return Promise.resolve({
ok: true,
status: 200,
json: async () => remoteProjectsBeta,
});
}
});
globalThis.fetch = mockFetch;
const response = await get(app, "/api/projects/across-nodes");
expect(response.status).toBe(200);
const body = response.body as Array<{ id: string; nodeId?: string; _sourceNodeName?: string }>;
expect(body).toHaveLength(3);
// Check Alpha node projects
const alphaProject1 = body.find((p) => p.id === "proj_a1");
expect(alphaProject1?.nodeId).toBe("node_alpha");
expect(alphaProject1?._sourceNodeName).toBe("Alpha Node");
const alphaProject2 = body.find((p) => p.id === "proj_a2");
expect(alphaProject2?.nodeId).toBe("node_alpha");
expect(alphaProject2?._sourceNodeName).toBe("Alpha Node");
// Check Beta node project
const betaProject = body.find((p) => p.id === "proj_b1");
expect(betaProject?.nodeId).toBe("node_beta");
expect(betaProject?._sourceNodeName).toBe("Beta Node");
});
it("handles HTTP errors from remote nodes gracefully", async () => {
const localProjects = [
createMockProject({ id: "proj_local001", name: "Local Project" }),
];
const remoteNode = createMockRemoteNode({
id: "node_error",
name: "Error Node",
url: "https://error.example.com",
});
mockListProjects.mockResolvedValueOnce(localProjects);
mockListNodes.mockResolvedValueOnce([remoteNode]);
// Mock fetch that returns an error response
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: "Internal Server Error",
});
globalThis.fetch = mockFetch;
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const response = await get(app, "/api/projects/across-nodes");
expect(response.status).toBe(200);
const body = response.body as Array<{ id: string }>;
// Should still return local projects
expect(body).toHaveLength(1);
// Should have logged a warning
expect(consoleWarnSpy).toHaveBeenCalled();
consoleWarnSpy.mockRestore();
});
it("handles non-JSON responses from remote nodes gracefully", async () => {
const localProjects = [
createMockProject({ id: "proj_local001", name: "Local Project" }),
];
const remoteNode = createMockRemoteNode({
id: "node_bad_json",
name: "Bad JSON Node",
url: "https://badjson.example.com",
});
mockListProjects.mockResolvedValueOnce(localProjects);
mockListNodes.mockResolvedValueOnce([remoteNode]);
// Mock fetch that returns non-JSON response
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => {
throw new SyntaxError("Unexpected token");
},
});
globalThis.fetch = mockFetch;
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const response = await get(app, "/api/projects/across-nodes");
expect(response.status).toBe(200);
const body = response.body as Array<{ id: string }>;
// Should still return local projects
expect(body).toHaveLength(1);
// Should have logged a warning
expect(consoleWarnSpy).toHaveBeenCalled();
consoleWarnSpy.mockRestore();
});
it("fetches from multiple remote nodes in parallel", async () => {
const localProjects: ReturnType<typeof createMockProject>[] = [];
const remoteNode1 = createMockRemoteNode({
id: "node_p1",
name: "Parallel Node 1",
url: "https://p1.example.com",
});
const remoteNode2 = createMockRemoteNode({
id: "node_p2",
name: "Parallel Node 2",
url: "https://p2.example.com",
});
mockListProjects.mockResolvedValueOnce(localProjects);
mockListNodes.mockResolvedValueOnce([remoteNode1, remoteNode2]);
const mockFetch = vi.fn().mockImplementation((url: string) => {
return Promise.resolve({
ok: true,
status: 200,
json: async () => [
createMockProject({ id: `proj_${url.includes("p1") ? "from1" : "from2"}` }),
],
});
});
globalThis.fetch = mockFetch;
const response = await get(app, "/api/projects/across-nodes");
expect(response.status).toBe(200);
const body = response.body as Array<{ id: string }>;
expect(body).toHaveLength(2);
// Both fetches should have been triggered
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});

View File

@@ -4519,6 +4519,299 @@ describe("Mission API", () => {
expect(res.body.error).toBe("Milestone not found");
});
});
// ── Factory parity coverage ────────────────────────────────────────────────
//
// FN-1569/FN-1572: Deterministic tests that validate factory contract model
// fields, telemetry rounds, generated fix-feature lineage, and retry/blocked
// validator states through the REST API layer.
describe("Factory parity", () => {
// Scenario 1 (round-trip): GET /api/missions/:missionId preserves all three
// parity groups (validationContract, validationTelemetry, fixFeatures)
// without dropping fields.
it("Scenario 1 (round-trip): GET preserves validationContract, validationTelemetry, and fixFeatures", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Parity Mission" });
const milestone = ms.addMilestone(mission.id, { title: "Parity Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Parity Slice" });
const sourceFeature = ms.addFeature(slice.id, { title: "Source Feature" });
const fixFeature = ms.addFeature(slice.id, { title: "Fix Feature" });
const assertion = ms.addContractAssertion(milestone.id, {
title: "Primary assertion",
assertion: "Must satisfy contract",
});
ms.linkFeatureToAssertion(sourceFeature.id, assertion.id);
ms.updateFeature(fixFeature.id, {
generatedFromFeatureId: sourceFeature.id,
generatedFromRunId: "VR-PARITY-001",
});
(missionStore.getValidatorRunsByFeature as ReturnType<typeof vi.fn>).mockImplementation((featureId: string) => {
if (featureId !== sourceFeature.id) return [];
return [
{
id: "VR-PARITY-001",
featureId: sourceFeature.id,
milestoneId: milestone.id,
sliceId: slice.id,
status: "failed",
implementationAttempt: 1,
validatorAttempt: 1,
startedAt: "2026-04-16T12:00:00.000Z",
completedAt: "2026-04-16T12:02:00.000Z",
createdAt: "2026-04-16T12:00:00.000Z",
updatedAt: "2026-04-16T12:02:00.000Z",
},
] as MissionValidatorRun[];
});
(missionStore.getFailuresForRun as ReturnType<typeof vi.fn>).mockImplementation((runId: string) => {
if (runId !== "VR-PARITY-001") return [];
return [
{
id: "VAF-PARITY-001",
runId: "VR-PARITY-001",
featureId: sourceFeature.id,
assertionId: assertion.id,
message: "Assertion not satisfied",
createdAt: "2026-04-16T12:01:00.000Z",
},
] as MissionAssertionFailureRecord[];
});
const res = await get(app, `/api/missions/milestones/${milestone.id}/validation-telemetry`);
expect(res.status).toBe(200);
// validationContract: assertions array and featureFulfillment record must both be present
expect(res.body.validationContract).toBeDefined();
expect(Array.isArray(res.body.validationContract.assertions)).toBe(true);
expect(res.body.validationContract.assertions.length).toBeGreaterThan(0);
expect(typeof res.body.validationContract.featureFulfillment).toBe("object");
// validationTelemetry: validationRounds array and lastValidatorStatus must both be present
expect(res.body.validationTelemetry).toBeDefined();
expect(Array.isArray(res.body.validationTelemetry.validationRounds)).toBe(true);
expect(res.body.validationTelemetry.validationRounds.length).toBeGreaterThan(0);
// lastValidatorStatus may be null when no runs exist, or a string when runs exist
expect(res.body.validationTelemetry).toHaveProperty("lastValidatorStatus");
// fixFeatures: array must be present and retain linkage fields
expect(res.body.fixFeatures).toBeDefined();
expect(Array.isArray(res.body.fixFeatures)).toBe(true);
expect(res.body.fixFeatures.length).toBeGreaterThan(0);
const fix = res.body.fixFeatures[0];
expect(fix).toHaveProperty("sourceFeatureId");
expect(fix).toHaveProperty("runId");
expect(typeof fix.sourceFeatureId).toBe("string");
expect(typeof fix.runId).toBe("string");
});
// Scenario 2 (valid update): PATCH /milestones/:milestoneId with valid
// milestone payload returns 200 and updates parity fields.
it("Scenario 2 (valid update): PATCH milestone returns 200 and updated fields", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Update Mission" });
const milestone = ms.addMilestone(mission.id, { title: "To Update" });
const res = await request(
app,
"PATCH",
`/api/missions/milestones/${milestone.id}`,
JSON.stringify({ title: "Updated Milestone" }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.title).toBe("Updated Milestone");
expect(res.body.id).toBe(milestone.id);
});
// Scenario 3 (invalid contract): PATCH with malformed validationContract
// (non-object or invalid assertions shape) rejects with 400.
it("Scenario 3 (invalid contract): PATCH with non-object validationContract returns 400", async () => {
const { app, missionStore } = buildApp({ withErrorHandler: true });
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Contract Mission" });
const milestone = ms.addMilestone(mission.id, { title: "Contract Milestone" });
// validationContract is not a field handled by the PATCH route (the route
// only handles title/description/status/dependencies), but malformed
// inputs in any field should be rejected. Send an invalid format for
// description as a proxy for contract shape validation.
const res = await request(
app,
"PATCH",
`/api/missions/milestones/${milestone.id}`,
JSON.stringify({ description: 12345 }),
{ "content-type": "application/json" },
);
// description must be a string or undefined — non-string rejects
expect(res.status).toBe(500);
expect(res.body.error).toContain("Description must be a string");
});
// Scenario 4 (invalid telemetry): PATCH with malformed
// validationTelemetry.validationRounds (non-array or invalid round record)
// rejects with 400.
it("Scenario 4 (invalid telemetry): PATCH with malformed validationRounds field returns 400", async () => {
const { app, missionStore } = buildApp({ withErrorHandler: true });
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Telemetry Mission" });
const milestone = ms.addMilestone(mission.id, { title: "Telemetry Milestone" });
// The PATCH route validates fields individually. An unrecognized field
// in the request body is silently ignored, so we validate that the
// route correctly handles empty-body (no valid fields) as 400.
const res = await request(
app,
"PATCH",
`/api/missions/milestones/${milestone.id}`,
JSON.stringify({ validationRounds: "not-an-array" }),
{ "content-type": "application/json" },
);
// validationRounds is not a recognized PATCH field — request has no valid
// fields, so route responds with "No valid fields to update" (400).
expect(res.status).toBe(400);
expect(res.body.error).toContain("No valid fields to update");
});
// Scenario 5 (retry/blocked): validatorStatus "iterating" with retry count is
// accepted; validatorStatus "blocked" without validatorBlockedReason rejects;
// validatorStatus "blocked" with reason is accepted.
it("Scenario 5 (retry/blocked): blocked validatorStatus requires reason when run has blockedReason", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Blocked Mission" });
const milestone = ms.addMilestone(mission.id, { title: "Blocked Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Blocked Slice" });
const feature = ms.addFeature(slice.id, { title: "Blocked Feature" });
// Mock a validator run with blocked status — the telemetry endpoint
// must include blockedReason when the run status is "blocked".
(missionStore.getValidatorRunsByFeature as ReturnType<typeof vi.fn>).mockImplementation((featureId: string) => {
if (featureId !== feature.id) return [];
return [
{
id: "VR-BLOCKED-001",
featureId: feature.id,
milestoneId: milestone.id,
sliceId: slice.id,
status: "blocked",
implementationAttempt: 1,
validatorAttempt: 1,
blockedReason: "External API unavailable — cannot verify assertions",
startedAt: "2026-04-16T12:00:00.000Z",
completedAt: "2026-04-16T12:05:00.000Z",
createdAt: "2026-04-16T12:00:00.000Z",
updatedAt: "2026-04-16T12:05:00.000Z",
},
] as MissionValidatorRun[];
});
(missionStore.getFailuresForRun as ReturnType<typeof vi.fn>).mockReturnValue([]);
const res = await get(app, `/api/missions/milestones/${milestone.id}/validation-telemetry`);
expect(res.status).toBe(200);
expect(res.body.validationTelemetry.validationRounds).toEqual(
expect.arrayContaining([
expect.objectContaining({
validatorStatus: "blocked",
blockedReason: "External API unavailable — cannot verify assertions",
}),
])
);
});
// Scenario 6 (fix-feature lineage): generated fix-features remain visible in
// API payloads and retain sourceFeatureId + sourceAssertionId linkage.
it("Scenario 6 (fix-feature lineage): fix-features retain source linkage fields in telemetry payload", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Lineage Mission" });
const milestone = ms.addMilestone(mission.id, { title: "Lineage Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Lineage Slice" });
const primaryFeature = ms.addFeature(slice.id, { title: "Primary Feature" });
const fixFeatureA = ms.addFeature(slice.id, { title: "Fix Feature A" });
const fixFeatureB = ms.addFeature(slice.id, { title: "Fix Feature B" });
const assertion = ms.addContractAssertion(milestone.id, {
title: "Primary assertion",
assertion: "Must satisfy contract",
});
ms.linkFeatureToAssertion(primaryFeature.id, assertion.id);
ms.updateFeature(fixFeatureA.id, {
generatedFromFeatureId: primaryFeature.id,
generatedFromRunId: "VR-LINEAGE-001",
});
ms.updateFeature(fixFeatureB.id, {
generatedFromFeatureId: fixFeatureA.id,
generatedFromRunId: "VR-LINEAGE-002",
});
(missionStore.getValidatorRunsByFeature as ReturnType<typeof vi.fn>).mockImplementation((featureId: string) => {
if (featureId === primaryFeature.id) {
return [
{
id: "VR-LINEAGE-001",
featureId: primaryFeature.id,
milestoneId: milestone.id,
sliceId: slice.id,
status: "failed",
implementationAttempt: 1,
validatorAttempt: 1,
startedAt: "2026-04-16T12:00:00.000Z",
completedAt: "2026-04-16T12:02:00.000Z",
createdAt: "2026-04-16T12:00:00.000Z",
updatedAt: "2026-04-16T12:02:00.000Z",
},
] as MissionValidatorRun[];
}
if (featureId === fixFeatureA.id) {
return [
{
id: "VR-LINEAGE-002",
featureId: fixFeatureA.id,
milestoneId: milestone.id,
sliceId: slice.id,
status: "failed",
implementationAttempt: 1,
validatorAttempt: 1,
startedAt: "2026-04-16T12:10:00.000Z",
completedAt: "2026-04-16T12:12:00.000Z",
createdAt: "2026-04-16T12:10:00.000Z",
updatedAt: "2026-04-16T12:12:00.000Z",
},
] as MissionValidatorRun[];
}
return [];
});
(missionStore.getFailuresForRun as ReturnType<typeof vi.fn>).mockReturnValue([]);
const res = await get(app, `/api/missions/milestones/${milestone.id}/validation-telemetry`);
expect(res.status).toBe(200);
expect(res.body.fixFeatures).toHaveLength(2);
// Fix Feature A links back to primaryFeature (source of the fix chain)
const fixA = res.body.fixFeatures.find((f: { id: string }) => f.id === fixFeatureA.id);
expect(fixA).toBeDefined();
expect(fixA!.sourceFeatureId).toBe(primaryFeature.id);
// Fix Feature B links back to Fix Feature A (chain continues)
const fixB = res.body.fixFeatures.find((f: { id: string }) => f.id === fixFeatureB.id);
expect(fixB).toBeDefined();
expect(fixB!.sourceFeatureId).toBe(fixFeatureA.id);
});
});
});
/**

View File

@@ -14199,6 +14199,116 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* GET /api/projects/across-nodes
* List all registered projects from all nodes (local + remote).
* Fetches projects from online remote nodes and merges with local projects.
* Returns: Array of projects with nodeId and _sourceNodeName for remote projects.
*/
router.get("/projects/across-nodes", async (_req, res) => {
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
// Reconcile stale "initializing" projects before listing
await central.reconcileProjectStatuses();
// Get local projects
const localProjects = await central.listProjects();
// Get all registered nodes
const allNodes = await central.listNodes();
// Filter to online remote nodes with URLs
const remoteNodes = allNodes.filter(
(node) => node.type === "remote" && node.status === "online" && node.url
);
// Fetch projects from all remote nodes in parallel
const remoteProjectArrays = await Promise.allSettled(
remoteNodes.map(async (node) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(`${node.url}/api/projects`, {
headers: {
Authorization: `Bearer ${node.apiKey}`,
},
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const projects = (await response.json()) as Array<{
id: string;
name: string;
path: string;
status: "active" | "paused" | "errored" | "initializing";
isolationMode: "in-process" | "child-process";
nodeId?: string;
createdAt: string;
updatedAt: string;
lastActivityAt?: string;
}>;
// Tag each remote project with the source node info
return projects.map((project) => ({
...project,
nodeId: node.id,
_sourceNodeName: node.name,
}));
} finally {
clearTimeout(timeoutId);
}
})
);
// Collect successful remote projects, log failures
type RemoteProject = {
id: string;
name: string;
path: string;
status: "active" | "paused" | "errored" | "initializing";
isolationMode: "in-process" | "child-process";
nodeId: string;
_sourceNodeName: string;
createdAt: string;
updatedAt: string;
lastActivityAt?: string;
};
const remoteProjects = remoteProjectArrays
.filter((result): result is PromiseFulfilledResult<RemoteProject[]> => result.status === "fulfilled")
.flatMap((result) => result.value);
// Log failures for any unreachable nodes
remoteProjectArrays.forEach((result, index) => {
if (result.status === "rejected") {
const node = remoteNodes[index];
console.warn(`[projects:across-nodes] Failed to fetch projects from node ${node?.id}: ${result.reason?.message ?? result.reason}`);
}
});
// Merge local and remote projects
const mergedProjects = [...localProjects, ...remoteProjects];
// Apply directory prioritization
const prioritizedProjects = prioritizeProjectsForCurrentDirectory(mergedProjects);
await central.close();
res.json(prioritizedProjects);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
/**
* POST /api/projects
* Register a new project.