fix: install exact app recommendations and retry failures (#111518)

* fix(onboard): preserve recommendation install retries

* fix(onboard): preserve bootstrap recommendation retries

* fix(onboard): commit recommendation outcomes after config

* fix(onboard): guard recommendation state transitions

* fix(onboard): reconcile durable skill installs

* docs(clawhub): clarify search publisher contract
This commit is contained in:
Peter Steinberger
2026-07-19 14:10:50 -07:00
committed by GitHub
parent 13ed8b5aa1
commit 171a3852ba
17 changed files with 1076 additions and 109 deletions
+11 -1
View File
@@ -46,6 +46,7 @@ openclaw onboard --import-from hermes --import-source ~/.hermes
openclaw onboard --skip-bootstrap
openclaw onboard recommendations --json
openclaw onboard recommendations acknowledge
openclaw onboard recommendations acknowledge --retry "<failed-id>"
openclaw onboard recommendations refresh
openclaw onboard --mode remote --remote-url wss://gateway-host:18789
```
@@ -63,7 +64,16 @@ onboarding run rescans installed apps and creates a new offer.
Fresh workspaces defer the recommendation choice to the bootstrap conversation.
After that conversation handles the user's choices,
`openclaw onboard recommendations acknowledge` marks the stored offer answered.
The acknowledgement is idempotent.
The acknowledgement is idempotent. If a chosen install fails, pass each failed
opaque ID with `--retry <id...>`; successful and declined matches are consumed,
while failed matches remain pending for a later onboarding run. Unknown IDs
fail without changing the stored offer. After an interrupted ClawHub skill
install, an existing target counts as successful only when
`openclaw skills verify "@owner/slug"` succeeds for the same
publisher-qualified recommendation ID and its JSON output reports
`openclaw.resolution.source: "installed"`. Registry verification alone is not
proof of a local install. Otherwise keep that ID pending with `--retry` and do
not overwrite the existing skill.
- `--classic`: opens the full step-by-step wizard. It cannot be combined with
`--non-interactive`; omit `--classic` for automated setup.
+24 -2
View File
@@ -62,13 +62,35 @@ convenience?"**
`openclaw skills install <id>`.
- If there are no stored matches, skip this beat without commentary.
After the user answers and any chosen installs finish, record completion so the
offer never appears again:
After the user answers and every chosen install succeeds, record completion so
the offer never appears again:
```bash
openclaw onboard recommendations acknowledge
```
If an install fails, consume the successful and declined recommendations but
leave every failed ID pending for a later onboarding run:
```bash
openclaw onboard recommendations acknowledge --retry "<failed-id>" ["<failed-id>"...]
```
Use the exact opaque IDs returned by the read command. Never acknowledge a
failed install without `--retry`. One interrupted skill install can report that
its target already exists on the next attempt. In that case, verify the exact
publisher-qualified ID before treating it as successful:
```bash
openclaw skills verify "@owner/slug"
```
Only count it as installed when verification succeeds for that same ID and its
JSON output has `openclaw.resolution.source` set to `installed`. A registry
verification is not proof of a local install. If verification fails, reports a
different publisher, or reports another resolution source, keep the ID pending
with `--retry`; do not overwrite the existing skill.
When the three beats are complete, delete this file. Then say one line:
> Ask me anything; for system things I'll ask OpenClaw.
+21 -1
View File
@@ -98,7 +98,27 @@ describe("registerOnboardCommand", () => {
it("routes the recommendations acknowledgement subcommand", async () => {
await runCli(["onboard", "recommendations", "acknowledge"]);
expect(mocks.acknowledgeOnboardRecommendationsCommand).toHaveBeenCalledWith(runtime);
expect(mocks.acknowledgeOnboardRecommendationsCommand).toHaveBeenCalledWith(
{ retry: undefined },
runtime,
);
expect(setupWizardCommandMock).not.toHaveBeenCalled();
});
it("routes failed recommendation ids through acknowledgement", async () => {
await runCli([
"onboard",
"recommendations",
"acknowledge",
"--retry",
"chat-plugin",
"@demo-owner/notes",
]);
expect(mocks.acknowledgeOnboardRecommendationsCommand).toHaveBeenCalledWith(
{ retry: ["chat-plugin", "@demo-owner/notes"] },
runtime,
);
expect(setupWizardCommandMock).not.toHaveBeenCalled();
});
+3 -2
View File
@@ -257,12 +257,13 @@ export function registerOnboardCommand(program: Command): void {
recommendations
.command("acknowledge")
.description("Mark the stored onboarding recommendation offer as answered")
.action(async () => {
.option("--retry <id...>", "Leave failed recommendation IDs pending for a later run")
.action(async (opts: { retry?: string[] }) => {
const { defaultRuntime } = await import("../../runtime.js");
await runCommandWithRuntime(defaultRuntime, async () => {
const { acknowledgeOnboardRecommendationsCommand } =
await import("../../commands/onboard-recommendations.js");
acknowledgeOnboardRecommendationsCommand(defaultRuntime);
acknowledgeOnboardRecommendationsCommand({ retry: opts.retry }, defaultRuntime);
});
});
@@ -135,7 +135,9 @@ function setupDeps(params: {
})),
persistRiskAcknowledgement: params.persistRiskAcknowledgement ?? vi.fn(async () => undefined),
runSetupMemoryImportStep: params.runSetupMemoryImportStep ?? vi.fn(async () => undefined),
runAppRecommendations: params.runAppRecommendations ?? vi.fn(async ({ config }) => config),
runAppRecommendations:
params.runAppRecommendations ??
vi.fn(async ({ config }) => ({ config, commitResult: vi.fn() })),
runSystemAgentChat,
...(params.handoffMode ? { handoffMode: params.handoffMode } : {}),
} satisfies GuidedOnboardingDeps;
+8 -3
View File
@@ -110,6 +110,10 @@ function setupApplyResult() {
};
}
function recommendationOutcome(config: OpenClawConfig) {
return { config, commitResult: vi.fn() };
}
function setupDeps(params: {
prompter: WizardPrompter;
detect?: GuidedOnboardingDeps["detect"];
@@ -149,7 +153,8 @@ function setupDeps(params: {
})),
persistRiskAcknowledgement: params.persistRiskAcknowledgement ?? vi.fn(async () => undefined),
runSetupMemoryImportStep: params.runSetupMemoryImportStep ?? vi.fn(async () => undefined),
runAppRecommendations: params.runAppRecommendations ?? vi.fn(async ({ config }) => config),
runAppRecommendations:
params.runAppRecommendations ?? vi.fn(async ({ config }) => recommendationOutcome(config)),
runBrowserHandoff:
params.runBrowserHandoff ??
(vi.fn(async () => ({
@@ -232,7 +237,7 @@ describe("runGuidedOnboarding", () => {
});
const applySetup = vi.fn(async () => setupApplyResult());
const runAppRecommendations = vi.fn<NonNullable<GuidedOnboardingDeps["runAppRecommendations"]>>(
async ({ config }) => config,
async ({ config }) => recommendationOutcome(config),
);
const deps = setupDeps({ prompter, applySetup, runAppRecommendations });
const runtime = makeRuntime();
@@ -275,7 +280,7 @@ describe("runGuidedOnboarding", () => {
const prompter = createWizardPrompter();
const applySetup = vi.fn(async () => setupApplyResult());
const runAppRecommendations = vi.fn<NonNullable<GuidedOnboardingDeps["runAppRecommendations"]>>(
async ({ config }) => config,
async ({ config }) => recommendationOutcome(config),
);
const probeBrowserHandoffGateway = vi.fn(async () => ({ ok: true }));
const runBrowserHandoff = vi.fn(async () => ({ handedOff: true as const }));
+3 -1
View File
@@ -449,13 +449,14 @@ async function runGuidedOnboardingFlow(
const runAppRecommendations =
deps.runAppRecommendations ??
(await import("../wizard/setup.app-recommendations.js")).setupAppRecommendations;
const recommendedConfig = await runAppRecommendations({
const recommendationOutcome = await runAppRecommendations({
config: persistedConfig,
prompter,
runtime,
workspaceDir: workspace,
modelRouteVerified: true,
});
const recommendedConfig = recommendationOutcome.config;
if (recommendedConfig !== persistedConfig) {
const latestSnapshot = await readConfigFileSnapshot();
if (!latestSnapshot.valid) {
@@ -476,6 +477,7 @@ async function runGuidedOnboardingFlow(
});
persistedConfig = mergedConfig;
}
recommendationOutcome.commitResult();
}
const hatchWorkspace = alreadyConfigured
? resolveUserPath(
+154 -1
View File
@@ -97,12 +97,165 @@ describe("onboard recommendations command", () => {
matches: [],
}));
acknowledgeOnboardRecommendationsCommand(runtime, { acknowledge });
acknowledgeOnboardRecommendationsCommand({}, runtime, { acknowledge });
expect(acknowledge).toHaveBeenCalledOnce();
expect(runtime.log).toHaveBeenCalledWith("Onboarding recommendations acknowledged.");
});
it("leaves failed bootstrap installs pending and consumes the other matches", () => {
const runtime = makeRuntime();
const updatePending = vi.fn(() => ({
inventoryHash: "hash",
offeredAt: 1,
acceptedAt: null,
updatedAt: 2,
matches: [],
}));
const matches = [
{
appLabel: "Chat",
candidateId: "chat-plugin",
tier: "recommended" as const,
reason: "Connects conversations",
candidate: {
id: "chat-plugin",
displayName: "Chat plugin",
summary: "Chat",
source: "official-channel" as const,
},
},
{
appLabel: "Notes",
candidateId: "@demo-owner/notes",
tier: "optional" as const,
reason: "Connects notes",
candidate: {
id: "@demo-owner/notes",
displayName: "Notes skill",
summary: "Notes",
source: "clawhub-skill" as const,
},
},
];
acknowledgeOnboardRecommendationsCommand({ retry: ["@demo-owner/notes"] }, runtime, {
read: () => ({
inventoryHash: "hash",
offeredAt: 1,
acceptedAt: null,
updatedAt: 1,
matches,
}),
updatePending,
});
expect(updatePending).toHaveBeenCalledWith({
matches: [matches[1]],
expected: {
inventoryHash: "hash",
offeredAt: 1,
acceptedAt: null,
updatedAt: 1,
matches,
},
});
expect(runtime.log).toHaveBeenCalledWith(
"Onboarding recommendations updated; 1 left pending for retry.",
);
});
it("rejects unknown bootstrap retry ids without consuming the offer", () => {
const runtime = makeRuntime();
const acknowledge = vi.fn();
const updatePending = vi.fn();
acknowledgeOnboardRecommendationsCommand({ retry: ["missing-skill"] }, runtime, {
read: () => ({
inventoryHash: "hash",
offeredAt: 1,
acceptedAt: null,
updatedAt: 1,
matches: [],
}),
acknowledge,
updatePending,
});
expect(runtime.error).toHaveBeenCalledWith("Unknown pending recommendation id: missing-skill");
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(acknowledge).not.toHaveBeenCalled();
expect(updatePending).not.toHaveBeenCalled();
});
it("fails closed when the pending offer changes before retry persistence", () => {
const runtime = makeRuntime();
const updatePending = vi.fn(() => null);
const match = {
appLabel: "Notes",
candidateId: "@demo-owner/notes",
tier: "optional" as const,
reason: "Connects notes",
candidate: {
id: "@demo-owner/notes",
displayName: "Notes skill",
summary: "Notes",
source: "clawhub-skill" as const,
},
};
acknowledgeOnboardRecommendationsCommand({ retry: [match.candidateId] }, runtime, {
read: () => ({
inventoryHash: "hash",
offeredAt: 1,
acceptedAt: null,
updatedAt: 1,
matches: [match],
}),
updatePending,
});
expect(runtime.error).toHaveBeenCalledWith(
"Stored recommendations changed; read them again before recording retries.",
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(runtime.log).not.toHaveBeenCalled();
});
it("clears pending bootstrap offers with legacy bare ClawHub ids", () => {
const runtime = makeRuntime();
const clearPending = vi.fn(() => true);
onboardRecommendationsCommand({ json: true }, runtime, {
read: () => ({
inventoryHash: "hash",
offeredAt: 1,
acceptedAt: null,
updatedAt: 1,
matches: [
{
appLabel: "Notes",
candidateId: "notes",
tier: "optional",
reason: "Connects notes",
candidate: {
id: "notes",
displayName: "Notes skill",
summary: "Notes",
source: "clawhub-skill",
},
},
],
}),
clearPending,
});
expect(clearPending).toHaveBeenCalledWith({
expected: expect.objectContaining({ inventoryHash: "hash", updatedAt: 1 }),
});
expect(runtime.log).toHaveBeenCalledWith("[]");
});
it("clears a stored offer for the next onboarding scan", () => {
const runtime = makeRuntime();
const clear = vi.fn(() => true);
+65 -2
View File
@@ -2,16 +2,24 @@ import type { RuntimeEnv } from "../runtime.js";
import {
acknowledgeOnboardingRecommendations,
clearOnboardingRecommendations,
clearPendingOnboardingRecommendations,
readOnboardingRecommendations,
updatePendingOnboardingRecommendations,
type OnboardingRecommendationsRecord,
} from "../state/onboarding-recommendations.js";
type OnboardRecommendationsDeps = {
read?: () => OnboardingRecommendationsRecord | null;
acknowledge?: () => OnboardingRecommendationsRecord | null;
updatePending?: typeof updatePendingOnboardingRecommendations;
clearPending?: typeof clearPendingOnboardingRecommendations;
clear?: () => boolean;
};
type AcknowledgeOnboardRecommendationsOptions = {
retry?: readonly string[];
};
const SAFE_INSTALL_ID_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/iu;
type BootstrapRecommendation = {
@@ -20,6 +28,14 @@ type BootstrapRecommendation = {
tier: "recommended" | "optional";
};
function isLegacyBareClawHubId(match: OnboardingRecommendationsRecord["matches"][number]): boolean {
return (
match.candidate.source === "clawhub-skill" &&
SAFE_INSTALL_ID_RE.test(match.candidate.id) &&
!match.candidate.id.startsWith("@")
);
}
function bootstrapRecommendations(
record: OnboardingRecommendationsRecord | null,
): BootstrapRecommendation[] {
@@ -29,7 +45,10 @@ function bootstrapRecommendations(
const byInstall = new Map<string, BootstrapRecommendation>();
for (const match of record?.matches ?? []) {
const id = match.candidate.id;
if (!SAFE_INSTALL_ID_RE.test(id)) {
if (
!SAFE_INSTALL_ID_RE.test(id) ||
(match.candidate.source === "clawhub-skill" && !id.startsWith("@"))
) {
continue;
}
const source = match.candidate.source === "clawhub-skill" ? "clawhub-skill" : "official-plugin";
@@ -47,7 +66,19 @@ export function onboardRecommendationsCommand(
runtime: RuntimeEnv,
deps: OnboardRecommendationsDeps = {},
): void {
const record = (deps.read ?? readOnboardingRecommendations)();
const stored = (deps.read ?? readOnboardingRecommendations)();
const hasLegacyClawHubId = stored?.matches.some(isLegacyBareClawHubId);
if (hasLegacyClawHubId && stored && stored.acceptedAt == null) {
const cleared = (deps.clearPending ?? clearPendingOnboardingRecommendations)({
expected: stored,
});
if (!cleared) {
runtime.error("Stored recommendations changed; read them again.");
runtime.exit(1);
return;
}
}
const record = hasLegacyClawHubId ? null : stored;
// The bootstrap consumes only safe opaque install ids. Marketplace prose,
// model reasons, and local app labels are untrusted prompt input.
const matches = bootstrapRecommendations(record);
@@ -70,9 +101,41 @@ export function onboardRecommendationsCommand(
}
export function acknowledgeOnboardRecommendationsCommand(
opts: AcknowledgeOnboardRecommendationsOptions,
runtime: RuntimeEnv,
deps: OnboardRecommendationsDeps = {},
): void {
const retryIds = [...new Set(opts.retry ?? [])];
if (retryIds.length > 0) {
const record = (deps.read ?? readOnboardingRecommendations)();
if (!record || record.acceptedAt != null) {
runtime.error("No pending onboarding recommendations to retry.");
runtime.exit(1);
return;
}
const pending = bootstrapRecommendations(record);
const pendingIds = new Set(pending.map((match) => match.id));
const unknownIds = retryIds.filter((id) => !pendingIds.has(id));
if (unknownIds.length > 0) {
runtime.error(`Unknown pending recommendation id: ${unknownIds.join(", ")}`);
runtime.exit(1);
return;
}
const retryIdSet = new Set(retryIds);
const retryMatches =
record?.matches.filter((match) => retryIdSet.has(match.candidate.id)) ?? [];
const updated = (deps.updatePending ?? updatePendingOnboardingRecommendations)({
matches: retryMatches,
expected: record,
});
if (!updated) {
runtime.error("Stored recommendations changed; read them again before recording retries.");
runtime.exit(1);
return;
}
runtime.log(`Onboarding recommendations updated; ${retryIds.length} left pending for retry.`);
return;
}
const record = (deps.acknowledge ?? acknowledgeOnboardingRecommendations)();
runtime.log(record ? "Onboarding recommendations acknowledged." : "No stored recommendations.");
}
+1
View File
@@ -270,6 +270,7 @@ export type ClawHubPackageSearchResult = {
export type ClawHubSkillSearchResult = {
score: number;
slug: string;
// Search may return the same slug for multiple publishers; exact install refs need this handle.
ownerHandle?: string | null;
displayName: string;
summary?: string;
@@ -3,8 +3,10 @@ import { afterEach, describe, expect, it } from "vitest";
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
import {
acknowledgeOnboardingRecommendations,
clearPendingOnboardingRecommendations,
clearOnboardingRecommendations,
readOnboardingRecommendations,
updatePendingOnboardingRecommendations,
writeOnboardingRecommendationsOffer,
type OnboardingRecommendationMatch,
} from "./onboarding-recommendations.js";
@@ -86,6 +88,98 @@ describe("onboarding recommendations store", () => {
});
});
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({
inventory: [{ label: "Chat" }, { label: "Notes" }],
matches,
answered: false,
nowMs: 2_000,
database,
});
const retryMatch = { ...matches[0]!, reason: "Retry this install" };
const updated = updatePendingOnboardingRecommendations({
matches: [retryMatch],
expected: record,
nowMs: 3_000,
database,
});
expect(updated).toEqual({
...record,
matches: [retryMatch],
updatedAt: 3_000,
});
expect(updated?.inventoryHash).toBe(record.inventoryHash);
});
});
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({
inventory: [{ label: "Chat" }],
matches,
answered: false,
nowMs: 2_000,
database,
});
const replacement = writeOnboardingRecommendationsOffer({
inventory: [{ label: "Notes" }],
matches: [],
answered: false,
nowMs: 2_500,
database,
});
expect(
updatePendingOnboardingRecommendations({
matches,
expected: original,
nowMs: 3_000,
database,
}),
).toBeNull();
expect(
acknowledgeOnboardingRecommendations({
expected: original,
nowMs: 3_000,
database,
}),
).toBeNull();
expect(readOnboardingRecommendations(database)).toEqual(replacement);
});
});
it("clears only pending offers", async () => {
await withOpenClawTestState(
{ label: "onboarding-recommendations-pending-clear" },
async (state) => {
const database = { env: state.env };
const pending = writeOnboardingRecommendationsOffer({
inventory: [{ label: "Chat" }],
matches,
answered: false,
database,
});
expect(clearPendingOnboardingRecommendations({ expected: pending, database })).toBe(true);
expect(readOnboardingRecommendations(database)).toBeNull();
const accepted = writeOnboardingRecommendationsOffer({
inventory: [{ label: "Chat" }],
matches,
answered: true,
database,
});
expect(clearPendingOnboardingRecommendations({ expected: accepted, database })).toBe(false);
expect(readOnboardingRecommendations(database)?.acceptedAt).toBeTypeOf("number");
},
);
});
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 };
+115 -7
View File
@@ -190,6 +190,7 @@ export function acknowledgeOnboardingRecommendations(
params: {
nowMs?: number;
database?: OpenClawStateDatabaseOptions;
expected?: OnboardingRecommendationsRecord;
} = {},
): OnboardingRecommendationsRecord | null {
const nowMs = params.nowMs ?? Date.now();
@@ -212,14 +213,33 @@ export function acknowledgeOnboardingRecommendations(
if (!existing) {
return null;
}
if (
params.expected &&
(existing.inventory_hash !== params.expected.inventoryHash ||
existing.matches_json !== JSON.stringify(params.expected.matches) ||
existing.offered_at_ms !== params.expected.offeredAt ||
existing.accepted_at_ms !== params.expected.acceptedAt ||
existing.updated_at_ms !== params.expected.updatedAt)
) {
return null;
}
if (typeof existing.accepted_at_ms !== "number") {
executeSqliteQuerySync(
database.db,
db
.updateTable("onboarding_recommendations")
.set({ accepted_at_ms: nowMs, updated_at_ms: nowMs })
.where("config_key", "=", ONBOARDING_RECOMMENDATIONS_KEY),
);
let update = db
.updateTable("onboarding_recommendations")
.set({ accepted_at_ms: nowMs, updated_at_ms: nowMs })
.where("config_key", "=", ONBOARDING_RECOMMENDATIONS_KEY);
if (params.expected) {
update = update
.where("inventory_hash", "=", params.expected.inventoryHash)
.where("matches_json", "=", JSON.stringify(params.expected.matches))
.where("offered_at_ms", "=", params.expected.offeredAt)
.where("accepted_at_ms", "is", params.expected.acceptedAt)
.where("updated_at_ms", "=", params.expected.updatedAt);
}
const result = executeSqliteQuerySync(database.db, update);
if ((result.numAffectedRows ?? 0n) === 0n) {
return null;
}
}
const acceptedAt = existing.accepted_at_ms ?? nowMs;
return {
@@ -235,6 +255,94 @@ export function acknowledgeOnboardingRecommendations(
);
}
export function updatePendingOnboardingRecommendations(params: {
matches: readonly OnboardingRecommendationMatch[];
expected: OnboardingRecommendationsRecord;
nowMs?: number;
database?: OpenClawStateDatabaseOptions;
}): OnboardingRecommendationsRecord | null {
const nowMs = params.nowMs ?? Date.now();
const matches = OnboardingRecommendationMatchesSchema.parse(params.matches);
return runOpenClawStateWriteTransaction(
(database) => {
const db = getNodeSqliteKysely<OnboardingRecommendationsDatabase>(database.db);
const existing = executeSqliteQueryTakeFirstSync(
database.db,
db
.selectFrom("onboarding_recommendations")
.select([
"inventory_hash",
"matches_json",
"offered_at_ms",
"accepted_at_ms",
"updated_at_ms",
])
.where("config_key", "=", ONBOARDING_RECOMMENDATIONS_KEY),
);
if (
!existing ||
typeof existing.accepted_at_ms === "number" ||
existing.inventory_hash !== params.expected.inventoryHash ||
existing.matches_json !== JSON.stringify(params.expected.matches) ||
existing.offered_at_ms !== params.expected.offeredAt ||
existing.accepted_at_ms !== params.expected.acceptedAt ||
existing.updated_at_ms !== params.expected.updatedAt
) {
return null;
}
const result = executeSqliteQuerySync(
database.db,
db
.updateTable("onboarding_recommendations")
.set({ matches_json: JSON.stringify(matches), updated_at_ms: nowMs })
.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),
);
if ((result.numAffectedRows ?? 0n) === 0n) {
return null;
}
return {
inventoryHash: existing.inventory_hash,
matches,
offeredAt: existing.offered_at_ms,
acceptedAt: null,
updatedAt: nowMs,
};
},
params.database,
{ 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(
databaseOptions: OpenClawStateDatabaseOptions = {},
): boolean {
@@ -33,6 +33,47 @@ function officialEntry(params: {
}
describe("setup app recommendation candidates", () => {
it("preserves the ClawHub publisher in candidate ids", async () => {
const result = await getSetupAppRecommendations({
inventorySource: async () => [{ label: "Notes" }],
runtime: defaultRuntime,
deps: {
listPlugins: () => [],
listChannels: () => [],
listProviders: () => [],
searchSkills: async () => [
{
score: 1,
slug: "notes-tools",
ownerHandle: "demo-owner",
displayName: "Notes Tools",
},
{
score: 0.9,
slug: "notes-tools",
ownerHandle: "other-owner",
displayName: "Other Notes Tools",
},
{
score: 0.8,
slug: "legacy-notes-tools",
displayName: "Ownerless Notes Tools",
},
],
complete: completeMatching([{ appLabel: "Notes", candidateId: "@demo-owner/notes-tools" }]),
},
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.groups[0]?.candidates.map((candidate) => candidate.id)).toEqual([
"@demo-owner/notes-tools",
"@other-owner/notes-tools",
]);
expect(result.matches[0]?.candidate.id).toBe("@demo-owner/notes-tools");
}
});
it("gathers, dedupes, and sorts official and ClawHub candidates", async () => {
const channel = officialEntry({
id: "chat",
@@ -46,8 +87,19 @@ describe("setup app recommendation candidates", () => {
description: "Notes integration",
});
const searchSkills = vi.fn(async () => [
{ score: 2, slug: "notes", displayName: "Duplicate notes" },
{ score: 1, slug: "notes-tools", displayName: "Notes Tools", summary: "Work with notes" },
{
score: 2,
slug: "notes-tools",
ownerHandle: "demo-owner",
displayName: "Duplicate notes",
},
{
score: 1,
slug: "notes-tools",
ownerHandle: "demo-owner",
displayName: "Notes Tools",
summary: "Work with notes",
},
]);
const result = await getSetupAppRecommendations({
@@ -68,7 +120,7 @@ describe("setup app recommendation candidates", () => {
const notes = result.groups.find((group) => group.app.label === "Notes");
expect(notes?.candidates.map((candidate) => [candidate.source, candidate.id])).toEqual([
["official-plugin", "notes"],
["clawhub-skill", "notes-tools"],
["clawhub-skill", "@demo-owner/notes-tools"],
]);
}
expect(searchSkills).toHaveBeenCalledTimes(2);
@@ -79,7 +131,7 @@ describe("setup app recommendation candidates", () => {
if (query === "Broken") {
throw new Error("offline");
}
return [{ score: 1, slug: "working", displayName: "Working" }];
return [{ score: 1, slug: "working", ownerHandle: "demo-owner", displayName: "Working" }];
});
const result = await getSetupAppRecommendations({
inventorySource: async () => [{ label: "Broken" }, { label: "Working" }],
@@ -89,7 +141,7 @@ describe("setup app recommendation candidates", () => {
listChannels: () => [],
listProviders: () => [],
searchSkills,
complete: completeMatching([{ appLabel: "Working", candidateId: "working" }]),
complete: completeMatching([{ appLabel: "Working", candidateId: "@demo-owner/working" }]),
},
});
expect(result.status).toBe("ok");
@@ -138,7 +190,13 @@ describe("setup app recommendation matcher", () => {
listChannels: () => [],
listProviders: () => [],
searchSkills: async () => [
{ score: 1, slug: "notes-tools", displayName: "Notes Tools", summary: "Work with notes" },
{
score: 1,
slug: "notes-tools",
ownerHandle: "demo-owner",
displayName: "Notes Tools",
summary: "Work with notes",
},
],
};
@@ -154,7 +212,7 @@ describe("setup app recommendation matcher", () => {
matches: [
{
appLabel: "Notes",
candidateId: "notes-tools",
candidateId: "@demo-owner/notes-tools",
tier: "recommended",
reason: "Connects directly to your notes",
},
@@ -165,7 +223,10 @@ describe("setup app recommendation matcher", () => {
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.matches[0]).toMatchObject({ candidateId: "notes-tools", tier: "recommended" });
expect(result.matches[0]).toMatchObject({
candidateId: "@demo-owner/notes-tools",
tier: "recommended",
});
}
});
@@ -185,7 +246,7 @@ describe("setup app recommendation matcher", () => {
matches: [
{
appLabel: "Notes",
candidateId: "notes-tools",
candidateId: "@demo-owner/notes-tools",
tier: "optional",
reason,
confidence: "high",
@@ -199,7 +260,7 @@ describe("setup app recommendation matcher", () => {
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.matches[0]?.candidateId).toBe("notes-tools");
expect(result.matches[0]?.candidateId).toBe("@demo-owner/notes-tools");
expect(result.matches[0]?.reason).toBe(`${"a".repeat(118)}`);
expect(result.matches[0]?.reason.length).toBeLessThanOrEqual(120);
}
+14 -8
View File
@@ -1,3 +1,4 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import pLimit from "p-limit";
import { z } from "zod";
@@ -255,14 +256,19 @@ async function gatherSetupAppCandidates(params: {
limit: CLAWHUB_SEARCH_LIMIT,
timeoutMs: CLAWHUB_SEARCH_TIMEOUT_MS,
});
return results.slice(0, CLAWHUB_SEARCH_LIMIT).map(
(result): SetupAppCandidate => ({
id: result.slug,
displayName: result.displayName,
summary: result.summary?.trim() || "ClawHub skill",
source: "clawhub-skill",
}),
);
return results.slice(0, CLAWHUB_SEARCH_LIMIT).flatMap((result): SetupAppCandidate[] => {
const ownerHandle = normalizeOptionalString(result.ownerHandle);
return ownerHandle
? [
{
id: `@${ownerHandle}/${result.slug}`,
displayName: result.displayName,
summary: result.summary?.trim() || "ClawHub skill",
source: "clawhub-skill",
},
]
: [];
});
} catch {
return [];
}
+323 -19
View File
@@ -2,10 +2,21 @@ import { describe, expect, it, vi } from "vitest";
import { refreshOnboardRecommendationsCommand } from "../commands/onboard-recommendations.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { RuntimeEnv } from "../runtime.js";
import type { OnboardingRecommendationsRecord } from "../state/onboarding-recommendations.js";
import type {
OnboardingRecommendationMatch,
OnboardingRecommendationsRecord,
} from "../state/onboarding-recommendations.js";
import type { SetupAppRecommendationsResult } from "../system-agent/setup-app-recommendations.js";
import type { WizardPrompter } from "./prompts.js";
import { setupAppRecommendations } from "./setup.app-recommendations.js";
import { setupAppRecommendations as setupAppRecommendationsWithOutcome } from "./setup.app-recommendations.js";
async function setupAppRecommendations(
params: Parameters<typeof setupAppRecommendationsWithOutcome>[0],
): Promise<OpenClawConfig> {
const outcome = await setupAppRecommendationsWithOutcome(params);
outcome.commitResult();
return outcome.config;
}
function createPrompter(selected: string[] = []): WizardPrompter {
return {
@@ -27,10 +38,68 @@ const runtime: RuntimeEnv = {
exit: vi.fn(),
};
function storeDeps() {
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],
) => {
now += 1;
current = {
inventoryHash: "hash",
matches: [...params.matches],
offeredAt: now,
acceptedAt: params.answered ? now : null,
updatedAt: now,
};
return current;
},
);
const acknowledgeStored = vi.fn(
(
params: Parameters<
typeof import("../state/onboarding-recommendations.js").acknowledgeOnboardingRecommendations
>[0] = {},
) => {
if (
!current ||
(params.expected &&
(params.expected.inventoryHash !== current.inventoryHash ||
params.expected.updatedAt !== current.updatedAt))
) {
return null;
}
now += 1;
current = { ...current, acceptedAt: now, updatedAt: now };
return current;
},
);
const updatePendingStored = vi.fn(
(
params: Parameters<
typeof import("../state/onboarding-recommendations.js").updatePendingOnboardingRecommendations
>[0],
) => {
if (
!current ||
params.expected.inventoryHash !== current.inventoryHash ||
params.expected.updatedAt !== current.updatedAt
) {
return null;
}
now += 1;
current = { ...current, matches: [...params.matches], updatedAt: now };
return current;
},
);
return {
readStored: vi.fn((): OnboardingRecommendationsRecord | null => null),
writeOffer: vi.fn(),
readStored: vi.fn((): OnboardingRecommendationsRecord | null => current),
writeOffer,
acknowledgeStored,
updatePendingStored,
deferOfferToBootstrap: vi.fn(() => false),
};
}
@@ -52,11 +121,11 @@ function recommendationResult(): Extract<SetupAppRecommendationsResult, { status
},
{
appLabel: "Chat",
candidateId: "chat-skill",
candidateId: "@demo-owner/chat-skill",
tier: "optional" as const,
reason: "Adds useful actions",
candidate: {
id: "chat-skill",
id: "@demo-owner/chat-skill",
displayName: "Chat skill",
summary: "Chat skill",
source: "clawhub-skill" as const,
@@ -89,7 +158,9 @@ describe("setupAppRecommendations", () => {
it("short-circuits before scanning when the offer was already answered", async () => {
const recommend = vi.fn(async () => recommendationResult());
const writeOffer = vi.fn();
const clearPendingStored = vi.fn();
const prompter = createPrompter();
const legacyMatch = recommendationResult().matches[1]!;
await setupAppRecommendations({
config: {},
@@ -101,9 +172,16 @@ describe("setupAppRecommendations", () => {
deps: {
recommend,
writeOffer,
clearPendingStored,
readStored: () => ({
inventoryHash: "hash",
matches: [],
matches: [
{
...legacyMatch,
candidateId: "chat-skill",
candidate: { ...legacyMatch.candidate, id: "chat-skill" },
},
],
offeredAt: 1,
acceptedAt: 2,
updatedAt: 2,
@@ -114,6 +192,7 @@ describe("setupAppRecommendations", () => {
expect(recommend).not.toHaveBeenCalled();
expect(prompter.progress).not.toHaveBeenCalled();
expect(writeOffer).not.toHaveBeenCalled();
expect(clearPendingStored).not.toHaveBeenCalled();
});
it("scans again after the refresh command clears an answered offer", async () => {
@@ -152,8 +231,6 @@ describe("setupAppRecommendations", () => {
it("reuses a pending stored offer without rescanning and acknowledges the answer", async () => {
const recommend = vi.fn(async () => recommendationResult());
const writeOffer = vi.fn();
const acknowledgeStored = vi.fn();
const prompter = createPrompter(["recommendation:0"]);
const pending: OnboardingRecommendationsRecord = {
inventoryHash: "hash",
@@ -162,6 +239,7 @@ describe("setupAppRecommendations", () => {
acceptedAt: null,
updatedAt: 1,
};
const store = storeDeps(pending);
const ensurePlugin = vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
cfg,
installed: true as const,
@@ -176,11 +254,8 @@ describe("setupAppRecommendations", () => {
modelRouteVerified: true,
platform: "darwin",
deps: {
...store,
recommend,
writeOffer,
acknowledgeStored,
readStored: () => pending,
deferOfferToBootstrap: () => false,
ensurePlugin: ensurePlugin as never,
resolveOfficialEntry: () => ({
pluginId: "chat-plugin",
@@ -194,11 +269,56 @@ describe("setupAppRecommendations", () => {
expect(recommend).not.toHaveBeenCalled();
expect(prompter.progress).not.toHaveBeenCalled();
expect(prompter.multiselect).toHaveBeenCalledOnce();
expect(acknowledgeStored).toHaveBeenCalledOnce();
expect(writeOffer).not.toHaveBeenCalled();
expect(store.acknowledgeStored).toHaveBeenCalledOnce();
expect(store.updatePendingStored).toHaveBeenCalledWith({
matches: [pending.matches[0]],
expected: pending,
});
expect(store.updatePendingStored.mock.invocationCallOrder[0]).toBeLessThan(
ensurePlugin.mock.invocationCallOrder[0]!,
);
expect(store.writeOffer).not.toHaveBeenCalled();
expect(ensurePlugin).toHaveBeenCalledOnce();
});
it("rescans a pending offer with a bare ClawHub id", async () => {
const pendingMatches = recommendationResult().matches;
pendingMatches[1] = {
...pendingMatches[1]!,
candidateId: "chat-skill",
candidate: { ...pendingMatches[1]!.candidate, id: "chat-skill" },
};
const recommend = vi.fn(async () => recommendationResult());
const clearPendingStored = vi.fn(() => true);
await setupAppRecommendations({
config: {},
prompter: createPrompter(),
runtime,
workspaceDir: "/tmp/workspace",
modelRouteVerified: true,
platform: "darwin",
deps: {
recommend,
readStored: () => ({
inventoryHash: "hash",
matches: pendingMatches,
offeredAt: 1,
acceptedAt: null,
updatedAt: 1,
}),
writeOffer: vi.fn(),
clearPendingStored,
deferOfferToBootstrap: () => false,
},
});
expect(clearPendingStored).toHaveBeenCalledWith({
expected: expect.objectContaining({ inventoryHash: "hash", updatedAt: 1 }),
});
expect(recommend).toHaveBeenCalledOnce();
});
it("leaves a pending stored offer to the bootstrap without rescanning", async () => {
const recommend = vi.fn(async () => recommendationResult());
const writeOffer = vi.fn();
@@ -255,6 +375,7 @@ describe("setupAppRecommendations", () => {
it("preselects recommended matches and installs selected plugin and skill", async () => {
const config: OpenClawConfig = {};
const prompter = createPrompter(["recommendation:0", "recommendation:1"]);
const store = storeDeps();
const ensurePlugin = vi.fn(async () => ({
cfg: { ...config, plugins: { entries: { "chat-plugin": { enabled: true } } } },
installed: true,
@@ -268,7 +389,7 @@ describe("setupAppRecommendations", () => {
targetDir: "/tmp/workspace/skills/chat-skill",
}));
const result = await setupAppRecommendations({
const outcome = await setupAppRecommendationsWithOutcome({
config,
prompter,
runtime,
@@ -276,7 +397,7 @@ describe("setupAppRecommendations", () => {
modelRouteVerified: true,
platform: "darwin",
deps: {
...storeDeps(),
...store,
recommend: async () => recommendationResult(),
ensurePlugin,
installSkill,
@@ -293,7 +414,190 @@ describe("setupAppRecommendations", () => {
);
expect(ensurePlugin).toHaveBeenCalledOnce();
expect(installSkill).toHaveBeenCalledOnce();
expect(result.plugins?.entries?.["chat-plugin"]?.enabled).toBe(true);
expect(installSkill).toHaveBeenCalledWith(
expect.objectContaining({ slug: "@demo-owner/chat-skill" }),
);
expect(store.writeOffer).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ answered: false, matches: recommendationResult().matches }),
);
expect(store.writeOffer).toHaveBeenCalledOnce();
expect(store.writeOffer.mock.invocationCallOrder[0]).toBeLessThan(
ensurePlugin.mock.invocationCallOrder[0]!,
);
expect(store.updatePendingStored).toHaveBeenCalledWith({
matches: [recommendationResult().matches[0]],
expected: expect.objectContaining({
matches: recommendationResult().matches,
updatedAt: 1,
}),
});
expect(installSkill.mock.invocationCallOrder[0]).toBeLessThan(
store.updatePendingStored.mock.invocationCallOrder[0]!,
);
expect(store.acknowledgeStored).not.toHaveBeenCalled();
outcome.commitResult();
expect(store.acknowledgeStored).toHaveBeenCalledWith({
expected: expect.objectContaining({
inventoryHash: "hash",
matches: [recommendationResult().matches[0]],
updatedAt: 2,
}),
});
expect(store.writeOffer).toHaveBeenCalledOnce();
expect(outcome.config.plugins?.entries?.["chat-plugin"]?.enabled).toBe(true);
});
it("reoffers a failed install and consumes it after a successful retry", async () => {
const storeState: { current: OnboardingRecommendationsRecord | null } = { current: null };
let now = 0;
const writeOffer = vi.fn(
(
params: Parameters<
typeof import("../state/onboarding-recommendations.js").writeOnboardingRecommendationsOffer
>[0],
) => {
now += 1;
storeState.current = {
inventoryHash: "hash",
matches: [...params.matches],
offeredAt: now,
acceptedAt: params.answered ? now : null,
updatedAt: now,
};
return storeState.current;
},
);
const acknowledgeStored = vi.fn(
(
params: Parameters<
typeof import("../state/onboarding-recommendations.js").acknowledgeOnboardingRecommendations
>[0] = {},
) => {
if (
!storeState.current ||
(params.expected &&
(params.expected.inventoryHash !== storeState.current.inventoryHash ||
params.expected.updatedAt !== storeState.current.updatedAt))
) {
return null;
}
now += 1;
storeState.current = { ...storeState.current, acceptedAt: now, updatedAt: now };
return storeState.current;
},
);
const updatePendingStored = vi.fn(
({
matches,
expected,
}: {
matches: readonly OnboardingRecommendationMatch[];
expected: OnboardingRecommendationsRecord;
}) => {
if (
!storeState.current ||
expected.inventoryHash !== storeState.current.inventoryHash ||
expected.updatedAt !== storeState.current.updatedAt
) {
return null;
}
now += 1;
storeState.current = {
...storeState.current,
matches: [...matches],
updatedAt: now,
};
return storeState.current;
},
);
const recommend = vi.fn(async () => recommendationResult());
const installSkill = vi
.fn()
.mockResolvedValueOnce({ ok: false as const, error: "offline" })
.mockResolvedValueOnce({
ok: true as const,
slug: "chat-skill",
version: "1.0.0",
targetDir: "/tmp/workspace/skills/chat-skill",
});
const prompter = createPrompter();
vi.mocked(prompter.multiselect)
.mockResolvedValueOnce(["recommendation:1"])
.mockResolvedValueOnce(["recommendation:0"]);
const deps = {
recommend,
installSkill,
readStored: () => storeState.current,
writeOffer,
acknowledgeStored,
updatePendingStored,
deferOfferToBootstrap: () => false,
};
await setupAppRecommendations({
config: {},
prompter,
runtime,
workspaceDir: "/tmp/workspace",
modelRouteVerified: true,
platform: "darwin",
deps,
});
expect(storeState.current).toMatchObject({
acceptedAt: null,
matches: [expect.objectContaining({ candidateId: "@demo-owner/chat-skill" })],
});
await setupAppRecommendations({
config: {},
prompter,
runtime,
workspaceDir: "/tmp/workspace",
modelRouteVerified: true,
platform: "darwin",
deps,
});
expect(recommend).toHaveBeenCalledOnce();
expect(installSkill).toHaveBeenCalledTimes(2);
expect(acknowledgeStored).toHaveBeenCalledOnce();
expect(storeState.current?.acceptedAt).toBeTypeOf("number");
});
it("consumes an exact installed skill left pending by an interrupted run", async () => {
const skill = recommendationResult().matches[1]!;
const stored: OnboardingRecommendationsRecord = {
inventoryHash: "hash",
matches: [skill],
offeredAt: 1,
acceptedAt: null,
updatedAt: 1,
};
const store = storeDeps(stored);
const installSkill = vi.fn();
const outcome = await setupAppRecommendationsWithOutcome({
config: {},
prompter: createPrompter(["recommendation:0"]),
runtime,
workspaceDir: "/tmp/workspace",
modelRouteVerified: true,
platform: "darwin",
deps: {
...store,
installSkill,
isSkillInstalled: vi.fn(async ({ skillRef }) => skillRef === skill.candidate.id),
},
});
expect(installSkill).not.toHaveBeenCalled();
expect(store.acknowledgeStored).toHaveBeenCalledWith({
expected: expect.objectContaining({ matches: [skill], updatedAt: 1 }),
});
outcome.commitResult();
expect(store.acknowledgeStored).toHaveBeenCalledOnce();
});
it("installs nothing when the explicit skip entry is selected", async () => {
+161 -50
View File
@@ -6,6 +6,7 @@ import {
type OnboardingPluginInstallEntry,
} from "../commands/onboarding-plugin-install.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { fetchClawHubSkillVerification } from "../infra/clawhub.js";
import { formatErrorMessage } from "../infra/errors.js";
import { scanInstalledApps } from "../infra/installed-apps.js";
import {
@@ -15,10 +16,15 @@ import {
resolveOfficialExternalPluginLabel,
} from "../plugins/official-external-plugin-catalog.js";
import type { RuntimeEnv } from "../runtime.js";
import { installSkillFromClawHub } from "../skills/lifecycle/clawhub.js";
import {
installSkillFromClawHub,
resolveClawHubSkillVerificationTarget,
} from "../skills/lifecycle/clawhub.js";
import {
acknowledgeOnboardingRecommendations,
clearPendingOnboardingRecommendations,
readOnboardingRecommendations,
updatePendingOnboardingRecommendations,
writeOnboardingRecommendationsOffer,
type OnboardingRecommendationsRecord,
} from "../state/onboarding-recommendations.js";
@@ -36,13 +42,45 @@ type SetupAppRecommendationDeps = {
recommend?: () => Promise<SetupAppRecommendationsResult>;
ensurePlugin?: typeof ensureOnboardingPluginInstalled;
installSkill?: typeof installSkillFromClawHub;
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;
deferOfferToBootstrap?: () => boolean;
};
async function isClawHubSkillInstalled(params: {
workspaceDir: string;
skillRef: string;
}): Promise<boolean> {
const target = await resolveClawHubSkillVerificationTarget({
workspaceDir: params.workspaceDir,
slug: params.skillRef,
});
if (!target.ok || target.resolution.source !== "installed") {
return false;
}
const verification = await fetchClawHubSkillVerification({
slug: target.slug,
...(target.ownerHandle ? { ownerHandle: target.ownerHandle } : {}),
version: target.version,
baseUrl: target.baseUrl,
});
return verification.ok && verification.decision === "pass";
}
export type SetupAppRecommendationsOutcome = {
config: OpenClawConfig;
commitResult: () => void;
};
function unchangedOutcome(config: OpenClawConfig): SetupAppRecommendationsOutcome {
return { config, commitResult: () => undefined };
}
function resolveOfficialEntry(pluginId: string): OnboardingPluginInstallEntry | undefined {
const catalogEntry = listOfficialExternalPluginCatalogEntries().find(
(entry) => resolveOfficialExternalPluginId(entry) === pluginId,
@@ -87,7 +125,7 @@ export async function setupAppRecommendations(params: {
modelRouteVerified: boolean;
platform?: NodeJS.Platform;
deps?: SetupAppRecommendationDeps;
}): Promise<OpenClawConfig> {
}): Promise<SetupAppRecommendationsOutcome> {
const platform = params.platform ?? process.platform;
// Product decision: default-on "magical" scan with a kill switch, not
// consent-first. App labels/bundle ids go to the user's configured model and
@@ -98,15 +136,30 @@ export async function setupAppRecommendations(params: {
platform !== "darwin" ||
!params.modelRouteVerified
) {
return params.config;
return unchangedOutcome(params.config);
}
const readStored = params.deps?.readStored ?? readOnboardingRecommendations;
const stored = readStored();
if (typeof stored?.acceptedAt === "number") {
return params.config;
const storedRecord = readStored();
if (typeof storedRecord?.acceptedAt === "number") {
return unchangedOutcome(params.config);
}
const clearPendingStored =
params.deps?.clearPendingStored ?? clearPendingOnboardingRecommendations;
// Pending recommendations are rebuildable cache. Rescan legacy bare
// ClawHub ids instead of installing without a publisher identity.
const hasLegacyClawHubId = storedRecord?.matches.some(
(match) => match.candidate.source === "clawhub-skill" && !match.candidate.id.startsWith("@"),
);
if (hasLegacyClawHubId && storedRecord) {
if (!clearPendingStored({ expected: storedRecord })) {
return unchangedOutcome(params.config);
}
}
const stored = hasLegacyClawHubId ? null : storedRecord;
const writeOffer = params.deps?.writeOffer ?? writeOnboardingRecommendationsOffer;
const acknowledgeStored = params.deps?.acknowledgeStored ?? acknowledgeOnboardingRecommendations;
const updatePendingStored =
params.deps?.updatePendingStored ?? updatePendingOnboardingRecommendations;
const deferOfferToBootstrap =
params.deps?.deferOfferToBootstrap ??
(() => existsSync(path.join(params.workspaceDir, DEFAULT_BOOTSTRAP_FILENAME)));
@@ -116,14 +169,29 @@ export async function setupAppRecommendations(params: {
// bootstrap still owns the ask, or the wizard presents the stored matches.
let matches: SetupAppRecommendationMatch[];
let appLabels: string[];
let recordAnswer: () => void;
let pendingRecord = stored;
let recordResult: (retryMatches: SetupAppRecommendationMatch[]) => void;
const commitStoredResult = (retryMatches: SetupAppRecommendationMatch[]) => {
if (!pendingRecord) {
throw new Error("Stored onboarding recommendations changed while setup was running.");
}
const expected = pendingRecord;
const updated =
retryMatches.length === 0
? acknowledgeStored({ expected })
: updatePendingStored({ matches: retryMatches, expected });
if (!updated) {
throw new Error("Stored onboarding recommendations changed while setup was running.");
}
pendingRecord = updated;
};
if (stored) {
if (deferOfferToBootstrap()) {
return params.config;
return unchangedOutcome(params.config);
}
matches = stored.matches;
appLabels = [...new Set(stored.matches.map((match) => match.appLabel))];
recordAnswer = () => void acknowledgeStored();
recordResult = commitStoredResult;
} else {
const progress = params.prompter.progress(t("wizard.appRecommendations.scanning"));
let result: SetupAppRecommendationsResult;
@@ -139,22 +207,31 @@ export async function setupAppRecommendations(params: {
params.runtime.log(
t("wizard.appRecommendations.skipped", { reason: formatErrorMessage(error) }),
);
return params.config;
return unchangedOutcome(params.config);
}
progress.stop();
if (result.status !== "ok") {
params.runtime.log(t("wizard.appRecommendations.noneFound"));
return params.config;
return unchangedOutcome(params.config);
}
if (deferOfferToBootstrap()) {
writeOffer({ inventory: result.apps, matches: result.matches, answered: false });
return params.config;
return unchangedOutcome(params.config);
}
const scanned = result;
matches = scanned.matches;
appLabels = scanned.apps.map((app) => app.label);
recordAnswer = () =>
void writeOffer({ inventory: scanned.apps, matches: scanned.matches, answered: true });
recordResult = (retryMatches) => {
if (!pendingRecord) {
pendingRecord = writeOffer({
inventory: scanned.apps,
matches: retryMatches.length > 0 ? retryMatches : scanned.matches,
answered: retryMatches.length === 0,
});
return;
}
commitStoredResult(retryMatches);
};
}
await params.prompter.note(
@@ -194,54 +271,74 @@ export async function setupAppRecommendations(params: {
: [],
),
});
// Returning from the prompt means the user answered even when every option
// was deselected. Cancellation throws before this point.
recordAnswer();
if (selected.includes(SKIP_VALUE)) {
return params.config;
recordResult([]);
return unchangedOutcome(params.config);
}
let next = params.config;
const selectedMatches = uniqueSelectedMatches(matches, selected);
if (selectedMatches.length === 0) {
recordResult([]);
return unchangedOutcome(params.config);
}
// Persist the selected set before external installs. Unselected matches are
// explicit declines; selected matches stay retryable until each install succeeds.
recordResult(selectedMatches);
let pendingMatches = selectedMatches;
const retryMatches: SetupAppRecommendationMatch[] = [];
const ensurePlugin = params.deps?.ensurePlugin ?? ensureOnboardingPluginInstalled;
const installSkill = params.deps?.installSkill ?? installSkillFromClawHub;
for (const match of uniqueSelectedMatches(matches, selected)) {
const isSkillInstalled = params.deps?.isSkillInstalled ?? isClawHubSkillInstalled;
for (const match of selectedMatches) {
let installed = false;
try {
if (match.candidate.source === "clawhub-skill") {
const installed = await installSkill({
const alreadyInstalled = await isSkillInstalled({
workspaceDir: params.workspaceDir,
slug: match.candidate.id,
config: next,
onClawHubRisk: async () =>
await params.prompter.confirm({
message: t("wizard.appRecommendations.skillTrust", {
name: match.candidate.displayName,
}),
initialValue: false,
}),
logger: { warn: (message) => params.runtime.error(message) },
skillRef: match.candidate.id,
});
if (!installed.ok) {
throw new Error(installed.error);
if (!alreadyInstalled) {
const result = await installSkill({
workspaceDir: params.workspaceDir,
slug: match.candidate.id,
config: next,
onClawHubRisk: async () =>
await params.prompter.confirm({
message: t("wizard.appRecommendations.skillTrust", {
name: match.candidate.displayName,
}),
initialValue: false,
}),
logger: { warn: (message) => params.runtime.error(message) },
});
if (!result.ok) {
throw new Error(result.error);
}
}
} else {
const entry = (params.deps?.resolveOfficialEntry ?? resolveOfficialEntry)(
match.candidate.id,
);
if (!entry) {
throw new Error(t("wizard.appRecommendations.catalogEntryMissing"));
}
const pluginResult = await ensurePlugin({
cfg: next,
entry,
prompter: params.prompter,
runtime: params.runtime,
workspaceDir: params.workspaceDir,
promptInstall: false,
});
next = pluginResult.cfg;
if (!pluginResult.installed) {
throw new Error(pluginResult.error ?? pluginResult.status);
}
continue;
}
const entry = (params.deps?.resolveOfficialEntry ?? resolveOfficialEntry)(match.candidate.id);
if (!entry) {
throw new Error(t("wizard.appRecommendations.catalogEntryMissing"));
}
const installed = await ensurePlugin({
cfg: next,
entry,
prompter: params.prompter,
runtime: params.runtime,
workspaceDir: params.workspaceDir,
promptInstall: false,
});
next = installed.cfg;
if (!installed.installed) {
throw new Error(installed.error ?? installed.status);
}
installed = true;
} catch (error) {
retryMatches.push(match);
params.runtime.error(
t("wizard.appRecommendations.installFailed", {
name: match.candidate.displayName,
@@ -249,6 +346,20 @@ export async function setupAppRecommendations(params: {
}),
);
}
if (installed && match.candidate.source === "clawhub-skill") {
// Skill installation is already durable on disk. Checkpoint it now so a
// later crash cannot turn an existing target into a permanent retry.
pendingMatches = pendingMatches.filter((candidate) => candidate !== match);
recordResult(pendingMatches);
}
}
return next;
// Official plugin config is durable only after the caller writes `next`.
// Commit recommendation outcomes at that owner boundary, never inside the install catch.
const hasDeferredOfficialResult = selectedMatches.some(
(match) => match.candidate.source !== "clawhub-skill",
);
return {
config: next,
commitResult: hasDeferredOfficialResult ? () => recordResult(retryMatches) : () => undefined,
};
}
+5 -1
View File
@@ -657,6 +657,7 @@ async function runSetupWizardOnce(
});
}
let commitAppRecommendationResult: (() => void) | undefined;
if (flow !== "quickstart") {
const { setupOfficialPluginInstalls } = await import("./setup.official-plugins.js");
nextConfig = await setupOfficialPluginInstalls({
@@ -666,13 +667,15 @@ async function runSetupWizardOnce(
workspaceDir,
});
const { setupAppRecommendations } = await import("./setup.app-recommendations.js");
nextConfig = await setupAppRecommendations({
const recommendationOutcome = await setupAppRecommendations({
config: nextConfig,
prompter,
runtime,
workspaceDir,
modelRouteVerified: liveModelVerified,
});
nextConfig = recommendationOutcome.config;
commitAppRecommendationResult = recommendationOutcome.commitResult;
const { setupPluginConfig } = await import("./setup.plugin-config.js");
nextConfig = await setupPluginConfig({
config: nextConfig,
@@ -690,6 +693,7 @@ async function runSetupWizardOnce(
nextConfig = await writeSetupConfigFile(nextConfig, {
allowConfigSizeDrop: false,
});
commitAppRecommendationResult?.();
const { finalizeSetupWizard } = await import("./setup.finalize.js");
const finalizeResult = await finalizeSetupWizard({