mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
+11
-1
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user