diff --git a/src/commands/doctor/repair-sequencing.test.ts b/src/commands/doctor/repair-sequencing.test.ts index a88790cb80f5..426d7486cacb 100644 --- a/src/commands/doctor/repair-sequencing.test.ts +++ b/src/commands/doctor/repair-sequencing.test.ts @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ maybeRepairGroupAllowFromFallback: vi.fn(), maybeRepairManagedNpmOpenClawPeerLinks: vi.fn(), maybeRepairLegacyOAuthSidecarProfiles: vi.fn(), + migrateLegacyOnboardingRecommendationsScope: vi.fn(), maybeMigrateAuthProfileJsonStoresToSqlite: vi.fn(), maybeRepairOpenAICodexAuthConfig: vi.fn(), maybeRepairOpenAICodexAuthProfileStores: vi.fn(), @@ -42,6 +43,10 @@ vi.mock("../doctor-auth-oauth-sidecar.js", () => ({ maybeRepairLegacyOAuthSidecarProfiles: mocks.maybeRepairLegacyOAuthSidecarProfiles, })); +vi.mock("../../infra/state-migrations.onboarding-recommendations.js", () => ({ + migrateLegacyOnboardingRecommendationsScope: mocks.migrateLegacyOnboardingRecommendationsScope, +})); + vi.mock("../doctor-auth-flat-profiles.js", () => ({ collectOpenAICodexAuthProfileStoreIdMap: vi.fn(() => new Map()), maybeMigrateAuthProfileJsonStoresToSqlite: mocks.maybeMigrateAuthProfileJsonStoresToSqlite, @@ -246,6 +251,10 @@ describe("doctor repair sequencing", () => { changes: [], warnings: [], }); + mocks.migrateLegacyOnboardingRecommendationsScope.mockReturnValue({ + changes: [], + warnings: [], + }); mocks.maybeMigrateAuthProfileJsonStoresToSqlite.mockResolvedValue({ detected: [], changes: [], @@ -286,6 +295,33 @@ describe("doctor repair sequencing", () => { })); }); + it("runs the doctor-only onboarding recommendation scope migration", async () => { + const env = { OPENCLAW_STATE_DIR: "/tmp/openclaw-doctor-test" }; + const candidate = {} as OpenClawConfig; + mocks.migrateLegacyOnboardingRecommendationsScope.mockReturnValue({ + changes: ["Migrated onboarding recommendation state."], + warnings: ["Migration warning."], + }); + + const result = await runDoctorRepairSequence({ + state: { + cfg: candidate, + candidate, + pendingChanges: false, + fixHints: [], + }, + doctorFixCommand: "openclaw doctor --fix", + env, + }); + + expect(mocks.migrateLegacyOnboardingRecommendationsScope).toHaveBeenCalledWith({ + cfg: candidate, + env, + }); + expect(result.changeNotes).toContain("Migrated onboarding recommendation state."); + expect(result.warningNotes).toContain("Migration warning."); + }); + it("applies ordered repairs and sanitizes empty-allowlist warnings", async () => { const result = await runDoctorRepairSequence({ state: { diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index 47009a82b2d7..ba7c003980e0 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -4,6 +4,7 @@ import { applyPluginAutoEnable, materializePluginAutoEnableCandidates, } from "../../config/plugin-auto-enable.js"; +import { migrateLegacyOnboardingRecommendationsScope } from "../../infra/state-migrations.onboarding-recommendations.js"; import { collectOpenAICodexAuthProfileStoreIdMap, maybeMigrateAuthProfileJsonStoresToSqlite, @@ -188,6 +189,16 @@ export async function runDoctorRepairSequence(params: { if (pluginDependencyCleanup.warnings.length > 0) { warningNotes.push(sanitizeLines(pluginDependencyCleanup.warnings)); } + const onboardingRecommendationsMigration = migrateLegacyOnboardingRecommendationsScope({ + cfg: state.candidate, + env, + }); + if (onboardingRecommendationsMigration.changes.length > 0) { + changeNotes.push(sanitizeLines(onboardingRecommendationsMigration.changes)); + } + if (onboardingRecommendationsMigration.warnings.length > 0) { + warningNotes.push(sanitizeLines(onboardingRecommendationsMigration.warnings)); + } const legacyOAuthSidecarRepair = await maybeRepairLegacyOAuthSidecarProfiles({ cfg: state.candidate, prompter: { confirmAutoFix: async () => true }, diff --git a/src/commands/onboard-recommendations.ts b/src/commands/onboard-recommendations.ts index 8ef5650635fc..ef2ed6e722ed 100644 --- a/src/commands/onboard-recommendations.ts +++ b/src/commands/onboard-recommendations.ts @@ -1,18 +1,17 @@ +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { getRuntimeConfig } from "../config/config.js"; import type { RuntimeEnv } from "../runtime.js"; import { - acknowledgeOnboardingRecommendations, - clearOnboardingRecommendations, - clearPendingOnboardingRecommendations, - readOnboardingRecommendations, - updatePendingOnboardingRecommendations, + createOnboardingRecommendationsStore, + type OnboardingRecommendationsStore, type OnboardingRecommendationsRecord, } from "../state/onboarding-recommendations.js"; type OnboardRecommendationsDeps = { read?: () => OnboardingRecommendationsRecord | null; acknowledge?: () => OnboardingRecommendationsRecord | null; - updatePending?: typeof updatePendingOnboardingRecommendations; - clearPending?: typeof clearPendingOnboardingRecommendations; + updatePending?: OnboardingRecommendationsStore["updatePending"]; + clearPending?: OnboardingRecommendationsStore["clearPending"]; clear?: () => boolean; }; @@ -28,6 +27,17 @@ type BootstrapRecommendation = { tier: "recommended" | "optional"; }; +function createDefaultOnboardingRecommendationsStore(): OnboardingRecommendationsStore { + const cfg = getRuntimeConfig(); + const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); + return createOnboardingRecommendationsStore({ workspaceDir }); +} + +function createDefaultStoreAccessor(): () => OnboardingRecommendationsStore { + let store: OnboardingRecommendationsStore | undefined; + return () => (store ??= createDefaultOnboardingRecommendationsStore()); +} + function isLegacyBareClawHubId(match: OnboardingRecommendationsRecord["matches"][number]): boolean { return ( match.candidate.source === "clawhub-skill" && @@ -66,10 +76,11 @@ export function onboardRecommendationsCommand( runtime: RuntimeEnv, deps: OnboardRecommendationsDeps = {}, ): void { - const stored = (deps.read ?? readOnboardingRecommendations)(); + const defaultStore = createDefaultStoreAccessor(); + const stored = (deps.read ?? defaultStore().read)(); const hasLegacyClawHubId = stored?.matches.some(isLegacyBareClawHubId); if (hasLegacyClawHubId && stored && stored.acceptedAt == null) { - const cleared = (deps.clearPending ?? clearPendingOnboardingRecommendations)({ + const cleared = (deps.clearPending ?? defaultStore().clearPending)({ expected: stored, }); if (!cleared) { @@ -105,9 +116,10 @@ export function acknowledgeOnboardRecommendationsCommand( runtime: RuntimeEnv, deps: OnboardRecommendationsDeps = {}, ): void { + const defaultStore = createDefaultStoreAccessor(); const retryIds = [...new Set(opts.retry ?? [])]; if (retryIds.length > 0) { - const record = (deps.read ?? readOnboardingRecommendations)(); + const record = (deps.read ?? defaultStore().read)(); if (!record || record.acceptedAt != null) { runtime.error("No pending onboarding recommendations to retry."); runtime.exit(1); @@ -124,7 +136,7 @@ export function acknowledgeOnboardRecommendationsCommand( const retryIdSet = new Set(retryIds); const retryMatches = record?.matches.filter((match) => retryIdSet.has(match.candidate.id)) ?? []; - const updated = (deps.updatePending ?? updatePendingOnboardingRecommendations)({ + const updated = (deps.updatePending ?? defaultStore().updatePending)({ matches: retryMatches, expected: record, }); @@ -136,7 +148,7 @@ export function acknowledgeOnboardRecommendationsCommand( runtime.log(`Onboarding recommendations updated; ${retryIds.length} left pending for retry.`); return; } - const record = (deps.acknowledge ?? acknowledgeOnboardingRecommendations)(); + const record = (deps.acknowledge ?? defaultStore().acknowledge)(); runtime.log(record ? "Onboarding recommendations acknowledged." : "No stored recommendations."); } @@ -144,7 +156,8 @@ export function refreshOnboardRecommendationsCommand( runtime: RuntimeEnv, deps: OnboardRecommendationsDeps = {}, ): void { - const cleared = (deps.clear ?? clearOnboardingRecommendations)(); + const defaultStore = createDefaultStoreAccessor(); + const cleared = (deps.clear ?? defaultStore().clear)(); runtime.log( cleared ? "Onboarding recommendations cleared. The next onboarding run will rescan." diff --git a/src/infra/state-migrations.onboarding-recommendations.test.ts b/src/infra/state-migrations.onboarding-recommendations.test.ts new file mode 100644 index 000000000000..a837267b0dec --- /dev/null +++ b/src/infra/state-migrations.onboarding-recommendations.test.ts @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { createOnboardingRecommendationsStore } from "../state/onboarding-recommendations.js"; +import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; +import { + closeOpenClawStateDatabaseForTest, + runOpenClawStateWriteTransaction, +} from "../state/openclaw-state-db.js"; +import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; +import { + executeSqliteQuerySync, + executeSqliteQueryTakeFirstSync, + getNodeSqliteKysely, +} from "./kysely-sync.js"; +import { migrateLegacyOnboardingRecommendationsScope } from "./state-migrations.onboarding-recommendations.js"; + +type OnboardingRecommendationsMigrationDatabase = Pick< + OpenClawStateKyselyDatabase, + "onboarding_recommendations" +>; + +function insertRecommendationRow(params: { + database: { env: NodeJS.ProcessEnv }; + configKey: string; + inventoryHash: string; +}): void { + runOpenClawStateWriteTransaction(({ db: sqlite }) => { + const db = getNodeSqliteKysely(sqlite); + executeSqliteQuerySync( + sqlite, + db.insertInto("onboarding_recommendations").values({ + config_key: params.configKey, + inventory_hash: params.inventoryHash, + matches_json: "[]", + offered_at_ms: 1_000, + accepted_at_ms: 2_000, + updated_at_ms: 2_000, + }), + ); + }, params.database); +} + +function readRecommendationKey( + database: { env: NodeJS.ProcessEnv }, + configKey: string, +): { config_key: string } | undefined { + return runOpenClawStateWriteTransaction(({ db: sqlite }) => { + const db = getNodeSqliteKysely(sqlite); + return executeSqliteQueryTakeFirstSync( + sqlite, + db + .selectFrom("onboarding_recommendations") + .select("config_key") + .where("config_key", "=", configKey), + ); + }, database); +} + +afterEach(() => { + closeOpenClawStateDatabaseForTest(); +}); + +describe("onboarding recommendations scope migration", () => { + it("moves the legacy singleton row to the default workspace", async () => { + await withOpenClawTestState( + { label: "onboarding-recommendations-migration" }, + async (state) => { + const database = { env: state.env }; + insertRecommendationRow({ + database, + configKey: "primary", + inventoryHash: "legacy-inventory", + }); + + const result = migrateLegacyOnboardingRecommendationsScope({ + cfg: { agents: { defaults: { workspace: state.workspaceDir } } } as OpenClawConfig, + env: state.env, + }); + + expect(result).toEqual({ + changes: ["Migrated onboarding recommendation state to the default workspace scope."], + warnings: [], + }); + expect( + createOnboardingRecommendationsStore({ + workspaceDir: state.workspaceDir, + database, + }).read(), + ).toEqual({ + inventoryHash: "legacy-inventory", + matches: [], + offeredAt: 1_000, + acceptedAt: 2_000, + updatedAt: 2_000, + }); + expect(readRecommendationKey(database, "primary")).toBeUndefined(); + }, + ); + }); + + it("keeps an existing scoped row when legacy state is also present", async () => { + await withOpenClawTestState( + { label: "onboarding-recommendations-migration-conflict" }, + async (state) => { + const database = { env: state.env }; + const store = createOnboardingRecommendationsStore({ + workspaceDir: state.workspaceDir, + database, + }); + const scoped = store.writeOffer({ + inventory: [{ label: "Scoped" }], + matches: [], + answered: false, + nowMs: 3_000, + }); + insertRecommendationRow({ + database, + configKey: "primary", + inventoryHash: "legacy-inventory", + }); + + const result = migrateLegacyOnboardingRecommendationsScope({ + cfg: { agents: { defaults: { workspace: state.workspaceDir } } } as OpenClawConfig, + env: state.env, + }); + + expect(result).toEqual({ + changes: [ + "Removed ambiguous legacy onboarding recommendation state; kept the default workspace record.", + ], + warnings: [], + }); + expect(store.read()).toEqual(scoped); + expect(readRecommendationKey(database, "primary")).toBeUndefined(); + }, + ); + }); +}); diff --git a/src/infra/state-migrations.onboarding-recommendations.ts b/src/infra/state-migrations.onboarding-recommendations.ts new file mode 100644 index 000000000000..cdbffec5a39e --- /dev/null +++ b/src/infra/state-migrations.onboarding-recommendations.ts @@ -0,0 +1,102 @@ +import { existsSync } from "node:fs"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { resolveWorkspaceStateIdentity } from "../agents/workspace-state-store.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; +import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js"; +import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; +import { + executeSqliteQuerySync, + executeSqliteQueryTakeFirstSync, + getNodeSqliteKysely, +} from "./kysely-sync.js"; +import type { MigrationMessages } from "./state-migrations.types.js"; + +const LEGACY_ONBOARDING_RECOMMENDATIONS_KEY = "primary"; + +type OnboardingRecommendationsMigrationDatabase = Pick< + OpenClawStateKyselyDatabase, + "onboarding_recommendations" +>; + +/** Move the shipped singleton row into the default workspace during doctor repair. */ +export function migrateLegacyOnboardingRecommendationsScope(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): MigrationMessages { + const env = params.env ?? process.env; + if (!existsSync(resolveOpenClawStateSqlitePath(env))) { + return { changes: [], warnings: [] }; + } + + try { + const workspaceDir = resolveAgentWorkspaceDir( + params.cfg, + resolveDefaultAgentId(params.cfg), + env, + ); + const workspaceKey = resolveWorkspaceStateIdentity(workspaceDir).workspaceKey; + const outcome = runOpenClawStateWriteTransaction( + ({ db: database }) => { + const db = getNodeSqliteKysely(database); + const legacy = executeSqliteQueryTakeFirstSync( + database, + db + .selectFrom("onboarding_recommendations") + .select("config_key") + .where("config_key", "=", LEGACY_ONBOARDING_RECOMMENDATIONS_KEY), + ); + if (!legacy) { + return "unchanged" as const; + } + const scoped = executeSqliteQueryTakeFirstSync( + database, + db + .selectFrom("onboarding_recommendations") + .select("config_key") + .where("config_key", "=", workspaceKey), + ); + if (scoped) { + executeSqliteQuerySync( + database, + db + .deleteFrom("onboarding_recommendations") + .where("config_key", "=", LEGACY_ONBOARDING_RECOMMENDATIONS_KEY), + ); + return "removed-legacy" as const; + } + executeSqliteQuerySync( + database, + db + .updateTable("onboarding_recommendations") + .set({ config_key: workspaceKey }) + .where("config_key", "=", LEGACY_ONBOARDING_RECOMMENDATIONS_KEY), + ); + return "migrated" as const; + }, + { env }, + { operationLabel: "onboarding.recommendations.migrate-scope" }, + ); + + if (outcome === "migrated") { + return { + changes: ["Migrated onboarding recommendation state to the default workspace scope."], + warnings: [], + }; + } + if (outcome === "removed-legacy") { + return { + changes: [ + "Removed ambiguous legacy onboarding recommendation state; kept the default workspace record.", + ], + warnings: [], + }; + } + return { changes: [], warnings: [] }; + } catch (err) { + return { + changes: [], + warnings: [`Failed migrating onboarding recommendation workspace scope: ${String(err)}`], + }; + } +} diff --git a/src/state/onboarding-recommendations.test.ts b/src/state/onboarding-recommendations.test.ts index ad87e44e5e25..fef8e7f749cc 100644 --- a/src/state/onboarding-recommendations.test.ts +++ b/src/state/onboarding-recommendations.test.ts @@ -2,12 +2,7 @@ import fs from "node:fs"; import { afterEach, describe, expect, it } from "vitest"; import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { - acknowledgeOnboardingRecommendations, - clearPendingOnboardingRecommendations, - clearOnboardingRecommendations, - readOnboardingRecommendations, - updatePendingOnboardingRecommendations, - writeOnboardingRecommendationsOffer, + createOnboardingRecommendationsStore, type OnboardingRecommendationMatch, } from "./onboarding-recommendations.js"; import { closeOpenClawStateDatabaseForTest } from "./openclaw-state-db.js"; @@ -32,19 +27,47 @@ afterEach(() => { }); describe("onboarding recommendations store", () => { - it("round-trips the singleton offer and answer timestamps", async () => { + it("isolates offers by workspace", async () => { + await withOpenClawTestState({ label: "onboarding-recommendations-scopes" }, async (state) => { + const database = { env: state.env }; + const workspaceA = createOnboardingRecommendationsStore({ + workspaceDir: state.path("workspace-a"), + database, + }); + const workspaceB = createOnboardingRecommendationsStore({ + workspaceDir: state.path("workspace-b"), + database, + }); + + const written = workspaceA.writeOffer({ + inventory: [{ label: "Chat" }], + matches, + answered: false, + nowMs: 1_234, + }); + + expect(workspaceB.read()).toBeNull(); + expect(workspaceB.acknowledge({ nowMs: 2_345 })).toBeNull(); + expect(workspaceA.read()).toEqual(written); + }); + }); + + it("round-trips the workspace offer and answer timestamps", async () => { await withOpenClawTestState({ label: "onboarding-recommendations" }, async (state) => { const database = { env: state.env }; + const store = createOnboardingRecommendationsStore({ + workspaceDir: state.workspaceDir, + database, + }); const inventory = [{ label: "Chat", bundleId: "com.example.chat" }]; - expect(readOnboardingRecommendations(database)).toBeNull(); + expect(store.read()).toBeNull(); expect(fs.existsSync(state.statePath("state", "openclaw.sqlite"))).toBe(false); - const written = writeOnboardingRecommendationsOffer({ + const written = store.writeOffer({ inventory, matches, answered: true, nowMs: 1_234, - database, }); expect(written).toEqual({ @@ -54,14 +77,13 @@ describe("onboarding recommendations store", () => { acceptedAt: 1_234, updatedAt: 1_234, }); - expect(readOnboardingRecommendations(database)).toEqual(written); + expect(store.read()).toEqual(written); - const staleCompletion = writeOnboardingRecommendationsOffer({ + const staleCompletion = store.writeOffer({ inventory: [{ label: "Different" }], matches: [], answered: false, nowMs: 2_000, - database, }); expect(staleCompletion).toEqual(written); }); @@ -69,42 +91,46 @@ describe("onboarding recommendations store", () => { it("keeps acceptedAt null when the offer was shown without an answer", async () => { await withOpenClawTestState({ label: "onboarding-recommendations-open" }, async (state) => { - const record = writeOnboardingRecommendationsOffer({ + const store = createOnboardingRecommendationsStore({ + workspaceDir: state.workspaceDir, + database: { env: state.env }, + }); + const record = store.writeOffer({ inventory: [{ label: "Chat" }], matches, answered: false, nowMs: 2_345, - database: { env: state.env }, }); expect(record.acceptedAt).toBeNull(); - const acknowledged = acknowledgeOnboardingRecommendations({ + const acknowledged = store.acknowledge({ nowMs: 3_456, - database: { env: state.env }, }); expect(acknowledged).toEqual({ ...record, acceptedAt: 3_456, updatedAt: 3_456 }); - expect(readOnboardingRecommendations({ env: state.env })).toEqual(acknowledged); + expect(store.read()).toEqual(acknowledged); }); }); it("updates pending matches without changing the inventory identity", async () => { await withOpenClawTestState({ label: "onboarding-recommendations-retry" }, async (state) => { const database = { env: state.env }; - const record = writeOnboardingRecommendationsOffer({ + const store = createOnboardingRecommendationsStore({ + workspaceDir: state.workspaceDir, + database, + }); + const record = store.writeOffer({ inventory: [{ label: "Chat" }, { label: "Notes" }], matches, answered: false, nowMs: 2_000, - database, }); const retryMatch = { ...matches[0]!, reason: "Retry this install" }; - const updated = updatePendingOnboardingRecommendations({ + const updated = store.updatePending({ matches: [retryMatch], expected: record, nowMs: 3_000, - database, }); expect(updated).toEqual({ @@ -119,37 +145,37 @@ describe("onboarding recommendations store", () => { it("does not overwrite a concurrently replaced pending offer", async () => { await withOpenClawTestState({ label: "onboarding-recommendations-stale" }, async (state) => { const database = { env: state.env }; - const original = writeOnboardingRecommendationsOffer({ + const store = createOnboardingRecommendationsStore({ + workspaceDir: state.workspaceDir, + database, + }); + const original = store.writeOffer({ inventory: [{ label: "Chat" }], matches, answered: false, nowMs: 2_000, - database, }); - const replacement = writeOnboardingRecommendationsOffer({ + const replacement = store.writeOffer({ inventory: [{ label: "Notes" }], matches: [], answered: false, nowMs: 2_500, - database, }); expect( - updatePendingOnboardingRecommendations({ + store.updatePending({ matches, expected: original, nowMs: 3_000, - database, }), ).toBeNull(); expect( - acknowledgeOnboardingRecommendations({ + store.acknowledge({ expected: original, nowMs: 3_000, - database, }), ).toBeNull(); - expect(readOnboardingRecommendations(database)).toEqual(replacement); + expect(store.read()).toEqual(replacement); }); }); @@ -158,24 +184,26 @@ describe("onboarding recommendations store", () => { { label: "onboarding-recommendations-pending-clear" }, async (state) => { const database = { env: state.env }; - const pending = writeOnboardingRecommendationsOffer({ + const store = createOnboardingRecommendationsStore({ + workspaceDir: state.workspaceDir, + database, + }); + const pending = store.writeOffer({ inventory: [{ label: "Chat" }], matches, answered: false, - database, }); - expect(clearPendingOnboardingRecommendations({ expected: pending, database })).toBe(true); - expect(readOnboardingRecommendations(database)).toBeNull(); + expect(store.clearPending({ expected: pending })).toBe(true); + expect(store.read()).toBeNull(); - const accepted = writeOnboardingRecommendationsOffer({ + const accepted = store.writeOffer({ inventory: [{ label: "Chat" }], matches, answered: true, - database, }); - expect(clearPendingOnboardingRecommendations({ expected: accepted, database })).toBe(false); - expect(readOnboardingRecommendations(database)?.acceptedAt).toBeTypeOf("number"); + expect(store.clearPending({ expected: accepted })).toBe(false); + expect(store.read()?.acceptedAt).toBeTypeOf("number"); }, ); }); @@ -183,17 +211,20 @@ describe("onboarding recommendations store", () => { it("deletes the stored offer so recommendations can be scanned again", async () => { await withOpenClawTestState({ label: "onboarding-recommendations-clear" }, async (state) => { const database = { env: state.env }; - writeOnboardingRecommendationsOffer({ + const store = createOnboardingRecommendationsStore({ + workspaceDir: state.workspaceDir, + database, + }); + store.writeOffer({ inventory: [{ label: "Chat" }], matches, answered: true, nowMs: 4_567, - database, }); - expect(clearOnboardingRecommendations(database)).toBe(true); - expect(readOnboardingRecommendations(database)).toBeNull(); - expect(clearOnboardingRecommendations(database)).toBe(false); + expect(store.clear()).toBe(true); + expect(store.read()).toBeNull(); + expect(store.clear()).toBe(false); }); }); }); diff --git a/src/state/onboarding-recommendations.ts b/src/state/onboarding-recommendations.ts index b470657b6378..1cbd51ea0adc 100644 --- a/src/state/onboarding-recommendations.ts +++ b/src/state/onboarding-recommendations.ts @@ -1,5 +1,6 @@ import { existsSync } from "node:fs"; import { z } from "zod"; +import { resolveWorkspaceStateIdentity } from "../agents/workspace-state-store.js"; import { sha256Hex } from "../infra/crypto-digest.js"; import { executeSqliteQuerySync, @@ -15,8 +16,6 @@ import { } from "./openclaw-state-db.js"; import { resolveOpenClawStateSqlitePath } from "./openclaw-state-db.paths.js"; -const ONBOARDING_RECOMMENDATIONS_KEY = "primary"; - const OnboardingRecommendationMatchSchema = z.object({ appLabel: z.string(), candidateId: z.string(), @@ -48,6 +47,43 @@ type OnboardingRecommendationInventoryItem = { bundleId?: string; }; +type WriteOnboardingRecommendationsOfferParams = { + inventory: readonly OnboardingRecommendationInventoryItem[]; + matches: readonly OnboardingRecommendationMatch[]; + answered: boolean; + nowMs?: number; +}; + +type AcknowledgeOnboardingRecommendationsParams = { + nowMs?: number; + expected?: OnboardingRecommendationsRecord; +}; + +type UpdatePendingOnboardingRecommendationsParams = { + matches: readonly OnboardingRecommendationMatch[]; + expected: OnboardingRecommendationsRecord; + nowMs?: number; +}; + +type ClearPendingOnboardingRecommendationsParams = { + expected: OnboardingRecommendationsRecord; +}; + +export type OnboardingRecommendationsStore = { + read: () => OnboardingRecommendationsRecord | null; + writeOffer: ( + params: WriteOnboardingRecommendationsOfferParams, + ) => OnboardingRecommendationsRecord; + acknowledge: ( + params?: AcknowledgeOnboardingRecommendationsParams, + ) => OnboardingRecommendationsRecord | null; + updatePending: ( + params: UpdatePendingOnboardingRecommendationsParams, + ) => OnboardingRecommendationsRecord | null; + clearPending: (params: ClearPendingOnboardingRecommendationsParams) => boolean; + clear: () => boolean; +}; + type OnboardingRecommendationsDatabase = Pick< OpenClawStateKyselyDatabase, "onboarding_recommendations" @@ -74,7 +110,8 @@ function hashOnboardingRecommendationInventory( return sha256Hex(JSON.stringify(canonicalInventory(inventory))); } -export function readOnboardingRecommendations( +function readOnboardingRecommendations( + configKey: string, options: OpenClawStateDatabaseOptions = {}, ): OnboardingRecommendationsRecord | null { const pathname = options.path ?? resolveOpenClawStateSqlitePath(options.env ?? process.env); @@ -98,7 +135,7 @@ export function readOnboardingRecommendations( "accepted_at_ms", "updated_at_ms", ]) - .where("config_key", "=", ONBOARDING_RECOMMENDATIONS_KEY), + .where("config_key", "=", configKey), ); if (!row) { return null; @@ -113,13 +150,11 @@ export function readOnboardingRecommendations( }, options); } -export function writeOnboardingRecommendationsOffer(params: { - inventory: readonly OnboardingRecommendationInventoryItem[]; - matches: readonly OnboardingRecommendationMatch[]; - answered: boolean; - nowMs?: number; - database?: OpenClawStateDatabaseOptions; -}): OnboardingRecommendationsRecord { +function writeOnboardingRecommendationsOffer( + configKey: string, + params: WriteOnboardingRecommendationsOfferParams, + databaseOptions: OpenClawStateDatabaseOptions = {}, +): OnboardingRecommendationsRecord { const nowMs = params.nowMs ?? Date.now(); const inventoryHash = hashOnboardingRecommendationInventory(params.inventory); const matches = OnboardingRecommendationMatchesSchema.parse(params.matches); @@ -138,7 +173,7 @@ export function writeOnboardingRecommendationsOffer(params: { "accepted_at_ms", "updated_at_ms", ]) - .where("config_key", "=", ONBOARDING_RECOMMENDATIONS_KEY), + .where("config_key", "=", configKey), ); // Once the user answers, concurrent or stale offer completions must not // clear acceptance and make later onboarding runs ask again. @@ -156,7 +191,7 @@ export function writeOnboardingRecommendationsOffer(params: { db .insertInto("onboarding_recommendations") .values({ - config_key: ONBOARDING_RECOMMENDATIONS_KEY, + config_key: configKey, inventory_hash: inventoryHash, matches_json: JSON.stringify(matches), offered_at_ms: nowMs, @@ -181,17 +216,15 @@ export function writeOnboardingRecommendationsOffer(params: { updatedAt: nowMs, }; }, - params.database, + databaseOptions, { operationLabel: "onboarding.recommendations.write" }, ); } -export function acknowledgeOnboardingRecommendations( - params: { - nowMs?: number; - database?: OpenClawStateDatabaseOptions; - expected?: OnboardingRecommendationsRecord; - } = {}, +function acknowledgeOnboardingRecommendations( + configKey: string, + params: AcknowledgeOnboardingRecommendationsParams = {}, + databaseOptions: OpenClawStateDatabaseOptions = {}, ): OnboardingRecommendationsRecord | null { const nowMs = params.nowMs ?? Date.now(); return runOpenClawStateWriteTransaction( @@ -208,7 +241,7 @@ export function acknowledgeOnboardingRecommendations( "accepted_at_ms", "updated_at_ms", ]) - .where("config_key", "=", ONBOARDING_RECOMMENDATIONS_KEY), + .where("config_key", "=", configKey), ); if (!existing) { return null; @@ -227,7 +260,7 @@ export function acknowledgeOnboardingRecommendations( let update = db .updateTable("onboarding_recommendations") .set({ accepted_at_ms: nowMs, updated_at_ms: nowMs }) - .where("config_key", "=", ONBOARDING_RECOMMENDATIONS_KEY); + .where("config_key", "=", configKey); if (params.expected) { update = update .where("inventory_hash", "=", params.expected.inventoryHash) @@ -250,17 +283,16 @@ export function acknowledgeOnboardingRecommendations( updatedAt: existing.accepted_at_ms == null ? nowMs : existing.updated_at_ms, }; }, - params.database, + databaseOptions, { operationLabel: "onboarding.recommendations.acknowledge" }, ); } -export function updatePendingOnboardingRecommendations(params: { - matches: readonly OnboardingRecommendationMatch[]; - expected: OnboardingRecommendationsRecord; - nowMs?: number; - database?: OpenClawStateDatabaseOptions; -}): OnboardingRecommendationsRecord | null { +function updatePendingOnboardingRecommendations( + configKey: string, + params: UpdatePendingOnboardingRecommendationsParams, + databaseOptions: OpenClawStateDatabaseOptions = {}, +): OnboardingRecommendationsRecord | null { const nowMs = params.nowMs ?? Date.now(); const matches = OnboardingRecommendationMatchesSchema.parse(params.matches); return runOpenClawStateWriteTransaction( @@ -277,7 +309,7 @@ export function updatePendingOnboardingRecommendations(params: { "accepted_at_ms", "updated_at_ms", ]) - .where("config_key", "=", ONBOARDING_RECOMMENDATIONS_KEY), + .where("config_key", "=", configKey), ); if ( !existing || @@ -295,7 +327,7 @@ export function updatePendingOnboardingRecommendations(params: { db .updateTable("onboarding_recommendations") .set({ matches_json: JSON.stringify(matches), updated_at_ms: nowMs }) - .where("config_key", "=", ONBOARDING_RECOMMENDATIONS_KEY) + .where("config_key", "=", configKey) .where("accepted_at_ms", "is", null) .where("inventory_hash", "=", params.expected.inventoryHash) .where("matches_json", "=", JSON.stringify(params.expected.matches)) @@ -313,37 +345,14 @@ export function updatePendingOnboardingRecommendations(params: { updatedAt: nowMs, }; }, - params.database, + databaseOptions, { operationLabel: "onboarding.recommendations.update-pending" }, ); } -export function clearPendingOnboardingRecommendations(params: { - expected: OnboardingRecommendationsRecord; - database?: OpenClawStateDatabaseOptions; -}): boolean { - return runOpenClawStateWriteTransaction( - (database) => { - const db = getNodeSqliteKysely(database.db); - const result = executeSqliteQuerySync( - database.db, - db - .deleteFrom("onboarding_recommendations") - .where("config_key", "=", ONBOARDING_RECOMMENDATIONS_KEY) - .where("accepted_at_ms", "is", null) - .where("inventory_hash", "=", params.expected.inventoryHash) - .where("matches_json", "=", JSON.stringify(params.expected.matches)) - .where("offered_at_ms", "=", params.expected.offeredAt) - .where("updated_at_ms", "=", params.expected.updatedAt), - ); - return (result.numAffectedRows ?? 0n) > 0n; - }, - params.database, - { operationLabel: "onboarding.recommendations.clear-pending" }, - ); -} - -export function clearOnboardingRecommendations( +function clearPendingOnboardingRecommendations( + configKey: string, + params: ClearPendingOnboardingRecommendationsParams, databaseOptions: OpenClawStateDatabaseOptions = {}, ): boolean { return runOpenClawStateWriteTransaction( @@ -353,7 +362,30 @@ export function clearOnboardingRecommendations( database.db, db .deleteFrom("onboarding_recommendations") - .where("config_key", "=", ONBOARDING_RECOMMENDATIONS_KEY), + .where("config_key", "=", configKey) + .where("accepted_at_ms", "is", null) + .where("inventory_hash", "=", params.expected.inventoryHash) + .where("matches_json", "=", JSON.stringify(params.expected.matches)) + .where("offered_at_ms", "=", params.expected.offeredAt) + .where("updated_at_ms", "=", params.expected.updatedAt), + ); + return (result.numAffectedRows ?? 0n) > 0n; + }, + databaseOptions, + { operationLabel: "onboarding.recommendations.clear-pending" }, + ); +} + +function clearOnboardingRecommendations( + configKey: string, + databaseOptions: OpenClawStateDatabaseOptions = {}, +): boolean { + return runOpenClawStateWriteTransaction( + (database) => { + const db = getNodeSqliteKysely(database.db); + const result = executeSqliteQuerySync( + database.db, + db.deleteFrom("onboarding_recommendations").where("config_key", "=", configKey), ); return (result.numAffectedRows ?? 0n) > 0n; }, @@ -361,3 +393,22 @@ export function clearOnboardingRecommendations( { operationLabel: "onboarding.recommendations.clear" }, ); } + +export function createOnboardingRecommendationsStore(params: { + workspaceDir: string; + database?: OpenClawStateDatabaseOptions; +}): OnboardingRecommendationsStore { + // Doctor owns the one-time `primary` migration; a runtime fallback would recreate + // cross-workspace reads. Every operation stays bound to one canonical workspace key. + const configKey = resolveWorkspaceStateIdentity(params.workspaceDir).workspaceKey; + const database = params.database ?? {}; + return { + read: () => readOnboardingRecommendations(configKey, database), + writeOffer: (offer) => writeOnboardingRecommendationsOffer(configKey, offer, database), + acknowledge: (options) => acknowledgeOnboardingRecommendations(configKey, options, database), + updatePending: (options) => + updatePendingOnboardingRecommendations(configKey, options, database), + clearPending: (options) => clearPendingOnboardingRecommendations(configKey, options, database), + clear: () => clearOnboardingRecommendations(configKey, database), + }; +} diff --git a/src/wizard/setup.app-recommendations.test.ts b/src/wizard/setup.app-recommendations.test.ts index 2bfc87d8e5a6..1d9e19992941 100644 --- a/src/wizard/setup.app-recommendations.test.ts +++ b/src/wizard/setup.app-recommendations.test.ts @@ -5,6 +5,7 @@ import type { RuntimeEnv } from "../runtime.js"; import type { OnboardingRecommendationMatch, OnboardingRecommendationsRecord, + OnboardingRecommendationsStore, } from "../state/onboarding-recommendations.js"; import type { SetupAppRecommendationsResult } from "../system-agent/setup-app-recommendations.js"; import type { WizardPrompter } from "./prompts.js"; @@ -42,11 +43,7 @@ function storeDeps(initial: OnboardingRecommendationsRecord | null = null) { let current = initial; let now = 0; const writeOffer = vi.fn( - ( - params: Parameters< - typeof import("../state/onboarding-recommendations.js").writeOnboardingRecommendationsOffer - >[0], - ) => { + (params: Parameters[0]) => { now += 1; current = { inventoryHash: "hash", @@ -59,11 +56,7 @@ function storeDeps(initial: OnboardingRecommendationsRecord | null = null) { }, ); const acknowledgeStored = vi.fn( - ( - params: Parameters< - typeof import("../state/onboarding-recommendations.js").acknowledgeOnboardingRecommendations - >[0] = {}, - ) => { + (params: Parameters[0] = {}) => { if ( !current || (params.expected && @@ -78,11 +71,7 @@ function storeDeps(initial: OnboardingRecommendationsRecord | null = null) { }, ); const updatePendingStored = vi.fn( - ( - params: Parameters< - typeof import("../state/onboarding-recommendations.js").updatePendingOnboardingRecommendations - >[0], - ) => { + (params: Parameters[0]) => { if ( !current || params.expected.inventoryHash !== current.inventoryHash || @@ -452,11 +441,7 @@ describe("setupAppRecommendations", () => { const storeState: { current: OnboardingRecommendationsRecord | null } = { current: null }; let now = 0; const writeOffer = vi.fn( - ( - params: Parameters< - typeof import("../state/onboarding-recommendations.js").writeOnboardingRecommendationsOffer - >[0], - ) => { + (params: Parameters[0]) => { now += 1; storeState.current = { inventoryHash: "hash", @@ -469,11 +454,7 @@ describe("setupAppRecommendations", () => { }, ); const acknowledgeStored = vi.fn( - ( - params: Parameters< - typeof import("../state/onboarding-recommendations.js").acknowledgeOnboardingRecommendations - >[0] = {}, - ) => { + (params: Parameters[0] = {}) => { if ( !storeState.current || (params.expected && diff --git a/src/wizard/setup.app-recommendations.ts b/src/wizard/setup.app-recommendations.ts index 1cb3e17e3132..c156ccbda163 100644 --- a/src/wizard/setup.app-recommendations.ts +++ b/src/wizard/setup.app-recommendations.ts @@ -21,11 +21,8 @@ import { resolveClawHubSkillVerificationTarget, } from "../skills/lifecycle/clawhub.js"; import { - acknowledgeOnboardingRecommendations, - clearPendingOnboardingRecommendations, - readOnboardingRecommendations, - updatePendingOnboardingRecommendations, - writeOnboardingRecommendationsOffer, + createOnboardingRecommendationsStore, + type OnboardingRecommendationsStore, type OnboardingRecommendationsRecord, } from "../state/onboarding-recommendations.js"; import { @@ -45,10 +42,10 @@ type SetupAppRecommendationDeps = { isSkillInstalled?: (params: { workspaceDir: string; skillRef: string }) => Promise; resolveOfficialEntry?: (pluginId: string) => OnboardingPluginInstallEntry | undefined; readStored?: () => OnboardingRecommendationsRecord | null; - writeOffer?: typeof writeOnboardingRecommendationsOffer; - acknowledgeStored?: typeof acknowledgeOnboardingRecommendations; - updatePendingStored?: typeof updatePendingOnboardingRecommendations; - clearPendingStored?: typeof clearPendingOnboardingRecommendations; + writeOffer?: OnboardingRecommendationsStore["writeOffer"]; + acknowledgeStored?: OnboardingRecommendationsStore["acknowledge"]; + updatePendingStored?: OnboardingRecommendationsStore["updatePending"]; + clearPendingStored?: OnboardingRecommendationsStore["clearPending"]; deferOfferToBootstrap?: () => boolean; }; @@ -138,13 +135,13 @@ export async function setupAppRecommendations(params: { ) { return unchangedOutcome(params.config); } - const readStored = params.deps?.readStored ?? readOnboardingRecommendations; + const store = createOnboardingRecommendationsStore({ workspaceDir: params.workspaceDir }); + const readStored = params.deps?.readStored ?? store.read; const storedRecord = readStored(); if (typeof storedRecord?.acceptedAt === "number") { return unchangedOutcome(params.config); } - const clearPendingStored = - params.deps?.clearPendingStored ?? clearPendingOnboardingRecommendations; + const clearPendingStored = params.deps?.clearPendingStored ?? store.clearPending; // Pending recommendations are rebuildable cache. Rescan legacy bare // ClawHub ids instead of installing without a publisher identity. const hasLegacyClawHubId = storedRecord?.matches.some( @@ -156,10 +153,9 @@ export async function setupAppRecommendations(params: { } } const stored = hasLegacyClawHubId ? null : storedRecord; - const writeOffer = params.deps?.writeOffer ?? writeOnboardingRecommendationsOffer; - const acknowledgeStored = params.deps?.acknowledgeStored ?? acknowledgeOnboardingRecommendations; - const updatePendingStored = - params.deps?.updatePendingStored ?? updatePendingOnboardingRecommendations; + const writeOffer = params.deps?.writeOffer ?? store.writeOffer; + const acknowledgeStored = params.deps?.acknowledgeStored ?? store.acknowledge; + const updatePendingStored = params.deps?.updatePendingStored ?? store.updatePending; const deferOfferToBootstrap = params.deps?.deferOfferToBootstrap ?? (() => existsSync(path.join(params.workspaceDir, DEFAULT_BOOTSTRAP_FILENAME)));