diff --git a/sdk/ARCHITECTURE.md b/sdk/ARCHITECTURE.md index 517f806ff5..e5d4cd513b 100644 --- a/sdk/ARCHITECTURE.md +++ b/sdk/ARCHITECTURE.md @@ -1252,3 +1252,7 @@ reports active plugin requests in both `session.list` activity and `hub.status`; replacement and recovery checks count these requests even when no chat is attached. Admission is drained before activity is checked, preventing new plugin work from starting between the idle check and retirement. + +The Hub owns the SQLite session store it creates for its default local runtime +and closes that store after runtime shutdown. An injected session host retains +ownership of its persistence resources. diff --git a/sdk/packages/core/src/hub/server/hub-server-transport.ts b/sdk/packages/core/src/hub/server/hub-server-transport.ts index aee32b3a0e..ef32cd89bf 100644 --- a/sdk/packages/core/src/hub/server/hub-server-transport.ts +++ b/sdk/packages/core/src/hub/server/hub-server-transport.ts @@ -281,6 +281,7 @@ export class HubServerTransport implements NativeHubTransport { private readonly settings: CoreSettingsService; private readonly pluginCommands: HubPluginCommandService; private readonly sessionSearch: SessionHistorySearchService; + private readonly sessionStore?: SqliteSessionStore; private readonly cronService?: CronService; private readonly sessionHost: RuntimeHost & Partial; @@ -306,14 +307,17 @@ export class HubServerTransport implements NativeHubTransport { }), ); }); - this.sessionHost = - options.sessionHost ?? - new LocalRuntimeHost({ - sessionService: new CoreSessionService(new SqliteSessionStore()), + if (options.sessionHost) { + this.sessionHost = options.sessionHost; + } else { + this.sessionStore = new SqliteSessionStore(); + this.sessionHost = new LocalRuntimeHost({ + sessionService: new CoreSessionService(this.sessionStore), fetch: options.fetch, logger: options.logger, telemetry: options.telemetry, }); + } this.sessionSearch = new SessionHistorySearchService( this.sessionHost, options.sessionSearchOptions ?? @@ -764,7 +768,12 @@ export class HubServerTransport implements NativeHubTransport { await this.pluginCommands.dispose(); await this.sessionSearch.dispose(); await this.tasks.dispose(); - await this.sessionHost.dispose("hub_server_stop"); + try { + await this.sessionHost.dispose("hub_server_stop"); + } finally { + // The Hub owns the default store; injected runtimes own theirs. + this.sessionStore?.close(); + } await this.schedules.dispose(); if (this.cronService) { try { diff --git a/sdk/packages/core/src/hub/server/plugin-command-service.test.ts b/sdk/packages/core/src/hub/server/plugin-command-service.test.ts index e677ce203f..3b6e804389 100644 --- a/sdk/packages/core/src/hub/server/plugin-command-service.test.ts +++ b/sdk/packages/core/src/hub/server/plugin-command-service.test.ts @@ -2,6 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { SqliteSessionStore } from "../../services/storage/sqlite-session-store"; import { localHubHasNoActiveSessions, NodeHubClient } from "../client"; import { HubPluginClient } from "../client/plugin-client"; import { createLocalHubScheduleRuntimeHandlers } from "../daemon/runtime-handlers"; @@ -205,3 +206,20 @@ it("reports a marketplace install as busy without an attached chat", async () => .payload?.idle, ).toBe(true); }); + +it("closes the Hub-owned session database before removing its directory", async () => { + const opened = vi.spyOn(SqliteSessionStore.prototype, "getRawDb"); + try { + await transport.handleCommand({ version: "v1", command: "session.list" }); + const db = opened.mock.results.find( + (result) => result.type === "return", + )?.value; + if (!db) + throw new Error("Expected session.list to open the session database"); + await transport.stop(); + stopped = true; + expect(() => db.prepare("SELECT 1").get()).toThrow(); + } finally { + opened.mockRestore(); + } +});