feat(FN-4313): complete Step 3-6 — orchestrator primary engine resolution
Fusion-Task-Id: FN-4313 Fusion-Task-Lineage: 36b8aad3-20f4-475b-9b0f-63e5458d1b91
This commit is contained in:
@@ -250,10 +250,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] [--no-auto-register]
|
||||
fn serve [--port <port>] [--host <host>] [--paused] [--daemon] [--project <id|name>] [--no-auto-register]
|
||||
Start Fusion as a headless node (API + engine, no UI)
|
||||
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]
|
||||
fn daemon [--port <port>] [--host <host>] [--token <token>] [--paused] [--token-only] [--project <id|name>] [--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)
|
||||
@@ -399,6 +399,11 @@ Supported file types: png, jpg, gif, webp, txt, log, json, yaml, yml, toml, csv,
|
||||
`.trim();
|
||||
|
||||
function extractGlobalProjectFlag(argv: string[]): { cleanedArgs: string[]; projectName?: string } {
|
||||
const command = argv[0];
|
||||
if (command === "serve" || command === "daemon") {
|
||||
return { cleanedArgs: [...argv] };
|
||||
}
|
||||
|
||||
const cleanedArgs: string[] = [];
|
||||
let projectName: string | undefined;
|
||||
|
||||
@@ -652,8 +657,9 @@ 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");
|
||||
const project = getFlagValue(args, "--project");
|
||||
const noAutoRegister = args.includes("--no-auto-register");
|
||||
await runServe(port, { paused, interactive, host, daemon, noAutoRegister });
|
||||
await runServe(port, { paused, interactive, host, daemon, project, noAutoRegister });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -669,8 +675,9 @@ 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");
|
||||
const project = getFlagValue(args, "--project");
|
||||
const noAutoRegister = args.includes("--no-auto-register");
|
||||
await runDaemon({ port, paused, interactive, host, token, tokenOnly, noAutoRegister });
|
||||
await runDaemon({ port, paused, interactive, host, token, tokenOnly, project, noAutoRegister });
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -165,6 +165,7 @@ const mocks = vi.hoisted(() => {
|
||||
Promise.resolve(projects.find((project) => project.id === id) ?? null),
|
||||
),
|
||||
listProjects: vi.fn().mockImplementation(() => Promise.resolve([...projects])),
|
||||
getDefaultProjectId: vi.fn().mockResolvedValue(undefined),
|
||||
listNodes: vi.fn().mockResolvedValue([
|
||||
{ id: "node-local", name: "local", type: "local", status: "offline" },
|
||||
]),
|
||||
@@ -412,6 +413,8 @@ const mocks = vi.hoisted(() => {
|
||||
heartbeatTriggerScheduler.stop();
|
||||
}),
|
||||
getTaskStore: vi.fn(() => store),
|
||||
getProjectId: vi.fn(() => runtimeConfig.projectId),
|
||||
getWorkingDirectory: vi.fn(() => runtimeConfig.workingDirectory),
|
||||
getAutomationStore: vi.fn(() => automationStore),
|
||||
getRuntime: vi.fn(() => ({
|
||||
getHeartbeatMonitor: () => heartbeatMonitor,
|
||||
@@ -847,7 +850,7 @@ describe("runDaemon", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("--no-auto-register preserves legacy exit behavior", async () => {
|
||||
it("--no-auto-register falls back to existing started engines", async () => {
|
||||
const freshCwd = mkdtempSync(join(tmpdir(), "daemon-no-auto-register-"));
|
||||
cwdSpy.mockReturnValue(freshCwd);
|
||||
|
||||
@@ -858,8 +861,12 @@ describe("runDaemon", () => {
|
||||
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);
|
||||
expect(process.exit).not.toHaveBeenCalledWith(1);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[daemon] HTTP layer bound to project")
|
||||
);
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
} finally {
|
||||
rmSync(freshCwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -193,6 +193,7 @@ const mocks = vi.hoisted(() => {
|
||||
Promise.resolve(projects.find((project) => project.id === id) ?? null),
|
||||
),
|
||||
listProjects: vi.fn().mockImplementation(() => Promise.resolve([...projects])),
|
||||
getDefaultProjectId: vi.fn().mockResolvedValue(undefined),
|
||||
listNodes: vi.fn().mockResolvedValue([
|
||||
{ id: "node-local", name: "local", type: "local", status: "offline" },
|
||||
]),
|
||||
@@ -461,6 +462,8 @@ const mocks = vi.hoisted(() => {
|
||||
heartbeatTriggerScheduler.stop();
|
||||
}),
|
||||
getTaskStore: vi.fn(() => store),
|
||||
getProjectId: vi.fn(() => runtimeConfig.projectId),
|
||||
getWorkingDirectory: vi.fn(() => runtimeConfig.workingDirectory),
|
||||
getAutomationStore: vi.fn(() => automationStore),
|
||||
getRuntime: vi.fn(() => ({
|
||||
getHeartbeatMonitor: () => heartbeatMonitor,
|
||||
@@ -1964,7 +1967,7 @@ describe("runServe — multi-project cwd/default engine resolution", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("--no-auto-register preserves legacy exit behavior", async () => {
|
||||
it("--no-auto-register falls back to existing started engines", async () => {
|
||||
const freshCwd = mkdtempSync(join(tmpdir(), "serve-no-auto-register-"));
|
||||
cwdSpy.mockReturnValue(freshCwd);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
@@ -1979,10 +1982,15 @@ describe("runServe — multi-project cwd/default engine resolution", () => {
|
||||
logPrefix: "serve",
|
||||
autoRegister: false,
|
||||
}));
|
||||
expect(process.exit).toHaveBeenCalledWith(1);
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[serve] No engine started for the current project")
|
||||
expect(process.exit).not.toHaveBeenCalledWith(1);
|
||||
expect(errorSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("[serve] No engines started")
|
||||
);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[serve] HTTP layer bound to project")
|
||||
);
|
||||
|
||||
await triggerSignal("SIGINT");
|
||||
} finally {
|
||||
ensureSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
|
||||
@@ -168,8 +168,10 @@ export interface DaemonOptions {
|
||||
interactive?: boolean;
|
||||
/** Just print/generate token without starting server */
|
||||
tokenOnly?: boolean;
|
||||
/** Disable cwd auto-registration and preserve legacy strict behavior */
|
||||
/** Disable cwd auto-registration */
|
||||
noAutoRegister?: boolean;
|
||||
/** Preferred primary project (id or name). */
|
||||
project?: string;
|
||||
}
|
||||
|
||||
export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
@@ -355,14 +357,72 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get the cwd project's engine and store for the HTTP layer
|
||||
const cwdEngine = ntfyProjectId ? engineManager.getEngine(ntfyProjectId) : undefined;
|
||||
if (!cwdEngine) {
|
||||
console.error("[daemon] No engine started for the current project — exiting");
|
||||
const startedEngines = [...engineManager.getAllEngines().values()];
|
||||
const projects = sharedCentralCore ? await sharedCentralCore.listProjects() : [];
|
||||
|
||||
const resolvePrimaryEngine = async (): Promise<{
|
||||
engine: (typeof startedEngines)[number];
|
||||
source: "cli-flag" | "default-setting" | "cwd" | "fallback";
|
||||
} | null> => {
|
||||
if (opts.project) {
|
||||
const byId = startedEngines.find((engine) => engine.getProjectId() === opts.project);
|
||||
if (byId) {
|
||||
return { engine: byId, source: "cli-flag" };
|
||||
}
|
||||
|
||||
const projectMatch = projects.find((project) => project.name === opts.project);
|
||||
if (projectMatch) {
|
||||
const byName = engineManager.getEngine(projectMatch.id);
|
||||
if (byName) {
|
||||
return { engine: byName, source: "cli-flag" };
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[daemon] --project "${opts.project}" did not match any started engine`);
|
||||
process.exit(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
const defaultProjectId = await sharedCentralCore?.getDefaultProjectId?.();
|
||||
if (defaultProjectId) {
|
||||
const defaultEngine = engineManager.getEngine(defaultProjectId);
|
||||
if (defaultEngine) {
|
||||
return { engine: defaultEngine, source: "default-setting" };
|
||||
}
|
||||
console.warn(`[daemon] defaultProjectId ${defaultProjectId} is set but no engine started for it — falling through`);
|
||||
}
|
||||
|
||||
const cwdEngine = ntfyProjectId ? engineManager.getEngine(ntfyProjectId) : undefined;
|
||||
if (cwdEngine) {
|
||||
return { engine: cwdEngine, source: "cwd" };
|
||||
}
|
||||
|
||||
const fallback = startedEngines[0];
|
||||
if (!fallback) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { engine: fallback, source: "fallback" };
|
||||
};
|
||||
|
||||
const primarySelection = await resolvePrimaryEngine();
|
||||
if (!primarySelection) {
|
||||
console.error("[daemon] No engines started — registry empty or all engines failed to start. Exiting.");
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
const store = cwdEngine.getTaskStore();
|
||||
|
||||
const primaryEngine = primarySelection.engine;
|
||||
const primaryProjectId = primaryEngine.getProjectId();
|
||||
ntfyProjectId = primaryProjectId;
|
||||
const primaryProject = projects.find((project) => project.id === primaryProjectId);
|
||||
const primaryProjectName = primaryProject?.name ?? primaryProjectId;
|
||||
const primaryCwd = primaryEngine.getWorkingDirectory();
|
||||
console.log(
|
||||
`[daemon] HTTP layer bound to project ${primaryProjectName} (${primaryProjectId}) [source: ${primarySelection.source}]`,
|
||||
);
|
||||
|
||||
const store = primaryEngine.getTaskStore();
|
||||
|
||||
await store.watch();
|
||||
|
||||
@@ -417,11 +477,11 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
// Get subsystems from the cwd engine for the HTTP layer
|
||||
const heartbeatMonitor = cwdEngine.getRuntime().getHeartbeatMonitor();
|
||||
const missionAutopilot = cwdEngine.getRuntime().getMissionAutopilot();
|
||||
const missionExecutionLoop = cwdEngine.getRuntime().getMissionExecutionLoop();
|
||||
const automationStore = cwdEngine.getAutomationStore();
|
||||
// Get subsystems from the primary engine for the HTTP layer
|
||||
const heartbeatMonitor = primaryEngine.getRuntime().getHeartbeatMonitor();
|
||||
const missionAutopilot = primaryEngine.getRuntime().getMissionAutopilot();
|
||||
const missionExecutionLoop = primaryEngine.getRuntime().getMissionExecutionLoop();
|
||||
const automationStore = primaryEngine.getAutomationStore();
|
||||
|
||||
const authStorage = AuthStorage.create(getFusionAuthPath());
|
||||
const supplementalAuthStorage = createReadOnlyAuthFileStorage([
|
||||
@@ -438,9 +498,9 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
try {
|
||||
const agentDir = getPackageManagerAgentDir();
|
||||
packageManager = new DefaultPackageManager({
|
||||
cwd,
|
||||
cwd: primaryCwd,
|
||||
agentDir,
|
||||
settingsManager: createReadOnlyProviderSettingsView(cwd, agentDir) as unknown as SettingsManager,
|
||||
settingsManager: createReadOnlyProviderSettingsView(primaryCwd, agentDir) as unknown as SettingsManager,
|
||||
});
|
||||
const resolvedPaths = await packageManager.resolve();
|
||||
const packageExtensionPaths = resolvedPaths.extensions
|
||||
@@ -515,14 +575,14 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
setHostExtensionPaths(selfExtensionPaths);
|
||||
|
||||
const reconciledExtensionPaths = reconcileClaudeCliPaths(
|
||||
[...selfExtensionPaths, ...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths, ...claudeCliPaths],
|
||||
[...selfExtensionPaths, ...getEnabledPiExtensionPaths(primaryCwd), ...packageExtensionPaths, ...claudeCliPaths],
|
||||
claudeCliPaths[0] ?? null,
|
||||
);
|
||||
|
||||
const extensionsResult = await discoverAndLoadExtensions(
|
||||
[...reconciledExtensionPaths, ...droidCliPaths, ...llamaCppPaths],
|
||||
cwd,
|
||||
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
|
||||
primaryCwd,
|
||||
join(primaryCwd, ".fusion", "disabled-auto-extension-discovery"),
|
||||
);
|
||||
|
||||
for (const { path, error } of extensionsResult.errors) {
|
||||
@@ -575,10 +635,10 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
}, DIAGNOSTIC_INTERVAL_MS).unref?.();
|
||||
|
||||
const app = createServer(store, {
|
||||
engine: cwdEngine,
|
||||
engine: primaryEngine,
|
||||
engineManager,
|
||||
centralCore: sharedCentralCore ?? undefined,
|
||||
onMerge: (taskId) => cwdEngine.onMerge(taskId),
|
||||
onMerge: (taskId) => primaryEngine.onMerge(taskId),
|
||||
authStorage: dashboardAuthStorage,
|
||||
modelRegistry,
|
||||
automationStore,
|
||||
@@ -586,7 +646,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
|
||||
missionExecutionLoop,
|
||||
heartbeatMonitor: heartbeatMonitor
|
||||
? {
|
||||
rootDir: cwd,
|
||||
rootDir: primaryCwd,
|
||||
startRun: heartbeatMonitor.startRun.bind(heartbeatMonitor),
|
||||
executeHeartbeat: heartbeatMonitor.executeHeartbeat.bind(heartbeatMonitor),
|
||||
stopRun: heartbeatMonitor.stopRun.bind(heartbeatMonitor),
|
||||
|
||||
@@ -224,7 +224,7 @@ function ensureProcessDiagnostics(): void {
|
||||
|
||||
export async function runServe(
|
||||
port: number,
|
||||
opts: { interactive?: boolean; paused?: boolean; host?: string; daemon?: boolean; noAutoRegister?: boolean } = {},
|
||||
opts: { interactive?: boolean; paused?: boolean; host?: string; daemon?: boolean; noAutoRegister?: boolean; project?: string } = {},
|
||||
) {
|
||||
serveStartTime = Date.now();
|
||||
ensureProcessDiagnostics();
|
||||
@@ -399,15 +399,72 @@ export async function runServe(
|
||||
}
|
||||
}
|
||||
|
||||
// Get the cwd project's engine and store for the HTTP layer.
|
||||
// serve.ts needs a store for plugin setup, diagnostics, and the server.
|
||||
const cwdEngine = ntfyProjectId ? engineManager.getEngine(ntfyProjectId) : undefined;
|
||||
if (!cwdEngine) {
|
||||
console.error("[serve] No engine started for the current project — exiting");
|
||||
const startedEngines = [...engineManager.getAllEngines().values()];
|
||||
const projects = sharedCentralCore ? await sharedCentralCore.listProjects() : [];
|
||||
|
||||
const resolvePrimaryEngine = async (): Promise<{
|
||||
engine: (typeof startedEngines)[number];
|
||||
source: "cli-flag" | "default-setting" | "cwd" | "fallback";
|
||||
} | null> => {
|
||||
if (opts.project) {
|
||||
const byId = startedEngines.find((engine) => engine.getProjectId() === opts.project);
|
||||
if (byId) {
|
||||
return { engine: byId, source: "cli-flag" };
|
||||
}
|
||||
|
||||
const projectMatch = projects.find((project) => project.name === opts.project);
|
||||
if (projectMatch) {
|
||||
const byName = engineManager.getEngine(projectMatch.id);
|
||||
if (byName) {
|
||||
return { engine: byName, source: "cli-flag" };
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[serve] --project "${opts.project}" did not match any started engine`);
|
||||
process.exit(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
const defaultProjectId = await sharedCentralCore?.getDefaultProjectId?.();
|
||||
if (defaultProjectId) {
|
||||
const defaultEngine = engineManager.getEngine(defaultProjectId);
|
||||
if (defaultEngine) {
|
||||
return { engine: defaultEngine, source: "default-setting" };
|
||||
}
|
||||
console.warn(`[serve] defaultProjectId ${defaultProjectId} is set but no engine started for it — falling through`);
|
||||
}
|
||||
|
||||
const cwdEngine = ntfyProjectId ? engineManager.getEngine(ntfyProjectId) : undefined;
|
||||
if (cwdEngine) {
|
||||
return { engine: cwdEngine, source: "cwd" };
|
||||
}
|
||||
|
||||
const fallback = startedEngines[0];
|
||||
if (!fallback) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { engine: fallback, source: "fallback" };
|
||||
};
|
||||
|
||||
const primarySelection = await resolvePrimaryEngine();
|
||||
if (!primarySelection) {
|
||||
console.error("[serve] No engines started — registry empty or all engines failed to start. Exiting.");
|
||||
process.exit(1);
|
||||
return; // unreachable in production, but needed for test mocks
|
||||
return;
|
||||
}
|
||||
const store = cwdEngine.getTaskStore();
|
||||
|
||||
const primaryEngine = primarySelection.engine;
|
||||
const primaryProjectId = primaryEngine.getProjectId();
|
||||
ntfyProjectId = primaryProjectId;
|
||||
const primaryProject = projects.find((project) => project.id === primaryProjectId);
|
||||
const primaryProjectName = primaryProject?.name ?? primaryProjectId;
|
||||
const primaryCwd = primaryEngine.getWorkingDirectory();
|
||||
console.log(
|
||||
`[serve] HTTP layer bound to project ${primaryProjectName} (${primaryProjectId}) [source: ${primarySelection.source}]`,
|
||||
);
|
||||
|
||||
const store = primaryEngine.getTaskStore();
|
||||
|
||||
// InProcessRuntime does not call store.watch() — do it here so SSE events
|
||||
// and file-watcher triggers are active for the HTTP layer.
|
||||
@@ -503,11 +560,11 @@ export async function runServe(
|
||||
);
|
||||
}
|
||||
|
||||
// Get subsystems from the cwd engine for the HTTP layer
|
||||
const heartbeatMonitor = cwdEngine.getRuntime().getHeartbeatMonitor();
|
||||
const missionAutopilot = cwdEngine.getRuntime().getMissionAutopilot();
|
||||
const missionExecutionLoop = cwdEngine.getRuntime().getMissionExecutionLoop();
|
||||
const automationStore = cwdEngine.getAutomationStore();
|
||||
// Get subsystems from the primary engine for the HTTP layer
|
||||
const heartbeatMonitor = primaryEngine.getRuntime().getHeartbeatMonitor();
|
||||
const missionAutopilot = primaryEngine.getRuntime().getMissionAutopilot();
|
||||
const missionExecutionLoop = primaryEngine.getRuntime().getMissionExecutionLoop();
|
||||
const automationStore = primaryEngine.getAutomationStore();
|
||||
|
||||
const authStorage = AuthStorage.create(getFusionAuthPath());
|
||||
const supplementalAuthStorage = createReadOnlyAuthFileStorage([
|
||||
@@ -524,9 +581,9 @@ export async function runServe(
|
||||
try {
|
||||
const agentDir = getPackageManagerAgentDir();
|
||||
packageManager = new DefaultPackageManager({
|
||||
cwd,
|
||||
cwd: primaryCwd,
|
||||
agentDir,
|
||||
settingsManager: createReadOnlyProviderSettingsView(cwd, agentDir) as unknown as SettingsManager,
|
||||
settingsManager: createReadOnlyProviderSettingsView(primaryCwd, agentDir) as unknown as SettingsManager,
|
||||
});
|
||||
const resolvedPaths = await packageManager.resolve();
|
||||
const packageExtensionPaths = resolvedPaths.extensions
|
||||
@@ -602,14 +659,14 @@ export async function runServe(
|
||||
const extensionsResult = await discoverAndLoadExtensions(
|
||||
[
|
||||
...selfExtensionPaths,
|
||||
...getEnabledPiExtensionPaths(cwd),
|
||||
...getEnabledPiExtensionPaths(primaryCwd),
|
||||
...packageExtensionPaths,
|
||||
...claudeCliPaths,
|
||||
...droidCliPaths,
|
||||
...llamaCppPaths,
|
||||
],
|
||||
cwd,
|
||||
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
|
||||
primaryCwd,
|
||||
join(primaryCwd, ".fusion", "disabled-auto-extension-discovery"),
|
||||
);
|
||||
|
||||
for (const { path, error } of extensionsResult.errors) {
|
||||
@@ -715,10 +772,10 @@ export async function runServe(
|
||||
: undefined;
|
||||
|
||||
const app = createServer(store, {
|
||||
engine: cwdEngine,
|
||||
engine: primaryEngine,
|
||||
engineManager,
|
||||
centralCore: sharedCentralCore ?? undefined,
|
||||
onMerge: (taskId) => cwdEngine.onMerge(taskId),
|
||||
onMerge: (taskId) => primaryEngine.onMerge(taskId),
|
||||
authStorage: dashboardAuthStorage,
|
||||
modelRegistry,
|
||||
automationStore,
|
||||
@@ -726,7 +783,7 @@ export async function runServe(
|
||||
missionExecutionLoop,
|
||||
heartbeatMonitor: heartbeatMonitor
|
||||
? {
|
||||
rootDir: cwd,
|
||||
rootDir: primaryCwd,
|
||||
startRun: heartbeatMonitor.startRun.bind(heartbeatMonitor),
|
||||
executeHeartbeat: heartbeatMonitor.executeHeartbeat.bind(heartbeatMonitor),
|
||||
stopRun: heartbeatMonitor.stopRun.bind(heartbeatMonitor),
|
||||
|
||||
Reference in New Issue
Block a user