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:
@@ -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=");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user