From db3e0bf9f69f8bc7cf102a2910ffe649c3263bd8 Mon Sep 17 00:00:00 2001 From: MemOS AutoDev Date: Thu, 4 Jun 2026 10:12:11 +0800 Subject: [PATCH] fix(memos-local-openclaw): connect to Hub on plugin load (QClaw desktop) The QClaw desktop app loads this plugin without calling service.start(), which used to mean connectToHub() inside startServiceCore() never ran. The Hub Server saw zero incoming connections from QClaw users and team sharing was completely non-functional, even though local memory worked. Split the client-side Hub connection out of startServiceCore() into a small idempotent helper and fire it eagerly at the end of register(), independent of the host's service lifecycle. The gateway CLI path still calls it via service.start() and the guard prevents a duplicate attempt. Fixes #1612 Cherry-picked from commit a645ddd0 --- apps/memos-local-openclaw/index.ts | 42 +++- .../tests/hub-eager-connect.test.ts | 224 ++++++++++++++++++ 2 files changed, 258 insertions(+), 8 deletions(-) create mode 100644 apps/memos-local-openclaw/tests/hub-eager-connect.test.ts diff --git a/apps/memos-local-openclaw/index.ts b/apps/memos-local-openclaw/index.ts index 5e2245198..2f46052ee 100644 --- a/apps/memos-local-openclaw/index.ts +++ b/apps/memos-local-openclaw/index.ts @@ -2382,6 +2382,29 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, let serviceStarted = false; + // Hub client connection is split out from startServiceCore so it can be + // attempted eagerly at plugin-load time, regardless of how the host + // chooses to launch the plugin. Some hosts (notably the QClaw desktop + // app) load the plugin without calling service.start(), which used to + // mean Hub connection never happened (see GitHub issue #1612). + // + // The guard makes this safe to call from multiple entry points: the + // service.start() callback (gateway CLI path) and the eager fire at the + // end of register() (QClaw desktop path) both funnel through here and + // only one attempt actually runs. + let hubClientConnectAttempted = false; + const connectClientToHubIfNeeded = async () => { + if (hubClientConnectAttempted) return; + if (!ctx.config.sharing?.enabled || ctx.config.sharing.role !== "client") return; + hubClientConnectAttempted = true; + try { + const session = await connectToHub(store, ctx.config, ctx.log); + api.logger.info(`memos-local: connected to Hub as "${session.username}" (${session.userId})`); + } catch (err) { + api.logger.warn(`memos-local: Hub connection failed: ${err}`); + } + }; + const startServiceCore = async () => { if (serviceStarted) return; serviceStarted = true; @@ -2391,14 +2414,7 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, api.logger.info(`memos-local: hub started at ${hubUrl}`); } - if (ctx.config.sharing?.enabled && ctx.config.sharing.role === "client") { - try { - const session = await connectToHub(store, ctx.config, ctx.log); - api.logger.info(`memos-local: connected to Hub as "${session.username}" (${session.userId})`); - } catch (err) { - api.logger.warn(`memos-local: Hub connection failed: ${err}`); - } - } + await connectClientToHubIfNeeded(); try { const viewerUrl = await viewer.start(); @@ -2438,6 +2454,16 @@ Groups: ${groupNames.length > 0 ? groupNames.join(", ") : "(none)"}`, }, }); + // Eager Hub client connection: kick off the Hub login as soon as the + // plugin is registered, independent of the host's service lifecycle. + // This guarantees team sharing works in hosts that load the plugin + // without calling service.start() (e.g. the QClaw desktop app — see + // GitHub issue #1612). The setTimeout(0) fallback below still handles + // viewer startup; this fire-and-forget call does not block register(). + connectClientToHubIfNeeded().catch((err) => { + api.logger.warn(`memos-local: eager Hub connection failed: ${err}`); + }); + // Fallback: OpenClaw may load this plugin via deferred reload after // startPluginServices has already run, so service.start() never fires. // Start on the next tick instead of waiting several seconds; the diff --git a/apps/memos-local-openclaw/tests/hub-eager-connect.test.ts b/apps/memos-local-openclaw/tests/hub-eager-connect.test.ts new file mode 100644 index 000000000..7286cfd08 --- /dev/null +++ b/apps/memos-local-openclaw/tests/hub-eager-connect.test.ts @@ -0,0 +1,224 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const noopLog = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +}; + +afterEach(() => { + vi.resetModules(); + vi.clearAllMocks(); +}); + +/** + * Regression coverage for GitHub issue #1612: + * "memos-local-openclaw-plugin: ctx.registerTools() never called in QClaw + * desktop app context" + * + * Hosts such as the QClaw desktop app load the plugin without calling + * `service.start()` and may not reliably tick `setTimeout(0)`. The plugin + * must still attempt the Hub client connection so team sharing works. + * + * These tests verify: + * 1. When `service.start()` is never called, `connectToHub` is still + * invoked (eager hub connect). + * 2. When sharing is disabled or the role is not "client", no connection + * attempt is made. + * 3. When both `service.start()` and the eager path run, the connection + * is attempted exactly once (idempotency guard). + */ +async function loadPluginWithMocks(opts: { + sharingConfig: any; + connectToHubImpl?: (...args: unknown[]) => Promise; + captureService?: (service: any) => void; +}): Promise<{ connectSpy: ReturnType }> { + const connectSpy = vi.fn(opts.connectToHubImpl ?? (async () => ({ userId: "u1", username: "tester" }))); + + vi.doMock("../src/config", () => ({ + buildContext: () => ({ + stateDir: "/tmp/memos-eager-hub", + workspaceDir: "/tmp/memos-eager-hub/workspace", + log: noopLog, + openclawAPI: undefined, + config: { + storage: { dbPath: "/tmp/memos-eager-hub/memos.db" }, + capture: { evidenceWrapperTag: "STORED_MEMORY" }, + telemetry: {}, + sharing: opts.sharingConfig, + }, + }), + })); + + vi.doMock("../src/storage/sqlite", () => ({ + SqliteStore: class { + recordToolCall() {} + recordApiLog() {} + close() {} + }, + })); + + vi.doMock("../src/embedding", () => ({ + Embedder: class { provider = "mock"; }, + })); + + vi.doMock("../src/ingest/worker", () => ({ + IngestWorker: class { + getTaskProcessor() { return { onTaskCompleted() {} }; } + enqueue() {} + async flush() {} + }, + })); + + vi.doMock("../src/recall/engine", () => ({ + RecallEngine: class { + async search() { return { hits: [], meta: {} }; } + async searchSkills() { return []; } + }, + })); + + vi.doMock("../src/ingest/providers", () => ({ + Summarizer: class { async filterRelevant() { return null; } }, + })); + + vi.doMock("../src/viewer/server", () => ({ + ViewerServer: class { + async start() { return "http://127.0.0.1:18799"; } + stop() {} + getResetToken() { return "token"; } + }, + })); + + vi.doMock("../src/hub/server", () => ({ + HubServer: class { + async start() { return "http://127.0.0.1:18800"; } + async stop() {} + }, + })); + + vi.doMock("../src/client/hub", () => ({ + hubGetMemoryDetail: async () => ({}), + hubRequestJson: async () => ({}), + hubSearchMemories: async () => ({ hits: [], meta: {} }), + hubSearchSkills: async () => ({ hits: [] }), + resolveHubClient: async () => ({ hubUrl: "", userToken: "", userId: "" }), + })); + + vi.doMock("../src/client/connector", () => ({ + connectToHub: connectSpy, + getHubStatus: async () => ({ connected: false }), + })); + + vi.doMock("../src/client/skill-sync", () => ({ + fetchHubSkillBundle: async () => ({}), + publishSkillBundleToHub: async () => ({}), + restoreSkillBundleFromHub: () => ({}), + unpublishSkillBundleFromHub: async () => ({}), + })); + + vi.doMock("../src/skill/evolver", () => ({ + SkillEvolver: class { async onTaskCompleted() {} async recoverOrphanedTasks() { return 0; } }, + })); + + vi.doMock("../src/skill/installer", () => ({ SkillInstaller: class {} })); + vi.doMock("../src/skill/bundled-memory-guide", () => ({ MEMORY_GUIDE_SKILL_MD: "# mock" })); + + vi.doMock("../src/telemetry", () => ({ + Telemetry: class { + trackToolCalled() {} + trackAutoRecall() {} + trackMemoryIngested() {} + trackSkillInstalled() {} + trackPluginStarted() {} + trackViewerOpened() {} + trackSkillEvolved() {} + async shutdown() {} + }, + })); + + const pluginModule = await import("../plugin-impl"); + pluginModule.default.register({ + pluginConfig: {}, + config: {}, + resolvePath: () => "/tmp/memos-eager-hub", + logger: { info() {}, warn() {} }, + registerTool: () => {}, + registerMemoryCapability: () => {}, + registerService: (service: any) => { opts.captureService?.(service); }, + on: () => {}, + } as any); + + return { connectSpy }; +} + +describe("eager hub connection (GitHub #1612)", () => { + it("attempts to connect to the hub at register-time when sharing is enabled in client role, even if service.start() is never called", async () => { + const { connectSpy } = await loadPluginWithMocks({ + sharingConfig: { + enabled: true, + role: "client", + client: { hubAddress: "127.0.0.1:18912", userToken: "tk" }, + }, + }); + + // Allow the fire-and-forget eager connectToHub() promise to settle + // without depending on setTimeout — the eager call runs as part of + // register(), so the next microtask flush is enough. + await Promise.resolve(); + await Promise.resolve(); + + expect(connectSpy).toHaveBeenCalledTimes(1); + }); + + it("does not attempt to connect when sharing is disabled", async () => { + const { connectSpy } = await loadPluginWithMocks({ + sharingConfig: { + enabled: false, + role: "client", + client: { hubAddress: "127.0.0.1:18912", userToken: "tk" }, + }, + }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(connectSpy).not.toHaveBeenCalled(); + }); + + it("does not attempt to connect when running in hub role", async () => { + const { connectSpy } = await loadPluginWithMocks({ + sharingConfig: { + enabled: true, + role: "hub", + hub: { port: 18912, teamName: "T", teamToken: "tk" }, + }, + }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(connectSpy).not.toHaveBeenCalled(); + }); + + it("only attempts the hub connection once when both the eager path and service.start() run", async () => { + let capturedService: any; + const { connectSpy } = await loadPluginWithMocks({ + sharingConfig: { + enabled: true, + role: "client", + client: { hubAddress: "127.0.0.1:18912", userToken: "tk" }, + }, + captureService: (s) => { capturedService = s; }, + }); + + // Eager connect runs first (already initiated at register-time). + await Promise.resolve(); + await Promise.resolve(); + + expect(capturedService).toBeDefined(); + await capturedService.start(); + + expect(connectSpy).toHaveBeenCalledTimes(1); + }); +});