feat(FN-4266): complete Step 3 — mirror auto-register in daemon

Ref: Runfusion/Fusion#216
Fusion-Task-Id: FN-4266
Fusion-Task-Lineage: 4105d2eb-f3d8-4297-9b49-bf7e5a2906be
This commit is contained in:
Fusion
2026-05-12 23:11:38 -07:00
committed by gsxdsm
parent 366816ea79
commit f19ccb8b34
4 changed files with 118 additions and 20 deletions

View File

@@ -1,5 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const { mockSyncStartupModels } = vi.hoisted(() => ({
mockSyncStartupModels: vi.fn().mockResolvedValue(undefined),
@@ -123,16 +126,45 @@ const mocks = vi.hoisted(() => {
});
const centralCoreCtor = vi.fn().mockImplementation(() => {
const now = new Date().toISOString();
const projects = [
{ id: "project-1", name: "Test Project", path: "/repo", status: "active", isolationMode: "in-process", createdAt: now, updatedAt: now },
];
const instance = {
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
getProject: vi.fn().mockImplementation((id: string) =>
Promise.resolve({ id, name: `Project ${id}`, path: `/repo/${id}`, status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
getProjectByPath: vi.fn().mockImplementation((path: string) =>
Promise.resolve(projects.find((project) => project.path === path) ?? null),
),
listProjects: vi.fn().mockResolvedValue([
{ id: "project-1", name: "Test Project", path: "/repo", status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
]),
registerProject: vi.fn().mockImplementation(({ name, path, isolationMode }: { name: string; path: string; isolationMode: "in-process" | "child-process" }) => {
const project = {
id: `project-${projects.length + 1}`,
name,
path,
status: "inactive",
isolationMode,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
projects.push(project);
return Promise.resolve(project);
}),
updateProject: vi.fn().mockImplementation((id: string, patch: { status?: string }) => {
const index = projects.findIndex((project) => project.id === id);
if (index >= 0) {
projects[index] = {
...projects[index],
...patch,
updatedAt: new Date().toISOString(),
};
}
return Promise.resolve();
}),
getProject: vi.fn().mockImplementation((id: string) =>
Promise.resolve(projects.find((project) => project.id === id) ?? null),
),
listProjects: vi.fn().mockImplementation(() => Promise.resolve([...projects])),
listNodes: vi.fn().mockResolvedValue([
{ id: "node-local", name: "local", type: "local", status: "offline" },
]),
@@ -591,6 +623,10 @@ vi.mock("../task-lifecycle.js", () => ({
processPullRequestMergeTask: vi.fn().mockResolvedValue("waiting"),
}));
vi.mock("../project-context.js", () => ({
resolveProject: vi.fn().mockRejectedValue(new Error("project not initialized")),
}));
const { runDaemon } = await import("../daemon.js");
describe("runDaemon", () => {
@@ -785,6 +821,50 @@ describe("runDaemon", () => {
await triggerSignal("SIGINT");
});
it("auto-registers cwd project when not previously registered", async () => {
const freshCwd = mkdtempSync(join(tmpdir(), "daemon-auto-register-"));
cwdSpy.mockReturnValue(freshCwd);
try {
await runDaemon({});
const registrationCalls = mocks.centralInstances.flatMap((instance) =>
instance.registerProject.mock.calls,
);
expect(registrationCalls).toContainEqual([
expect.objectContaining({ path: freshCwd, isolationMode: "in-process" }),
]);
const updateCalls = mocks.centralInstances.flatMap((instance) =>
instance.updateProject.mock.calls,
);
expect(updateCalls).toContainEqual([expect.any(String), { status: "active" }]);
expect(process.exit).not.toHaveBeenCalledWith(1);
await triggerSignal("SIGINT");
} finally {
rmSync(freshCwd, { recursive: true, force: true });
}
});
it("--no-auto-register preserves legacy exit behavior", async () => {
const freshCwd = mkdtempSync(join(tmpdir(), "daemon-no-auto-register-"));
cwdSpy.mockReturnValue(freshCwd);
try {
await runDaemon({ noAutoRegister: true });
const registrationCalls = mocks.centralInstances.flatMap((instance) =>
instance.registerProject.mock.calls,
);
expect(registrationCalls).toHaveLength(0);
expect(errorSpy).toHaveBeenCalledWith("[daemon] No engine started for the current project — exiting");
expect(process.exit).toHaveBeenCalledWith(1);
} finally {
rmSync(freshCwd, { recursive: true, force: true });
}
});
it("stops engine services during shutdown", async () => {
await runDaemon({});

View File

@@ -64,6 +64,7 @@ import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, g
import { resolveProject } from "../project-context.js";
import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js";
import { syncStartupModels } from "./startup-model-sync.js";
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let daemonStartTime = 0;
@@ -167,6 +168,8 @@ export interface DaemonOptions {
interactive?: boolean;
/** Just print/generate token without starting server */
tokenOnly?: boolean;
/** Disable cwd auto-registration and preserve legacy strict behavior */
noAutoRegister?: boolean;
}
export async function runDaemon(opts: DaemonOptions = {}) {
@@ -246,10 +249,6 @@ export async function runDaemon(opts: DaemonOptions = {}) {
try {
sharedCentralCore = new CentralCore();
await sharedCentralCore.init();
const registered = await sharedCentralCore.getProjectByPath(cwd);
if (registered) {
ntfyProjectId = registered.id;
}
} catch {
// Central DB unavailable or project not registered — backward compatible
}
@@ -307,6 +306,16 @@ export async function runDaemon(opts: DaemonOptions = {}) {
}
}
if (sharedCentralCore) {
const registered = await ensureCwdProjectRegistered({
cwd,
central: sharedCentralCore,
logPrefix: "daemon",
autoRegister: !opts.noAutoRegister,
});
ntfyProjectId = registered?.id;
}
const engineManager = new ProjectEngineManager(sharedCentralCore, {
getMergeStrategy,
processPullRequestMerge: (s, wd, taskId) =>

View File

@@ -65,6 +65,7 @@ import { resolveSelfExtension } from "./self-extension.js";
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
import { syncStartupModels } from "./startup-model-sync.js";
import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js";
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let diagnosticIntervalHandle: ReturnType<typeof setInterval> | null = null;
@@ -223,7 +224,7 @@ function ensureProcessDiagnostics(): void {
export async function runServe(
port: number,
opts: { interactive?: boolean; paused?: boolean; host?: string; daemon?: boolean } = {},
opts: { interactive?: boolean; paused?: boolean; host?: string; daemon?: boolean; noAutoRegister?: boolean } = {},
) {
serveStartTime = Date.now();
ensureProcessDiagnostics();
@@ -265,10 +266,6 @@ export async function runServe(
try {
sharedCentralCore = new CentralCore();
await sharedCentralCore.init();
const registered = await sharedCentralCore.getProjectByPath(cwd);
if (registered) {
ntfyProjectId = registered.id;
}
} catch {
// Central DB unavailable or project not registered — backward compatible
}
@@ -337,6 +334,16 @@ export async function runServe(
}
}
if (sharedCentralCore) {
const registered = await ensureCwdProjectRegistered({
cwd,
central: sharedCentralCore,
logPrefix: "serve",
autoRegister: !opts.noAutoRegister,
});
ntfyProjectId = registered?.id;
}
const engineManager = new ProjectEngineManager(sharedCentralCore, {
getMergeStrategy,
processPullRequestMerge: (s, wd, taskId) =>