Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ import type * as lib_packageSearchDigest from "../lib/packageSearchDigest.js";
import type * as lib_packageSecurity from "../lib/packageSecurity.js";
import type * as lib_packageStatEvents from "../lib/packageStatEvents.js";
import type * as lib_pluginCategoryClassification from "../lib/pluginCategoryClassification.js";
import type * as lib_pluginSearchObservations from "../lib/pluginSearchObservations.js";
import type * as lib_public from "../lib/public.js";
import type * as lib_publicBrowse from "../lib/publicBrowse.js";
import type * as lib_publicRouteReservations from "../lib/publicRouteReservations.js";
Expand Down Expand Up @@ -184,6 +185,7 @@ import type * as packagePublishRecovery from "../packagePublishRecovery.js";
import type * as packagePublishTokens from "../packagePublishTokens.js";
import type * as packages from "../packages.js";
import type * as pluginCategoryRefresh from "../pluginCategoryRefresh.js";
import type * as pluginSearchObservations from "../pluginSearchObservations.js";
import type * as prepublicationObservability from "../prepublicationObservability.js";
import type * as promotions from "../promotions.js";
import type * as promotionsFeed from "../promotionsFeed.js";
Expand Down Expand Up @@ -347,6 +349,7 @@ declare const fullApi: ApiFromModules<{
"lib/packageSecurity": typeof lib_packageSecurity;
"lib/packageStatEvents": typeof lib_packageStatEvents;
"lib/pluginCategoryClassification": typeof lib_pluginCategoryClassification;
"lib/pluginSearchObservations": typeof lib_pluginSearchObservations;
"lib/public": typeof lib_public;
"lib/publicBrowse": typeof lib_publicBrowse;
"lib/publicRouteReservations": typeof lib_publicRouteReservations;
Expand Down Expand Up @@ -414,6 +417,7 @@ declare const fullApi: ApiFromModules<{
packagePublishTokens: typeof packagePublishTokens;
packages: typeof packages;
pluginCategoryRefresh: typeof pluginCategoryRefresh;
pluginSearchObservations: typeof pluginSearchObservations;
prepublicationObservability: typeof prepublicationObservability;
promotions: typeof promotions;
promotionsFeed: typeof promotionsFeed;
Expand Down
16 changes: 16 additions & 0 deletions convex/crons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => {
const skillStatEventPruneRef = Symbol("skill-stat-event-prune");
const skillHourlyStatsPruneRef = Symbol("skill-hourly-stats-prune");
const packageStatEventPruneRef = Symbol("package-stat-event-prune");
const pluginSearchObservationPruneRef = Symbol("plugin-search-observation-prune");
const authSessionsPruneRef = Symbol("auth-sessions-prune");
const authRefreshTokensPruneRef = Symbol("auth-refresh-tokens-prune");
const publisherInvitesPruneRef = Symbol("publisher-invites-prune");
Expand All @@ -36,6 +37,7 @@ const mocks = vi.hoisted(() => {
skillStatEventPruneRef,
skillHourlyStatsPruneRef,
packageStatEventPruneRef,
pluginSearchObservationPruneRef,
authSessionsPruneRef,
authRefreshTokensPruneRef,
publisherInvitesPruneRef,
Expand Down Expand Up @@ -85,6 +87,9 @@ vi.mock("./_generated/api", () => ({
pruneProcessedPackageStatEventsInternal: mocks.packageStatEventPruneRef,
backfillPackageReleaseScansInternal: Symbol("package-scan-backfill"),
},
pluginSearchObservations: {
pruneExpiredInternal: mocks.pluginSearchObservationPruneRef,
},
publisherAbuse: {
runPublisherAbuseScoreRunInternal: mocks.publisherAbuseScoreRefreshRef,
processPublisherAbuseAutobansInternal: mocks.publisherAbuseAutobanRef,
Expand Down Expand Up @@ -369,6 +374,17 @@ describe("crons", () => {
);
});

it("prunes plugin search observations daily with the standard batch size", async () => {
await import("./crons");

expect(mocks.interval).toHaveBeenCalledWith(
"plugin-search-observations-prune",
{ hours: 24 },
mocks.pluginSearchObservationPruneRef,
{ batchSize: 500 },
);
});

it("prunes processed skill stat events daily with a seven-day retention window", async () => {
await import("./crons");

Expand Down
7 changes: 7 additions & 0 deletions convex/crons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ if (process.env.CLAWHUB_DISABLE_CRONS !== "1" && process.env.CLAWHUB_PREVIEW !==
},
);

crons.interval(
"plugin-search-observations-prune",
{ hours: 24 },
internal.pluginSearchObservations.pruneExpiredInternal,
{ batchSize: RETENTION_STANDARD_BATCH_SIZE },
);

crons.interval(
"global-stats-update",
{ hours: 24 },
Expand Down
188 changes: 188 additions & 0 deletions convex/httpApiV1.handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12678,6 +12678,194 @@ describe("httpApiV1 handlers", () => {
}
});

it.each(["clawhub-web", "openclaw-control-ui"] as const)(
"records one marked %s plugin search from the exact combined visible response",
async (source) => {
const observationWrites: Record<string, unknown>[] = [];
const runQuery = vi.fn((_, args: Record<string, unknown>) => {
if (args.family === "code-plugin") {
return [
{
score: 10,
package: {
...makeCatalogItem("weather-code", { family: "code-plugin", updatedAt: 100 }),
isOfficial: true,
},
},
];
}
if (args.family === "bundle-plugin") {
return [
{
score: 8,
package: makeCatalogItem("weather-bundle", {
family: "bundle-plugin",
updatedAt: 80,
}),
},
];
}
throw new Error(`unexpected family ${String(args.family)}`);
});
const ctx = makeCtx({
runQuery,
runMutation: (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
observationWrites.push(args);
return null;
},
});

const response = await __handlers.pluginsGetRouterV1Handler(
ctx,
new Request(
`https://example.com/api/v1/plugins/search?q=%20Weather%20%20API%20&category=tools&topic=automation&searchSource=${source}`,
),
);

expect(response.status).toBe(200);
expect(observationWrites).toEqual([
{
source,
artifactKind: "plugin",
normalizedQuery: "weather api",
category: "tools",
topic: "automation",
resultCount: 2,
officialResultCount: 1,
},
]);
},
);

it.each([
[
"oversized marked query",
`https://example.com/api/v1/plugins/search?q=${"x".repeat(257)}&searchSource=clawhub-web`,
200,
],
[
"oversized marked topic",
`https://example.com/api/v1/plugins/search?q=weather&topic=${"x".repeat(121)}&searchSource=clawhub-web`,
200,
],
[
"marked skill family",
"https://example.com/api/v1/plugins/search?q=weather&family=skill&searchSource=clawhub-web",
200,
],
[
"marked claw family",
"https://example.com/api/v1/plugins/search?q=weather&family=claw&searchSource=clawhub-web",
200,
],
["unmarked plugin request", "https://example.com/api/v1/plugins/search?q=weather", 200],
[
"unknown plugin source",
"https://example.com/api/v1/plugins/search?q=weather&searchSource=crawler",
200,
],
[
"marked generic package request",
"https://example.com/api/v1/packages/search?q=weather&searchSource=clawhub-web",
200,
],
[
"marked empty plugin query",
"https://example.com/api/v1/plugins/search?q=%20%20&searchSource=clawhub-web",
400,
],
])("does not record %s", async (_case, requestUrl, expectedStatus) => {
if (_case === "marked claw family") vi.stubEnv("CLAWHUB_EXPERIMENTAL_CLAWS", "1");
const observationWrites: Record<string, unknown>[] = [];
const ctx = makeCtx({
runQuery: vi.fn().mockResolvedValue([]),
runMutation: (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
observationWrites.push(args);
return null;
},
});
const handler = requestUrl.includes("/plugins/")
? __handlers.pluginsGetRouterV1Handler
: __handlers.packagesGetRouterV1Handler;

const response = await handler(ctx, new Request(requestUrl));

expect(response.status).toBe(expectedStatus);
expect(observationWrites).toEqual([]);
});

it("does not record a marked plugin search when result assembly fails", async () => {
const observationWrites: Record<string, unknown>[] = [];
const ctx = makeCtx({
runQuery: vi.fn().mockRejectedValue(new Error("search unavailable")),
runMutation: (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
observationWrites.push(args);
return null;
},
});

await expect(
__handlers.pluginsGetRouterV1Handler(
ctx,
new Request(
"https://example.com/api/v1/plugins/search?q=weather&searchSource=openclaw-control-ui",
),
),
).rejects.toThrow("search unavailable");
expect(observationWrites).toEqual([]);
});

it("excludes a request aborted before the completed result is recorded", async () => {
const controller = new AbortController();
const observationWrites: unknown[] = [];
const ctx = makeCtx({
runQuery: async () => {
controller.abort();
return [];
},
runMutation: (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
observationWrites.push(args);
return null;
},
});
await __handlers.pluginsGetRouterV1Handler(
ctx,
new Request("https://example.com/api/v1/plugins/search?q=weather&searchSource=clawhub-web", {
signal: controller.signal,
}),
);
expect(observationWrites).toEqual([]);
});

it("keeps search available and logs no query when observation storage fails", async () => {
const log = vi.spyOn(console, "error").mockImplementation(() => {});
const ctx = makeCtx({
runQuery: vi.fn().mockResolvedValue([]),
runMutation: (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
throw new Error("storage failed for sensitive query text");
},
});

const response = await __handlers.pluginsGetRouterV1Handler(
ctx,
new Request(
"https://example.com/api/v1/plugins/search?q=private-query&searchSource=openclaw-control-ui",
),
);

expect(response.status).toBe(200);
expect(log).toHaveBeenCalledWith(
"[plugin-search-observations] failed to record marked search",
{ source: "openclaw-control-ui" },
);
expect(JSON.stringify(log.mock.calls)).not.toContain("private-query");
});

it("plugins search forwards New eligibility to both plugin families", async () => {
const runQuery = vi.fn().mockResolvedValue([]);
const runMutation = vi.fn().mockResolvedValue(okRate());
Expand Down
44 changes: 42 additions & 2 deletions convex/httpApiV1/packagesV1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ import {
getPackageTrustReasons,
resolvePackageReleaseScanStatus,
} from "../lib/packageSecurity";
import {
buildPluginSearchObservation,
parsePluginSearchSource,
} from "../lib/pluginSearchObservations";
import type { PublicPublisher } from "../lib/public";
import {
getClawPackSizeError,
Expand Down Expand Up @@ -195,6 +199,9 @@ const internalRefs = internal as unknown as {
enqueueBulkPackageRescanBatchForAdminInternal: unknown;
getBulkPackageRescanBatchStatusForAdminInternal: unknown;
};
pluginSearchObservations: {
recordInternal: unknown;
};
publishAttempts: {
getPackagePublishAttemptStatusInternal: unknown;
};
Expand Down Expand Up @@ -3985,7 +3992,11 @@ async function getSkillVersionForRequest(
async function searchPackages(
ctx: ActionCtx,
request: Request,
options?: { includeSkills?: boolean; pluginFamilies?: Array<"code-plugin" | "bundle-plugin"> },
options?: {
includeSkills?: boolean;
pluginFamilies?: Array<"code-plugin" | "bundle-plugin">;
recordPluginSearch?: boolean;
},
) {
const rate = await applyRateLimit(ctx, request, "read");
if (!rate.ok) return rate.response;
Expand Down Expand Up @@ -4137,7 +4148,35 @@ async function searchPackages(
.sort(compareCatalogSearchEntries)
.slice(0, limit);
}
return json({ results: results.map(toPublicCatalogSearchEntry) }, 200, rate.headers);
const publicResults = results.map(toPublicCatalogSearchEntry);
if (
options?.recordPluginSearch &&
!request.signal.aborted &&
(!family || family === "code-plugin" || family === "bundle-plugin")
) {
const observation = buildPluginSearchObservation({
source: parsePluginSearchSource(url.searchParams.get("searchSource")),
query: queryText,
category,
topic,
results: publicResults,
});
if (observation) {
try {
await runMutationRef(
ctx,
internalRefs.pluginSearchObservations.recordInternal,
observation,
);
} catch {
// Search demand is optional product analytics. Never expose or log the raw query on failure.
console.error("[plugin-search-observations] failed to record marked search", {
source: observation.source,
});
}
}
}
return json({ results: publicResults }, 200, rate.headers);
}

export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Request) {
Expand Down Expand Up @@ -4939,6 +4978,7 @@ export async function pluginsGetRouterV1Handler(ctx: ActionCtx, request: Request
return await searchPackages(ctx, request, {
includeSkills: false,
pluginFamilies: ["code-plugin", "bundle-plugin"],
recordPluginSearch: true,
});
}
return text("Not found", 404);
Expand Down
Loading
Loading