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:
@@ -247,10 +247,10 @@ Usage:
|
||||
fn dashboard --paused Start with automation paused
|
||||
fn dashboard --dev Start web UI only (no AI engine)
|
||||
fn dashboard --interactive Start with interactive port selection
|
||||
fn serve [--port <port>] [--host <host>] [--paused] [--daemon]
|
||||
fn serve [--port <port>] [--host <host>] [--paused] [--daemon] [--no-auto-register]
|
||||
Start Fusion as a headless node (API + engine, no UI)
|
||||
Use --daemon to enable bearer token authentication
|
||||
fn daemon [--port <port>] [--host <host>] [--token <token>] [--paused] [--token-only]
|
||||
Auto-registers cwd project on first run (use --no-auto-register to disable)
|
||||
fn daemon [--port <port>] [--host <host>] [--token <token>] [--paused] [--token-only] [--no-auto-register]
|
||||
Start Fusion daemon (API + engine, auth required)
|
||||
fn desktop Launch the Fusion desktop app (Electron)
|
||||
fn desktop --dev Launch with hot-reload (connects to Vite dev server)
|
||||
@@ -646,7 +646,8 @@ async function main() {
|
||||
const hostIdx = args.indexOf("--host");
|
||||
const host = hostIdx !== -1 && hostIdx + 1 < args.length ? args[hostIdx + 1] : undefined;
|
||||
const daemon = args.includes("--daemon");
|
||||
await runServe(port, { paused, interactive, host, daemon });
|
||||
const noAutoRegister = args.includes("--no-auto-register");
|
||||
await runServe(port, { paused, interactive, host, daemon, noAutoRegister });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -662,7 +663,8 @@ async function main() {
|
||||
const tokenIdx = args.indexOf("--token");
|
||||
const token = tokenIdx !== -1 && tokenIdx + 1 < args.length ? args[tokenIdx + 1] : undefined;
|
||||
const tokenOnly = args.includes("--token-only");
|
||||
await runDaemon({ port, paused, interactive, host, token, tokenOnly });
|
||||
const noAutoRegister = args.includes("--no-auto-register");
|
||||
await runDaemon({ port, paused, interactive, host, token, tokenOnly, noAutoRegister });
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({});
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user