feat(FN-2001): merge fusion/fn-2001
This commit is contained in:
@@ -21,6 +21,7 @@ const mockGetSettings = vi.fn();
|
||||
const mockGlobalInit = vi.fn();
|
||||
const mockTaskStoreInit = vi.fn();
|
||||
const mockTaskStoreListTasks = vi.fn();
|
||||
const mockEnsureMemoryFileWithBackend = vi.fn();
|
||||
|
||||
// Mock @fusion/core
|
||||
vi.mock("@fusion/core", () => ({
|
||||
@@ -43,6 +44,7 @@ vi.mock("@fusion/core", () => ({
|
||||
init: mockTaskStoreInit,
|
||||
listTasks: mockTaskStoreListTasks,
|
||||
})),
|
||||
ensureMemoryFileWithBackend: mockEnsureMemoryFileWithBackend,
|
||||
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
|
||||
COLUMN_LABELS: {
|
||||
triage: "Triage",
|
||||
@@ -70,6 +72,7 @@ vi.mock("../project-context.js", () => ({
|
||||
|
||||
describe("project commands", () => {
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
@@ -77,6 +80,7 @@ describe("project commands", () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
|
||||
throw new Error(`process.exit:${code ?? 0}`);
|
||||
@@ -91,6 +95,7 @@ describe("project commands", () => {
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
consoleWarnSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
@@ -341,4 +346,88 @@ describe("project commands", () => {
|
||||
const { runProjectSetDefault } = await import("./project.js");
|
||||
await expect(runProjectSetDefault("")).rejects.toThrow("process.exit:1");
|
||||
});
|
||||
|
||||
describe("runProjectAdd memory bootstrap", () => {
|
||||
// Use "." as path like the existing runProjectAdd test - it resolves to cwd which exists
|
||||
const testPath = ".";
|
||||
|
||||
beforeEach(() => {
|
||||
mockEnsureMemoryFileWithBackend.mockReset();
|
||||
mockEnsureMemoryFileWithBackend.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("calls ensureMemoryFileWithBackend after project registration", async () => {
|
||||
mockListProjects.mockResolvedValue([]);
|
||||
mockRegisterProject.mockResolvedValue({
|
||||
id: "proj-1",
|
||||
name: "demo",
|
||||
path: "/fake/demo",
|
||||
isolationMode: "in-process",
|
||||
});
|
||||
|
||||
const { runProjectAdd } = await import("./project.js");
|
||||
await runProjectAdd("demo", testPath, { force: true });
|
||||
|
||||
expect(mockEnsureMemoryFileWithBackend).toHaveBeenCalled();
|
||||
// Verify it was called with an absolute path
|
||||
const callArg = mockEnsureMemoryFileWithBackend.mock.calls[0][0];
|
||||
expect(callArg).toBe(process.cwd());
|
||||
});
|
||||
|
||||
it("shows memory initialized message when memory files are created", async () => {
|
||||
mockListProjects.mockResolvedValue([]);
|
||||
mockRegisterProject.mockResolvedValue({
|
||||
id: "proj-1",
|
||||
name: "demo",
|
||||
path: "/fake/demo",
|
||||
isolationMode: "in-process",
|
||||
});
|
||||
mockEnsureMemoryFileWithBackend.mockResolvedValue(true);
|
||||
|
||||
const { runProjectAdd } = await import("./project.js");
|
||||
await runProjectAdd("demo", testPath, { force: true });
|
||||
|
||||
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).toContain("Memory: initialized");
|
||||
});
|
||||
|
||||
it("does not show memory message when memory files already exist", async () => {
|
||||
mockListProjects.mockResolvedValue([]);
|
||||
mockRegisterProject.mockResolvedValue({
|
||||
id: "proj-1",
|
||||
name: "demo",
|
||||
path: "/fake/demo",
|
||||
isolationMode: "in-process",
|
||||
});
|
||||
mockEnsureMemoryFileWithBackend.mockResolvedValue(false); // Files already exist
|
||||
|
||||
const { runProjectAdd } = await import("./project.js");
|
||||
await runProjectAdd("demo", testPath, { force: true });
|
||||
|
||||
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(output).not.toContain("Memory: initialized");
|
||||
});
|
||||
|
||||
it("does not block project registration when memory bootstrap fails", async () => {
|
||||
mockListProjects.mockResolvedValue([]);
|
||||
mockRegisterProject.mockResolvedValue({
|
||||
id: "proj-1",
|
||||
name: "demo",
|
||||
path: "/fake/demo",
|
||||
isolationMode: "in-process",
|
||||
});
|
||||
mockEnsureMemoryFileWithBackend.mockRejectedValue(new Error("disk full"));
|
||||
|
||||
const { runProjectAdd } = await import("./project.js");
|
||||
await runProjectAdd("demo", testPath, { force: true });
|
||||
|
||||
// Project should still be registered
|
||||
expect(mockRegisterProject).toHaveBeenCalled();
|
||||
expect(mockUpdateProject).toHaveBeenCalledWith("proj-1", { status: "active" });
|
||||
|
||||
// Should show warning about memory failure on console.warn
|
||||
const warnOutput = consoleWarnSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(warnOutput).toContain("Could not initialize project memory");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
CentralCore,
|
||||
GlobalSettingsStore,
|
||||
TaskStore,
|
||||
ensureMemoryFileWithBackend,
|
||||
type RegisteredProject,
|
||||
type IsolationMode,
|
||||
type ProjectHealth,
|
||||
@@ -364,11 +365,22 @@ export async function runProjectAdd(
|
||||
// Activate the project (registration sets it to 'initializing')
|
||||
await central.updateProject(project.id, { status: "active" });
|
||||
|
||||
// Bootstrap memory files (non-fatal if it fails)
|
||||
let memoryInitialized = false;
|
||||
try {
|
||||
memoryInitialized = await ensureMemoryFileWithBackend(absolutePath);
|
||||
} catch (err) {
|
||||
console.warn(` ⚠ Warning: Could not initialize project memory: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Registered project '${projectName}'`);
|
||||
console.log(` Location: ${formatDisplayPath(project.path)}`);
|
||||
console.log(` ID: ${project.id}`);
|
||||
console.log(` Isolation: ${project.isolationMode}`);
|
||||
if (memoryInitialized) {
|
||||
console.log(` Memory: initialized`);
|
||||
}
|
||||
console.log();
|
||||
} finally {
|
||||
await central.close();
|
||||
|
||||
@@ -39,6 +39,7 @@ const {
|
||||
mockGetOrCreateProjectStore,
|
||||
mockListNodes,
|
||||
mockGetNode,
|
||||
mockEnsureMemoryFileWithBackend,
|
||||
} = vi.hoisted(() => ({
|
||||
mockListProjects: vi.fn().mockResolvedValue([]),
|
||||
mockGetProject: vi.fn().mockResolvedValue(null),
|
||||
@@ -90,6 +91,7 @@ const {
|
||||
mockGetOrCreateProjectStore: vi.fn(),
|
||||
mockListNodes: vi.fn().mockResolvedValue([]),
|
||||
mockGetNode: vi.fn().mockResolvedValue(null),
|
||||
mockEnsureMemoryFileWithBackend: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", async () => {
|
||||
@@ -112,6 +114,7 @@ vi.mock("@fusion/core", async () => {
|
||||
listNodes: mockListNodes,
|
||||
getNode: mockGetNode,
|
||||
})),
|
||||
ensureMemoryFileWithBackend: mockEnsureMemoryFileWithBackend,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -579,6 +582,7 @@ describe("POST /api/projects route handler", () => {
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
mockEnsureMemoryFileWithBackend.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("calls updateProject with status 'active' after registration", async () => {
|
||||
@@ -627,6 +631,43 @@ describe("POST /api/projects route handler", () => {
|
||||
nodeId: "node-remote-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("calls ensureMemoryFileWithBackend after project activation", async () => {
|
||||
const store = new MockStoreForRoutes();
|
||||
const app = createServer(store as any);
|
||||
mockEnsureMemoryFileWithBackend.mockResolvedValue(true);
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/projects",
|
||||
JSON.stringify({ name: "Test Project", path: "/tmp" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
// Allow fire-and-forget promise to complete
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(mockEnsureMemoryFileWithBackend).toHaveBeenCalledWith("/tmp");
|
||||
});
|
||||
|
||||
it("returns 201 even when memory bootstrap fails", async () => {
|
||||
const store = new MockStoreForRoutes();
|
||||
const app = createServer(store as any);
|
||||
mockEnsureMemoryFileWithBackend.mockRejectedValue(new Error("disk full"));
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/projects",
|
||||
JSON.stringify({ name: "Test Project", path: "/tmp" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
// Project registration should still succeed
|
||||
expect(res.status).toBe(201);
|
||||
expect((res.body as any).status).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/projects route handler", () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ import * as nodeFs from "node:fs";
|
||||
|
||||
import { promisify } from "node:util";
|
||||
import type { TaskStore, Column, ScheduleType, ActivityEventType, ModelPreset, MessageType, ParticipantType, RoutineTriggerType, ProjectSettings } from "@fusion/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, type PiExtensionEntry, type PiExtensionSettings, getCurrentRepo, isGhAuthenticated, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupRoutine, exportSettings, importSettings, validateImportData, MessageStore, RoutineStore, isWebhookTrigger, resolveMemoryBackend, getMemoryBackendCapabilities, listMemoryBackendTypes, listProjectMemoryFiles, readProjectMemoryFile, readProjectMemoryFileContent, writeProjectMemoryFile, readMemory, writeMemory, searchProjectMemory, isQmdAvailable, installQmd, refreshQmdProjectMemoryIndex, QMD_INSTALL_COMMAND, MemoryBackendError, scheduleQmdProjectMemoryRefresh, discoverPiExtensions, updatePiExtensionDisabledIds, getFusionAgentDir, getLegacyPiAgentDir } from "@fusion/core";
|
||||
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, type PiExtensionEntry, type PiExtensionSettings, getCurrentRepo, isGhAuthenticated, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupRoutine, exportSettings, importSettings, validateImportData, MessageStore, RoutineStore, isWebhookTrigger, resolveMemoryBackend, getMemoryBackendCapabilities, listMemoryBackendTypes, listProjectMemoryFiles, readProjectMemoryFile, readProjectMemoryFileContent, writeProjectMemoryFile, readMemory, writeMemory, searchProjectMemory, isQmdAvailable, installQmd, refreshQmdProjectMemoryIndex, QMD_INSTALL_COMMAND, MemoryBackendError, scheduleQmdProjectMemoryRefresh, discoverPiExtensions, updatePiExtensionDisabledIds, getFusionAgentDir, getLegacyPiAgentDir, ensureMemoryFileWithBackend } from "@fusion/core";
|
||||
import type { ServerOptions } from "./server.js";
|
||||
import { GitHubClient, parseBadgeUrl } from "./github.js";
|
||||
import { githubRateLimiter } from "./github-poll.js";
|
||||
@@ -14198,6 +14198,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
// Activate the project (registration sets it to 'initializing')
|
||||
const activeProject = await central.updateProject(project.id, { status: "active" });
|
||||
|
||||
// Bootstrap memory files (non-blocking, non-fatal)
|
||||
ensureMemoryFileWithBackend(path.trim()).catch(() => {
|
||||
// Memory bootstrap failure is non-fatal - project registration succeeded
|
||||
});
|
||||
|
||||
await central.close();
|
||||
|
||||
res.status(201).json({ ...activeProject, _meta: { hasFusionDir: hasFusionDir ? undefined : false } });
|
||||
|
||||
Reference in New Issue
Block a user