feat(FN-670): enforce merge build verification and activity feed fallback

- Add activity feed fallback to the local project log when central activity is empty or unavailable
- Pass configured build commands into executor and merger prompts with hard-gate instructions
- Add merger build-failure reporting via report_build_failure and stop commits when verification fails
- Expand engine and dashboard regression coverage for activity feed fallback and merge build verification
This commit is contained in:
gsxdsm
2026-04-01 13:24:57 -07:00
parent 3cc10b21eb
commit 826a495679
6 changed files with 242 additions and 62 deletions

View File

@@ -2012,6 +2012,7 @@ describe("fetchActivityFeed", () => {
expect(call[0]).toContain("since=2026-01-01T00%3A00%3A00.000Z");
expect(call[0]).toContain("projectId=proj_abc123");
expect(call[0]).toContain("type=task%3Acreated");
expect(call[0]).not.toContain("types=");
});
});

View File

@@ -1830,7 +1830,7 @@ export function fetchProjectHealth(id: string): Promise<ProjectHealth> {
return api<ProjectHealth>(`/projects/${encodeURIComponent(id)}/health`);
}
/** Fetch unified activity feed */
/** Fetch unified activity feed. Supports singular type filtering and server fallback to the current project's local activity log when the central feed is unavailable or empty. */
export function fetchActivityFeed(options?: FeedOptions): Promise<ActivityFeedEntry[]> {
const params = new URLSearchParams();
if (options?.limit !== undefined) params.set("limit", String(options.limit));

View File

@@ -4925,6 +4925,7 @@ body {
}
.list-cell-title {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -5184,6 +5185,7 @@ body {
}
.list-cell-title {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

View File

@@ -81,6 +81,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
updateGlobalSettings: vi.fn(),
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()),
getActivityLog: vi.fn().mockResolvedValue([]),
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
getActivityLog: vi.fn().mockResolvedValue([]),
@@ -156,6 +157,163 @@ function buildMultipart(fieldName: string, filename: string, contentType: string
return { body, boundary };
}
describe("GET /activity-feed", () => {
function mockCentralCoreModule(options?: {
entries?: unknown[];
getRecentActivityError?: Error;
}) {
const close = vi.fn().mockResolvedValue(undefined);
const getRecentActivity = options?.getRecentActivityError
? vi.fn().mockRejectedValue(options.getRecentActivityError)
: vi.fn().mockResolvedValue(options?.entries ?? []);
class MockCentralCore {
init = vi.fn().mockResolvedValue(undefined);
getRecentActivity = getRecentActivity;
close = close;
}
return { MockCentralCore, getRecentActivity, close };
}
function buildApp(store: TaskStore) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
afterEach(() => {
vi.doUnmock("@fusion/core");
});
it("returns central activity when available", async () => {
const store = createMockStore();
const centralEntry = {
id: "act_1",
timestamp: "2026-01-01T00:00:00.000Z",
type: "task:created",
projectId: "proj_123",
projectName: "Central Project",
taskId: "FN-001",
taskTitle: "Test Task",
details: "Created task",
metadata: { source: "central" },
};
const mockCentral = mockCentralCoreModule({ entries: [centralEntry] });
vi.doMock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return { ...actual, CentralCore: mockCentral.MockCentralCore };
});
const { createApiRoutes: createRoutesWithMock } = await import("./routes.js");
const app = express();
app.use(express.json());
app.use("/api", createRoutesWithMock(store));
const res = await GET(app, "/api/activity-feed?type=task:created");
expect(res.status).toBe(200);
expect(res.body).toEqual([centralEntry]);
expect(mockCentral.getRecentActivity).toHaveBeenCalledWith({
limit: 50,
projectId: undefined,
types: ["task:created"],
});
expect(mockCentral.close).toHaveBeenCalled();
expect(store.getActivityLog).not.toHaveBeenCalled();
});
it("falls back to local activity log when central feed is empty", async () => {
const store = createMockStore({
getRootDir: vi.fn().mockReturnValue("/fake/projects/dashboard-app"),
getActivityLog: vi.fn().mockResolvedValue([
{
id: "local_1",
timestamp: "2026-01-01T00:00:00.000Z",
type: "task:created",
taskId: "FN-001",
details: "Task created locally",
metadata: { from: "triage", to: "todo" },
},
]),
});
const mockCentral = mockCentralCoreModule({ entries: [] });
vi.doMock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return { ...actual, CentralCore: mockCentral.MockCentralCore };
});
const { createApiRoutes: createRoutesWithMock } = await import("./routes.js");
const app = express();
app.use(express.json());
app.use("/api", createRoutesWithMock(store));
const res = await GET(app, "/api/activity-feed?type=task:created");
expect(res.status).toBe(200);
expect(res.body).toEqual([
{
id: "local_1",
timestamp: "2026-01-01T00:00:00.000Z",
type: "task:created",
projectId: "local-project",
projectName: "dashboard-app",
taskId: "FN-001",
details: "Task created locally",
metadata: { from: "triage", to: "todo" },
},
]);
expect(store.getActivityLog).toHaveBeenCalledWith({ limit: 50, type: "task:created" });
expect(mockCentral.close).toHaveBeenCalled();
});
it("falls back to local activity log when central feed throws", async () => {
const store = createMockStore({
getRootDir: vi.fn().mockReturnValue("/fake/projects/local-root"),
getActivityLog: vi.fn().mockResolvedValue([
{
id: "local_2",
timestamp: "2026-01-02T00:00:00.000Z",
type: "task:updated",
taskId: "FN-002",
details: "Task updated locally",
},
]),
});
const mockCentral = mockCentralCoreModule({ getRecentActivityError: new Error("require is not defined") });
vi.doMock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return { ...actual, CentralCore: mockCentral.MockCentralCore };
});
const { createApiRoutes: createRoutesWithMock } = await import("./routes.js");
const app = express();
app.use(express.json());
app.use("/api", createRoutesWithMock(store));
const res = await GET(app, "/api/activity-feed?type=task:updated");
expect(res.status).toBe(200);
expect(res.body[0].projectName).toBe("local-root");
expect(store.getActivityLog).toHaveBeenCalledWith({ limit: 50, type: "task:updated" });
expect(mockCentral.close).toHaveBeenCalled();
});
it("returns 400 for invalid type filters", async () => {
const store = createMockStore();
const app = buildApp(store);
const res = await GET(app, "/api/activity-feed?type=not-real");
expect(res.status).toBe(400);
expect(res.body.error).toContain("Invalid type");
});
});
describe("GET /tasks", () => {
let store: TaskStore;

View File

@@ -1,6 +1,7 @@
import { Router, type Request, type Response, type NextFunction } from "express";
import multer from "multer";
import { createReadStream, existsSync } from "node:fs";
import { basename } from "node:path";
import { execSync } from "node:child_process";
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset, AutomationStep } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation, exportSettings, importSettings, validateImportData } from "@fusion/core";
@@ -6667,73 +6668,91 @@ Output ONLY the prompt text (no markdown, no explanations).`;
/**
* GET /api/activity-feed
* Get unified activity feed across all projects.
* Falls back to per-project activity when central activity is empty or unavailable.
*
* Fallback Strategy:
* 1. First attempts to read from CentralCore.getRecentActivity() (multi-project mode)
* 2. If central returns empty array OR CentralCore init fails, falls back to store.getActivityLog()
* 3. Per-project entries are adapted to ActivityFeedEntry format with projectId="local"
* 4. This ensures single-project dashboards show activity even without engine ProjectManager
*
* Query: limit, projectId, types
* Get unified activity feed across all projects, falling back to the current
* project's local activity log when the central feed is unavailable or empty.
* Query: limit, projectId, type (preferred), types (legacy comma-separated)
* Returns: ActivityFeedEntry[]
*/
router.get("/activity-feed", async (req, res) => {
try {
const limit = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : 50;
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
const typesParam = typeof req.query.types === "string" ? req.query.types.split(",") : undefined;
const types = typesParam as import("@fusion/core").ActivityEventType[] | undefined;
let centralEntries: any[] = [];
let centralError: Error | null = null;
// Try to get central activity data first
try {
const { CentralCore } = await import("@fusion/core");
const central = new CentralCore();
await central.init();
centralEntries = await central.getRecentActivity({ limit, projectId, types });
await central.close();
} catch (err) {
centralError = err as Error;
const validTypes: ActivityEventType[] = [
"task:created",
"task:moved",
"task:updated",
"task:deleted",
"task:merged",
"task:failed",
"settings:updated",
];
const limitParam = req.query.limit;
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
const typeParam = typeof req.query.type === "string" ? req.query.type : undefined;
const legacyTypesParam = typeof req.query.types === "string" ? req.query.types : undefined;
const limit = limitParam !== undefined ? Number.parseInt(limitParam as string, 10) : 50;
if (!Number.isFinite(limit) || limit < 0) {
res.status(400).json({ error: "limit must be a non-negative integer" });
return;
}
const requestedTypes = typeParam
? [typeParam]
: legacyTypesParam
? legacyTypesParam.split(",").map((value) => value.trim()).filter(Boolean)
: undefined;
if (requestedTypes && requestedTypes.some((value) => !validTypes.includes(value as ActivityEventType))) {
res.status(400).json({ error: `Invalid type. Must be one of: ${validTypes.join(", ")}` });
return;
}
const types = requestedTypes as ActivityEventType[] | undefined;
const mapLocalEntryToFeedEntry = (
entry: Awaited<ReturnType<TaskStore["getActivityLog"]>>[number],
) => ({
id: entry.id,
timestamp: entry.timestamp,
type: entry.type,
projectId: projectId ?? "local-project",
projectName: basename(store.getRootDir()),
taskId: entry.taskId,
taskTitle: undefined,
details: entry.details,
metadata: entry.metadata,
});
const loadFallbackEntries = async () => {
if (projectId && projectId !== "local-project") {
return [];
}
// If central data exists and is not empty, return it
if (centralEntries.length > 0) {
res.json(centralEntries);
const localEntries = await store.getActivityLog({ limit, type: typeParam as ActivityEventType | undefined });
return localEntries.map(mapLocalEntryToFeedEntry);
};
let central: InstanceType<(typeof import("@fusion/core"))["CentralCore"]> | undefined;
try {
const { CentralCore } = await import("@fusion/core");
central = new CentralCore();
await central.init();
const entries = await central.getRecentActivity({ limit, projectId, types });
if (entries.length > 0) {
res.json(entries);
return;
}
// Fall back to per-project activity data
const options: { limit?: number; type?: import("@fusion/core").ActivityEventType } = { limit };
if (types && types.length === 1) {
options.type = types[0];
} catch {
const fallbackEntries = await loadFallbackEntries();
res.json(fallbackEntries);
return;
} finally {
if (central) {
await central.close();
}
const perProjectEntries = await store.getActivityLog(options);
// Convert per-project ActivityLogEntry[] to ActivityFeedEntry[] format
const projectName = (store as any).rootDir ?
require("path").basename((store as any).rootDir) : "Current Project";
const feedEntries = perProjectEntries.map((entry) => ({
id: entry.id,
timestamp: entry.timestamp,
type: entry.type,
projectId: "local",
projectName: projectName,
taskId: entry.taskId,
taskTitle: entry.taskTitle,
details: entry.details,
metadata: entry.metadata,
}));
res.json(feedEntries);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
const fallbackEntries = await loadFallbackEntries();
res.json(fallbackEntries);
});
/**

View File

@@ -1662,8 +1662,8 @@ describe("buildExecutionPrompt", () => {
expect(result).toContain("> Second comment");
});
it("omits Comments section when comments is empty", () => {
const task = createMockTaskDetail({ comments: [] });
it("omits Comments section when steeringComments is empty", () => {
const task = createMockTaskDetail({ steeringComments: [] });
const result = buildExecutionPrompt(task);
expect(result).not.toContain("## Steering Comments");