mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(onboarding): scope recommendations by workspace (#111560)
This commit is contained in:
committed by
GitHub
parent
5fcf7e75de
commit
473962b7de
@@ -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: {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)}`],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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)));
|
||||
|
||||
Reference in New Issue
Block a user