fix(onboarding): scope recommendations by workspace (#111560)

This commit is contained in:
Peter Steinberger
2026-07-19 15:34:56 -07:00
committed by GitHub
parent 5fcf7e75de
commit 473962b7de
9 changed files with 516 additions and 157 deletions
@@ -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: {
+11
View File
@@ -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 },
+26 -13
View File
@@ -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."
@@ -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<OnboardingRecommendationsMigrationDatabase>(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<OnboardingRecommendationsMigrationDatabase>(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();
},
);
});
});
@@ -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<OnboardingRecommendationsMigrationDatabase>(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)}`],
};
}
}
+75 -44
View File
@@ -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);
});
});
});
+110 -59
View File
@@ -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<OnboardingRecommendationsDatabase>(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<OnboardingRecommendationsDatabase>(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),
};
}
+6 -25
View File
@@ -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<OnboardingRecommendationsStore["writeOffer"]>[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<OnboardingRecommendationsStore["acknowledge"]>[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<OnboardingRecommendationsStore["updatePending"]>[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<OnboardingRecommendationsStore["writeOffer"]>[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<OnboardingRecommendationsStore["acknowledge"]>[0] = {}) => {
if (
!storeState.current ||
(params.expected &&
+12 -16
View File
@@ -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<boolean>;
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)));