From 15b8e380df7906c7d2c0afa1233ac832e819c66e Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 16 Sep 2026 13:02:26 -0700 Subject: [PATCH 1/8] feat: store editable Featured reservations and allow sixteen selections --- convex/_generated/api.d.ts | 4 + convex/featuredPublication.runtime.test.ts | 38 +++--- convex/featuredSelections.runtime.test.ts | 74 ++++++++++++ convex/featuredSelections.ts | 131 +++++++++++++++++++++ convex/lib/featuredPolicy.ts | 2 +- convex/lib/featuredSelections.ts | 62 ++++++++++ convex/lib/retentionPolicy.ts | 3 + convex/schema.ts | 12 ++ 8 files changed, 308 insertions(+), 18 deletions(-) create mode 100644 convex/featuredSelections.runtime.test.ts create mode 100644 convex/featuredSelections.ts create mode 100644 convex/lib/featuredSelections.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index f14233669f..19a684829e 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -28,6 +28,7 @@ import type * as downloads from "../downloads.js"; import type * as emailsNode from "../emailsNode.js"; import type * as featuredArtifacts from "../featuredArtifacts.js"; import type * as featuredIntelligence from "../featuredIntelligence.js"; +import type * as featuredSelections from "../featuredSelections.js"; import type * as functions from "../functions.js"; import type * as githubAccountAgeBackfill from "../githubAccountAgeBackfill.js"; import type * as githubIdentity from "../githubIdentity.js"; @@ -90,6 +91,7 @@ import type * as lib_embeddings from "../lib/embeddings.js"; import type * as lib_experimentalClaws from "../lib/experimentalClaws.js"; import type * as lib_featuredIntelligence from "../lib/featuredIntelligence.js"; import type * as lib_featuredPolicy from "../lib/featuredPolicy.js"; +import type * as lib_featuredSelections from "../lib/featuredSelections.js"; import type * as lib_githubAccount from "../lib/githubAccount.js"; import type * as lib_githubActionsOidc from "../lib/githubActionsOidc.js"; import type * as lib_githubAuth from "../lib/githubAuth.js"; @@ -282,6 +284,7 @@ declare const fullApi: ApiFromModules<{ emailsNode: typeof emailsNode; featuredArtifacts: typeof featuredArtifacts; featuredIntelligence: typeof featuredIntelligence; + featuredSelections: typeof featuredSelections; functions: typeof functions; githubAccountAgeBackfill: typeof githubAccountAgeBackfill; githubIdentity: typeof githubIdentity; @@ -344,6 +347,7 @@ declare const fullApi: ApiFromModules<{ "lib/experimentalClaws": typeof lib_experimentalClaws; "lib/featuredIntelligence": typeof lib_featuredIntelligence; "lib/featuredPolicy": typeof lib_featuredPolicy; + "lib/featuredSelections": typeof lib_featuredSelections; "lib/githubAccount": typeof lib_githubAccount; "lib/githubActionsOidc": typeof lib_githubActionsOidc; "lib/githubAuth": typeof lib_githubAuth; diff --git a/convex/featuredPublication.runtime.test.ts b/convex/featuredPublication.runtime.test.ts index d7816518c9..9fb6de9405 100644 --- a/convex/featuredPublication.runtime.test.ts +++ b/convex/featuredPublication.runtime.test.ts @@ -21,7 +21,7 @@ async function fixture() { }); await ctx.db.patch(actorUserId, { personalPublisherId: publisherId }); const items = []; - for (let i = 0; i < 9; i++) { + for (let i = 0; i < 17; i++) { const name = `workflow-${i}`; const packageId = await ctx.db.insert("packages", { name, @@ -79,9 +79,9 @@ describe("Featured publication", () => { }); }); const runBackfill = () => t.action(internal.maintenance.backfillSkillBadgeTableInternal, {}); - expect((await runBackfill()).stats).toEqual({ skillsScanned: 10, recordsInserted: 10 }); + expect((await runBackfill()).stats).toEqual({ skillsScanned: 18, recordsInserted: 18 }); const before = await t.run((ctx) => ctx.db.query("skillBadges").collect()); - expect(before.filter((badge) => badge.kind === "highlighted")).toHaveLength(9); + expect(before.filter((badge) => badge.kind === "highlighted")).toHaveLength(17); expect((await runBackfill()).stats.recordsInserted).toBe(0); expect(await t.run((ctx) => ctx.db.query("skillBadges").collect())).toEqual(before); await expect( @@ -91,15 +91,15 @@ describe("Featured publication", () => { byUserId: actorUserId, at: 2, }), - ).rejects.toThrow(/eight|8/i); + ).rejects.toThrow(/sixteen|16/i); await expect( t .withIdentity({ subject: `${actorUserId}|test-session` }) .mutation(api.skills.setBatch, { skillId: newSkillId, batch: "highlighted" }), - ).rejects.toThrow(/eight|8/i); + ).rejects.toThrow(/sixteen|16/i); }); - it("limits each catalog to eight through both UI and admin entry points, and allows replacement", async () => { + it("limits each catalog to sixteen through both UI and admin entry points, and allows replacement", async () => { const { t, actorUserId, items } = await fixture(); const staff = t.withIdentity({ subject: `${actorUserId}|test-session` }); const clawId = await t.run(async (ctx) => { @@ -114,40 +114,44 @@ describe("Featured publication", () => { }); }); await staff.mutation(api.packages.setBatch, { packageId: clawId, batch: "highlighted" }); - for (const item of items.slice(0, 8)) { + for (const item of items.slice(0, 16)) { await staff.mutation(api.packages.setBatch, { packageId: item.packageId, batch: "highlighted", }); await staff.mutation(api.skills.setBatch, { skillId: item.skillId, batch: "highlighted" }); } - const ninth = items[8]; + const seventeenth = items[16]; for (const operation of [ () => - staff.mutation(api.packages.setBatch, { packageId: ninth.packageId, batch: "highlighted" }), - () => staff.mutation(api.skills.setBatch, { skillId: ninth.skillId, batch: "highlighted" }), + staff.mutation(api.packages.setBatch, { + packageId: seventeenth.packageId, + batch: "highlighted", + }), + () => + staff.mutation(api.skills.setBatch, { skillId: seventeenth.skillId, batch: "highlighted" }), () => t.mutation(internal.packages.setPackageFeaturedForUserInternal, { actorUserId, - name: ninth.name, + name: seventeenth.name, featured: true, }), () => t.mutation(internal.skills.setSkillFeaturedForUserInternal, { actorUserId, - slug: ninth.name, + slug: seventeenth.name, ownerHandle: "curator", featured: true, }), () => t.mutation(internal.maintenance.upsertSkillBadgeRecordInternal, { - skillId: ninth.skillId, + skillId: seventeenth.skillId, kind: "highlighted", byUserId: actorUserId, at: 2, }), ]) - await expect(operation()).rejects.toThrow(/eight|8/i); + await expect(operation()).rejects.toThrow(/sixteen|16/i); await t.mutation(internal.packages.setPackageFeaturedForUserInternal, { actorUserId, @@ -162,12 +166,12 @@ describe("Featured publication", () => { }); await t.mutation(internal.packages.setPackageFeaturedForUserInternal, { actorUserId, - name: ninth.name, + name: seventeenth.name, featured: true, }); await t.mutation(internal.skills.setSkillFeaturedForUserInternal, { actorUserId, - slug: ninth.name, + slug: seventeenth.name, ownerHandle: "curator", featured: true, }); @@ -176,7 +180,7 @@ describe("Featured publication", () => { skills: (await ctx.db.query("skillBadges").collect()).length, })); // Claws use the same badge table but are a separate catalog. - expect(counts).toEqual({ plugins: 9, skills: 8 }); + expect(counts).toEqual({ plugins: 17, skills: 16 }); }); it("rejects excluded install purposes through UI and admin publication while allowing removal", async () => { diff --git a/convex/featuredSelections.runtime.test.ts b/convex/featuredSelections.runtime.test.ts new file mode 100644 index 0000000000..f16477ed2e --- /dev/null +++ b/convex/featuredSelections.runtime.test.ts @@ -0,0 +1,74 @@ +/// +/* @vitest-environment edge-runtime */ +import { convexTest } from "convex-test"; +import { describe, expect, it } from "vitest"; +import { api } from "./_generated/api"; +import schema from "./schema"; + +const modules = import.meta.glob("./**/*.ts"); +const reservation = { + id: "plugin:community-workflow", + name: "community-workflow", + displayName: "Community workflow", + reason: "An editorial choice that remains reserved before publication.", +}; + +async function fixture() { + const t = convexTest(schema, modules); + const actor = await t.run((ctx) => + ctx.db.insert("users", { handle: "curator", role: "moderator" }), + ); + return { t, staff: t.withIdentity({ subject: `${actor}|test-session` }) }; +} + +describe("editable Featured reservations", () => { + it("reserves missing editorial identities without publishing, and rejects a stale overwrite", async () => { + const { t, staff } = await fixture(); + await staff.mutation(api.featuredSelections.saveEditorial, { + expectedRevision: 0, + items: [reservation], + }); + const saved = await staff.query(api.featuredSelections.get, { artifactKind: "plugin" }); + expect(saved).toMatchObject({ + revision: 1, + editorial: [reservation], + published: null, + reservations: [ + { ...reservation, currentArtifact: null, pendingReasons: ["not-in-public-catalog"] }, + ], + }); + await expect( + staff.mutation(api.featuredSelections.saveEditorial, { expectedRevision: 0, items: [] }), + ).rejects.toThrow(/changed|revision/i); + expect( + (await staff.query(api.featuredSelections.get, { artifactKind: "plugin" })).editorial, + ).toEqual([reservation]); + expect(await t.run((ctx) => ctx.db.query("packageBadges").collect())).toEqual([]); + expect( + (await staff.query(api.featuredSelections.get, { artifactKind: "skill" })).editorial, + ).toEqual([]); + }); + + it("rejects anonymous edits, duplicate identities, invalid identities and more than eight reservations", async () => { + const { t, staff } = await fixture(); + await expect( + t.mutation(api.featuredSelections.saveEditorial, { expectedRevision: 0, items: [] }), + ).rejects.toThrow(/Unauthorized/); + for (const items of [ + [reservation, reservation], + [{ ...reservation, id: "clawhub:other" }], + Array.from({ length: 9 }, (_, i) => ({ + ...reservation, + id: `plugin:workflow-${i}`, + name: `workflow-${i}`, + })), + ]) { + await expect( + staff.mutation(api.featuredSelections.saveEditorial, { expectedRevision: 0, items }), + ).rejects.toThrow(); + } + expect( + (await staff.query(api.featuredSelections.get, { artifactKind: "plugin" })).revision, + ).toBe(0); + }); +}); diff --git a/convex/featuredSelections.ts b/convex/featuredSelections.ts new file mode 100644 index 0000000000..a8b8bdae79 --- /dev/null +++ b/convex/featuredSelections.ts @@ -0,0 +1,131 @@ +import { ConvexError, v } from "convex/values"; +import { internal } from "./_generated/api"; +import type { Doc, Id } from "./_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { internalMutation, internalQuery, mutation, query } from "./functions"; +import { assertModerator, requireUser } from "./lib/access"; +import { + editorialSelection, + validateEditorial, + type EditorialSelection, +} from "./lib/featuredSelections"; +import { searchArtifactKind, type SearchCurrentResult } from "./lib/searchInsights"; + +type Kind = "plugin" | "skill"; +type SelectionState = { + artifactKind: Kind; + revision: number; + editorial: EditorialSelection[]; + published: Doc<"featuredSelections">["published"] | null; + reservations: Array< + EditorialSelection & { currentArtifact: SearchCurrentResult | null; pendingReasons: string[] } + >; +}; + +async function read(ctx: Pick, artifactKind: Kind) { + return ctx.db + .query("featuredSelections") + .withIndex("by_artifact_kind", (q) => q.eq("artifactKind", artifactKind)) + .unique(); +} +async function requireActor(ctx: Pick, actorUserId: Id<"users">) { + const actor = await ctx.db.get(actorUserId); + if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized"); + assertModerator(actor); + return actor; +} +async function getState(ctx: QueryCtx, artifactKind: Kind): Promise { + const row = await read(ctx, artifactKind); + const editorial = row?.editorial ?? []; + const current: SearchCurrentResult[] = editorial.length + ? await ctx.runQuery(internal.featuredArtifacts.readInternal, { + identities: editorial.map((item) => item.id), + }) + : []; + const artifacts = new Map(current.map((item) => [item.id, item])); + return { + artifactKind, + revision: row?.revision ?? 0, + editorial, + published: row?.published ?? null, + reservations: editorial.map((item) => { + const currentArtifact = artifacts.get(item.id) ?? null; + return { + ...item, + currentArtifact, + pendingReasons: currentArtifact?.eligibilityReasons ?? ["not-in-public-catalog"], + }; + }), + }; +} +export const get = query({ + args: { artifactKind: searchArtifactKind }, + handler: async (ctx, { artifactKind }): Promise => { + const { user } = await requireUser(ctx); + assertModerator(user); + return getState(ctx, artifactKind); + }, +}); +export const getForUserInternal = internalQuery({ + args: { actorUserId: v.id("users"), artifactKind: searchArtifactKind }, + handler: async (ctx, args): Promise => { + await requireActor(ctx, args.actorUserId); + return getState(ctx, args.artifactKind); + }, +}); +export const readEditorialInternal = internalQuery({ + args: { artifactKind: searchArtifactKind }, + handler: async ( + ctx, + { artifactKind }, + ): Promise<{ revision: number; items: EditorialSelection[] }> => { + const row = await read(ctx, artifactKind); + return { revision: row?.revision ?? 0, items: row?.editorial ?? [] }; + }, +}); +const saveArgs = { expectedRevision: v.number(), items: v.array(editorialSelection) }; +async function save( + ctx: MutationCtx, + actorUserId: Id<"users">, + args: { expectedRevision: number; items: EditorialSelection[] }, +): Promise<{ revision: number }> { + validateEditorial(args.items); + const row = await read(ctx, "plugin"); + if ((row?.revision ?? 0) !== args.expectedRevision) + throw new ConvexError("Editorial revision changed. Reload before saving."); + const revision = args.expectedRevision + 1; + const now = Date.now(); + const fields = { + artifactKind: "plugin" as const, + revision, + editorial: args.items, + updatedAt: now, + updatedBy: actorUserId, + }; + if (row) await ctx.db.patch(row._id, fields); + else await ctx.db.insert("featuredSelections", fields); + await ctx.db.insert("auditLogs", { + actorUserId, + action: "featured.editorial.save", + targetType: "featuredSelection", + targetId: "plugin", + metadata: { revision, before: row?.editorial ?? [], after: args.items }, + createdAt: now, + }); + return { revision }; +} +export const saveEditorial = mutation({ + args: saveArgs, + handler: async (ctx, args): Promise<{ revision: number }> => { + const { userId, user } = await requireUser(ctx); + assertModerator(user); + return save(ctx, userId, args); + }, +}); +export const saveEditorialForUserInternal = internalMutation({ + args: { ...saveArgs, actorUserId: v.id("users") }, + handler: async (ctx, args): Promise<{ revision: number }> => { + await requireActor(ctx, args.actorUserId); + return save(ctx, args.actorUserId, args); + }, +}); diff --git a/convex/lib/featuredPolicy.ts b/convex/lib/featuredPolicy.ts index 250e4e68c2..4436f052ca 100644 --- a/convex/lib/featuredPolicy.ts +++ b/convex/lib/featuredPolicy.ts @@ -1,7 +1,7 @@ import { ConvexError } from "convex/values"; import type { MutationCtx } from "../_generated/server"; -export const FEATURED_CATALOG_SIZE = 8; +export const FEATURED_CATALOG_SIZE = 16; export async function assertFeaturedCapacity( ctx: Pick, diff --git a/convex/lib/featuredSelections.ts b/convex/lib/featuredSelections.ts new file mode 100644 index 0000000000..b7310d1953 --- /dev/null +++ b/convex/lib/featuredSelections.ts @@ -0,0 +1,62 @@ +import { ConvexError, v, type Infer } from "convex/values"; +import type { QueryCtx } from "../_generated/server"; + +export const FEATURED_EDITORIAL_SLOTS = 8; +export const editorialSelection = v.object({ + id: v.string(), + name: v.string(), + displayName: v.string(), + reason: v.string(), +}); +export type EditorialSelection = Infer; +export const publishedSelection = v.object({ + id: v.string(), + version: v.string(), + selectionBasis: v.union(v.literal("editorial"), v.literal("telemetry")), + reason: v.string(), + installs30d: v.optional(v.number()), + installs7d: v.optional(v.number()), +}); +export const featuredPublication = v.object({ + items: v.array(publishedSelection), + periodStart: v.number(), + periodEnd: v.number(), + at: v.number(), + byUserId: v.id("users"), +}); + +export function validateEditorial(items: EditorialSelection[]) { + if (items.length > FEATURED_EDITORIAL_SLOTS) + throw new ConvexError("At most eight editorial reservations are allowed."); + const identities = new Set(); + for (const item of items) { + if ( + !item.name || + item.name.length > 200 || + item.name !== item.name.trim().toLowerCase() || + item.id !== `plugin:${item.name}` || + identities.has(item.id) || + !item.displayName.trim() || + item.displayName.length > 120 || + !item.reason.trim() || + item.reason.length > 500 + ) + throw new ConvexError( + "Editorial selections need unique plugin identities, names and bounded reasons.", + ); + identities.add(item.id); + } +} + +// Badges own membership. This snapshot orders only badges still publicly eligible; +// changing editorial reservations does not reorder or publish the public catalog. +export async function readPublishedFeaturedOrder( + ctx: Pick, + artifactKind: "plugin" | "skill", +): Promise { + const selection = await ctx.db + .query("featuredSelections") + .withIndex("by_artifact_kind", (q) => q.eq("artifactKind", artifactKind)) + .unique(); + return selection?.published?.items.map((item) => item.id) ?? []; +} diff --git a/convex/lib/retentionPolicy.ts b/convex/lib/retentionPolicy.ts index 799af45144..39505fe2e5 100644 --- a/convex/lib/retentionPolicy.ts +++ b/convex/lib/retentionPolicy.ts @@ -352,6 +352,9 @@ export const RETENTION_POLICIES = { catalogFeedPublications: permanent("Current published hosted catalog feed snapshot."), stars: permanent("User star records."), promotions: permanent("Curated promotional offers; ended records stay for launch-page history."), + featuredSelections: permanent( + "Staff editorial reservations and the current approved publication order.", + ), auditLogs: permanent("Audit logs are durable compliance/security history."), systemSettings: permanent("Durable operator-controlled system settings."), skillsShCatalogControls: permanent("Durable skills.sh catalog operator controls."), diff --git a/convex/schema.ts b/convex/schema.ts index 164702080c..bf97be1523 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -7,6 +7,7 @@ import { canonicalTrendingSourceRefValidator, } from "./lib/canonicalTrending"; import { EMBEDDING_DIMENSIONS } from "./lib/embeddings"; +import { editorialSelection, featuredPublication } from "./lib/featuredSelections"; import { pluginCategoryClassificationValidator, pluginCategoryReviewValidator, @@ -1418,6 +1419,16 @@ const skillBadges = defineTable({ .index("by_skill_kind", ["skillId", "kind"]) .index("by_kind_at", ["kind", "at"]); +// Two bounded catalog records; reservations are editable product data. +const featuredSelections = defineTable({ + artifactKind: searchArtifactKind, + revision: v.number(), + editorial: v.array(editorialSelection), + published: v.optional(featuredPublication), + updatedAt: v.number(), + updatedBy: v.id("users"), +}).index("by_artifact_kind", ["artifactKind"]); + const packageBadges = defineTable({ packageId: v.id("packages"), kind: v.union(v.literal("highlighted")), @@ -4697,6 +4708,7 @@ export default defineSchema({ packagePublishTokens, packagePublishUploadTickets, packageBadges, + featuredSelections, packageSearchDigest, packageTopicSearchDigest, packagePluginCategorySearchDigest, From e303935d7038a9bb50f72cd6d1528fc97b2e9d69 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 16 Sep 2026 13:12:20 -0700 Subject: [PATCH 2/8] fix: omit Convex stack frames from staff validation errors --- convex/httpApiV1.shared.test.ts | 2 +- convex/httpApiV1/shared.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/convex/httpApiV1.shared.test.ts b/convex/httpApiV1.shared.test.ts index 57623ff597..c7fe77163d 100644 --- a/convex/httpApiV1.shared.test.ts +++ b/convex/httpApiV1.shared.test.ts @@ -23,7 +23,7 @@ describe("http API v1 shared helpers", () => { expect( formatUserFacingErrorMessage( new Error( - "[CONVEX A] [Request ID: abc] Server Error Called by client Uncaught ConvexError: Bad publish payload", + "[CONVEX A] [Request ID: abc] Server Error Called by client Uncaught ConvexError: Bad publish payload\n at save (../convex/featuredSelections.ts:100:19)\n at async handler (functions.js:2:7)", ), "Request failed", ), diff --git a/convex/httpApiV1/shared.ts b/convex/httpApiV1/shared.ts index 46d6942041..44bf493189 100644 --- a/convex/httpApiV1/shared.ts +++ b/convex/httpApiV1/shared.ts @@ -609,6 +609,7 @@ export function cleanUserFacingErrorMessage(message: string) { let cleaned = message .replace(/\[CONVEX[^\]]*\]\s*/g, "") .replace(/\[Request ID:[^\]]*\]\s*/g, "") + .replace(/\n\s+at [\s\S]*$/, "") .replace(/^Server Error Called by client\s*/i, "") .trim(); From 970c0122417462803aa56bf1d091e018db69065c Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 16 Sep 2026 13:12:20 -0700 Subject: [PATCH 3/8] feat: publish approved Featured lineups through audited staff API --- convex/_generated/api.d.ts | 2 + convex/featuredSelections.runtime.test.ts | 194 ++++++++++++++++++ convex/featuredSelections.ts | 188 +++++++++++++++++ convex/http.ts | 4 + convex/httpApiV1.ts | 3 + convex/httpApiV1/featuredV1.ts | 89 ++++++++ convex/packages.ts | 2 +- convex/skills.ts | 5 +- docs/http-api.md | 42 ++++ packages/clawhub-admin/src/cli.ts | 22 ++ .../src/commands/featuredSelections.ts | 45 ++++ packages/clawhub/src/schema/routes.ts | 1 + packages/schema/dist/featuredSelections.d.ts | 24 +++ packages/schema/dist/featuredSelections.js | 21 ++ .../schema/dist/featuredSelections.js.map | 1 + packages/schema/dist/index.d.ts | 1 + packages/schema/dist/index.js | 1 + packages/schema/dist/index.js.map | 2 +- packages/schema/dist/routes.d.ts | 1 + packages/schema/dist/routes.js | 1 + packages/schema/dist/routes.js.map | 2 +- packages/schema/src/featuredSelections.ts | 36 ++++ packages/schema/src/index.ts | 1 + packages/schema/src/routes.ts | 1 + 24 files changed, 684 insertions(+), 5 deletions(-) create mode 100644 convex/httpApiV1/featuredV1.ts create mode 100644 packages/clawhub-admin/src/commands/featuredSelections.ts create mode 100644 packages/schema/dist/featuredSelections.d.ts create mode 100644 packages/schema/dist/featuredSelections.js create mode 100644 packages/schema/dist/featuredSelections.js.map create mode 100644 packages/schema/src/featuredSelections.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 19a684829e..66bc1363a9 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -43,6 +43,7 @@ import type * as httpApiV1 from "../httpApiV1.js"; import type * as httpApiV1_catalogFeedV1 from "../httpApiV1/catalogFeedV1.js"; import type * as httpApiV1_contentRightsV1 from "../httpApiV1/contentRightsV1.js"; import type * as httpApiV1_docsSessionV1 from "../httpApiV1/docsSessionV1.js"; +import type * as httpApiV1_featuredV1 from "../httpApiV1/featuredV1.js"; import type * as httpApiV1_packagePublishRecoveryV1 from "../httpApiV1/packagePublishRecoveryV1.js"; import type * as httpApiV1_packagesV1 from "../httpApiV1/packagesV1.js"; import type * as httpApiV1_promotionsV1 from "../httpApiV1/promotionsV1.js"; @@ -299,6 +300,7 @@ declare const fullApi: ApiFromModules<{ "httpApiV1/catalogFeedV1": typeof httpApiV1_catalogFeedV1; "httpApiV1/contentRightsV1": typeof httpApiV1_contentRightsV1; "httpApiV1/docsSessionV1": typeof httpApiV1_docsSessionV1; + "httpApiV1/featuredV1": typeof httpApiV1_featuredV1; "httpApiV1/packagePublishRecoveryV1": typeof httpApiV1_packagePublishRecoveryV1; "httpApiV1/packagesV1": typeof httpApiV1_packagesV1; "httpApiV1/promotionsV1": typeof httpApiV1_promotionsV1; diff --git a/convex/featuredSelections.runtime.test.ts b/convex/featuredSelections.runtime.test.ts index f16477ed2e..855414046a 100644 --- a/convex/featuredSelections.runtime.test.ts +++ b/convex/featuredSelections.runtime.test.ts @@ -1,8 +1,10 @@ /// /* @vitest-environment edge-runtime */ +import { register as registerRateLimiter } from "@convex-dev/rate-limiter/test"; import { convexTest } from "convex-test"; import { describe, expect, it } from "vitest"; import { api } from "./_generated/api"; +import { hashToken } from "./lib/tokens"; import schema from "./schema"; const modules = import.meta.glob("./**/*.ts"); @@ -15,6 +17,7 @@ const reservation = { async function fixture() { const t = convexTest(schema, modules); + registerRateLimiter(t); const actor = await t.run((ctx) => ctx.db.insert("users", { handle: "curator", role: "moderator" }), ); @@ -72,3 +75,194 @@ describe("editable Featured reservations", () => { ).toBe(0); }); }); + +it("publishes an approved lineup atomically, preserving retained badges and rejecting changed versions", async () => { + const { t, staff } = await fixture(); + const actorUserId = await t.run(async (ctx) => (await ctx.db.query("users").first())!._id); + for (const artifactKind of ["plugin", "skill"] as const) { + const items = await t.run(async (ctx) => { + const items = []; + const storageId = await ctx.storage.store(new Blob(["fixture"])); + const files = [{ path: "SKILL.md", size: 7, storageId, sha256: "a".repeat(64) }]; + for (let i = 0; i < 17; i++) { + const name = `workflow-${i}`; + let id: string; + if (artifactKind === "plugin") { + const packageId = await ctx.db.insert("packages", { + name, + normalizedName: name, + displayName: name, + family: "code-plugin", + ownerUserId: actorUserId, + channel: "community", + isOfficial: false, + categories: ["developer-tools"], + tags: {}, + scanStatus: "clean", + stats: { downloads: 1, installs: 1, stars: 0, versions: 1 }, + createdAt: 1, + updatedAt: 1, + }); + const releaseId = await ctx.db.insert("packageReleases", { + packageId, + version: "1.0.0", + changelog: "Initial", + distTags: ["latest"], + files, + integritySha256: "a".repeat(64), + verification: { tier: "structural", scope: "artifact-only", scanStatus: "clean" }, + createdBy: actorUserId, + createdAt: 1, + }); + await ctx.db.patch(packageId, { + latestReleaseId: releaseId, + tags: { latest: releaseId }, + }); + if (i === 0 || i === 16) + await ctx.db.insert("packageBadges", { + packageId, + kind: "highlighted", + byUserId: actorUserId, + at: 1, + }); + id = `plugin:${name}`; + } else { + const skillId = await ctx.db.insert("skills", { + slug: name, + displayName: name, + ownerUserId: actorUserId, + tags: {}, + stats: { comments: 0, downloads: 1, stars: 0, versions: 1 }, + createdAt: 1, + updatedAt: 1, + }); + const versionId = await ctx.db.insert("skillVersions", { + skillId, + version: "1.0.0", + changelog: "Initial", + files, + parsed: { frontmatter: {} }, + createdBy: actorUserId, + createdAt: 1, + llmAnalysis: { status: "clean", checkedAt: 1 }, + }); + await ctx.db.patch(skillId, { latestVersionId: versionId }); + if (i === 0 || i === 16) + await ctx.db.insert("skillBadges", { + skillId, + kind: "highlighted", + byUserId: actorUserId, + at: 1, + }); + id = `clawhub:${skillId}`; + } + items.push({ + id, + version: "1.0.0", + selectionBasis: + artifactKind === "plugin" && i < 8 ? ("editorial" as const) : ("telemetry" as const), + reason: "Approved fixture selection", + ...(artifactKind === "skill" || i >= 8 ? { installs30d: 40 - i, installs7d: 2 } : {}), + }); + } + return items; + }); + if (artifactKind === "plugin") + await staff.mutation(api.featuredSelections.saveEditorial, { + expectedRevision: 0, + items: items.slice(0, 8).map((item) => ({ + id: item.id, + name: item.id.slice(7), + displayName: item.id.slice(7), + reason: item.reason, + })), + }); + const payload = { + artifactKind, + expectedEditorialRevision: artifactKind === "plugin" ? 1 : 0, + expectedPublicationAt: null, + periodStart: Date.UTC(2026, 7, 17), + periodEnd: Date.UTC(2026, 8, 16), + items: items.slice(0, 16), + }; + const snapshot = () => + t.run(async (ctx) => ({ + badges: await ctx.db + .query(artifactKind === "plugin" ? "packageBadges" : "skillBadges") + .collect(), + audits: await ctx.db.query("auditLogs").collect(), + scheduled: await ctx.db.system.query("_scheduled_functions").collect(), + })); + const before = await snapshot(); + await expect( + staff.mutation(api.featuredSelections.publish, { + ...payload, + dryRun: false, + items: payload.items.map((item, i) => + i === 15 ? { ...item, version: "old-version" } : item, + ), + }), + ).rejects.toThrow(/version changed/i); + expect(await snapshot()).toEqual(before); + await staff.mutation(api.featuredSelections.publish, { ...payload, dryRun: true }); + expect(await snapshot()).toEqual(before); + await staff.mutation(api.featuredSelections.publish, { ...payload, dryRun: false }); + const state = await staff.query(api.featuredSelections.get, { artifactKind }); + expect(state.published?.items).toEqual(payload.items); + const after = await snapshot(); + expect(after.badges).toHaveLength(16); + expect(after.badges.find((badge) => badge._id === before.badges[0]._id)).toEqual( + before.badges[0], + ); + expect(after.badges.some((badge) => badge._id === before.badges[1]._id)).toBe(false); + expect(after.scheduled).toEqual(before.scheduled); + await expect( + staff.mutation(api.featuredSelections.publish, { ...payload, dryRun: false }), + ).rejects.toThrow(/publication changed/i); + } +}); + +it("exposes revision-checked editorial changes only to authenticated staff over HTTP", async () => { + const { t } = await fixture(); + await t.run(async (ctx) => { + const moderator = (await ctx.db.query("users").first())!; + const ordinary = await ctx.db.insert("users", { handle: "reader", role: "user" }); + for (const [userId, token] of [ + [moderator._id, "staff-fixture"], + [ordinary, "reader-fixture"], + ] as const) { + await ctx.db.insert("apiTokens", { + userId, + label: "fixture", + prefix: "fixture", + tokenHash: await hashToken(token), + createdAt: 1, + }); + } + }); + const path = "/api/v1/featured/plugin"; + expect((await t.fetch(path)).status).toBe(401); + expect( + (await t.fetch(path, { headers: { Authorization: "Bearer reader-fixture" } })).status, + ).toBe(403); + const headers = { Authorization: "Bearer staff-fixture", "Content-Type": "application/json" }; + const initial = await t.fetch(path, { headers }); + expect(initial.headers.get("Cache-Control")).toBe("private, no-store"); + expect(await initial.json()).toMatchObject({ revision: 0, editorial: [] }); + const save = () => + t.fetch(`${path}/editorial`, { + method: "POST", + headers, + body: JSON.stringify({ expectedRevision: 0, items: [reservation] }), + }); + expect((await save()).status).toBe(200); + expect((await save()).status).toBe(409); + expect(await (await t.fetch(path, { headers })).json()).toMatchObject({ + revision: 1, + editorial: [reservation], + }); + expect( + (await t.fetch("/api/v1/featured/skill/editorial", { method: "POST", headers, body: "{}" })) + .status, + ).toBe(404); +}); diff --git a/convex/featuredSelections.ts b/convex/featuredSelections.ts index a8b8bdae79..fac6a82252 100644 --- a/convex/featuredSelections.ts +++ b/convex/featuredSelections.ts @@ -4,12 +4,17 @@ import type { Doc, Id } from "./_generated/dataModel"; import type { MutationCtx, QueryCtx } from "./_generated/server"; import { internalMutation, internalQuery, mutation, query } from "./functions"; import { assertModerator, requireUser } from "./lib/access"; +import { FEATURED_CATALOG_SIZE } from "./lib/featuredPolicy"; import { + FEATURED_EDITORIAL_SLOTS, editorialSelection, + publishedSelection, validateEditorial, type EditorialSelection, } from "./lib/featuredSelections"; import { searchArtifactKind, type SearchCurrentResult } from "./lib/searchInsights"; +import { setPackageFeaturedForActor } from "./packages"; +import { setSkillFeaturedForActor } from "./skills"; type Kind = "plugin" | "skill"; type SelectionState = { @@ -129,3 +134,186 @@ export const saveEditorialForUserInternal = internalMutation({ return save(ctx, args.actorUserId, args); }, }); + +const publishArgs = { + artifactKind: searchArtifactKind, + expectedEditorialRevision: v.number(), + expectedPublicationAt: v.union(v.number(), v.null()), + periodStart: v.number(), + periodEnd: v.number(), + items: v.array(publishedSelection), + dryRun: v.boolean(), +}; +type PublishInput = { + artifactKind: Kind; + expectedEditorialRevision: number; + expectedPublicationAt: number | null; + periodStart: number; + periodEnd: number; + items: NonNullable["published"]>["items"]; + dryRun: boolean; +}; +type PublishResult = { + ok: true; + dryRun: boolean; + artifactKind: Kind; + identities: string[]; + removed: string[]; + publishedAt: number | null; +}; + +async function publishForActor( + ctx: MutationCtx, + actor: Doc<"users">, + args: PublishInput, +): Promise { + const row = await read(ctx, args.artifactKind); + if ((row?.revision ?? 0) !== args.expectedEditorialRevision) + throw new ConvexError("Editorial revision changed. Generate and review a new proposal."); + if ((row?.published?.at ?? null) !== args.expectedPublicationAt) + throw new ConvexError("Featured publication changed. Reload and review before publishing."); + const DAY = 86_400_000; + if ( + !Number.isSafeInteger(args.periodStart) || + !Number.isSafeInteger(args.periodEnd) || + args.periodStart % DAY || + args.periodEnd % DAY || + args.periodEnd - args.periodStart !== 30 * DAY || + args.periodEnd > Math.floor(Date.now() / DAY) * DAY + ) + throw new ConvexError("Use exactly 30 completed UTC days for publication evidence."); + if ( + args.items.length !== FEATURED_CATALOG_SIZE || + new Set(args.items.map((item) => item.id)).size !== args.items.length + ) + throw new ConvexError("Publish exactly sixteen distinct reviewed selections."); + const editorial = args.artifactKind === "plugin" ? (row?.editorial ?? []) : []; + if (args.artifactKind === "plugin" && editorial.length !== FEATURED_EDITORIAL_SLOTS) + throw new ConvexError("Save all eight editorial reservations before publishing plugins."); + for (const [index, item] of args.items.entries()) { + const isEditorial = index < editorial.length; + if ( + !item.id.startsWith(args.artifactKind === "plugin" ? "plugin:" : "clawhub:") || + item.id.length > 300 || + !item.version || + item.version.length > 120 || + !item.reason.trim() || + item.reason.length > 500 || + item.selectionBasis !== (isEditorial ? "editorial" : "telemetry") || + (isEditorial && item.id !== editorial[index].id) + ) + throw new ConvexError( + "Selection identities, order and provenance must match the reviewed catalog and editorial reservations.", + ); + if ( + !isEditorial && + (!Number.isSafeInteger(item.installs30d) || + !Number.isSafeInteger(item.installs7d) || + item.installs30d! <= 0 || + item.installs7d! < 0 || + item.installs7d! > item.installs30d!) + ) + throw new ConvexError( + "Telemetry selections require positive recorded installs and valid final-seven-day counts.", + ); + } + // All eligibility reads and badge changes belong to one transaction. A scan, + // version, editorial edit or competing publication cannot race this approval. + const current: SearchCurrentResult[] = await ctx.runQuery( + internal.featuredArtifacts.readInternal, + { identities: args.items.map((item) => item.id) }, + ); + const artifacts = new Map(current.map((item) => [item.id, item])); + for (const item of args.items) { + const artifact = artifacts.get(item.id); + if (!artifact?.eligibleForFeatured) + throw new ConvexError( + `${item.id} is not eligible: ${artifact?.eligibilityReasons.join(", ") ?? "not-in-public-catalog"}`, + ); + if (artifact.version !== item.version) + throw new ConvexError( + `${item.id} version changed: reviewed ${item.version}, current ${artifact.version}. Review it before publishing.`, + ); + } + const baseline = await ctx.runQuery(internal.featuredArtifacts.readCurrentFeaturedInternal, { + artifactKind: args.artifactKind, + }); + const selected = new Set(args.items.map((item) => item.id)); + const removed = baseline.filter((item) => !selected.has(item.id)).map((item) => item.id); + const result = { + ok: true as const, + dryRun: args.dryRun, + artifactKind: args.artifactKind, + identities: args.items.map((item) => item.id), + removed, + }; + if (args.dryRun) return { ...result, publishedAt: null }; + for (const id of [...removed, ...selected]) { + const featured = selected.has(id); + if (args.artifactKind === "plugin") { + const pkg = await ctx.db + .query("packages") + .withIndex("by_name", (q) => q.eq("normalizedName", id.slice(7))) + .unique(); + if (!pkg) throw new ConvexError(`Missing catalog entry: ${id}`); + await setPackageFeaturedForActor(ctx, actor, pkg, featured); + } else { + const skillId = ctx.db.normalizeId("skills", id.slice(8)); + const skill = skillId ? await ctx.db.get(skillId) : null; + if (!skill) throw new ConvexError(`Missing catalog entry: ${id}`); + // Publishing the reviewed set is curation, not authorization to send messages. + await setSkillFeaturedForActor( + ctx, + actor, + skill, + featured ? "highlighted" : undefined, + false, + ); + } + } + const now = Math.max(Date.now(), (row?.published?.at ?? 0) + 1); + const published = { + items: args.items, + periodStart: args.periodStart, + periodEnd: args.periodEnd, + at: now, + byUserId: actor._id, + }; + if (row) await ctx.db.patch(row._id, { published, updatedAt: now, updatedBy: actor._id }); + else + await ctx.db.insert("featuredSelections", { + artifactKind: args.artifactKind, + revision: 0, + editorial: [], + published, + updatedAt: now, + updatedBy: actor._id, + }); + await ctx.db.insert("auditLogs", { + actorUserId: actor._id, + action: "featured.selection.publish", + targetType: "featuredSelection", + targetId: args.artifactKind, + metadata: { + editorialRevision: args.expectedEditorialRevision, + before: baseline.map((item) => item.id), + published, + }, + createdAt: now, + }); + return { ...result, publishedAt: now }; +} +export const publish = mutation({ + args: publishArgs, + handler: async (ctx, args): Promise => { + const { user } = await requireUser(ctx); + assertModerator(user); + return publishForActor(ctx, user, args); + }, +}); +export const publishForUserInternal = internalMutation({ + args: { ...publishArgs, actorUserId: v.id("users") }, + handler: async (ctx, args): Promise => { + return publishForActor(ctx, await requireActor(ctx, args.actorUserId), args); + }, +}); diff --git a/convex/http.ts b/convex/http.ts index 2ab0ed7b82..439890f903 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -17,6 +17,7 @@ import { searchSkillsHttp, } from "./httpApi"; import { + featuredV1Http, searchInsightsV1Http, searchReportsV1Http, exportSkillsV1Http, @@ -201,6 +202,9 @@ http.route({ handler: listBundlePluginsV1Http, }); +http.route({ pathPrefix: `${ApiRoutes.featured}/`, method: "GET", handler: featuredV1Http }); +http.route({ pathPrefix: `${ApiRoutes.featured}/`, method: "POST", handler: featuredV1Http }); + http.route({ path: ApiRoutes.promotions, method: "GET", diff --git a/convex/httpApiV1.ts b/convex/httpApiV1.ts index 6721bfcf62..c89a4d222c 100644 --- a/convex/httpApiV1.ts +++ b/convex/httpApiV1.ts @@ -7,6 +7,7 @@ import { } from "./httpApiV1/catalogFeedV1"; import { contentRightsV1Handler } from "./httpApiV1/contentRightsV1"; import { verifyDocsSessionV1Handler } from "./httpApiV1/docsSessionV1"; +import { featuredV1Handler } from "./httpApiV1/featuredV1"; import { recoverPackagePublishAttemptV1Handler } from "./httpApiV1/packagePublishRecoveryV1"; import { exportPluginsV1Handler, @@ -118,6 +119,8 @@ export const promotionsGetRouterV1Http = httpAction(promotionsGetRouterV1Handler export const createPromotionV1Http = httpAction(createPromotionV1Handler); export const promotionsPostRouterV1Http = httpAction(promotionsPostRouterV1Handler); +export const featuredV1Http = httpAction(featuredV1Handler); + export const whoamiV1Http = httpAction(whoamiV1Handler); export const usersGetRouterV1Http = httpAction(usersGetRouterV1Handler); export const usersPostRouterV1Http = httpAction(usersPostRouterV1Handler); diff --git a/convex/httpApiV1/featuredV1.ts b/convex/httpApiV1/featuredV1.ts new file mode 100644 index 0000000000..b6a79be6e3 --- /dev/null +++ b/convex/httpApiV1/featuredV1.ts @@ -0,0 +1,89 @@ +import { + FeaturedEditorialSaveSchema, + FeaturedSelectionPublishSchema, + parseArk, +} from "clawhub-schema"; +import { ConvexError } from "convex/values"; +import { internal } from "../_generated/api"; +import type { ActionCtx } from "../_generated/server"; +import { applyRateLimit } from "../lib/httpRateLimit"; +import { + formatUserFacingErrorMessage, + getPathSegments, + json, + parseJsonPayload, + requireApiTokenUserOrResponse, + requireModeratorOrResponse, + text, +} from "./shared"; + +export async function featuredV1Handler(ctx: ActionCtx, request: Request): Promise { + const rate = await applyRateLimit(ctx, request, request.method === "GET" ? "read" : "write"); + if (!rate.ok) return rate.response; + const headers = new Headers(rate.headers); + headers.set("Cache-Control", "private, no-store"); + const auth = await requireApiTokenUserOrResponse(ctx, request, headers); + if (!auth.ok) return auth.response; + const staff = requireModeratorOrResponse(auth.user, headers); + if (!staff.ok) return staff.response; + const [artifactKind, operation, extra] = getPathSegments(request, "/api/v1/featured/"); + if ((artifactKind !== "plugin" && artifactKind !== "skill") || extra) + return text("Not found", 404, headers); + if (request.method === "GET" && !operation) { + const result = await ctx.runQuery(internal.featuredSelections.getForUserInternal, { + actorUserId: auth.userId, + artifactKind, + }); + return json(result, 200, headers); + } + if ( + request.method !== "POST" || + (operation !== "publish" && !(operation === "editorial" && artifactKind === "plugin")) + ) + return text("Not found", 404, headers); + const payload = await parseJsonPayload(request, headers); + if (!payload.ok) return payload.response; + let input: + | { operation: "editorial"; value: typeof FeaturedEditorialSaveSchema.infer } + | { operation: "publish"; value: typeof FeaturedSelectionPublishSchema.infer }; + try { + input = + operation === "editorial" + ? { + operation, + value: parseArk(FeaturedEditorialSaveSchema, payload.payload, "editorial selection"), + } + : { + operation: "publish", + value: parseArk( + FeaturedSelectionPublishSchema, + payload.payload, + "Featured publication", + ), + }; + } catch (error) { + return text(formatUserFacingErrorMessage(error, "Invalid Featured selection"), 400, headers); + } + try { + const result = + input.operation === "editorial" + ? await ctx.runMutation(internal.featuredSelections.saveEditorialForUserInternal, { + ...input.value, + actorUserId: auth.userId, + }) + : await ctx.runMutation(internal.featuredSelections.publishForUserInternal, { + ...input.value, + actorUserId: auth.userId, + artifactKind, + }); + return json(result, 200, headers); + } catch (error) { + if ( + !(error instanceof ConvexError) && + !/(?:Uncaught\s+)?ConvexError:/.test(error instanceof Error ? error.message : "") + ) + return text("Internal Server Error", 500, headers); + const message = formatUserFacingErrorMessage(error, "Featured selection failed"); + return text(message, /changed/i.test(message) ? 409 : 400, headers); + } +} diff --git a/convex/packages.ts b/convex/packages.ts index b531706aa0..dac4f24b99 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -13020,7 +13020,7 @@ export const setBatch = mutation({ }, }); -async function setPackageFeaturedForActor( +export async function setPackageFeaturedForActor( ctx: MutationCtx, actor: Doc<"users">, pkg: Doc<"packages">, diff --git a/convex/skills.ts b/convex/skills.ts index 961c48510d..16ac1b0259 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -10873,11 +10873,12 @@ export const setBatch = mutation({ }, }); -async function setSkillFeaturedForActor( +export async function setSkillFeaturedForActor( ctx: MutationCtx, actor: Doc<"users">, skill: Doc<"skills">, nextBatch: string | undefined, + notify = true, ) { const existingBadges = await getSkillBadgeMap(ctx, skill._id); const previousHighlighted = isSkillHighlighted({ badges: existingBadges }); @@ -10907,7 +10908,7 @@ async function setSkillFeaturedForActor( createdAt: now, }); - if (featured && !previousHighlighted) { + if (featured && !previousHighlighted && notify) { await queueHighlightedWebhook(ctx, skill._id); } diff --git a/docs/http-api.md b/docs/http-api.md index 10fad903a5..8b5de673c0 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -1815,3 +1815,45 @@ Schema: ``` If you self-host, serve this file (or set `CLAWHUB_REGISTRY` explicitly; legacy `CLAWDHUB_REGISTRY`). + +## Staff Featured curation + +These endpoints require an active moderator/admin API token and return private, +uncached results. Recommendations never publish themselves. + +- `GET /api/v1/featured/{plugin|skill}` returns editorial revision, reservations + (including pending reasons), and the last approved publication in its explicit order. +- `POST /api/v1/featured/plugin/editorial` accepts `expectedRevision` and up to + eight `{ id, name, displayName, reason }` entries. Identities use `plugin:`. + Missing catalog entries remain reserved; saving does not change public badges. +- `POST /api/v1/featured/{plugin|skill}/publish` accepts exactly sixteen distinct + `items`, `expectedEditorialRevision`, `expectedPublicationAt` (null initially), + `periodStart`, `periodEnd`, and `dryRun`. Timestamps are Unix milliseconds; + the evidence period is thirty completed UTC days, end exclusive. + +Each publication item has `id`, `version`, `selectionBasis` (`editorial` or +`telemetry`) and `reason`. Telemetry entries include positive `installs30d` and +nonnegative `installs7d`, counted within that same window. Plugin order is all eight +saved editorial reservations followed by eight telemetry selections. Skills use +sixteen native `clawhub:` identities, all telemetry selections. + +Publication revalidates current public versions, security and installability before +changing any badges. Version/revision/publication conflicts return `409`; other +invalid selections return `400`. With `dryRun: true`, no badges, audit records or +notifications change. Applying the set atomically removes former members outside +it, preserves retained badge timestamps, records selection provenance/order in the +audit history, and sends no digest or Featured notification. + +The staff CLI reads the same API and emits JSON: + +```bash +clawhub-admin featured get plugin +clawhub-admin featured editorial editorial.json +clawhub-admin featured publish plugin approved-plugins.json +clawhub-admin featured publish plugin approved-plugins.json --apply +clawhub-admin featured publish skill approved-skills.json --apply +``` + +`publish` defaults to a dry run regardless of the file's `dryRun` value. Use +`--apply` only after the exact selection has been approved. Counts describe recorded +install events, not unique users or proven successful runtime installations. diff --git a/packages/clawhub-admin/src/cli.ts b/packages/clawhub-admin/src/cli.ts index 20ccd96b43..febc8f7a6f 100644 --- a/packages/clawhub-admin/src/cli.ts +++ b/packages/clawhub-admin/src/cli.ts @@ -28,6 +28,7 @@ import { } from "./commands/contentRights.js"; import { cmdSendStaffEmail } from "./commands/email.js"; import { cmdSetPackageFeatured, cmdSetSkillFeatured } from "./commands/featured.js"; +import { cmdFeaturedSelection } from "./commands/featuredSelections.js"; import { cmdBanUser, cmdLiftModerationHold, @@ -388,6 +389,27 @@ const promotions = program .showHelpAfterError() .showSuggestionAfterError(); +const featured = program + .command("featured") + .description("Review editorial reservations and publish approved complete catalog selections"); +featured + .command("get ") + .description("Read current plugin or skill editorial reservations and publication (JSON)") + .action(async (catalog) => cmdFeaturedSelection(await resolveGlobalOpts(), catalog, "get")); +featured + .command("editorial ") + .description("Save plugin editorial reservations from revision-checked JSON; does not publish") + .action(async (file) => + cmdFeaturedSelection(await resolveGlobalOpts(), "plugin", "editorial", file), + ); +featured + .command("publish ") + .description("Validate a reviewed complete selection; publish only with --apply") + .option("--apply", "Apply this approved selection atomically (default is dry run)") + .action(async (catalog, file, options) => + cmdFeaturedSelection(await resolveGlobalOpts(), catalog, "publish", file, !options.apply), + ); + registerPluginOperations(plugins); registerPluginModerationCommands(plugins); registerPluginGovernanceCommands(plugins); diff --git a/packages/clawhub-admin/src/commands/featuredSelections.ts b/packages/clawhub-admin/src/commands/featuredSelections.ts new file mode 100644 index 0000000000..3a90636fbe --- /dev/null +++ b/packages/clawhub-admin/src/commands/featuredSelections.ts @@ -0,0 +1,45 @@ +import { readFile } from "node:fs/promises"; +import { requireAuthToken } from "../../../clawhub/src/cli/authToken.js"; +import { getRegistry } from "../../../clawhub/src/cli/registry.js"; +import type { GlobalOpts } from "../../../clawhub/src/cli/types.js"; +import { fail } from "../../../clawhub/src/cli/ui.js"; +import { apiRequest } from "../../../clawhub/src/http.js"; +import { ApiRoutes, parseArk } from "../../../clawhub/src/schema/index.js"; +import { + FeaturedEditorialSaveSchema, + FeaturedSelectionPublishSchema, +} from "../../../schema/src/featuredSelections.js"; + +export async function cmdFeaturedSelection( + opts: GlobalOpts, + catalog: string, + operation: "get" | "editorial" | "publish", + file?: string, + dryRun = true, +) { + if (catalog !== "plugin" && catalog !== "skill") fail("Catalog must be plugin or skill"); + if (operation === "editorial" && catalog !== "plugin") + fail("Only plugins have editorial reservations"); + let body; + if (operation !== "get") { + if (!file) fail("A reviewed selection JSON file is required"); + const payload: unknown = JSON.parse(await readFile(file, "utf8")); + body = + operation === "editorial" + ? parseArk(FeaturedEditorialSaveSchema, payload, "editorial selection") + : parseArk( + FeaturedSelectionPublishSchema, + { ...(payload as object), dryRun }, + "Featured publication", + ); + } + const token = await requireAuthToken(); + const registry = await getRegistry(opts, { cache: true }); + const result = await apiRequest(registry, { + method: operation === "get" ? "GET" : "POST", + path: `${ApiRoutes.featured}/${catalog}${operation === "get" ? "" : `/${operation}`}`, + token, + ...(body ? { body } : {}), + }); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} diff --git a/packages/clawhub/src/schema/routes.ts b/packages/clawhub/src/schema/routes.ts index 5fd5e48bf5..ed5841dcc5 100644 --- a/packages/clawhub/src/schema/routes.ts +++ b/packages/clawhub/src/schema/routes.ts @@ -25,6 +25,7 @@ export const ApiRoutes = { packages: "/api/v1/packages", codePlugins: "/api/v1/code-plugins", bundlePlugins: "/api/v1/bundle-plugins", + featured: "/api/v1/featured", promotions: "/api/v1/promotions", stars: "/api/v1/stars", transfers: "/api/v1/transfers", diff --git a/packages/schema/dist/featuredSelections.d.ts b/packages/schema/dist/featuredSelections.d.ts new file mode 100644 index 0000000000..ccccf02eae --- /dev/null +++ b/packages/schema/dist/featuredSelections.d.ts @@ -0,0 +1,24 @@ +export declare const FeaturedEditorialSaveSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + expectedRevision: number; + items: { + id: string; + name: string; + displayName: string; + reason: string; + }[]; +}, {}>; +export declare const FeaturedSelectionPublishSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + expectedEditorialRevision: number; + expectedPublicationAt: number | null; + periodStart: number; + periodEnd: number; + items: { + id: string; + version: string; + selectionBasis: "editorial" | "telemetry"; + reason: string; + installs30d?: number | undefined; + installs7d?: number | undefined; + }[]; + dryRun: boolean; +}, {}>; diff --git a/packages/schema/dist/featuredSelections.js b/packages/schema/dist/featuredSelections.js new file mode 100644 index 0000000000..b8a39f4358 --- /dev/null +++ b/packages/schema/dist/featuredSelections.js @@ -0,0 +1,21 @@ +import { type } from "arktype"; +export const FeaturedEditorialSaveSchema = type({ + "+": "reject", + expectedRevision: "number.integer >= 0", + items: type({ + "+": "reject", id: "string", name: "string", displayName: "string", reason: "string", + }).array().atMostLength(8), +}); +export const FeaturedSelectionPublishSchema = type({ + "+": "reject", + expectedEditorialRevision: "number.integer >= 0", + expectedPublicationAt: "number | null", + periodStart: "number.integer", + periodEnd: "number.integer", + items: type({ + "+": "reject", id: "string", version: "string", selectionBasis: '"editorial" | "telemetry"', reason: "string", + "installs30d?": "number.integer >= 0", "installs7d?": "number.integer >= 0", + }).array().atLeastLength(16).atMostLength(16), + dryRun: "boolean", +}); +//# sourceMappingURL=featuredSelections.js.map \ No newline at end of file diff --git a/packages/schema/dist/featuredSelections.js.map b/packages/schema/dist/featuredSelections.js.map new file mode 100644 index 0000000000..d08e6556ad --- /dev/null +++ b/packages/schema/dist/featuredSelections.js.map @@ -0,0 +1 @@ +{"version":3,"file":"featuredSelections.js","sourceRoot":"","sources":["../src/featuredSelections.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAE/B,MAAM,CAAC,MAAM,2BAA2B,GAAG,IAAI,CAAC;IAC9C,GAAG,EAAE,QAAQ;IACb,gBAAgB,EAAE,qBAAqB;IACvC,KAAK,EAAE,IAAI,CAAC;QACV,GAAG,EAAE,QAAQ,EAAE,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,EAAE,QAAQ;KACrF,CAAC,CAAC,KAAK,EAAE,CAAC,YAAY,CAAC,CAAC,CAAC;CAC3B,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,8BAA8B,GAAG,IAAI,CAAC;IACjD,GAAG,EAAE,QAAQ;IACb,yBAAyB,EAAE,qBAAqB;IAChD,qBAAqB,EAAE,eAAe;IACtC,WAAW,EAAE,gBAAgB;IAC7B,SAAS,EAAE,gBAAgB;IAC3B,KAAK,EAAE,IAAI,CAAC;QACV,GAAG,EAAE,QAAQ,EAAE,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE,cAAc,EAAE,2BAA2B,EAAE,MAAM,EAAE,QAAQ;QAC7G,cAAc,EAAE,qBAAqB,EAAE,aAAa,EAAE,qBAAqB;KAC5E,CAAC,CAAC,KAAK,EAAE,CAAC,aAAa,CAAC,EAAE,CAAC,CAAC,YAAY,CAAC,EAAE,CAAC;IAC7C,MAAM,EAAE,SAAS;CAClB,CAAC,CAAC"} \ No newline at end of file diff --git a/packages/schema/dist/index.d.ts b/packages/schema/dist/index.d.ts index 7cce4f313e..9dbac4d73f 100644 --- a/packages/schema/dist/index.d.ts +++ b/packages/schema/dist/index.d.ts @@ -7,6 +7,7 @@ export * from "./experimentalClawFeed.js"; export * from "./catalogMetadata.js"; export * from "./docsLinks.js"; export * from "./license.js"; +export * from "./featuredSelections.js"; export * from "./openclawContract.js"; export * from "./openClawExtensionSlugs.js"; export * from "./packages.js"; diff --git a/packages/schema/dist/index.js b/packages/schema/dist/index.js index 181df277a1..bf469c69c5 100644 --- a/packages/schema/dist/index.js +++ b/packages/schema/dist/index.js @@ -6,6 +6,7 @@ export * from "./experimentalClawFeed.js"; export * from "./catalogMetadata.js"; export * from "./docsLinks.js"; export * from "./license.js"; +export * from "./featuredSelections.js"; export * from "./openclawContract.js"; export * from "./openClawExtensionSlugs.js"; export * from "./packages.js"; diff --git a/packages/schema/dist/index.js.map b/packages/schema/dist/index.js.map index 5015338112..8a5feed90f 100644 --- a/packages/schema/dist/index.js.map +++ b/packages/schema/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,YAAY,CAAC;AAC3B,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC;AACjC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,0BAA0B,CAAC;AACzC,cAAc,oBAAoB,CAAC;AACnC,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,YAAY,CAAC;AAC3B,cAAc,kBAAkB,CAAC;AACjC,cAAc,kBAAkB,CAAC;AACjC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,yBAAyB,CAAC;AACxC,cAAc,uBAAuB,CAAC;AACtC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,0BAA0B,CAAC;AACzC,cAAc,oBAAoB,CAAC;AACnC,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"} \ No newline at end of file diff --git a/packages/schema/dist/routes.d.ts b/packages/schema/dist/routes.d.ts index 9cd213ab9b..9be082527c 100644 --- a/packages/schema/dist/routes.d.ts +++ b/packages/schema/dist/routes.d.ts @@ -29,6 +29,7 @@ export declare const ApiRoutes: { readonly packageCategoriesBatch: "/api/v1/packages/categories:batch"; readonly codePlugins: "/api/v1/code-plugins"; readonly bundlePlugins: "/api/v1/bundle-plugins"; + readonly featured: "/api/v1/featured"; readonly promotions: "/api/v1/promotions"; readonly catalogFeed: "/api/v1/feeds/plugins"; readonly catalogSkillsFeed: "/api/v1/feeds/skills"; diff --git a/packages/schema/dist/routes.js b/packages/schema/dist/routes.js index c2edd7075b..6ef7a7f1e4 100644 --- a/packages/schema/dist/routes.js +++ b/packages/schema/dist/routes.js @@ -29,6 +29,7 @@ export const ApiRoutes = { packageCategoriesBatch: "/api/v1/packages/categories:batch", codePlugins: "/api/v1/code-plugins", bundlePlugins: "/api/v1/bundle-plugins", + featured: "/api/v1/featured", promotions: "/api/v1/promotions", catalogFeed: "/api/v1/feeds/plugins", catalogSkillsFeed: "/api/v1/feeds/skills", diff --git a/packages/schema/dist/routes.js.map b/packages/schema/dist/routes.js.map index 7802854362..b679132d24 100644 --- a/packages/schema/dist/routes.js.map +++ b/packages/schema/dist/routes.js.map @@ -1 +1 @@ -{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,mBAAmB,EAAE,4BAA4B;IACjD,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,eAAe,EAAE,0BAA0B;IAC3C,MAAM,EAAE,gBAAgB;IACxB,cAAc,EAAE,6BAA6B;IAC7C,QAAQ,EAAE,kBAAkB;IAC5B,QAAQ,EAAE,mBAAmB;IAC7B,UAAU,EAAE,uBAAuB;IACnC,OAAO,EAAE,iBAAiB;IAC1B,cAAc,EAAE,0BAA0B;IAC1C,gBAAgB,EAAE,4BAA4B;IAC9C,aAAa,EAAE,wBAAwB;IACvC,QAAQ,EAAE,kBAAkB;IAC5B,sBAAsB,EAAE,mCAAmC;IAC3D,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,UAAU,EAAE,oBAAoB;IAChC,WAAW,EAAE,uBAAuB;IACpC,iBAAiB,EAAE,sBAAsB;IACzC,gBAAgB,EAAE,qBAAqB;IACvC,cAAc,EAAE,0BAA0B;IAC1C,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,UAAU,EAAE,oBAAoB;IAChC,KAAK,EAAE,eAAe;IACtB,aAAa,EAAE,wBAAwB;IACvC,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,CAAC"} \ No newline at end of file +{"version":3,"file":"routes.js","sourceRoot":"","sources":["../src/routes.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,aAAa;IACrB,KAAK,EAAE,YAAY;IACnB,YAAY,EAAE,oBAAoB;IAClC,SAAS,EAAE,iBAAiB;IAC5B,YAAY,EAAE,qBAAqB;IACnC,UAAU,EAAE,kBAAkB;IAC9B,mBAAmB,EAAE,4BAA4B;IACjD,cAAc,EAAE,uBAAuB;IACvC,gBAAgB,EAAE,yBAAyB;CACnC,CAAC;AAEX,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,MAAM,EAAE,gBAAgB;IACxB,OAAO,EAAE,iBAAiB;IAC1B,QAAQ,EAAE,kBAAkB;IAC5B,gBAAgB,EAAE,4BAA4B;IAC9C,eAAe,EAAE,0BAA0B;IAC3C,MAAM,EAAE,gBAAgB;IACxB,cAAc,EAAE,6BAA6B;IAC7C,QAAQ,EAAE,kBAAkB;IAC5B,QAAQ,EAAE,mBAAmB;IAC7B,UAAU,EAAE,uBAAuB;IACnC,OAAO,EAAE,iBAAiB;IAC1B,cAAc,EAAE,0BAA0B;IAC1C,gBAAgB,EAAE,4BAA4B;IAC9C,aAAa,EAAE,wBAAwB;IACvC,QAAQ,EAAE,kBAAkB;IAC5B,sBAAsB,EAAE,mCAAmC;IAC3D,WAAW,EAAE,sBAAsB;IACnC,aAAa,EAAE,wBAAwB;IACvC,QAAQ,EAAE,kBAAkB;IAC5B,UAAU,EAAE,oBAAoB;IAChC,WAAW,EAAE,uBAAuB;IACpC,iBAAiB,EAAE,sBAAsB;IACzC,gBAAgB,EAAE,qBAAqB;IACvC,cAAc,EAAE,0BAA0B;IAC1C,KAAK,EAAE,eAAe;IACtB,SAAS,EAAE,mBAAmB;IAC9B,UAAU,EAAE,oBAAoB;IAChC,KAAK,EAAE,eAAe;IACtB,aAAa,EAAE,wBAAwB;IACvC,MAAM,EAAE,gBAAgB;IACxB,YAAY,EAAE,uBAAuB;CAC7B,CAAC"} \ No newline at end of file diff --git a/packages/schema/src/featuredSelections.ts b/packages/schema/src/featuredSelections.ts new file mode 100644 index 0000000000..1d1e65af79 --- /dev/null +++ b/packages/schema/src/featuredSelections.ts @@ -0,0 +1,36 @@ +import { type } from "arktype"; + +export const FeaturedEditorialSaveSchema = type({ + "+": "reject", + expectedRevision: "number.integer >= 0", + items: type({ + "+": "reject", + id: "string", + name: "string", + displayName: "string", + reason: "string", + }) + .array() + .atMostLength(8), +}); + +export const FeaturedSelectionPublishSchema = type({ + "+": "reject", + expectedEditorialRevision: "number.integer >= 0", + expectedPublicationAt: "number | null", + periodStart: "number.integer", + periodEnd: "number.integer", + items: type({ + "+": "reject", + id: "string", + version: "string", + selectionBasis: '"editorial" | "telemetry"', + reason: "string", + "installs30d?": "number.integer >= 0", + "installs7d?": "number.integer >= 0", + }) + .array() + .atLeastLength(16) + .atMostLength(16), + dryRun: "boolean", +}); diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 7cce4f313e..9dbac4d73f 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -7,6 +7,7 @@ export * from "./experimentalClawFeed.js"; export * from "./catalogMetadata.js"; export * from "./docsLinks.js"; export * from "./license.js"; +export * from "./featuredSelections.js"; export * from "./openclawContract.js"; export * from "./openClawExtensionSlugs.js"; export * from "./packages.js"; diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index ca4433cd1a..7ef0c320bd 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -30,6 +30,7 @@ export const ApiRoutes = { packageCategoriesBatch: "/api/v1/packages/categories:batch", codePlugins: "/api/v1/code-plugins", bundlePlugins: "/api/v1/bundle-plugins", + featured: "/api/v1/featured", promotions: "/api/v1/promotions", catalogFeed: "/api/v1/feeds/plugins", catalogSkillsFeed: "/api/v1/feeds/skills", From be99bf5595e9912776e636cb9c5c88573f299956 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 16 Sep 2026 13:15:16 -0700 Subject: [PATCH 4/8] feat: rank Featured reports by complete monthly install evidence --- convex/featuredIntelligence.test.ts | 514 +++++++----------- convex/featuredIntelligence.ts | 389 ++++++++----- convex/lib/featuredIntelligence.test.ts | 300 +++++----- convex/lib/featuredIntelligence.ts | 150 +++-- convex/lib/searchDigestContract.ts | 59 ++ convex/lib/searchEvidenceDigest.test.ts | 87 ++- convex/lib/searchEvidenceDigest.ts | 59 +- convex/lib/searchReportContract.ts | 2 +- convex/lib/searchReportEvidence.ts | 26 +- convex/schema.ts | 2 +- convex/searchInsightsFixtures.ts | 131 ++++- convex/searchReports.test.ts | 43 +- convex/searchReports.ts | 34 +- convex/searchWeeklyDigest.test.ts | 4 +- convex/searchWeeklyDigest.ts | 87 +-- .../src/commands/searchInsights.test.ts | 44 +- .../src/commands/searchInsights.ts | 18 +- packages/clawhub/src/schema/searchInsights.ts | 56 +- packages/clawhub/src/schema/searchReports.ts | 2 +- specs/search-insights.md | 76 ++- 20 files changed, 1260 insertions(+), 823 deletions(-) diff --git a/convex/featuredIntelligence.test.ts b/convex/featuredIntelligence.test.ts index a618e12376..a2689c479d 100644 --- a/convex/featuredIntelligence.test.ts +++ b/convex/featuredIntelligence.test.ts @@ -1,218 +1,173 @@ -import { register as registerRateLimiter } from "@convex-dev/rate-limiter/test"; -import { register as registerWorkpool } from "@convex-dev/workpool/test"; /// /* @vitest-environment edge-runtime */ +import { register as registerRateLimiter } from "@convex-dev/rate-limiter/test"; +import { register as registerWorkpool } from "@convex-dev/workpool/test"; import { convexTest } from "convex-test"; import { afterEach, expect, it, vi } from "vitest"; import { FeaturedIntelligenceReportSchema } from "../packages/clawhub/src/schema/searchInsights"; -import { api, internal } from "./_generated/api"; -import { CANONICAL_TRENDING_RANKING_VERSION } from "./lib/canonicalTrending"; -import { getCompletedRolling24HourWindow } from "./lib/skillHourlyStats"; +import { api } from "./_generated/api"; import { hashToken } from "./lib/tokens"; import schema from "./schema"; const modules = import.meta.glob("./**/*.ts"); const DAY = 86_400_000; +const END = Date.UTC(2026, 8, 16); +const END_DAY = END / DAY; afterEach(() => vi.useRealTimers()); - -it("serves existing package adoption without search history and rechecks current Featured eligibility", async () => { +async function setup() { + vi.useFakeTimers(); + vi.setSystemTime(END + 12 * 3_600_000); const t = convexTest(schema, modules); registerRateLimiter(t); - const now = Date.UTC(2026, 8, 15, 12); - vi.useFakeTimers(); - vi.setSystemTime(now); - const ids = await t.run(async (ctx) => { - const staff = await ctx.db.insert("users", { handle: "author", role: "moderator" }); - const pkg = await ctx.db.insert("packages", { - name: "calendar", - normalizedName: "calendar", - displayName: "Calendar", - ownerUserId: staff, - family: "code-plugin", - channel: "community", - isOfficial: false, - tags: {}, - scanStatus: "clean", - stats: { downloads: 40, installs: 3, stars: 0, versions: 1 }, - createdAt: now - DAY, - updatedAt: now, - }); - const release = await ctx.db.insert("packageReleases", { - packageId: pkg, - version: "1.0.0", - changelog: "Initial", - distTags: ["latest"], - files: [ - { - path: "index.js", - size: 1, - storageId: await ctx.storage.store(new Blob(["x"])), - sha256: "a".repeat(64), - }, - ], - integritySha256: "a".repeat(64), - verification: { tier: "structural", scope: "artifact-only", scanStatus: "clean" }, - createdBy: staff, - createdAt: now, - }); - await ctx.db.patch(pkg, { latestReleaseId: release, tags: { latest: release } }); - await ctx.db.insert("packageLeaderboards", { - kind: "package_trending", - generatedAt: now, - rangeStartDay: Math.floor(now / DAY) - 6, - rangeEndDay: Math.floor(now / DAY), - items: [{ packageId: pkg, score: 49, installs: 3, downloads: 40 }], - }); + registerWorkpool(t, "searchReports"); + const staff = await t.run(async (ctx) => { + const id = await ctx.db.insert("users", { handle: "reviewer", role: "moderator" }); await ctx.db.insert("apiTokens", { - userId: staff, + userId: id, label: "fixture", prefix: "fixture", - tokenHash: await hashToken("featured-api-fixture"), - createdAt: now, + tokenHash: await hashToken("monthly-report-fixture"), + createdAt: Date.now(), }); - return { staff, pkg }; + return id; }); - const report = await t.withIdentity({ subject: ids.staff }).action(api.featuredIntelligence.get, { - artifactKind: "plugin", + return { t, staff, signed: t.withIdentity({ subject: staff }) }; +} + +it("serves the full monthly install order independently of Trending, partial days, downloads and existing badges", async () => { + const { t, staff } = await setup(); + await t.run(async (ctx) => { + const items = [ + { name: "month-winner", day: END_DAY - 30, installs: 20 }, + { name: "week-winner", day: END_DAY - 7, installs: 10 }, + { name: "tie-a", day: END_DAY - 8, installs: 10 }, + { name: "tie-b", day: END_DAY - 8, installs: 10 }, + { name: "too-old", day: END_DAY - 31, installs: 999 }, + { name: "partial-day", day: END_DAY, installs: 999 }, + { name: "channel", day: END_DAY - 1, installs: 999, categories: ["channels"] }, + ]; + for (const item of items) { + const packageId = await ctx.db.insert("packages", { + name: item.name, + normalizedName: item.name, + displayName: item.name, + ownerUserId: staff, + family: "code-plugin", + channel: "community", + isOfficial: false, + tags: {}, + categories: item.categories ?? ["productivity"], + scanStatus: "clean", + stats: { downloads: 999999, installs: 1, stars: 0, versions: 1 }, + createdAt: END - DAY, + updatedAt: END, + }); + const release = await ctx.db.insert("packageReleases", { + packageId, + version: "1.0.0", + changelog: "Initial", + distTags: ["latest"], + files: [ + { + path: "index.js", + size: 1, + storageId: await ctx.storage.store(new Blob(["x"])), + sha256: "a".repeat(64), + }, + ], + integritySha256: "a".repeat(64), + verification: { tier: "structural", scope: "artifact-only", scanStatus: "clean" }, + createdBy: staff, + createdAt: END, + }); + await ctx.db.patch(packageId, { latestReleaseId: release, tags: { latest: release } }); + await ctx.db.insert("packageDailyStats", { + packageId, + day: item.day, + installs: item.installs, + downloads: 0, + updatedAt: END, + ...(item.name === "month-winner" + ? { rankingDatasetVersion: "import-fixture", rankingImportedAt: END - 1 } + : {}), + }); + if (item.name === "tie-b") { + await ctx.db.insert("packageBadges", { + packageId, + kind: "highlighted", + byUserId: staff, + at: END, + }); + await ctx.db.insert("packageLeaderboards", { + kind: "package_trending", + generatedAt: END, + rangeStartDay: END_DAY - 6, + rangeEndDay: END_DAY, + items: [{ packageId, score: 999999, downloads: 999999, installs: 1 }], + }); + } + } }); - expect(report.searchReport.totalSearches7d).toBe(0); const response = await t.fetch( "/api/v1/search-insights?view=recommendations&artifactKind=plugin", { - headers: { Authorization: "Bearer featured-api-fixture" }, + headers: { Authorization: "Bearer monthly-report-fixture" }, }, ); expect(response.status).toBe(200); expect(response.headers.get("Cache-Control")).toBe("private, no-store"); - expect(FeaturedIntelligenceReportSchema.assert(await response.json())).toEqual(report); - expect((await t.fetch("/api/v1/search-insights?view=recommendations")).status).toBe(401); - expect(report.recommendations.candidates).toMatchObject([ - { - id: "plugin:calendar", - support: "adoption-only", - search: null, - adoption: { - rank: 1, - downloads: 40, - installs: 3, - periodEnd: now, - rankingVersion: "unversioned", - }, - }, + const report = FeaturedIntelligenceReportSchema.assert(await response.json()); + expect(report.recommendations.lineup.proposed.map((entry) => entry.id)).toEqual([ + "plugin:month-winner", + "plugin:week-winner", + "plugin:tie-a", + "plugin:tie-b", ]); - await t.run(async (ctx) => { - await ctx.db.insert("packageBadges", { - packageId: ids.pkg, - kind: "highlighted", - byUserId: ids.staff, - at: now, - }); - }); - const changed = await t.action(internal.featuredIntelligence.getInternal, { - artifactKind: "plugin", + expect(report.adoption).toMatchObject({ + status: "available", + periodStart: END - 30 * DAY, + periodStart7d: END - 7 * DAY, + periodEnd: END, + scannedRows: 5, + totalItems: 5, + inspectedItems: 5, + importedRows: 1, + importDatasetVersions: ["import-fixture"], }); - expect(changed.recommendations.lineup.proposed).toMatchObject([ - { id: "plugin:calendar", change: "retain" }, - ]); - expect(changed.recommendations.lineup.baseline).toEqual([ - { id: "plugin:calendar", version: "1.0.0", featuredAt: now }, - ]); - expect(changed.recommendations.excluded).toEqual([]); - // An old unfiltered snapshot must not let setup entries consume the first - // 100 discovery slots, and membership remains visible beyond that limit. - const setupIds = await t.run(async (ctx) => { - const source = await ctx.db.get(ids.pkg); - const { _id, _creationTime, latestReleaseId: _release, ...fields } = source!; - const packages = []; - for (let index = 0; index < 100; index++) - packages.push( - await ctx.db.insert("packages", { - ...fields, - tags: {}, - name: `setup-${index}`, - normalizedName: `setup-${index}`, - categories: ["channels"], - }), - ); - const snapshot = await ctx.db.query("packageLeaderboards").unique(); - await ctx.db.patch(snapshot!._id, { - items: [ - ...packages.map((packageId) => ({ packageId, score: 100, downloads: 90, installs: 3 })), - ...snapshot!.items, - ], - }); - return packages; + expect(report.recommendations.lineup.proposed[0].adoption).toMatchObject({ + installs30d: 20, + installs7d: 0, }); - const filtered = await t.action(internal.featuredIntelligence.getInternal, { - artifactKind: "plugin", + expect(report.recommendations.lineup.proposed[1].adoption).toMatchObject({ + installs30d: 10, + installs7d: 10, }); - expect(filtered.recommendations.lineup.proposed).toMatchObject([ - { id: "plugin:calendar", adoption: { rank: 101 }, change: "retain" }, + expect(report.recommendations.excluded).toMatchObject([ + { id: "plugin:channel", reasons: ["discovery-excluded:channels"] }, ]); - await t.run(async (ctx) => { - for (const packageId of setupIds) - await ctx.db.patch(packageId, { categories: ["productivity"] }); - }); - const beyondLimit = await t.action(internal.featuredIntelligence.getInternal, { - artifactKind: "plugin", + expect(report.recommendations.lineup).toMatchObject({ + pendingCount: 8, + telemetryShortfall: 4, + shortfall: 12, }); - expect(beyondLimit.adoption).toMatchObject({ - inspectedItems: 100, - totalItems: 101, - truncated: true, - }); - expect(beyondLimit.recommendations.lineup.proposed).toMatchObject([ - { id: "plugin:calendar", support: "current-only", adoption: null, change: "retain" }, - ]); - expect(beyondLimit.recommendations.lineup.shortfall).toBe(7); - await t.run(async (ctx) => { - const source = await ctx.db.get(ids.pkg); - const { _id, _creationTime, ...fields } = source!; - for (let index = 0; index < 101; index++) { - const packageId = await ctx.db.insert("packages", { - ...fields, - family: "claw", - name: `claw-${index}`, - normalizedName: `claw-${index}`, - }); - await ctx.db.insert("packageBadges", { - packageId, - kind: "highlighted", - byUserId: ids.staff, - at: now + index + 1, - }); - } - }); - const withoutClaws = await t.query(internal.featuredArtifacts.readCurrentFeaturedInternal, { - artifactKind: "plugin", - }); - expect(withoutClaws.map((entry) => entry.id)).toEqual(["plugin:calendar"]); - expect(await t.run((ctx) => ctx.db.query("searchWeeklyDigests").collect())).toEqual([]); }); -it("reuses canonical skill ranks and exact completed-hour periods, and refuses stale public snapshots", async () => { - const t = convexTest(schema, modules); - registerWorkpool(t, "searchReports"); - const now = Date.UTC(2026, 8, 15, 12, 30); - vi.useFakeTimers(); - vi.setSystemTime(now); - const window = getCompletedRolling24HourWindow(now); - const skillId = await t.run(async (ctx) => { - const author = await ctx.db.insert("users", { handle: "author" }); - const skill = await ctx.db.insert("skills", { +it("reads sparse native skill installs across raw pages and freezes monthly evidence while refreshing public eligibility and editorial revision", async () => { + const { t, staff, signed } = await setup(); + const ids = await t.run(async (ctx) => { + const skillId = await ctx.db.insert("skills", { slug: "calendar", displayName: "Calendar", - summary: - "Manage your calendar events and schedule meetings with clear reminders for your team.", - ownerUserId: author, + summary: "Calendar tool", + ownerUserId: staff, tags: {}, - stats: { downloads: 60, stars: 2, versions: 1, comments: 0 }, - createdAt: now, - updatedAt: now, + stats: { downloads: 0, stars: 0, versions: 1, comments: 0 }, + createdAt: END, + updatedAt: END, }); - const version = await ctx.db.insert("skillVersions", { - skillId: skill, + const versionId = await ctx.db.insert("skillVersions", { + skillId, version: "1.0.0", changelog: "Initial", files: [ @@ -220,153 +175,102 @@ it("reuses canonical skill ranks and exact completed-hour periods, and refuses s path: "SKILL.md", size: 1, storageId: await ctx.storage.store(new Blob(["x"])), - sha256: "b".repeat(64), + sha256: "a".repeat(64), }, ], parsed: { frontmatter: {} }, - createdBy: author, - createdAt: now, - llmAnalysis: { status: "clean", checkedAt: now }, - }); - await ctx.db.patch(skill, { latestVersionId: version }); - await ctx.db.insert("skillSearchDigest", { - skillId: skill, - slug: "calendar", - displayName: "Calendar", - summary: - "Manage your calendar events and schedule meetings with clear reminders for your team.", - ownerUserId: author, - ownerHandle: "author", - ownerKind: "user", - ownerName: "author", - ownerDisplayName: "author", - latestVersionId: version, - latestVersionSkillId: skill, - publicVersion: { status: "available", versionId: version }, - tags: {}, - stats: { downloads: 60, stars: 2, versions: 1, comments: 0 }, - createdAt: now, - updatedAt: now, - }); - await ctx.db.insert("canonicalTrendingSnapshots", { - snapshotId: "skills-observed", - kind: "skills", - status: "ready", - rankingVersion: CANONICAL_TRENDING_RANKING_VERSION, - generatedAt: now, - expiresAt: now + DAY, - windowHours: 24, - windowStartDay: Math.floor(now / DAY) - 1, - windowEndDay: Math.floor(now / DAY), - windowStartHour: window.startHour, - windowEndHour: window.endHour, - writtenItems: 1, - totalItems: 1, - }); - const id = `clawhub:${skill}`; - await ctx.db.insert("canonicalTrendingItems", { - snapshotId: "skills-observed", - position: 0, - lane: "clawhub-rising", - sourceRef: { kind: "clawhub", skillId: skill }, - expiresAt: now + DAY, - card: { - id, - source: "clawhub", - slug: "calendar", - displayName: "Calendar", - summary: - "Manage your calendar events and schedule meetings with clear reminders for your team.", - - canonicalUrl: "/author/skills/calendar", - links: { canonical: "/author/skills/calendar", source: null }, - publisher: { - kind: "user", - handle: "author", - displayName: "Author", - image: null, - official: false, - }, - official: false, - featured: false, - install: { kind: "clawhub", reference: "author/calendar", sourceUrl: null }, - sourceIdentity: { - id: String(skill), - owner: "author", - repo: null, - host: null, - lifetimeInstalls: null, - }, - trust: { - visibility: "public", - installability: "installable", - clawHubVerdict: null, - upstreamScanners: null, - sourceFreshness: "native", - }, - metrics: { - trending24hDownloads: 60, - trending24hInstalls: 4, - trending24hBookmarks: 2, - lifetimeInstalls: null, - lifetimeInstallsPeriod: "lifetime", - updatedAt: now, - }, - }, + createdBy: staff, + createdAt: END, + llmAnalysis: { status: "clean", checkedAt: END }, }); - return skill; + await ctx.db.patch(skillId, { latestVersionId: versionId }); + for (let index = 0; index < 5001; index++) + await ctx.db.insert("skillDailyStats", { + skillId, + day: END_DAY - 1, + installs: index === 5000 ? 7 : 0, + downloads: 0, + updatedAt: END, + }); + return { skillId, versionId }; }); - const report = await t.action(internal.featuredIntelligence.getInternal, { + const queued = await signed.mutation(api.searchReports.start, { + view: "recommendations", artifactKind: "skill", }); - expect(report.recommendations.candidates).toMatchObject([ - { - id: `clawhub:${skillId}`, - artifactKind: "skill", - support: "adoption-only", - adoption: { - source: "clawhub-rising", - rank: 1, - downloads: 60, - installs: 4, - bookmarks: 2, - periodStart: window.startHour * 3_600_000, - periodEnd: (window.endHour + 1) * 3_600_000, - }, - }, + await t.finishAllScheduledFunctions(vi.runAllTimers); + const ready = await signed.action(api.searchReports.get, { reportId: queued.reportId }); + if (ready.status !== "ready" || ready.view !== "recommendations") + throw new Error("Expected ready skill report"); + expect(ready.report.adoption).toMatchObject({ scannedRows: 5001, totalItems: 1 }); + expect(ready.report.recommendations.lineup.proposed).toMatchObject([ + { id: `clawhub:${ids.skillId}`, adoption: { installs30d: 7, installs7d: 7 } }, ]); - const queued = await t.mutation(internal.searchReports.startInternal, { + expect(ready.report.recommendations.lineup).toMatchObject({ + reservedSlots: 0, + telemetryTarget: 16, + shortfall: 15, + }); + await t.run((ctx) => + ctx.db.patch(ids.versionId, { llmAnalysis: { status: "suspicious", checkedAt: END + 1 } }), + ); + vi.setSystemTime(Date.now() + 3 * 3_600_000); + const changed = await signed.action(api.searchReports.get, { reportId: queued.reportId }); + if (changed.status !== "ready" || changed.view !== "recommendations") + throw new Error("Expected frozen report"); + expect(changed.report.adoption).toEqual(ready.report.adoption); + expect(changed.report.recommendations.lineup.proposed).toEqual([]); + const plugin = await signed.mutation(api.searchReports.start, { view: "recommendations", - artifactKind: "skill", + artifactKind: "plugin", }); await t.finishAllScheduledFunctions(vi.runAllTimers); - const saved = await t.action(internal.searchReports.getInternal, { reportId: queued.reportId }); - if (saved.status !== "ready" || saved.view !== "recommendations") - throw new Error("Expected saved skill evidence"); - expect(saved.report.adoption.status).toBe("available"); - vi.setSystemTime(now + 3 * 3_600_000); - const stale = await t.action(internal.featuredIntelligence.getInternal, { - artifactKind: "skill", + await signed.mutation(api.featuredSelections.saveEditorial, { + expectedRevision: 0, + items: [ + { + id: "plugin:pending", + name: "pending", + displayName: "Pending", + reason: "Awaiting public release", + }, + ], + }); + const stale = await signed.action(api.searchReports.get, { reportId: plugin.reportId }); + if (stale.status !== "ready" || stale.view !== "recommendations") + throw new Error("Expected frozen editorial report"); + expect(stale.report.recommendations.lineup).toMatchObject({ + editorialRevision: 0, + currentEditorialRevision: 1, + staleEditorial: true, }); - expect(stale.adoption.status).toBe("unavailable"); - expect(stale.recommendations.candidates).toEqual([]); - const expired = await t.action(internal.searchReports.getInternal, { reportId: queued.reportId }); - if (expired.status !== "ready" || expired.view !== "recommendations") - throw new Error("Expected retained evidence with stale adoption withheld"); - expect(expired.report.adoption.status).toBe("unavailable"); - expect(expired.report.recommendations.candidates).toEqual([]); - expect(expired.report.searchReport.generatedAt).toBe(saved.report.searchReport.generatedAt); + expect(stale.report.recommendations.lineup.reservations[0].id).toBeNull(); + const refresh = await signed.mutation(api.searchReports.start, { + view: "recommendations", + refreshOf: plugin.reportId, + }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + const current = await signed.action(api.searchReports.get, { reportId: refresh.reportId }); + if (current.status !== "ready" || current.view !== "recommendations") + throw new Error("Expected regenerated editorial report"); + expect(current.report.recommendations.lineup.reservations[0]).toMatchObject({ + id: "plugin:pending", + status: "pending", + artifact: null, + }); + expect(current.report.recommendations.lineup.staleEditorial).toBe(false); }); -it("denies anonymous and ordinary users access to candidate evidence", async () => { - const t = convexTest(schema, modules); +it("denies anonymous and ordinary users access to evidence", async () => { + const { t } = await setup(); + expect((await t.fetch("/api/v1/search-insights?view=recommendations")).status).toBe(401); await expect( t.action(api.featuredIntelligence.get, { artifactKind: "plugin" }), ).rejects.toThrow(); const user = await t.run((ctx) => ctx.db.insert("users", { role: "user" })); await expect( - t.withIdentity({ subject: user }).action(api.featuredIntelligence.get, { - artifactKind: "skill", - }), + t + .withIdentity({ subject: user }) + .action(api.featuredIntelligence.get, { artifactKind: "skill" }), ).rejects.toThrow(); }); diff --git a/convex/featuredIntelligence.ts b/convex/featuredIntelligence.ts index f6f8f7ceb0..ff751492f3 100644 --- a/convex/featuredIntelligence.ts +++ b/convex/featuredIntelligence.ts @@ -1,7 +1,7 @@ -import { getPluginDiscoveryExclusion } from "clawhub-schema"; +import { paginationOptsValidator, type PaginationOptions } from "convex/server"; import { v } from "convex/values"; import { internal } from "./_generated/api"; -import type { ActionCtx, QueryCtx } from "./_generated/server"; +import type { ActionCtx } from "./_generated/server"; import { action, internalAction, internalQuery } from "./functions"; import { assertModerator, requireUserFromAction } from "./lib/access"; import { @@ -9,6 +9,8 @@ import { type AdoptionArtifact, type AdoptionSummary, type CurrentFeaturedArtifact, + type EditorialSelection, + type RecommendationArtifact, } from "./lib/featuredIntelligence"; import { SEARCH_DAY_MS, @@ -19,7 +21,6 @@ import { type SearchInsightArgs, type SearchInsightReport, } from "./lib/searchInsights"; -import { PACKAGE_TRENDING_LEADERBOARD_KIND } from "./packageLeaderboards"; import { readReport as readSearchReport } from "./searchInsights"; const ADOPTION_INSPECTION_LIMIT = 100; @@ -55,6 +56,9 @@ export type FeaturedEvidence = { currentFeatured: CurrentFeaturedArtifact[]; adoption: AdoptionReport; metadataCheckedAt: number; + editorial: EditorialSelection; + editorialArtifacts: RecommendationArtifact[]; + currentEditorialRevision: number; }; async function readReport( @@ -72,19 +76,26 @@ export async function collectFeaturedEvidence( if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error("limit must be between 1 and 100"); const startedAt = Date.now(); - const [searchReport, currentFeatured, adoption] = await Promise.all([ + const endDay = input.endDay ?? Math.floor(startedAt / SEARCH_DAY_MS) * SEARCH_DAY_MS; + const [searchReport, currentFeatured, adoption, editorial] = await Promise.all([ readSearchReport(ctx, { ...input, includeCurrentResults: true, limit: 100 }), ctx.runQuery(internal.featuredArtifacts.readCurrentFeaturedInternal, { artifactKind: input.artifactKind, }), - ctx.runQuery(internal.featuredIntelligence.readAdoptionInternal, { - artifactKind: input.artifactKind, - }), + readMonthlyAdoption(ctx, input.artifactKind, endDay), + input.artifactKind === "plugin" + ? ctx.runQuery(internal.featuredSelections.readEditorialInternal, { artifactKind: "plugin" }) + : Promise.resolve({ revision: 0, items: [] }), ]); return { searchReport, currentFeatured, adoption, + editorial, + currentEditorialRevision: editorial.revision, + editorialArtifacts: await ctx.runQuery(internal.featuredArtifacts.readInternal, { + identities: editorial.items.map((item) => item.id), + }), metadataCheckedAt: Math.max( startedAt, adoption.metadataCheckedAt ?? 0, @@ -101,6 +112,10 @@ export function renderFeaturedEvidence( return { searchReport, recommendations: recommendFeatured({ + artifactKind: searchReport.artifactKind, + editorial: evidence.editorial, + editorialArtifacts: evidence.editorialArtifacts, + currentEditorialRevision: evidence.currentEditorialRevision, rows: searchReport.rows, adoption: adoption.artifacts, window: searchReport.window, @@ -114,162 +129,248 @@ export function renderFeaturedEvidence( } export type AdoptionReport = { - snapshotCursor?: string; summary: AdoptionSummary; artifacts: AdoptionArtifact[]; - metadataCheckedAt: number | null; + metadataCheckedAt: number; }; -export const unavailableAdoption: AdoptionReport = { - summary: { - status: "unavailable", - generatedAt: null, - periodStart: null, - periodEnd: null, - snapshotId: null, - rankingVersion: null, - totalItems: 0, - inspectedItems: 0, - truncated: false, - }, - artifacts: [], - metadataCheckedAt: null, +const MONTHLY_RANKING_VERSION = "featured-installs-30d-v1"; +type InstallTotal = { + id: string; + installs30d: number; + installs7d: number; + importedRows: number; + importDatasetVersions: Set; }; -export const readAdoptionInternal = internalQuery({ - args: { artifactKind: searchArtifactKind }, - handler: async (ctx, { artifactKind }): Promise => - artifactKind === "plugin" ? readPluginAdoption(ctx) : readSkillAdoption(ctx), +// Read raw index pages before selecting nonzero rows. A database filter can +// exhaust its scan budget before finding a page, silently losing install facts. +export const readDailyInstallsInternal = internalQuery({ + args: { + artifactKind: searchArtifactKind, + day: v.number(), + paginationOpts: paginationOptsValidator, + }, + handler: async (ctx, { artifactKind, day, paginationOpts }) => { + const result = + artifactKind === "plugin" + ? await ctx.db + .query("packageDailyStats") + .withIndex("by_day", (q) => q.eq("day", day)) + .paginate(paginationOpts) + : await ctx.db + .query("skillDailyStats") + .withIndex("by_day", (q) => q.eq("day", day)) + .paginate(paginationOpts); + return { + ...result, + scannedRows: result.page.length, + page: result.page + .filter((row) => row.installs !== 0) + .map((row) => ({ + id: "packageId" in row ? String(row.packageId) : `clawhub:${row.skillId}`, + installs: row.installs, + imported: row.rankingImportedAt !== undefined, + dataset: row.rankingDatasetVersion ?? null, + })), + }; + }, }); -async function readPluginAdoption(ctx: QueryCtx): Promise { - const snapshot = await ctx.db - .query("packageLeaderboards") - .withIndex("by_kind", (q) => q.eq("kind", PACKAGE_TRENDING_LEADERBOARD_KIND)) - .order("desc") - .first(); - if (!snapshot) return unavailableAdoption; - // Old snapshots can still contain setup categories. Filter the canonical - // purpose before the inspection cap so they cannot starve discovery candidates. - const inspected: Array<{ - item: (typeof snapshot.items)[number]; - rank: number; - identity: string; - }> = []; - let scannedItems = 0; - for (const [index, item] of snapshot.items.entries()) { - scannedItems += 1; - const pkg = await ctx.db.get(item.packageId); - if (!pkg || getPluginDiscoveryExclusion(pkg.categories)) continue; - inspected.push({ item, rank: index + 1, identity: `plugin:${pkg.name}` }); - if (inspected.length === ADOPTION_INSPECTION_LIMIT) break; - } - const artifacts = await ctx.runQuery(internal.featuredArtifacts.readInternal, { - identities: inspected.map((entry) => entry.identity), - }); - const byId = new Map(artifacts.map((artifact) => [artifact.id, artifact])); - const periodStart = snapshot.rangeStartDay * SEARCH_DAY_MS; - // Package Trending includes the current partial UTC day. Its evidence ends - // at generation, not at an unobserved future midnight. - const periodEnd = Math.min((snapshot.rangeEndDay + 1) * SEARCH_DAY_MS, snapshot.generatedAt); - const rankingVersion = "unversioned"; - const evidence: AdoptionArtifact[] = []; - inspected.forEach(({ item, rank, identity }) => { - const artifact = identity ? byId.get(identity) : undefined; - if (!artifact) return; - evidence.push({ - artifact, - evidence: { - source: "package-trending", - rank, - snapshotId: String(snapshot._id), - rankingVersion, - periodStart, - periodEnd, - generatedAt: snapshot.generatedAt, - sourceObservedAt: null, - downloads: item.downloads, - installs: item.installs, - bookmarks: null, - lifetimeInstalls: null, - }, +export const readPackageIdentitiesInternal = internalQuery({ + args: { ids: v.array(v.id("packages")) }, + handler: async (ctx, { ids }) => { + if (ids.length > 100) throw new Error("Too many package identities"); + return Promise.all( + ids.map(async (id) => { + const pkg = await ctx.db.get(id); + return { id: String(id), identity: pkg ? `plugin:${pkg.name}` : null }; + }), + ); + }, +}); + +async function readMonthlyAdoption( + ctx: ActionCtx, + artifactKind: SearchArtifactKind, + periodEnd: number, +): Promise { + const collectionStartedAt = Date.now(); + if ( + !Number.isSafeInteger(periodEnd) || + periodEnd % SEARCH_DAY_MS || + periodEnd > Math.floor(collectionStartedAt / SEARCH_DAY_MS) * SEARCH_DAY_MS + ) + throw new Error("Monthly adoption requires completed UTC days"); + const endDay = periodEnd / SEARCH_DAY_MS; + const periodStart = periodEnd - 30 * SEARCH_DAY_MS; + const periodStart7d = periodEnd - 7 * SEARCH_DAY_MS; + const totals = new Map(); + let scannedRows = 0; + let importedRows = 0; + const importDatasetVersions = new Set(); + const readPage = async ( + day: number, + paginationOpts: PaginationOptions, + ): Promise<{ isDone: boolean; continueCursor: string }> => { + const result = await ctx.runQuery(internal.featuredIntelligence.readDailyInstallsInternal, { + artifactKind, + day, + paginationOpts, }); - }); - return { - summary: { - status: "available", - generatedAt: snapshot.generatedAt, - periodStart, - periodEnd, - snapshotId: String(snapshot._id), - rankingVersion, - totalItems: snapshot.items.length, - inspectedItems: scannedItems, - truncated: snapshot.items.length > scannedItems, - }, - artifacts: evidence, - metadataCheckedAt: Date.now(), + if (result.pageStatus === "SplitRequired") { + // Native cursor ranges replace the incomplete parent; never count both. + if ( + !result.splitCursor || + result.splitCursor === paginationOpts.cursor || + result.splitCursor === result.continueCursor + ) + throw new Error("Monthly adoption page cannot be completed"); + await readPage(day, { ...paginationOpts, endCursor: result.splitCursor }); + await readPage(day, { + ...paginationOpts, + cursor: result.splitCursor, + endCursor: result.continueCursor, + }); + } else { + scannedRows += result.scannedRows; + for (const row of result.page) { + let total = totals.get(row.id); + if (!total) { + total = { + id: row.id, + installs30d: 0, + installs7d: 0, + importedRows: 0, + importDatasetVersions: new Set(), + }; + totals.set(row.id, total); + } + total.installs30d += row.installs; + if (day >= endDay - 7) total.installs7d += row.installs; + if (row.imported) { + total.importedRows++; + importedRows++; + } + if (row.dataset) { + total.importDatasetVersions.add(row.dataset); + importDatasetVersions.add(row.dataset); + } + } + } + return { isDone: result.isDone, continueCursor: result.continueCursor }; }; -} - -async function readSkillAdoption(ctx: QueryCtx): Promise { - // Use the same serving owner as public Trending, including its freshness, - // rollout and visibility checks. Do not reconstruct its interleaved ranks. - const result = await ctx.runQuery(internal.canonicalTrending.getPageInternal, { - cursor: null, - limit: ADOPTION_INSPECTION_LIMIT, - }); - if (result.status !== "ok") return unavailableAdoption; - const page = result.page; - const snapshot = await ctx.db - .query("canonicalTrendingSnapshots") - .withIndex("by_snapshot_id", (q) => q.eq("snapshotId", page.snapshotId)) - .unique(); - const artifacts = await ctx.runQuery(internal.featuredArtifacts.readInternal, { - identities: page.items.map((item) => item.id), - }); - const byId = new Map(artifacts.map((artifact) => [artifact.id, artifact])); - const periodStart = - snapshot?.windowStartHour === undefined ? null : snapshot.windowStartHour * 3_600_000; - const periodEnd = - snapshot?.windowEndHour === undefined ? null : (snapshot.windowEndHour + 1) * 3_600_000; - const generatedAt = Date.parse(page.generatedAt); - const evidence: AdoptionArtifact[] = []; - for (const item of page.items) { - const artifact = byId.get(item.id); - if (!artifact) continue; - evidence.push({ - artifact, - evidence: { - source: item.lane, - rank: item.rank, - snapshotId: page.snapshotId, - rankingVersion: page.rankingVersion, - periodStart: item.source === "skills-sh" ? null : periodStart, - periodEnd: item.source === "skills-sh" ? null : periodEnd, - generatedAt, - sourceObservedAt: item.source === "skills-sh" ? item.metrics.updatedAt : null, - downloads: item.metrics.trending24hDownloads ?? null, - installs: item.metrics.trending24hInstalls, - bookmarks: item.metrics.trending24hBookmarks, - lifetimeInstalls: item.metrics.lifetimeInstalls, - }, + // Four independent day scans bound active transactions. Ranking starts only + // after every day completes; no Trending or download-sorted admission cap. + let nextDay = endDay - 30; + let failed = false; + const scans = await Promise.allSettled( + Array.from({ length: 4 }, async () => { + try { + while (nextDay < endDay) { + if (failed) break; + const day = nextDay++; + let cursor: string | null = null; + for (;;) { + if (failed) break; + const page = await readPage(day, { + cursor, + numItems: 5000, + maximumBytesRead: 8_000_000, + }); + if (page.isDone) break; + cursor = page.continueCursor; + } + } + } catch (error) { + failed = true; + throw error; + } + }), + ); + for (const result of scans) if (result.status === "rejected") throw result.reason; + let ranked = [...totals.values()].filter((entry) => entry.installs30d > 0); + if (artifactKind === "plugin") { + const resolved: InstallTotal[] = []; + for (let offset = 0; offset < ranked.length; offset += 100) { + const batch = ranked.slice(offset, offset + 100); + const identities = await ctx.runQuery( + internal.featuredIntelligence.readPackageIdentitiesInternal, + { ids: batch.map((entry) => entry.id as import("./_generated/dataModel").Id<"packages">) }, + ); + identities.forEach((entry, index) => { + if (entry.identity) resolved.push({ ...batch[index], id: entry.identity }); + }); + } + ranked = resolved; + } + ranked.sort( + (a, b) => + b.installs30d - a.installs30d || + b.installs7d - a.installs7d || + (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), + ); + const generatedAt = Date.now(); + const snapshotId = `${artifactKind}:${periodEnd}:${collectionStartedAt}`; + const artifacts: AdoptionArtifact[] = []; + let inspectedItems = 0; + let eligibleItems = 0; + let retainedExclusions = 0; + // Limit rich report metadata after ranking the complete install population. + // Ineligible leading rows cannot starve lower-ranked eligible candidates. + for ( + let offset = 0; + offset < ranked.length && eligibleItems < ADOPTION_INSPECTION_LIMIT; + offset += 100 + ) { + const batch = ranked.slice(offset, offset + 100); + const metadata = await ctx.runQuery(internal.featuredArtifacts.readInternal, { + identities: batch.map((entry) => entry.id), }); + const byId = new Map(metadata.map((entry) => [entry.id, entry])); + inspectedItems += batch.length; + for (const [index, entry] of batch.entries()) { + const artifact = byId.get(entry.id); + if (!artifact) continue; + if (artifact.eligibleForFeatured) eligibleItems++; + else if (retainedExclusions++ >= ADOPTION_INSPECTION_LIMIT) continue; + artifacts.push({ + artifact, + evidence: { + source: artifactKind === "plugin" ? "package-daily-installs" : "skill-daily-installs", + rank: offset + index + 1, + snapshotId, + rankingVersion: MONTHLY_RANKING_VERSION, + periodStart, + periodStart7d, + periodEnd, + generatedAt, + installs30d: entry.installs30d, + installs7d: entry.installs7d, + importedRows: entry.importedRows, + importDatasetVersions: [...entry.importDatasetVersions].sort(), + }, + }); + } } return { - snapshotCursor: page.snapshotCursor, summary: { status: "available", generatedAt, + collectionStartedAt, periodStart, + periodStart7d, periodEnd, - snapshotId: page.snapshotId, - rankingVersion: page.rankingVersion, - totalItems: page.totalItems, - inspectedItems: page.items.length, - truncated: page.nextCursor !== null, + snapshotId, + rankingVersion: MONTHLY_RANKING_VERSION, + totalItems: ranked.length, + inspectedItems, + truncated: inspectedItems < ranked.length || artifacts.length < inspectedItems, + scannedRows, + importedRows, + importDatasetVersions: [...importDatasetVersions].sort(), }, - artifacts: evidence, + artifacts, metadataCheckedAt: Date.now(), }; } diff --git a/convex/lib/featuredIntelligence.test.ts b/convex/lib/featuredIntelligence.test.ts index 6279bef8b3..cd81f5ebbf 100644 --- a/convex/lib/featuredIntelligence.test.ts +++ b/convex/lib/featuredIntelligence.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { expect, it } from "vitest"; import { recommendFeatured, type RecommendationArtifact } from "./featuredIntelligence"; const artifact = (id: string): RecommendationArtifact => ({ @@ -19,169 +19,181 @@ const demand = (query: string, searches7d: number, results: RecommendationArtifa searches30d: searches7d + 2, currentResults: results, }); -const adoption = (rank: number) => ({ - source: "package-trending" as const, - rank, - snapshotId: "plugin-snapshot", - rankingVersion: "existing-package-trending", - periodStart: 1_000, - periodEnd: 2_000, - generatedAt: 2_000, - sourceObservedAt: null, - downloads: 40, - installs: 3, - bookmarks: null, - lifetimeInstalls: null, +const adoption = (installs30d: number, installs7d = 0) => ({ + source: "package-daily-installs" as const, + rank: 1, + snapshotId: "fixture", + rankingVersion: "featured-installs-30d-v1", + periodStart: 500, + periodStart7d: 1000, + periodEnd: 2000, + generatedAt: 2000, + installs30d, + installs7d, + importedRows: 0, + importDatasetVersions: [], }); const window = { start7d: 1_000, start30d: 500, endDay: 2_000, days: 7 as const }; const coverage = { dataThrough: 2_000, collectionStartedAt: 1_000 }; -describe("Featured recommendation evidence", () => { - it("unites distinct signal cohorts without creating a blended score or duplicate candidate", () => { - const both = artifact("plugin:both"); - const searched = artifact("plugin:searched"); - const chosen = artifact("plugin:chosen"); - const report = recommendFeatured({ - rows: [demand("calendar", 8, [both, searched, both]), demand("schedule", 4, [both])], - adoption: [ - { artifact: chosen, evidence: adoption(1) }, - { artifact: both, evidence: adoption(2) }, - ], - window, - coverage, - limit: 20, - }); - expect(report.candidates.map((row) => [row.id, row.support])).toEqual([ - [both.id, "both"], - [searched.id, "search-only"], - [chosen.id, "adoption-only"], - ]); - expect(report.candidates[0].search).toMatchObject({ matchedSearches7d: 12, searches30d: 16 }); - expect(report.candidates[0].adoption).toEqual(adoption(2)); - expect(report.candidates[2].search).toBeNull(); - expect(report.candidates.every((row) => !("score" in row))).toBe(true); - }); +const base = { + artifactKind: "plugin" as const, + rows: [], + adoption: [], + editorial: { revision: 0, items: [] }, + editorialArtifacts: [], + currentEditorialRevision: 0, + window, + coverage, + limit: 20, +}; - it("retains observed adoption when the newly deployed search window is empty", () => { - const native = { ...artifact("clawhub:skill-id"), artifactKind: "skill" as const }; - const report = recommendFeatured({ - rows: [], - adoption: [{ artifact: native, evidence: { ...adoption(3), source: "clawhub-trending" } }], - window, - coverage, - limit: 20, - }); - expect(report.candidates).toMatchObject([ - { id: native.id, support: "adoption-only", search: null }, - ]); - expect(report.candidates[0].adoption?.installs).toBe(3); +it("ranks telemetry by monthly installs, final-week installs, then identity without search or incumbent bonuses", () => { + const entries = ["plugin:monthly", "plugin:weekly", "plugin:stable-a", "plugin:stable-b"].map( + artifact, + ); + const report = recommendFeatured({ + ...base, + rows: [demand("familiar", 10000, [entries[3], artifact("plugin:search-only")])], + adoption: entries.map((entry, index) => ({ + artifact: entry, + evidence: adoption([20, 10, 10, 10][index], [0, 8, 3, 3][index]), + })), + currentFeatured: [{ ...entries[3], featuredAt: 1 }], }); - - it("keeps ineligible entries as explicit exclusions instead of promoting popularity", () => { - const blocked = { - ...artifact("plugin:blocked"), - eligibleForFeatured: false, - eligibilityReasons: ["security_not_clean"], - }; - const report = recommendFeatured({ - rows: [demand("popular", 900, [blocked])], - adoption: [{ artifact: blocked, evidence: adoption(1) }], - window, - coverage, - limit: 20, - }); - expect(report.candidates).toEqual([]); - expect(report.excluded).toEqual([ - { - id: blocked.id, - displayName: blocked.displayName, - url: blocked.url, - reasons: ["security_not_clean"], - }, - ]); + expect(report.lineup.proposed.map((entry) => entry.id)).toEqual(entries.map((entry) => entry.id)); + expect(report.lineup).toMatchObject({ + targetSize: 16, + reservedSlots: 8, + telemetryTarget: 8, + pendingCount: 8, + telemetryShortfall: 4, + shortfall: 12, }); + expect(report.lineup.proposed[3]).toMatchObject({ + change: "retain", + support: "both", + selectionBasis: "telemetry", + slot: 11, + }); +}); - it("keeps filtered demand scope and reports bounded omissions", () => { - const candidate = artifact("plugin:calendar"); - const rows = Array.from({ length: 12 }, (_, index) => ({ - ...demand(`query ${index}`, index + 1, [candidate]), - scope: "shelf" as const, - })); - const report = recommendFeatured({ rows, adoption: [], window, coverage, limit: 20 }); - expect(report.candidates[0].search).toMatchObject({ matchedSearches7d: 78, omittedQueries: 4 }); - expect(report.candidates[0].search?.queries).toHaveLength(8); - expect(report.candidates[0].search?.queries.every((row) => row.scope === "shelf")).toBe(true); +it("reserves missing editorial slots, deduplicates their telemetry and exposes stale revisions without substituting choices", () => { + const chosen = artifact("plugin:chosen"); + const missing = { + ...artifact("plugin:missing"), + eligibleForFeatured: false, + eligibilityReasons: ["no-public-version"], + }; + const report = recommendFeatured({ + ...base, + editorial: { + revision: 4, + items: [ + { + id: chosen.id, + name: chosen.name, + displayName: chosen.displayName, + reason: "Useful review tool", + }, + { + id: missing.id, + name: missing.name, + displayName: missing.displayName, + reason: "Awaiting publication", + }, + ], + }, + editorialArtifacts: [chosen, missing], + currentEditorialRevision: 5, + adoption: [ + chosen, + ...Array.from({ length: 12 }, (_, index) => artifact(`plugin:item-${index}`)), + ].map((entry, index) => ({ artifact: entry, evidence: adoption(100 - index) })), + }); + expect(report.lineup.proposed).toHaveLength(9); + expect(report.lineup.proposed[0]).toMatchObject({ + id: chosen.id, + slot: 0, + selectionBasis: "editorial", + reason: "Useful review tool", + }); + expect(report.lineup.proposed[1]).toMatchObject({ + id: "plugin:item-0", + slot: 8, + selectionBasis: "telemetry", + }); + expect(report.lineup.reservations[1]).toMatchObject({ + id: missing.id, + status: "pending", + artifact: null, + pendingReasons: ["no-public-version"], + }); + expect(report.lineup).toMatchObject({ + editorialRevision: 4, + currentEditorialRevision: 5, + staleEditorial: true, + pendingCount: 7, + telemetryShortfall: 0, + shortfall: 7, }); }); -it("reassesses a complete eight-member lineup, retains observed members and explains removals without padding", () => { - const existing = { ...artifact("plugin:existing"), version: "1.0.0", featuredAt: 10 }; - const unknown = { ...artifact("plugin:unknown"), version: "1.0.0", featuredAt: 11 }; +it("fills sixteen skill telemetry slots, excludes unsafe entries and never pads with current-only or search-only cards", () => { + const entries = Array.from({ length: 20 }, (_, index) => ({ + ...artifact(`clawhub:${index.toString().padStart(2, "0")}`), + artifactKind: "skill" as const, + })); const unsafe = { - ...artifact("plugin:unsafe"), - featuredAt: 12, + ...entries[0], eligibleForFeatured: false, eligibilityReasons: ["security-not-clean"], }; - const newcomers = Array.from({ length: 9 }, (_, index) => artifact(`plugin:new-${index}`)); - const result = recommendFeatured({ - rows: [], - adoption: [ - { artifact: existing, evidence: adoption(2) }, - ...newcomers.map((entry, index) => ({ artifact: entry, evidence: adoption(index + 3) })), - ], - currentFeatured: [existing, unknown, unsafe], - window, - coverage, + const report = recommendFeatured({ + ...base, + artifactKind: "skill", limit: 1, + adoption: [unsafe, ...entries.slice(1)].map((entry, index) => ({ + artifact: entry, + evidence: adoption(100 - index), + })), }); - expect(result.lineup.proposed).toHaveLength(8); - expect(result.lineup.proposed[0]).toMatchObject({ id: existing.id, change: "retain" }); - expect(result.lineup.proposed.slice(1).every((entry) => entry.change === "add")).toBe(true); - expect(result.lineup.removals).toMatchObject([ - { id: unknown.id, reasons: ["outside-proposed-set"] }, - { id: unsafe.id, reasons: ["security-not-clean"] }, - ]); - expect(result.lineup.baseline).toHaveLength(3); - expect(result.lineup.shortfall).toBe(0); - const sparse = recommendFeatured({ - rows: [], - adoption: [], - currentFeatured: [unknown, unsafe], - window, - coverage, - limit: 20, + expect(report.lineup.proposed).toHaveLength(16); + expect(report.lineup.proposed[0]).toMatchObject({ id: "clawhub:01", slot: 0 }); + expect(report.lineup.proposed.at(-1)?.id).toBe("clawhub:16"); + expect(report.lineup.reservations).toEqual([]); + expect(report.excluded).toMatchObject([{ id: unsafe.id, reasons: ["security-not-clean"] }]); + const empty = recommendFeatured({ + ...base, + artifactKind: "skill", + currentFeatured: [{ ...entries[1], featuredAt: 1 }], + rows: [demand("popular", 99, entries)], }); - expect(sparse.lineup.proposed).toMatchObject([ - { id: unknown.id, change: "retain", support: "current-only", search: null, adoption: null }, + expect(empty.lineup.proposed).toEqual([]); + expect(empty.lineup.removals).toMatchObject([ + { id: entries[1].id, reasons: ["outside-proposed-set"] }, ]); - expect(sparse.lineup.shortfall).toBe(7); + expect(empty.lineup.shortfall).toBe(16); }); -it("labels recent publication with observed adoption and existing Rising evidence without inventing growth", () => { - const recent = { ...artifact("plugin:recent"), createdAt: 1_500 }; - const old = { ...artifact("plugin:old"), createdAt: 0 }; - const snapshot = { ...adoption(1), generatedAt: 20 * 86_400_000 }; - const rows = [recent, old].map((entry, index) => ({ - artifact: { ...entry, createdAt: index ? 0 : snapshot.generatedAt - 86_400_000 }, - evidence: snapshot, - })); - const result = recommendFeatured({ - rows: [], - adoption: rows, - currentFeatured: [], - window, - coverage, - limit: 20, - }); - expect(result.lineup.proposed.find((entry) => entry.id === recent.id)?.emerging).toBe(true); - expect(result.lineup.proposed.find((entry) => entry.id === old.id)?.emerging).toBe(false); - const noUsage = recommendFeatured({ - rows: [], - adoption: [{ artifact: recent, evidence: { ...adoption(1), downloads: 0, installs: 0 } }], - window, - coverage, - limit: 20, +it("retains bounded query context without changing install selection or mistaking absent evidence for zero", () => { + const candidate = artifact("plugin:calendar"); + const report = recommendFeatured({ + ...base, + rows: Array.from({ length: 12 }, (_, index) => ({ + ...demand(`query ${index}`, index + 1, [candidate, candidate]), + scope: "shelf" as const, + })), + adoption: [{ artifact: candidate, evidence: adoption(3) }], }); - expect(noUsage.lineup.proposed[0].emerging).toBe(false); + expect(report.candidates[0].search).toMatchObject({ matchedSearches7d: 78, omittedQueries: 4 }); + expect(report.candidates[0].search?.queries).toHaveLength(8); + expect( + recommendFeatured({ ...base, adoption: [{ artifact: candidate, evidence: adoption(3) }] }) + .candidates[0].search, + ).toBeNull(); + expect( + recommendFeatured({ ...base, adoption: [{ artifact: candidate, evidence: adoption(0) }] }) + .lineup.proposed, + ).toEqual([]); }); diff --git a/convex/lib/featuredIntelligence.ts b/convex/lib/featuredIntelligence.ts index f1e38c7722..24a59f65f5 100644 --- a/convex/lib/featuredIntelligence.ts +++ b/convex/lib/featuredIntelligence.ts @@ -1,5 +1,9 @@ import { DISCOVERY_RECENT_WINDOW_MS } from "./discoveryWindows"; import { FEATURED_CATALOG_SIZE } from "./featuredPolicy"; +import { + FEATURED_EDITORIAL_SLOTS, + type EditorialSelection as EditorialItem, +} from "./featuredSelections"; export type RecommendationArtifact = { id: string; artifactKind: "plugin" | "skill"; @@ -15,18 +19,23 @@ export type RecommendationArtifact = { }; export type AdoptionEvidence = { - source: "package-trending" | "clawhub-trending" | "clawhub-rising" | "skills-sh-trending"; + source: "package-daily-installs" | "skill-daily-installs"; rank: number; snapshotId: string; rankingVersion: string; - periodStart: number | null; - periodEnd: number | null; + periodStart: number; + periodStart7d: number; + periodEnd: number; generatedAt: number; - sourceObservedAt: number | null; - downloads: number | null; - installs: number | null; - bookmarks: number | null; - lifetimeInstalls: number | null; + installs30d: number; + installs7d: number; + importedRows: number; + importDatasetVersions: string[]; +}; + +export type EditorialSelection = { + revision: number; + items: EditorialItem[]; }; export type RecommendationQuery = { @@ -71,18 +80,27 @@ export type AdoptionArtifact = { artifact: RecommendationArtifact; evidence: Ado export type AdoptionSummary = { status: "available" | "unavailable"; generatedAt: number | null; - periodStart: number | null; - periodEnd: number | null; + collectionStartedAt: number; + periodStart: number; + periodStart7d: number; + periodEnd: number; snapshotId: string | null; - rankingVersion: string | null; + rankingVersion: string; totalItems: number; inspectedItems: number; truncated: boolean; + scannedRows: number; + importedRows: number; + importDatasetVersions: string[]; }; -// Each call ranks one catalog. The existing adoption rank and observed search -// counts remain separate; cohort order is not a new combined popularity score. +// Search associations explain context only. Selection uses completed-month +// installs, the final week's installs, and stable identity in that order. export function recommendFeatured(params: { + artifactKind: "plugin" | "skill"; + editorial: EditorialSelection; + editorialArtifacts: RecommendationArtifact[]; + currentEditorialRevision: number; rows: DemandRow[]; adoption: AdoptionArtifact[]; window: { start7d: number; start30d: number; endDay: number; days: 7 | 30 }; @@ -129,6 +147,7 @@ export function recommendFeatured(params: { // Trending result mentions the item. Missing evidence is not zero demand. const currentFeatured = params.currentFeatured ?? []; for (const artifact of currentFeatured) entryFor(artifact).artifact = artifact; + for (const artifact of params.editorialArtifacts) entryFor(artifact).artifact = artifact; const excluded: Array<{ id: string; displayName: string; url: string; reasons: string[] }> = []; const recommendations: FeaturedRecommendation[] = []; for (const { artifact, queries: byQuery, adoption } of candidates.values()) { @@ -173,40 +192,87 @@ export function recommendFeatured(params: { adoption, }); } - const cohort = { both: 0, "search-only": 1, "adoption-only": 2, "current-only": 3 }; - recommendations.sort( + const byId = new Map(recommendations.map((entry) => [entry.id, entry])); + const telemetry = recommendations.filter((entry) => (entry.adoption?.installs30d ?? 0) > 0); + telemetry.sort( (a, b) => - cohort[a.support] - cohort[b.support] || - (params.window.days === 7 - ? (b.search?.matchedSearches7d ?? 0) - (a.search?.matchedSearches7d ?? 0) - : (b.search?.searches30d ?? 0) - (a.search?.searches30d ?? 0)) || - (a.adoption?.rank ?? Number.MAX_SAFE_INTEGER) - - (b.adoption?.rank ?? Number.MAX_SAFE_INTEGER) || - a.id.localeCompare(b.id), + b.adoption!.installs30d - a.adoption!.installs30d || + b.adoption!.installs7d - a.adoption!.installs7d || + (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), ); - const previous = new Set(currentFeatured.map((artifact) => artifact.id)); - const proposed = recommendations.slice(0, FEATURED_CATALOG_SIZE).map((candidate) => { - const adoption = candidate.adoption; - const observed = - adoption && - [adoption.downloads, adoption.installs, adoption.bookmarks].some( - (count) => count !== null && count > 0, - ); - const recentlyPublished = - candidate.createdAt !== undefined && - adoption && - candidate.createdAt <= adoption.generatedAt && - candidate.createdAt >= adoption.generatedAt - DISCOVERY_RECENT_WINDOW_MS; + const reservedSlots = params.artifactKind === "plugin" ? FEATURED_EDITORIAL_SLOTS : 0; + const telemetryTarget = FEATURED_CATALOG_SIZE - reservedSlots; + const reservations = Array.from({ length: reservedSlots }, (_, slot) => { + const item = params.editorial.items[slot]; + const artifact = item ? (byId.get(item.id) ?? null) : null; + const rejected = item ? candidates.get(item.id)?.artifact : null; return { - ...candidate, - change: previous.has(candidate.id) ? ("retain" as const) : ("add" as const), - emerging: Boolean(observed && (adoption?.source === "clawhub-rising" || recentlyPublished)), + slot, + id: item?.id ?? null, + name: item?.name ?? null, + displayName: item?.displayName ?? null, + reason: item?.reason ?? null, + status: artifact ? ("ready" as const) : ("pending" as const), + pendingReasons: artifact + ? [] + : item + ? (rejected?.eligibilityReasons ?? ["no-public-version"]) + : ["unassigned-editorial-slot"], + artifact, }; }); + const previous = new Set(currentFeatured.map((artifact) => artifact.id)); + const selectedEditorial = new Set( + params.artifactKind === "plugin" ? params.editorial.items.map((item) => item.id) : [], + ); + const selection = [ + ...reservations.flatMap((entry) => + entry.artifact + ? [ + { + candidate: entry.artifact, + slot: entry.slot, + selectionBasis: "editorial" as const, + reason: entry.reason!, + }, + ] + : [], + ), + ...telemetry + .filter((entry) => !selectedEditorial.has(entry.id)) + .slice(0, telemetryTarget) + .map((candidate, index) => ({ + candidate, + slot: reservedSlots + index, + selectionBasis: "telemetry" as const, + reason: `${candidate.adoption!.installs30d} installs in 30 completed UTC days; ${candidate.adoption!.installs7d} in the final 7 days.`, + })), + ]; + const proposed = selection.map(({ candidate, ...choice }) => ({ + ...candidate, + ...choice, + change: previous.has(candidate.id) ? ("retain" as const) : ("add" as const), + emerging: Boolean( + candidate.adoption && + candidate.createdAt !== undefined && + candidate.createdAt <= candidate.adoption.generatedAt && + candidate.createdAt >= candidate.adoption.generatedAt - DISCOVERY_RECENT_WINDOW_MS, + ), + })); + const pendingCount = reservations.filter((entry) => entry.status === "pending").length; const selected = new Set(proposed.map((candidate) => candidate.id)); return { lineup: { - targetSize: FEATURED_CATALOG_SIZE as 8, + targetSize: FEATURED_CATALOG_SIZE as 16, + reservedSlots, + telemetryTarget, + reservations, + pendingCount, + telemetryShortfall: + telemetryTarget - proposed.filter((entry) => entry.selectionBasis === "telemetry").length, + editorialRevision: params.editorial.revision, + currentEditorialRevision: params.currentEditorialRevision, + staleEditorial: params.editorial.revision !== params.currentEditorialRevision, baseline: currentFeatured.map((artifact) => ({ id: artifact.id, version: artifact.version ?? null, @@ -225,9 +291,9 @@ export function recommendFeatured(params: { })), shortfall: FEATURED_CATALOG_SIZE - proposed.length, }, - candidates: recommendations.slice(0, params.limit), - totalCandidates: recommendations.length, - omittedCandidates: Math.max(0, recommendations.length - params.limit), + candidates: telemetry.slice(0, params.limit), + totalCandidates: telemetry.length, + omittedCandidates: Math.max(0, telemetry.length - params.limit), excluded, }; } diff --git a/convex/lib/searchDigestContract.ts b/convex/lib/searchDigestContract.ts index 668b22b29a..a8bca59aae 100644 --- a/convex/lib/searchDigestContract.ts +++ b/convex/lib/searchDigestContract.ts @@ -195,11 +195,70 @@ export const lineupSearchDigestValidator = evidenceSearchDigestValidator catalogs: v.object({ plugins: lineupCatalog, skills: lineupCatalog }), }); +// Period, capture and ranking identity are shared once per catalog. Repeating +// them on all32 cards exhausts the wire budget without adding evidence. +const monthlyAdoption = v.object({ + source: v.union(v.literal("package-daily-installs"), v.literal("skill-daily-installs")), + rank: v.number(), + installs30d: v.number(), + installs7d: v.number(), + importedRows: v.number(), + importDatasetVersions: v.array(v.string()), +}); +export const monthlyRecommendationValidator = lineupRecommendationValidator + .omit("adoption") + .extend({ + adoption: v.union(monthlyAdoption, v.null()), + slot: v.number(), + selectionBasis: v.union(v.literal("editorial"), v.literal("telemetry")), + reason: v.string(), + }); +const monthlyCatalog = lineupCatalog.omit("adoption", "lineup", "recommendations").extend({ + adoption: catalog.fields.adoption.extend({ + collectionStartedAt: v.number(), + periodStart7d: v.number(), + scannedRows: v.number(), + importedRows: v.number(), + importDatasetVersions: v.array(v.string()), + }), + lineup: featuredLineupValidator.omit("targetSize").extend({ + targetSize: v.literal(16), + reservedSlots: v.number(), + telemetryTarget: v.number(), + pendingCount: v.number(), + telemetryShortfall: v.number(), + editorialRevision: v.number(), + currentEditorialRevision: v.number(), + staleEditorial: v.boolean(), + reservations: v.array( + v.object({ + slot: v.number(), + id: nullableString, + name: nullableString, + displayName: nullableString, + reason: nullableString, + status: v.union(v.literal("ready"), v.literal("pending")), + pendingReasons: v.array(v.string()), + }), + ), + }), + recommendations: v.array(monthlyRecommendationValidator), +}); +export const monthlySearchDigestValidator = lineupSearchDigestValidator + .omit("kind", "catalogs") + .extend({ + kind: v.literal("search_intelligence_weekly_v4"), + catalogs: v.object({ plugins: monthlyCatalog, skills: monthlyCatalog }), + }); +export type MonthlySearchDigest = Infer; +export type MonthlySearchRecommendation = Infer; + // Frozen weeks retain their original contract and receipt hash across upgrades. export const searchDigestValidator = v.union( legacySearchDigestValidator, evidenceSearchDigestValidator, lineupSearchDigestValidator, + monthlySearchDigestValidator, ); export type WeeklySearchDigest = Infer; diff --git a/convex/lib/searchEvidenceDigest.test.ts b/convex/lib/searchEvidenceDigest.test.ts index c4aa765968..c23d69eea9 100644 --- a/convex/lib/searchEvidenceDigest.test.ts +++ b/convex/lib/searchEvidenceDigest.test.ts @@ -15,6 +15,11 @@ const catalog = (): DigestCatalogInput => ({ }, adoption: { status: "available", + collectionStartedAt: weekEnd + 86_400_000, + periodStart7d: weekEnd - 7 * 86_400_000, + scannedRows: 1, + importedRows: 0, + importDatasetVersions: [], generatedAt: weekEnd + 86_400_000, periodStart: weekEnd, periodEnd: weekEnd + 86_400_000, @@ -48,7 +53,21 @@ const catalog = (): DigestCatalogInput => ({ }, ], recommendations: { - lineup: { targetSize: 8, baseline: [], proposed: [], removals: [], shortfall: 8 }, + lineup: { + targetSize: 16, + baseline: [], + proposed: [], + removals: [], + shortfall: 16, + reservedSlots: 0, + telemetryTarget: 16, + pendingCount: 0, + telemetryShortfall: 16, + editorialRevision: 0, + currentEditorialRevision: 0, + staleEditorial: false, + reservations: [], + }, omittedCandidates: 0, candidates: [ { @@ -81,18 +100,12 @@ const catalog = (): DigestCatalogInput => ({ collectionStartedAt: weekEnd - 604_800_000, }, adoption: { - source: "package-trending", + source: "package-daily-installs", rank: 2, - snapshotId: "latest", - rankingVersion: "v1", - periodStart: weekEnd, - periodEnd: weekEnd + 86_400_000, - generatedAt: weekEnd + 86_400_000, - sourceObservedAt: null, - downloads: 341, - installs: 1, - bookmarks: null, - lifetimeInstalls: null, + installs30d: 341, + installs7d: 1, + importedRows: 0, + importDatasetVersions: [], }, }, ], @@ -105,17 +118,36 @@ const build = ( recommendations: { candidates: [], omittedCandidates: 0, - lineup: { targetSize: 8, baseline: [], proposed: [], removals: [], shortfall: 8 }, + lineup: { + targetSize: 16, + baseline: [], + proposed: [], + removals: [], + shortfall: 16, + reservedSlots: 0, + telemetryTarget: 16, + pendingCount: 0, + telemetryShortfall: 16, + editorialRevision: 0, + currentEditorialRevision: 0, + staleEditorial: false, + reservations: [], + }, }, }, ) => { for (const input of [plugins, skills]) { - input.recommendations.lineup.proposed = input.recommendations.candidates.map((candidate) => ({ - ...candidate, - change: "add", - emerging: false, - })); - input.recommendations.lineup.shortfall = 8 - input.recommendations.lineup.proposed.length; + input.recommendations.lineup.proposed = input.recommendations.candidates.map( + (candidate, index) => ({ + ...candidate, + slot: index, + selectionBasis: "telemetry", + reason: "Recorded monthly installs", + change: "add", + emerging: false, + }), + ); + input.recommendations.lineup.shortfall = 16 - input.recommendations.lineup.proposed.length; } return buildSearchEvidenceDigest({ weekEnd, @@ -138,10 +170,7 @@ it("projects both catalogs, separate periods and scoped demand without leaking i search: null, adoption: { ...plugins.recommendations.candidates[0].adoption!, - source: "skills-sh-trending", - periodStart: null, - periodEnd: null, - sourceObservedAt: weekEnd - 86_400_000, + source: "skill-daily-installs", }, }, ]; @@ -153,7 +182,7 @@ it("projects both catalogs, separate periods and scoped demand without leaking i }); expect(digest.catalogs.skills.recommendations[0]).toMatchObject({ search: null, - adoption: { sourceObservedAt: weekEnd - 86_400_000, periodStart: null, periodEnd: null }, + adoption: { source: "skill-daily-installs", installs30d: 341, installs7d: 1 }, }); expect(digest.catalogs.skills.companyOpportunities).toHaveLength(1); expect(digest.catalogs.skills.officialGaps).toHaveLength(2); @@ -217,24 +246,24 @@ it("bounds sections and UTF-8 while preserving leading rows from each catalog an searchUrl: `/plugins?q=${encodeURIComponent("界".repeat(190) + index)}`, })); input.moverRows = input.rows; - input.recommendations.candidates = Array.from({ length: 8 }, (_, index) => ({ + input.recommendations.candidates = Array.from({ length: 16 }, (_, index) => ({ ...input.recommendations.candidates[0], id: `plugin:item-${index}`, - url: `/plugins/item-${index}?q=${"x".repeat(600)}`, + url: `/plugins/item-${index}?q=${"x".repeat(50)}`, })); const skills = structuredClone(input); skills.recommendations.candidates = skills.recommendations.candidates.map((row) => ({ ...row, artifactKind: "skill", id: row.id.replace("plugin:", "clawhub:"), - adoption: { ...row.adoption!, source: "clawhub-trending" }, + adoption: { ...row.adoption!, source: "skill-daily-installs" }, })); const result = build(input, skills); expect(new TextEncoder().encode(JSON.stringify(result)).byteLength).toBeLessThanOrEqual(30_000); expect(result.truncated).toBe(true); for (const value of Object.values(result.catalogs)) { - expect(value.recommendations).toHaveLength(8); - expect(value.lineup.changes).toHaveLength(8); + expect(value.recommendations).toHaveLength(16); + expect(value.lineup.changes).toHaveLength(16); expect(value.lineup.shortfall).toBe(0); expect(value.recommendations[0].id).toContain("item-0"); } diff --git a/convex/lib/searchEvidenceDigest.ts b/convex/lib/searchEvidenceDigest.ts index d69159fe1e..9037777d94 100644 --- a/convex/lib/searchEvidenceDigest.ts +++ b/convex/lib/searchEvidenceDigest.ts @@ -1,4 +1,4 @@ -import type { LineupSearchDigest, LineupSearchRecommendation } from "./searchDigestContract"; +import type { MonthlySearchDigest, MonthlySearchRecommendation } from "./searchDigestContract"; import { SEARCH_DIGEST_MAX_BYTES } from "./searchDigestContract"; type Scope = "catalog" | "shelf" | "legacy"; @@ -11,7 +11,7 @@ type EvidenceRow = { searchUrl: string; classification: { intentKind: string; confidence: number; companyProductName?: string } | null; }; -type Catalog = LineupSearchDigest["catalogs"]["plugins"]; +type Catalog = MonthlySearchDigest["catalogs"]["plugins"]; export type DigestCatalogInput = { totalSearches7d: number; sources7d: { "clawhub-web": number; "openclaw-control-ui": number }; @@ -24,10 +24,13 @@ export type DigestCatalogInput = { rows: EvidenceRow[]; moverRows: EvidenceRow[]; recommendations: { - candidates: Omit[]; + candidates: Omit< + MonthlySearchRecommendation, + "metadataCheckedAt" | "slot" | "selectionBasis" | "reason" + >[]; lineup: Omit & { proposed: Array< - Omit & { + Omit & { change: "retain" | "add"; emerging: boolean; } @@ -42,7 +45,7 @@ export function buildSearchEvidenceDigest(input: { weekEnd: number; siteUrl: string; catalogs: { plugins: DigestCatalogInput; skills: DigestCatalogInput }; -}): LineupSearchDigest { +}): MonthlySearchDigest { const site = new URL(input.siteUrl); const absolute = (path: string) => { const url = new URL(path, site); @@ -143,6 +146,11 @@ export function buildSearchEvidenceDigest(input: { adoption: { status: source.adoption.status, generatedAt: source.adoption.generatedAt, + collectionStartedAt: source.adoption.collectionStartedAt, + periodStart7d: source.adoption.periodStart7d, + scannedRows: source.adoption.scannedRows, + importedRows: source.adoption.importedRows, + importDatasetVersions: source.adoption.importDatasetVersions, periodStart: source.adoption.periodStart, periodEnd: source.adoption.periodEnd, snapshotId: source.adoption.snapshotId, @@ -161,7 +169,23 @@ export function buildSearchEvidenceDigest(input: { officialGaps: gaps.slice(0, 5).map(row), movers: moving.slice(0, 5).map(row), lineup: { - targetSize: 8, + targetSize: 16, + reservedSlots: source.recommendations.lineup.reservedSlots, + telemetryTarget: source.recommendations.lineup.telemetryTarget, + pendingCount: source.recommendations.lineup.pendingCount, + telemetryShortfall: source.recommendations.lineup.telemetryShortfall, + editorialRevision: source.recommendations.lineup.editorialRevision, + currentEditorialRevision: source.recommendations.lineup.currentEditorialRevision, + staleEditorial: source.recommendations.lineup.staleEditorial, + reservations: source.recommendations.lineup.reservations.map((entry) => ({ + slot: entry.slot, + id: entry.id, + name: entry.name, + displayName: entry.displayName, + reason: entry.reason, + status: entry.status, + pendingReasons: entry.pendingReasons, + })), baseline: source.recommendations.lineup.baseline.map(({ id, version, featuredAt }) => ({ id, version, @@ -174,7 +198,7 @@ export function buildSearchEvidenceDigest(input: { url: absolute(entry.url), reasons: entry.reasons, })), - shortfall: 8 - qualified.length, + shortfall: 16 - qualified.length, }, recommendations: qualified.map((candidate) => { const search = candidate.search; @@ -187,6 +211,9 @@ export function buildSearchEvidenceDigest(input: { const adoption = candidate.adoption; return { artifactKind: candidate.artifactKind, + slot: candidate.slot, + selectionBasis: candidate.selectionBasis, + reason: candidate.reason, version: candidate.version, id: candidate.id, displayName: descriptor(candidate.displayName) || descriptor(candidate.id), @@ -217,16 +244,10 @@ export function buildSearchEvidenceDigest(input: { ? { source: adoption.source, rank: adoption.rank, - snapshotId: adoption.snapshotId, - rankingVersion: adoption.rankingVersion, - periodStart: adoption.periodStart, - periodEnd: adoption.periodEnd, - generatedAt: adoption.generatedAt, - sourceObservedAt: adoption.sourceObservedAt, - downloads: adoption.downloads, - installs: adoption.installs, - bookmarks: adoption.bookmarks, - lifetimeInstalls: adoption.lifetimeInstalls, + installs30d: adoption.installs30d, + installs7d: adoption.installs7d, + importedRows: adoption.importedRows, + importDatasetVersions: adoption.importDatasetVersions, } : null, }; @@ -237,8 +258,8 @@ export function buildSearchEvidenceDigest(input: { plugins: project(input.catalogs.plugins, "plugin"), skills: project(input.catalogs.skills, "skill"), }; - const digest: LineupSearchDigest = { - kind: "search_intelligence_weekly_v3", + const digest: MonthlySearchDigest = { + kind: "search_intelligence_weekly_v4", weekStart: input.weekEnd - 604_800_000, weekEnd: input.weekEnd, minimumSearches: 3, diff --git a/convex/lib/searchReportContract.ts b/convex/lib/searchReportContract.ts index 4550b4d46c..15a54b57d8 100644 --- a/convex/lib/searchReportContract.ts +++ b/convex/lib/searchReportContract.ts @@ -1,7 +1,7 @@ import { v, type Infer } from "convex/values"; import { searchInsightArgs, SEARCH_DAY_MS } from "./searchInsights"; -export const REPORT_VERSION = "search-report-v1" as const; +export const REPORT_VERSION = "search-report-v2" as const; export const REPORT_TTL_MS = SEARCH_DAY_MS; export const REPORT_CHUNK_BYTES = 256 * 1024; export const REPORT_MAX_CHUNKS = 8; diff --git a/convex/lib/searchReportEvidence.ts b/convex/lib/searchReportEvidence.ts index 9c6f267c6b..da16cc97b7 100644 --- a/convex/lib/searchReportEvidence.ts +++ b/convex/lib/searchReportEvidence.ts @@ -4,7 +4,6 @@ import type { ActionCtx } from "../_generated/server"; import { collectFeaturedEvidence, renderFeaturedEvidence, - unavailableAdoption, type FeaturedEvidence, } from "../featuredIntelligence"; import { readReport } from "../searchInsights"; @@ -46,7 +45,10 @@ export async function renderReportEvidence(ctx: ActionCtx, saved: ReportEvidence ...new Set([ ...searchReport.rows.flatMap((row) => row.currentResults.map((entry) => entry.id)), ...(saved.view === "recommendations" - ? saved.evidence.adoption.artifacts.map((entry) => entry.artifact.id) + ? [ + ...saved.evidence.adoption.artifacts.map((entry) => entry.artifact.id), + ...saved.evidence.editorial.items.map((entry) => entry.id), + ] : []), ]), ]; @@ -71,19 +73,17 @@ export async function renderReportEvidence(ctx: ActionCtx, saved: ReportEvidence }), }; if (saved.view === "demand") return { view: saved.view, report: refreshed } as const; - let adoption = saved.evidence.adoption; - if (adoption.snapshotCursor) { - const current = await ctx.runQuery(internal.canonicalTrending.getPageInternal, { - cursor: adoption.snapshotCursor, - limit: 1, - now: Date.now(), - }); - if (current.status !== "ok") adoption = unavailableAdoption; - } + const adoption = saved.evidence.adoption; const currentFeatured = await ctx.runQuery( internal.featuredArtifacts.readCurrentFeaturedInternal, { artifactKind: searchReport.artifactKind }, ); + const editorial = + searchReport.artifactKind === "plugin" + ? await ctx.runQuery(internal.featuredSelections.readEditorialInternal, { + artifactKind: "plugin", + }) + : { revision: 0, items: [] }; return { view: saved.view, report: renderFeaturedEvidence( @@ -91,6 +91,10 @@ export async function renderReportEvidence(ctx: ActionCtx, saved: ReportEvidence ...saved.evidence, searchReport: refreshed, currentFeatured, + currentEditorialRevision: editorial.revision, + editorialArtifacts: saved.evidence.editorial.items.flatMap( + (entry) => byId.get(entry.id) ?? [], + ), metadataCheckedAt: Date.now(), adoption: { ...adoption, diff --git a/convex/schema.ts b/convex/schema.ts index bf97be1523..56606db61c 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -4636,7 +4636,7 @@ const searchReportRuns = defineTable({ request: reportRequest, requestKey: v.string(), sourceRevision: v.string(), - reportVersion: v.literal("search-report-v1"), + reportVersion: v.union(v.literal("search-report-v1"), v.literal("search-report-v2")), refreshOf: v.optional(v.id("searchReportRuns")), workId: v.optional(v.string()), requestedAt: v.number(), diff --git a/convex/searchInsightsFixtures.ts b/convex/searchInsightsFixtures.ts index cc41596ab6..7bc7a72904 100644 --- a/convex/searchInsightsFixtures.ts +++ b/convex/searchInsightsFixtures.ts @@ -186,12 +186,36 @@ export const seedFeaturedLineup = internalAction({ args: {}, handler: async ( ctx, - ): Promise<{ fixture: string; expected: number; actorUserId: Id<"users"> }> => { + ): Promise<{ + fixture: string; + actorUserId: Id<"users">; + editorialRevision: number; + pluginIds: string[]; + skillIds: string[]; + periodEnd: number; + }> => { assertLocal(); const storageId = await ctx.storage.store(new Blob(["x"])); - return ctx.runMutation(internal.searchInsightsFixtures.seedFeaturedLineupInternal, { - storageId, + const seeded = await ctx.runMutation( + internal.searchInsightsFixtures.seedFeaturedLineupInternal, + { storageId }, + ); + const current = await ctx.runQuery(internal.featuredSelections.readEditorialInternal, { + artifactKind: "plugin", + }); + const saved = await ctx.runMutation(internal.featuredSelections.saveEditorialForUserInternal, { + actorUserId: seeded.actorUserId, + expectedRevision: current.revision, + items: seeded.editorial, }); + return { + fixture: seeded.fixture, + actorUserId: seeded.actorUserId, + editorialRevision: saved.revision, + pluginIds: seeded.pluginIds, + skillIds: seeded.skillIds, + periodEnd: seeded.periodEnd, + }; }, }); export const seedFeaturedLineupInternal = internalMutation({ @@ -204,53 +228,114 @@ export const seedFeaturedLineupInternal = internalMutation({ role: "admin", }); const file = { path: "index.js", size: 1, storageId, sha256: "a".repeat(64) }; - const items = []; - for (let index = 0; index < 10; index++) { - const name = `lineup-tool-${index}`; + const periodEnd = Math.floor(now / SEARCH_DAY_MS) * SEARCH_DAY_MS; + const endDay = periodEnd / SEARCH_DAY_MS; + const pluginIds: string[] = []; + const skillIds: string[] = []; + for (let index = 0; index < 20; index++) { + const name = `monthly-lineup-${now}-plugin-${index}`; const packageId = await ctx.db.insert("packages", { name, normalizedName: name, - displayName: `Local discovery tool ${index + 1}`, - summary: "Local fixture: a useful workflow with observed adoption.", + displayName: `Local monthly tool ${index + 1}`, + summary: "Disposable monthly install evidence fixture.", ownerUserId: ownerId, family: "code-plugin", channel: "community", - isOfficial: false, - categories: [index === 9 ? "channels" : "developer-tools"], + isOfficial: index % 2 === 0, + categories: [index === 18 ? "channels" : "developer-tools"], tags: {}, scanStatus: "clean", - stats: { downloads: 100 - index, installs: 10 - index, stars: 0, versions: 1 }, + stats: { downloads: index * 1000, installs: 100 - index * 3, stars: 0, versions: 1 }, createdAt: now - (index === 0 ? 1 : 100) * SEARCH_DAY_MS, updatedAt: now, }); const releaseId = await ctx.db.insert("packageReleases", { packageId, version: "1.0.0", - changelog: "Local lineup fixture", + changelog: "Local monthly fixture", distTags: ["latest"], files: [file], integritySha256: "a".repeat(64), - verification: { tier: "structural", scope: "artifact-only", scanStatus: "clean" }, + verification: { + tier: "structural", + scope: "artifact-only", + scanStatus: index === 19 ? "suspicious" : "clean", + }, createdBy: ownerId, createdAt: now, }); await ctx.db.patch(packageId, { latestReleaseId: releaseId, tags: { latest: releaseId } }); - if (index === 0 || index >= 8) + for (const [age, installs] of [ + [20, 70 - index * 2], + [1, index >= 18 ? 1000 : 30 - index], + ]) + await ctx.db.insert("packageDailyStats", { + packageId, + day: endDay - age, + installs, + downloads: index * 1000, + updatedAt: now, + }); + if (index === 0 || index === 16) await ctx.db.insert("packageBadges", { packageId, kind: "highlighted", byUserId: ownerId, at: now - index, }); - items.push({ packageId, score: 100 - index, downloads: 100 - index, installs: 10 - index }); + pluginIds.push(`plugin:${name}`); + const slug = `monthly-lineup-${now}-skill-${index}`; + const skillId = await ctx.db.insert("skills", { + slug, + displayName: `Local monthly skill ${index + 1}`, + summary: "Disposable skill monthly install evidence.", + ownerUserId: ownerId, + tags: {}, + stats: { downloads: index * 1000, stars: 0, versions: 1, comments: 0 }, + createdAt: now, + updatedAt: now, + }); + const versionId = await ctx.db.insert("skillVersions", { + skillId, + version: "1.0.0", + changelog: "Local monthly fixture", + files: [{ ...file, path: "SKILL.md" }], + parsed: { frontmatter: {} }, + createdBy: ownerId, + createdAt: now, + llmAnalysis: { status: index >= 18 ? "suspicious" : "clean", checkedAt: now }, + }); + await ctx.db.patch(skillId, { latestVersionId: versionId }); + for (const [age, installs] of [ + [20, 70 - index * 2], + [1, index >= 18 ? 1000 : 30 - index], + ]) + await ctx.db.insert("skillDailyStats", { + skillId, + day: endDay - age, + installs, + downloads: index * 1000, + updatedAt: now, + }); + skillIds.push(`clawhub:${skillId}`); } - await ctx.db.insert("packageLeaderboards", { - kind: "package_trending", - generatedAt: now, - rangeStartDay: Math.floor(now / SEARCH_DAY_MS) - 6, - rangeEndDay: Math.floor(now / SEARCH_DAY_MS), - items, - }); - return { fixture: "local-featured-lineup", expected: 8, actorUserId: ownerId }; + const editorial = [ + ...pluginIds.slice(0, 5), + ...Array.from({ length: 3 }, (_, index) => `plugin:monthly-pending-${now}-${index}`), + ].map((id, index) => ({ + id, + name: id.slice(7), + displayName: `Local editorial ${index + 1}`, + reason: index < 5 ? "Reviewed local workflow" : "Awaiting a public release", + })); + return { + fixture: "local-monthly-featured-lineup", + actorUserId: ownerId, + editorial, + pluginIds, + skillIds, + periodEnd, + }; }, }); diff --git a/convex/searchReports.test.ts b/convex/searchReports.test.ts index fc194655ac..d29d60ccce 100644 --- a/convex/searchReports.test.ts +++ b/convex/searchReports.test.ts @@ -85,9 +85,9 @@ it("completes the real component job, keeps final GET compatibility, and reuses if (ready.status !== "ready" || ready.view !== "recommendations") throw new Error("Expected ready plugin report"); expect(ready.report.recommendations.lineup).toMatchObject({ - targetSize: 8, + targetSize: 16, proposed: [], - shortfall: 8, + shortfall: 16, }); const compatible = await t.fetch("/api/v1/search-insights?view=recommendations", { headers: authHeaders, @@ -399,18 +399,14 @@ it("rechecks the entire saved adoption cohort beyond displayed cards and current await ctx.db.patch(packageId, { latestReleaseId: release, tags: { latest: release } }); result.push({ packageId, release }); } - await ctx.db.insert("packageLeaderboards", { - kind: "package_trending", - generatedAt: Date.now(), - rangeStartDay: Math.floor(Date.now() / REPORT_TTL_MS) - 6, - rangeEndDay: Math.floor(Date.now() / REPORT_TTL_MS), - items: result.map(({ packageId }, index) => ({ + for (const [index, { packageId }] of result.entries()) + await ctx.db.insert("packageDailyStats", { packageId, - score: 40 - index, - installs: 3, + day: Math.floor(Date.now() / REPORT_TTL_MS) - 1, + installs: 4 - index, downloads: 40 - index, - })), - }); + updatedAt: Date.now(), + }); return result; }); const queued = await signed.mutation(api.searchReports.start, { @@ -524,3 +520,26 @@ it("retries failed nonempty catalog associations instead of permanently caching }); expect(await t.run((ctx) => ctx.db.query("searchReportChunks").collect())).toEqual([]); }); + +it("does not reinterpret retained v1 evidence and refreshes it explicitly under the current report version", async () => { + const { t, signed } = await setup(); + const queued = await signed.mutation(api.searchReports.start, { view: "demand" }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + await t.run((ctx) => + ctx.db.patch(queued.reportId as import("./_generated/dataModel").Id<"searchReportRuns">, { + reportVersion: "search-report-v1", + requestKey: "old-v1-hash", + }), + ); + expect(await signed.action(api.searchReports.get, { reportId: queued.reportId })).toMatchObject({ + status: "incomplete", + reportVersion: "search-report-v1", + failureCode: "report_version_unsupported", + }); + const fresh = await signed.mutation(api.searchReports.start, { + view: "demand", + refreshOf: queued.reportId, + }); + expect(fresh).toMatchObject({ status: "pending", reportVersion: "search-report-v2" }); + expect(fresh.reportId).not.toBe(queued.reportId); +}); diff --git a/convex/searchReports.ts b/convex/searchReports.ts index dcabc22d48..12c4b9bf5e 100644 --- a/convex/searchReports.ts +++ b/convex/searchReports.ts @@ -58,7 +58,14 @@ async function sourceRevision(ctx: QueryCtx, request: ReportRequest): Promise | undefined; if (input.refreshOf) { const parent = await readRun(ctx, input.refreshOf); - if (parent.requestKey !== requestKey) throw new ConvexError("refresh_request_mismatch"); + const parentRequestKey = await hashToken( + JSON.stringify([REPORT_VERSION, normalizeReportRequest(parent.request, Date.now())]), + ); + if (parentRequestKey !== requestKey) throw new ConvexError("refresh_request_mismatch"); refreshOf = parent._id; previous = await ctx.db .query("searchReportRuns") @@ -129,6 +144,7 @@ async function startReport(ctx: MutationCtx, input: ReportRequest): Promise Date.now() && + previous.reportVersion === REPORT_VERSION && (input.refreshOf || previous.sourceRevision === revision) ) return envelope(ctx, previous, Date.now()); @@ -194,6 +210,8 @@ export const generateInternal = internalAction({ now: Date.now(), }); if (state.status === "ready") return { reportId }; + if (row.reportVersion !== REPORT_VERSION) + throw new NonRetryableError("report_version_unsupported"); if (state.status === "expired" || state.status === "failed") throw new NonRetryableError("report_expired"); phase = "report_collection_failed"; diff --git a/convex/searchWeeklyDigest.test.ts b/convex/searchWeeklyDigest.test.ts index 69502995f5..fc2a885991 100644 --- a/convex/searchWeeklyDigest.test.ts +++ b/convex/searchWeeklyDigest.test.ts @@ -79,7 +79,7 @@ it("ships deterministic gaps when classification is unavailable, freezing one pa await t.action(internal.searchWeeklyDigest.deliverInternal, { weekEnd }); expect(delivered).toHaveLength(1); expect(delivered[0]).toMatchObject({ - kind: "search_intelligence_weekly_v3", + kind: "search_intelligence_weekly_v4", catalogs: { plugins: { totalSearches: 4, @@ -215,7 +215,7 @@ it("classifies only the bounded aggregate gap cohort and shares the exact persis ).toBe(true); expect(providerInputs[0].some((row) => row.query === "low volume")).toBe(false); expect(deliveries[0]).toMatchObject({ - kind: "search_intelligence_weekly_v3", + kind: "search_intelligence_weekly_v4", truncated: true, catalogs: { plugins: { totalSearches: 512, classificationStatus: "partial" }, diff --git a/convex/searchWeeklyDigest.ts b/convex/searchWeeklyDigest.ts index 424cc73e20..b85322eb77 100644 --- a/convex/searchWeeklyDigest.ts +++ b/convex/searchWeeklyDigest.ts @@ -9,7 +9,10 @@ import { searchDigestValidator, SEARCH_DIGEST_MAX_BYTES, type WeeklySearchDigest, + type LineupSearchDigest, + type MonthlySearchDigest, type LineupSearchRecommendation, + type MonthlySearchRecommendation, } from "./lib/searchDigestContract"; import { deliverSearchDigest } from "./lib/searchDigestDelivery"; import { buildSearchEvidenceDigest, type DigestCatalogInput } from "./lib/searchEvidenceDigest"; @@ -130,10 +133,19 @@ export const savePayloadInternal = internalMutation({ : Object.values(payload.catalogs).some( (catalog) => catalog.recommendations.length > - (payload.kind === "search_intelligence_weekly_v3" ? 8 : 5) || + (payload.kind === "search_intelligence_weekly_v4" + ? 16 + : payload.kind === "search_intelligence_weekly_v3" + ? 8 + : 5) || catalog.recommendations.some( - (candidate: Omit) => + ( + candidate: + | Omit + | MonthlySearchRecommendation, + ) => (payload.kind !== "search_intelligence_weekly_v3" && + payload.kind !== "search_intelligence_weekly_v4" && candidate.support === "search-only" && (candidate.search?.matchedSearches7d ?? 0) < 3) || (candidate.search && @@ -141,37 +153,46 @@ export const savePayloadInternal = internalMutation({ candidate.search.queries.some((query) => query.searches7d < 3))), ), )) || - (payload.kind === "search_intelligence_weekly_v3" && - Object.values(payload.catalogs).some((catalog) => { - const { lineup, recommendations } = catalog; - const selected = new Set(recommendations.map((candidate) => candidate.id)); - const baseline = new Set(lineup.baseline.map((entry) => entry.id)); - const removed = new Set(lineup.removals.map((entry) => entry.id)); - return ( - selected.size !== recommendations.length || - baseline.size !== lineup.baseline.length || - removed.size !== lineup.removals.length || - lineup.baseline.length > 100 || - lineup.shortfall !== 8 - recommendations.length || - lineup.changes.length !== recommendations.length || - lineup.changes.some( - (entry, index) => - entry.id !== recommendations[index].id || - entry.change !== (baseline.has(entry.id) ? "retain" : "add"), - ) || - lineup.removals.some( - (entry) => !baseline.has(entry.id) || selected.has(entry.id) || !entry.reasons.length, - ) || - lineup.baseline.some((entry) => !selected.has(entry.id) && !removed.has(entry.id)) || - recommendations.some( - (candidate) => - candidate.support === "current-only" && - (candidate.search !== null || - candidate.adoption !== null || - !baseline.has(candidate.id)), - ) - ); - })) || + ((payload.kind === "search_intelligence_weekly_v3" || + payload.kind === "search_intelligence_weekly_v4") && + Object.values(payload.catalogs).some( + ( + catalog: + | LineupSearchDigest["catalogs"]["plugins"] + | MonthlySearchDigest["catalogs"]["plugins"], + ) => { + const { lineup, recommendations } = catalog; + const selected = new Set(recommendations.map((candidate) => candidate.id)); + const baseline = new Set(lineup.baseline.map((entry) => entry.id)); + const removed = new Set(lineup.removals.map((entry) => entry.id)); + return ( + selected.size !== recommendations.length || + baseline.size !== lineup.baseline.length || + removed.size !== lineup.removals.length || + lineup.baseline.length > 100 || + lineup.shortfall !== lineup.targetSize - recommendations.length || + lineup.changes.length !== recommendations.length || + lineup.changes.some( + (entry, index) => + entry.id !== recommendations[index].id || + entry.change !== (baseline.has(entry.id) ? "retain" : "add"), + ) || + lineup.removals.some( + (entry) => + !baseline.has(entry.id) || selected.has(entry.id) || !entry.reasons.length, + ) || + lineup.baseline.some((entry) => !selected.has(entry.id) && !removed.has(entry.id)) || + recommendations.some( + (candidate) => + payload.kind !== "search_intelligence_weekly_v4" && + candidate.support === "current-only" && + (candidate.search !== null || + candidate.adoption !== null || + !baseline.has(candidate.id)), + ) + ); + }, + )) || new TextEncoder().encode(JSON.stringify(payload)).byteLength > SEARCH_DIGEST_MAX_BYTES || (args.catalogClassifications && (args.classification || diff --git a/packages/clawhub-admin/src/commands/searchInsights.test.ts b/packages/clawhub-admin/src/commands/searchInsights.test.ts index 6b0ab720ff..89e4b1d321 100644 --- a/packages/clawhub-admin/src/commands/searchInsights.test.ts +++ b/packages/clawhub-admin/src/commands/searchInsights.test.ts @@ -69,6 +69,11 @@ const intelligence = { metadataCheckedAt: fixture.generatedAt, adoption: { status: "available", + collectionStartedAt: fixture.generatedAt, + periodStart7d: fixture.window.start7d, + scannedRows: 1, + importedRows: 0, + importDatasetVersions: [], generatedAt: fixture.generatedAt, periodStart: fixture.window.start7d, periodEnd: fixture.window.endDay, @@ -80,10 +85,18 @@ const intelligence = { }, recommendations: { lineup: { - targetSize: 8, + targetSize: 16, + reservedSlots: 0, + telemetryTarget: 16, + pendingCount: 0, + telemetryShortfall: 15, + editorialRevision: 0, + currentEditorialRevision: 0, + staleEditorial: false, + reservations: [], baseline: [], removals: [], - shortfall: 7, + shortfall: 15, proposed: [] as unknown[], }, totalCandidates: 1, @@ -104,25 +117,32 @@ const intelligence = { support: "adoption-only", search: null, adoption: { - source: "clawhub-trending", + source: "skill-daily-installs", rank: 1, snapshotId: "observed", rankingVersion: "skills-trending-v4", periodStart: fixture.window.start7d, periodEnd: fixture.window.endDay, generatedAt: fixture.generatedAt, - sourceObservedAt: null, - downloads: 40, - installs: 3, - bookmarks: 2, - lifetimeInstalls: null, + periodStart7d: fixture.window.start7d, + installs30d: 40, + installs7d: 3, + importedRows: 0, + importDatasetVersions: [], }, }, ], }, }; intelligence.recommendations.lineup.proposed = intelligence.recommendations.candidates.map( - (candidate) => ({ ...candidate, change: "add", emerging: false }), + (candidate) => ({ + ...candidate, + change: "add", + emerging: false, + slot: 0, + selectionBasis: "telemetry", + reason: "Recorded monthly installs", + }), ); type Request = { method: string; path: string; body: unknown; authorization: string | undefined }; @@ -136,7 +156,7 @@ function envelope(status: string, view = "demand", extra: Record expect(JSON.parse(recommendations.stdout)).toEqual(intelligence); const text = await cli(["--view", "recommendations"]); for (const fact of [ - "Calendar · adoption-only", - "40 downloads, 3 installs, 2 bookmarks", + "Calendar · telemetry", + "40 installs in 30 completed UTC days, 3 in the final 7 days", "No search evidence in the inspected queries.", "advisory, requires approval", ]) diff --git a/packages/clawhub-admin/src/commands/searchInsights.ts b/packages/clawhub-admin/src/commands/searchInsights.ts index 366f33213d..df32629eae 100644 --- a/packages/clawhub-admin/src/commands/searchInsights.ts +++ b/packages/clawhub-admin/src/commands/searchInsights.ts @@ -130,7 +130,7 @@ export async function cmdSearchInsights( `Search collection started ${time(report.searchReport.coverage.collectionStartedAt)}; aggregated through ${time(report.searchReport.coverage.dataThrough)}.`, ); console.log( - `Adoption ${report.adoption.status}: ${time(report.adoption.periodStart)} to ${time(report.adoption.periodEnd)}, generated ${time(report.adoption.generatedAt)}; ${report.adoption.inspectedItems}/${report.adoption.totalItems} snapshot entries inspected.`, + `Adoption ${report.adoption.status}: ${time(report.adoption.periodStart)} to ${time(report.adoption.periodEnd)}, generated ${time(report.adoption.generatedAt)}; ${report.adoption.inspectedItems}/${report.adoption.totalItems} install-bearing identities inspected for current eligibility.`, ); console.log( `Current eligibility checked ${time(report.metadataCheckedAt)}. Search metadata ${report.searchReport.currentMetadataStatus}.`, @@ -139,6 +139,15 @@ export async function cmdSearchInsights( console.log( `Complete proposed set: ${lineup.proposed.length}/${lineup.targetSize}; ${lineup.shortfall} open places. No automatic publication.`, ); + console.log( + `${lineup.pendingCount} pending editorial reservations; ${lineup.telemetryShortfall} open telemetry places.`, + ); + if (lineup.staleEditorial) + console.log("Editorial choices changed. Regenerate and review before publication."); + for (const entry of lineup.reservations.filter((item) => item.status === "pending")) + console.log( + `Pending slot ${entry.slot + 1}: ${entry.displayName ?? "Unassigned"}; ${entry.pendingReasons.join(", ")}.`, + ); for (const entry of lineup.baseline) console.log( `Current ${entry.id}: ${entry.version ?? "unavailable"}; Featured ${time(entry.featuredAt)}.`, @@ -151,8 +160,9 @@ export async function cmdSearchInsights( console.log("No eligible Featured candidates in available evidence."); for (const candidate of lineup.proposed) { console.log( - `${candidate.change === "retain" ? "Retain" : "Add"} ${candidate.displayName}${candidate.emerging ? " · Emerging (recent publication or Rising feed with observed adoption)" : ""} · ${candidate.support} · category ${candidate.category ?? "uncategorized"}`, + `${candidate.change === "retain" ? "Retain" : "Add"} ${candidate.displayName}${candidate.emerging ? " · Recently published with observed installs" : ""} · ${candidate.selectionBasis} · category ${candidate.category ?? "uncategorized"}`, ); + console.log(` ${candidate.reason}`); if (candidate.summary) console.log(` ${candidate.summary}`); if (candidate.search) { console.log( @@ -168,7 +178,7 @@ export async function cmdSearchInsights( if (candidate.adoption) { const evidence = candidate.adoption; console.log( - ` ${evidence.source} #${evidence.rank}: ${evidence.downloads ?? "unknown"} downloads, ${evidence.installs ?? "unknown"} installs, ${evidence.bookmarks ?? "unknown"} bookmarks; ${time(evidence.periodStart)} to ${time(evidence.periodEnd)}.`, + ` ${evidence.source} #${evidence.rank}: ${evidence.installs30d} installs in 30 completed UTC days, ${evidence.installs7d} in the final 7 days; ${time(evidence.periodStart)} to ${time(evidence.periodEnd)}.`, ); console.log( ` Snapshot ${evidence.snapshotId}, generated ${time(evidence.generatedAt)}, ranking ${evidence.rankingVersion}.`, @@ -179,7 +189,7 @@ export async function cmdSearchInsights( for (const excluded of report.recommendations.excluded) console.log(`Excluded ${excluded.displayName}: ${excluded.reasons.join(", ")}`); console.log( - `Showing ${report.recommendations.candidates.length}/${report.recommendations.totalCandidates} candidates; search coverage ${report.searchReport.rows.length}/${report.searchReport.totalQueries} queries. Review usefulness, quality, security and category coverage before publishing.`, + `Showing ${report.recommendations.candidates.length}/${report.recommendations.totalCandidates} candidates; search coverage ${report.searchReport.rows.length}/${report.searchReport.totalQueries} queries. Review current eligibility and the complete selection before publishing.`, ); } return report; diff --git a/packages/clawhub/src/schema/searchInsights.ts b/packages/clawhub/src/schema/searchInsights.ts index 0ccdf2f915..8a8122338a 100644 --- a/packages/clawhub/src/schema/searchInsights.ts +++ b/packages/clawhub/src/schema/searchInsights.ts @@ -90,18 +90,18 @@ export const SearchInsightsReportSchema = type({ }); const adoptionEvidence = type({ - source: '"package-trending" | "clawhub-trending" | "clawhub-rising" | "skills-sh-trending"', + source: '"package-daily-installs" | "skill-daily-installs"', rank: "number", snapshotId: "string", rankingVersion: "string", - periodStart: "number | null", - periodEnd: "number | null", + periodStart: "number", + periodStart7d: "number", + periodEnd: "number", generatedAt: "number", - sourceObservedAt: "number | null", - downloads: "number | null", - installs: "number | null", - bookmarks: "number | null", - lifetimeInstalls: "number | null", + installs30d: "number", + installs7d: "number", + importedRows: "number", + importDatasetVersions: "string[]", }); const featuredCandidate = type({ @@ -143,12 +143,17 @@ export const FeaturedIntelligenceReportSchema = type({ adoption: { status: '"available" | "unavailable"', generatedAt: "number | null", - periodStart: "number | null", - periodEnd: "number | null", + collectionStartedAt: "number", + periodStart: "number", + periodStart7d: "number", + periodEnd: "number", snapshotId: "string | null", - rankingVersion: "string | null", + rankingVersion: "string", totalItems: "number", inspectedItems: "number", + scannedRows: "number", + importedRows: "number", + importDatasetVersions: "string[]", truncated: "boolean", }, recommendations: { @@ -162,9 +167,34 @@ export const FeaturedIntelligenceReportSchema = type({ }).array(), candidates: featuredCandidate.array(), lineup: { - targetSize: "8", + targetSize: "16", + reservedSlots: "number", + telemetryTarget: "number", + editorialRevision: "number", + currentEditorialRevision: "number", + staleEditorial: "boolean", + pendingCount: "number", + telemetryShortfall: "number", + reservations: type({ + slot: "number", + id: "string | null", + name: "string | null", + displayName: "string | null", + reason: "string | null", + status: '"ready" | "pending"', + pendingReasons: "string[]", + artifact: featuredCandidate.or("null"), + }).array(), baseline: type({ id: "string", version: "string | null", featuredAt: "number" }).array(), - proposed: featuredCandidate.and({ change: '"retain" | "add"', emerging: "boolean" }).array(), + proposed: featuredCandidate + .and({ + change: '"retain" | "add"', + emerging: "boolean", + slot: "number", + selectionBasis: '"editorial" | "telemetry"', + reason: "string", + }) + .array(), removals: type({ id: "string", displayName: "string", diff --git a/packages/clawhub/src/schema/searchReports.ts b/packages/clawhub/src/schema/searchReports.ts index 2d25092db7..c2f52a49b9 100644 --- a/packages/clawhub/src/schema/searchReports.ts +++ b/packages/clawhub/src/schema/searchReports.ts @@ -20,7 +20,7 @@ const common = { expirationTime: "number", previousAttempts: "number", failureCode: "string | null", - reportVersion: '"search-report-v1"', + reportVersion: '"search-report-v1" | "search-report-v2"', } as const; export const SearchReportStatusSchema = type({ ...common, diff --git a/specs/search-insights.md b/specs/search-insights.md index c8cfa1b770..d02b303977 100644 --- a/specs/search-insights.md +++ b/specs/search-insights.md @@ -24,10 +24,9 @@ staff report used by Management, HTTP, the admin CLI, and the weekly digest. releases, clean public native skill versions, and an existing local Featured owner. External skill mirrors are explicit ineligible leads. Stable identities are `plugin:`, `clawhub:`, and `skills-sh:`. - The separate advisory recommendation owner combines search evidence with existing - catalog-specific Trending/adoption evidence; it preserves each evidence period and - freshness instead of changing historical counts or public ranking. Nothing is - automatically featured. + The advisory recommendation owner ranks native daily install aggregates. Search + associations remain separate context and never change install selection. Public + Trending keeps its own ranking contract. Nothing is automatically featured. Management, HTTP, and CLI select `artifactKind: plugin | skill` (default plugin) and optional `scope: catalog | shelf | legacy` (omitted means all scopes, kept @@ -36,15 +35,30 @@ remain legacy/unknown; no migration guesses that they searched the whole catalog ## Featured selection -Each catalog has an eight-member target. Every recommendation iteration proposes the -complete set, including keeps, additions, removals, exact existing membership and its -version/timestamp baseline. Fewer eligible candidates yield an explicit shortfall; -the system never fills slots with ineligible items. Current members are rechecked. -Evidence ordering stays within each catalog: combined search/adoption, search-only, -adoption-only, then eligible current members without inspected evidence. Supporting -counts, periods and freshness remain visible; no cross-catalog weighted score is added. -Emerging means an existing New/Rising signal plus positive observed adoption, not an -invented growth estimate. Quality, usefulness and category coverage still need review. +Each catalog has sixteen slots. Plugins reserve eight slots for editable editorial +choices and use eight distinct telemetry choices; skills use sixteen telemetry choices. +Editorial identities and reasons live in staff-managed data, never a source allowlist. +Unavailable or ineligible editorial entries stay pending with explicit reasons. Their +slots are never filled from telemetry and cannot publish broken cards. + +Telemetry ranks the complete native positive-install population by installs over the +last thirty completed UTC days, then installs in the final seven days, then stable +source-qualified identity. Search, downloads, official status and current Featured +membership contribute no bonus. The raw daily aggregate scan finishes before ranking; +only the subsequent current-metadata inspection is bounded, continuing through +ineligible leading entries until at least one hundred eligible review candidates are +found or the population ends. Reports disclose both scan and inspection coverage. + +Every iteration proposes the complete set with keeps, additions, removals and the +independent membership/version/timestamp baseline. Current metadata and public +installability/security gates remain authoritative. Recent publication with observed +installs may be labeled emerging, without inventing a growth estimate. Missing +search evidence is unknown rather than zero demand. + +Daily install facts retain their producer semantics and import provenance; they are +not proof of unique people or successful running installations. The scan exposes its +actual collection start/end and imported dataset tags. It spans multiple query +snapshots, not one atomic database snapshot; no new metric revision store is implied. Plugins with a canonical single category of channels, models or agent-runtimes are excluded from Featured/Trending discovery and Featured recommendations. Official and @@ -52,9 +66,9 @@ community tools remain eligible; other adapters are not broadly excluded. All, s direct access and full search-demand/company-gap reports retain those plugins. Legacy multi-category assignments require reviewed source repair before final recommendations. -New publications enforce the eight-member cap transactionally per catalog. Keeping a +New publications enforce the sixteen-member cap transactionally per catalog. Keeping a member preserves timestamps, audit history and notifications. Badge-table backfill -restores already persisted legacy membership, even above eight, without applying new +restores already persisted legacy membership, even above sixteen, without applying new admission rules. Existing over-cap membership stays visible for curator review; no automatic removal occurs, and new additions wait until there is capacity. Publishing the proposed set requires Patrick's approval. The homepage default changes only after @@ -138,15 +152,21 @@ generic HTTP deadline. results and errors contain no report body, search query text, credentials or user identity. - Private report chunks retain the full evidence ingredients, including all inspected search associations and adoption artifacts, rather than only the displayed candidate - cards. Encoded evidence is capped at 2 MiB per report in at most eight 256 KiB chunks; + cards. Editorial choices and their revision are frozen in the same generation. + Encoded evidence is capped at 2 MiB per report in at most eight 256 KiB chunks; oversize or incomplete content fails explicitly without truncating the cohort. - On result retrieval, canonical hydration rechecks visibility, category/security eligibility and independent live Featured membership. The existing recommendation function recomputes the proposed set from those facts and the saved evidence. This - does not rerun searches or silently add replacement search results. + does not rerun searches or silently add replacement search results. Saved editorial + choices do not change on retrieval: a different current revision marks the report + stale and requires regeneration and review before publication. - Search association timestamps and adoption snapshot/source/ranking timestamps remain - the original observed times. Current eligibility has its own check time. Adoption - expiry remains governed by its canonical source; reading a report never renews it. + the original observed times. Current eligibility has its own check time. Monthly + counts remain frozen for the report lifetime; expiration of a public Trending + snapshot cannot invalidate independent daily install evidence. Report-v1 generations + are explicitly unsupported after this shape upgrade and can be refreshed into v2; + their old evidence is never reinterpreted as monthly counts. - Report working state and chunks expire after 24 hours through indexed bounded cleanup. This is a deletion bound, not a freshness promise. Expired or removed generations cannot be revived by late result writes or callbacks. @@ -160,6 +180,18 @@ continues to use the shared evidence builder inside its own delivery lifecycle. separate recommendation and official-gap cohorts keep their original independent bounds; an interactive report is not a digest delivery attempt. +## Monthly digest contract + +New weekly digests use v4 and preserve the entire sixteen-slot selection per catalog, +including pending plugin reservations, selection basis, reasons, baseline and removals. +Monthly period, scan and ranking metadata appear once per catalog; each card retains +its own install counts, rank and import provenance. This avoids repeating shared +metadata on all32 cards while retaining the existing30KB wire bound. Rare query text +remains suppressed and secondary query detail may be compacted; selected members are +never dropped to fit. The receiving Hermit contract must support v4 before delivery. +Already frozen v1/v2/v3 weeks retain their original payload shape and receipt hash. +Dry-run report generation never sends a digest. + ## Verification and local fixtures `convex/searchInsights.test.ts` exercises staff/public denial, HTTP parity, daily @@ -172,3 +204,9 @@ against a local HTTP fixture in human and JSON modes. It seeds empty, typical, or dense synthetic datasets, a local staff persona, and an optional hashed API-token fixture for real local browser/API/CLI proof. It never runs from production crons. These fixtures are not historical demand. + +`searchInsightsFixtures.seedFeaturedLineup` creates a guarded local monthly fixture: +twenty plugins and twenty native skills, eighteen eligible entries per catalog, +category/security exclusion controls, daily install facts, and five ready plus three +pending editable editorial reservations. It returns exact generated identities and +uses the canonical editorial save owner. These are local test facts only. From ffaa1e729fe46f25f26320c52ee3988ca08f4432 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 16 Sep 2026 13:16:00 -0700 Subject: [PATCH 5/8] feat: edit editorial reservations and review sixteen Featured slots --- .../-management/FeaturedEditorial.test.tsx | 78 +++++ src/routes/-management/FeaturedEditorial.tsx | 293 ++++++++++++++++ .../-management/FeaturedRecommendations.tsx | 321 +++++++++--------- .../-management/SearchInsightsPage.test.tsx | 50 ++- src/routes/-management/SearchInsightsPage.tsx | 23 +- src/styles.css | 77 +++++ 6 files changed, 671 insertions(+), 171 deletions(-) create mode 100644 src/routes/-management/FeaturedEditorial.test.tsx create mode 100644 src/routes/-management/FeaturedEditorial.tsx diff --git a/src/routes/-management/FeaturedEditorial.test.tsx b/src/routes/-management/FeaturedEditorial.test.tsx new file mode 100644 index 0000000000..93178e7d16 --- /dev/null +++ b/src/routes/-management/FeaturedEditorial.test.tsx @@ -0,0 +1,78 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, expect, it, vi } from "vitest"; +import { FeaturedEditorial } from "./FeaturedEditorial"; +const { save, current } = vi.hoisted(() => ({ save: vi.fn(), current: { revision: 3 } })); +const editorial = [ + { + id: "plugin:pending", + name: "pending", + displayName: "Pending workflow", + reason: "Useful discovery", + }, +]; +vi.mock("convex/react", () => ({ + useMutation: () => save, + useQuery: () => ({ + artifactKind: "plugin", + revision: current.revision, + editorial, + published: null, + reservations: editorial.map((item) => ({ + ...item, + currentArtifact: null, + pendingReasons: ["not-in-public-catalog"], + })), + }), +})); +beforeEach(() => { + save.mockReset(); + current.revision = 3; +}); +it("shows pending reservations without links and saves editorial data without publishing", async () => { + save.mockResolvedValue({ revision: 4 }); + render(); + expect(screen.queryByRole("link", { name: "Pending workflow" })).toBeNull(); + expect(screen.getByText(/not-in-public-catalog/)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Edit editorial choices" })); + fireEvent.change(screen.getByLabelText("Editorial reason"), { + target: { value: "Revised editorial rationale" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save editorial choices" })); + await screen.findByText(/Public Featured has not changed/); + expect(save).toHaveBeenCalledExactlyOnceWith({ + expectedRevision: 3, + items: [{ ...editorial[0], reason: "Revised editorial rationale" }], + }); +}); +it("preserves the editor and visibly refuses a concurrent revision overwrite", async () => { + const page = render(); + fireEvent.click(screen.getByRole("button", { name: "Edit editorial choices" })); + fireEvent.change(screen.getByLabelText("Display name"), { + target: { value: "My unsaved choice" }, + }); + current.revision = 4; + page.rerender(); + expect((screen.getByLabelText("Display name") as HTMLInputElement).value).toBe( + "My unsaved choice", + ); + expect( + (screen.getByRole("button", { name: "Save editorial choices" }) as HTMLButtonElement).disabled, + ).toBe(true); + expect( + screen + .getAllByRole("alert") + .map((item) => item.textContent) + .join(" "), + ).toMatch(/saved choices are revision 4/); + expect(save).not.toHaveBeenCalled(); +}); +it("retains a failed save for correction and makes the outcome visible", async () => { + save.mockRejectedValue(new Error("Duplicate plugin identity")); + render(); + fireEvent.click(screen.getByRole("button", { name: "Edit editorial choices" })); + fireEvent.click(screen.getByRole("button", { name: "Save editorial choices" })); + await waitFor(() => + expect(screen.getByRole("alert").textContent).toContain("Duplicate plugin identity"), + ); + expect((screen.getByLabelText("Plugin name") as HTMLInputElement).value).toBe("pending"); +}); diff --git a/src/routes/-management/FeaturedEditorial.tsx b/src/routes/-management/FeaturedEditorial.tsx new file mode 100644 index 0000000000..c04a572c19 --- /dev/null +++ b/src/routes/-management/FeaturedEditorial.tsx @@ -0,0 +1,293 @@ +import { useMutation, useQuery } from "convex/react"; +import { useState } from "react"; +import { api } from "../../../convex/_generated/api"; +import { + FEATURED_EDITORIAL_SLOTS, + type EditorialSelection, +} from "../../../convex/lib/featuredSelections"; +import { Button } from "../../components/ui/button"; +import { insightTime as date } from "./insightTime"; + +export function FeaturedEditorial({ + artifactKind, + reportRevision, +}: { + artifactKind: "plugin" | "skill"; + reportRevision?: number; +}) { + const state = useQuery(api.featuredSelections.get, { artifactKind }); + const save = useMutation(api.featuredSelections.saveEditorial); + const [draft, setDraft] = useState<{ revision: number; items: EditorialSelection[] } | null>( + null, + ); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + if (!state) return

Loading saved Featured selections…

; + const published = state.published; + const editing = artifactKind === "plugin" && draft !== null; + const stale = editing && draft.revision !== state.revision; + function update(index: number, field: "name" | "displayName" | "reason", value: string) { + setDraft( + (current) => + current && { + ...current, + items: current.items.map((item, i) => + i === index + ? { ...item, [field]: value, ...(field === "name" ? { id: `plugin:${value}` } : {}) } + : item, + ), + }, + ); + } + function move(index: number, direction: -1 | 1) { + setDraft((current) => { + if (!current) return null; + const items = [...current.items]; + [items[index], items[index + direction]] = [items[index + direction], items[index]]; + return { ...current, items }; + }); + } + async function saveDraft() { + if (!draft) return; + setSaving(true); + setError(null); + setMessage(null); + try { + await save({ expectedRevision: draft.revision, items: draft.items }); + setDraft(null); + setMessage( + "Editorial choices saved. Refresh the recommendation report to use this revision. Public Featured has not changed.", + ); + } catch (failure) { + setError( + failure instanceof Error ? failure.message : "Editorial choices could not be saved.", + ); + } finally { + setSaving(false); + } + } + return ( +
+
+
+

Saved Featured selections

+

+ {published + ? `${published.items.length} published ${artifactKind === "plugin" ? "plugins" : "skills"} · ${date(published.at)}` + : "No approved selection has been published through this workflow yet."} +

+
+ {artifactKind === "plugin" && !editing ? ( + + ) : null} +
+ {artifactKind === "plugin" && + reportRevision !== undefined && + reportRevision !== state.revision ? ( +

+ The report uses editorial revision {reportRevision}; saved choices are revision{" "} + {state.revision}. Refresh before approving publication. +

+ ) : null} + {artifactKind === "plugin" ? ( +

+ Eight editorial slots stay reserved, followed by eight distinct install-ranked plugins. + Saving choices does not publish them. +

+ ) : ( +

+ Sixteen eligible native ClawHub skills, ranked by recorded installs. No editorial slots. +

+ )} + {published ? ( +
+ Published order and evidence +

+ {date(published.periodStart)} inclusive to {date(published.periodEnd)} exclusive. + Published by staff {published.byUserId}. +

+
    + {published.items.map((item) => ( +
  1. + {item.id} · {item.version} ·{" "} + {item.selectionBasis === "editorial" ? "Editorial" : "Recorded installs"} + {item.installs30d === undefined + ? null + : ` · ${item.installs30d.toLocaleString()} installs / 30 days · ${item.installs7d?.toLocaleString() ?? "Unknown"} / final 7 days`} +

    {item.reason}

    +
  2. + ))} +
+
+ ) : null} + {editing ? ( +
{ + event.preventDefault(); + void saveDraft(); + }} + > +

Editing revision {draft.revision}. Order determines editorial positions.

+ {stale ? ( +

+ The saved editorial choices changed while you were editing. Cancel and reload before + saving. +

+ ) : null} +
    + {draft.items.map((item, index) => ( +
  1. +
    + Editorial slot {index + 1} + + +