feat(FN-2001): merge fusion/fn-2001

This commit is contained in:
gsxdsm
2026-04-17 14:09:45 -07:00
parent e51393285b
commit 633ed3e591
4 changed files with 148 additions and 1 deletions

View File

@@ -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");
});
});
});

View File

@@ -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();

View File

@@ -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", () => {

View File

@@ -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 } });