diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml
index 689da4d8..e4d41865 100644
--- a/.github/workflows/deploy-test.yml
+++ b/.github/workflows/deploy-test.yml
@@ -39,7 +39,9 @@ jobs:
(github.ref == 'refs/heads/pe/claw-583-mirrored-search-journey' &&
inputs.branch_test_confirm == 'deploy-claw-583-to-permanent-test') ||
(github.ref == 'refs/heads/pe/claw-560-verified-adoption' &&
- inputs.branch_test_confirm == 'deploy-claw-560-to-permanent-test')) &&
+ inputs.branch_test_confirm == 'deploy-claw-560-to-permanent-test') ||
+ (github.ref == 'refs/heads/pe/claw-590-trending-snapshot' &&
+ inputs.branch_test_confirm == 'deploy-claw-590-to-permanent-test')) &&
github.actor == 'Patrick-Erichsen' &&
inputs.expected_sha != '')) ||
(github.event_name == 'pull_request' &&
@@ -126,6 +128,14 @@ jobs:
then
branch_test_allowed=true
fi
+ if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]] &&
+ [[ "$GITHUB_REF" == refs/heads/pe/claw-590-trending-snapshot ]] &&
+ [[ "$GITHUB_ACTOR" == Patrick-Erichsen ]] &&
+ [[ "${{ inputs.branch_test_confirm }}" == deploy-claw-590-to-permanent-test ]] &&
+ [[ "${{ inputs.expected_sha }}" == "$deploy_sha" ]]
+ then
+ branch_test_allowed=true
+ fi
if [[ "$GITHUB_EVENT_NAME" == pull_request ]] &&
[[ "$GITHUB_HEAD_REF" == pe/claw-563-skills-sh-mirror-10k ]] &&
[[ "$GITHUB_ACTOR" == Patrick-Erichsen ]] &&
@@ -1120,3 +1130,217 @@ jobs:
claw583-cleanup-status.json
claw583-deployment-readback.json
claw583-deployment.json
+
+ claw590-canonical-trending-proof:
+ needs: deploy-test
+ if: >-
+ github.event_name == 'workflow_dispatch' &&
+ github.ref == 'refs/heads/pe/claw-590-trending-snapshot' &&
+ inputs.branch_test_confirm == 'deploy-claw-590-to-permanent-test' &&
+ inputs.expected_sha == needs.deploy-test.outputs.deploy_sha
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ environment:
+ name: Test
+ steps:
+ - uses: actions/checkout@v7.0.1
+ with:
+ fetch-depth: 0
+ ref: ${{ inputs.expected_sha }}
+
+ - uses: ./.github/actions/setup-bun
+
+ - name: Prove canonical Trending materialization and API in permanent Test
+ env:
+ CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
+ DEPLOY_SHA: ${{ needs.deploy-test.outputs.deploy_sha }}
+ TEST_SITE_URL: ${{ vars.SITE_URL }}
+ VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
+ run: |
+ set -euo pipefail
+ confirm=manage-claw-590-canonical-trending-test-proof
+ snapshot_id="claw-590-proof-$DEPLOY_SHA"
+ mkdir -p proof/claw-590
+ printf '{"armed":false}\n' > proof/claw-590/active-snapshot.json
+ cleanup() {
+ set +e
+ armed="$(jq -r '.armed // false' proof/claw-590/active-snapshot.json 2>/dev/null)"
+ active_snapshot_id="$(jq -r '.snapshotId // empty' proof/claw-590/active-snapshot.json 2>/dev/null)"
+ cleanup_exit=0
+ if [[ "$armed" == true && -n "$active_snapshot_id" ]]; then
+ cleanup_args="$(
+ jq -cn \
+ --arg confirm "$confirm" \
+ --arg snapshotId "$active_snapshot_id" \
+ '{confirm:$confirm,snapshotId:$snapshotId}'
+ )"
+ bunx convex run --no-push \
+ canonicalTrendingTestFixtures:cleanupCanonicalTrendingProof \
+ "$cleanup_args" > claw590-cleanup.json 2>&1
+ cleanup_exit=$?
+ bunx convex run --no-push \
+ canonicalTrendingTestFixtures:readCanonicalTrendingProof \
+ "$cleanup_args" > claw590-cleanup-readback.json 2>&1
+ readback_exit=$?
+ else
+ jq -n '{ok:true,skipped:true}' > claw590-cleanup.json
+ jq -n '{present:false,skipped:true}' > claw590-cleanup-readback.json
+ readback_exit=0
+ fi
+ snapshot_removed=false
+ if [[ "$cleanup_exit" -eq 0 && "$readback_exit" -eq 0 ]] && \
+ jq -e '.present == false' claw590-cleanup-readback.json >/dev/null; then
+ snapshot_removed=true
+ fi
+ source_args="$(jq -cn --arg confirm "$confirm" '{confirm:$confirm}')"
+ if [[ "$snapshot_removed" == true ]]; then
+ bunx convex run --no-push \
+ canonicalTrendingTestFixtures:cleanupCanonicalTrendingSourceFixture \
+ "$source_args" > claw590-source-cleanup.json 2>&1
+ source_cleanup_exit=$?
+ else
+ jq -n \
+ '{ok:false,skipped:true,reason:"snapshot cleanup incomplete; source retained"}' \
+ > claw590-source-cleanup.json
+ source_cleanup_exit=0
+ fi
+ bunx convex run --no-push \
+ canonicalTrendingTestFixtures:readCanonicalTrendingSourceFixture \
+ "$source_args" > claw590-source-cleanup-readback.json 2>&1
+ source_readback_exit=$?
+ jq -n \
+ --argjson cleanupExit "$cleanup_exit" \
+ --argjson readbackExit "$readback_exit" \
+ --argjson sourceCleanupExit "$source_cleanup_exit" \
+ --argjson sourceReadbackExit "$source_readback_exit" \
+ '{
+ cleanupExit:$cleanupExit,
+ readbackExit:$readbackExit,
+ sourceCleanupExit:$sourceCleanupExit,
+ sourceReadbackExit:$sourceReadbackExit
+ }' \
+ > claw590-cleanup-status.json
+ set -e
+ [[ \
+ "$cleanup_exit" -eq 0 && \
+ "$readback_exit" -eq 0 && \
+ "$source_cleanup_exit" -eq 0 && \
+ "$source_readback_exit" -eq 0 \
+ ]]
+ jq -e '.present == false' claw590-cleanup-readback.json >/dev/null
+ jq -e '.present == false' claw590-source-cleanup-readback.json >/dev/null
+ }
+ trap cleanup EXIT
+
+ [[ "$(git rev-parse HEAD)" == "$DEPLOY_SHA" ]]
+ bunx convex run --no-push appMeta:getDeploymentInfo '{}' > claw590-deployment-readback.json
+ jq -e --arg sha "$DEPLOY_SHA" '.appBuildSha == $sha' claw590-deployment-readback.json
+ proof_args="$(
+ jq -cn \
+ --arg confirm "$confirm" \
+ --arg snapshotId "$snapshot_id" \
+ '{confirm:$confirm,snapshotId:$snapshotId}'
+ )"
+ bunx convex run --no-push \
+ canonicalTrendingTestFixtures:readCanonicalTrendingProof \
+ "$proof_args" > claw590-before.json
+ if jq -e '.present == true' claw590-before.json >/dev/null; then
+ bunx convex run --no-push \
+ canonicalTrendingTestFixtures:cleanupCanonicalTrendingProof \
+ "$proof_args" > claw590-recovery.json
+ bunx convex run --no-push \
+ canonicalTrendingTestFixtures:readCanonicalTrendingProof \
+ "$proof_args" > claw590-recovery-readback.json
+ jq -e '.present == false' claw590-recovery-readback.json >/dev/null
+ else
+ jq -n '{ok:true,skipped:true}' > claw590-recovery.json
+ cp claw590-before.json claw590-recovery-readback.json
+ fi
+ jq -e '.present == false' claw590-recovery-readback.json >/dev/null
+
+ source_args="$(jq -cn --arg confirm "$confirm" '{confirm:$confirm}')"
+ bunx convex run --no-push \
+ canonicalTrendingTestFixtures:readCanonicalTrendingSourceFixture \
+ "$source_args" > claw590-source-before.json
+ if jq -e '.present == true' claw590-source-before.json >/dev/null; then
+ bunx convex run --no-push \
+ canonicalTrendingTestFixtures:cleanupCanonicalTrendingSourceFixture \
+ "$source_args" > claw590-source-recovery.json
+ bunx convex run --no-push \
+ canonicalTrendingTestFixtures:readCanonicalTrendingSourceFixture \
+ "$source_args" > claw590-source-recovery-readback.json
+ jq -e '.present == false' claw590-source-recovery-readback.json >/dev/null
+ else
+ jq -n '{ok:true,skipped:true}' > claw590-source-recovery.json
+ cp claw590-source-before.json claw590-source-recovery-readback.json
+ fi
+ bunx convex run --no-push \
+ canonicalTrendingTestFixtures:seedCanonicalTrendingSourceFixture \
+ "$source_args" > claw590-source-seed.json
+ bunx convex run --no-push \
+ canonicalTrendingTestFixtures:readCanonicalTrendingSourceFixture \
+ "$source_args" > claw590-source-active.json
+ jq -e '
+ .present == true and
+ .nativeCount == 12 and
+ .nativePublisherCount == 6 and
+ .externalCount == 8 and
+ .scansPlanned == 0 and
+ .scansAdmitted == 0
+ ' claw590-source-active.json >/dev/null
+
+ bun run trending:prove-test > claw590-proof-output.json
+ jq -e '
+ .target.environment == "permanent Test" and
+ .target.deploySha == env.DEPLOY_SHA and
+ .materialization.snapshotId == ("claw-590-proof-" + env.DEPLOY_SHA) and
+ .materialization.totalItems >= 20 and
+ .materialization.operations.documentsRead > 0 and
+ .materialization.operations.documentsWritten > 0 and
+ .materialization.operations.functionCalls > 0 and
+ .assertions.laneCounts["clawhub-trending"] == 8 and
+ .assertions.laneCounts["clawhub-rising"] == 4 and
+ .assertions.laneCounts["skills-sh-trending"] == 8 and
+ .assertions.maximumPublisherCount <= 2 and
+ .assertions.stableSnapshot == true and
+ .assertions.stableCursor == true and
+ .assertions.stableOrder == true and
+ .assertions.completePagination == true and
+ .assertions.skillsShTrending24hInstalls == null
+ ' proof/claw-590/canonical-trending-test-proof.json >/dev/null
+
+ cleanup
+ trap - EXIT
+ jq -e '
+ .cleanupExit == 0 and
+ .readbackExit == 0 and
+ .sourceCleanupExit == 0 and
+ .sourceReadbackExit == 0
+ ' claw590-cleanup-status.json >/dev/null
+ jq -e '.present == false' claw590-cleanup-readback.json >/dev/null
+ jq -e '.present == false' claw590-source-cleanup-readback.json >/dev/null
+
+ - name: Upload permanent Test canonical Trending proof
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: claw590-canonical-trending-proof
+ if-no-files-found: error
+ path: |
+ proof/claw-590/canonical-trending-test-proof.json
+ proof/claw-590/active-snapshot.json
+ claw590-proof-output.json
+ claw590-before.json
+ claw590-recovery.json
+ claw590-recovery-readback.json
+ claw590-cleanup.json
+ claw590-cleanup-readback.json
+ claw590-cleanup-status.json
+ claw590-deployment-readback.json
+ claw590-source-before.json
+ claw590-source-recovery.json
+ claw590-source-recovery-readback.json
+ claw590-source-seed.json
+ claw590-source-active.json
+ claw590-source-cleanup.json
+ claw590-source-cleanup-readback.json
diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts
index 85731e8f..1a3756e2 100644
--- a/convex/_generated/api.d.ts
+++ b/convex/_generated/api.d.ts
@@ -11,6 +11,8 @@
import type * as agentSkillsHttp from "../agentSkillsHttp.js";
import type * as appMeta from "../appMeta.js";
import type * as auth from "../auth.js";
+import type * as canonicalTrending from "../canonicalTrending.js";
+import type * as canonicalTrendingTestFixtures from "../canonicalTrendingTestFixtures.js";
import type * as catalogClassification from "../catalogClassification.js";
import type * as catalogClassificationNode from "../catalogClassificationNode.js";
import type * as catalogFeed from "../catalogFeed.js";
@@ -46,6 +48,7 @@ import type * as httpApiV1_skillsShCatalogV1 from "../httpApiV1/skillsShCatalogV
import type * as httpApiV1_skillsV1 from "../httpApiV1/skillsV1.js";
import type * as httpApiV1_starsV1 from "../httpApiV1/starsV1.js";
import type * as httpApiV1_transfersV1 from "../httpApiV1/transfersV1.js";
+import type * as httpApiV1_trendingV1 from "../httpApiV1/trendingV1.js";
import type * as httpApiV1_usersV1 from "../httpApiV1/usersV1.js";
import type * as httpApiV1_whoamiV1 from "../httpApiV1/whoamiV1.js";
import type * as httpPreflight from "../httpPreflight.js";
@@ -60,6 +63,7 @@ import type * as lib_batching from "../lib/batching.js";
import type * as lib_canonicalSkillSearch from "../lib/canonicalSkillSearch.js";
import type * as lib_canonicalSkillSearchBounds from "../lib/canonicalSkillSearchBounds.js";
import type * as lib_canonicalSkillSearchResponse from "../lib/canonicalSkillSearchResponse.js";
+import type * as lib_canonicalTrending from "../lib/canonicalTrending.js";
import type * as lib_catalogClassification from "../lib/catalogClassification.js";
import type * as lib_catalogClassifier from "../lib/catalogClassifier.js";
import type * as lib_changelog from "../lib/changelog.js";
@@ -208,6 +212,8 @@ declare const fullApi: ApiFromModules<{
agentSkillsHttp: typeof agentSkillsHttp;
appMeta: typeof appMeta;
auth: typeof auth;
+ canonicalTrending: typeof canonicalTrending;
+ canonicalTrendingTestFixtures: typeof canonicalTrendingTestFixtures;
catalogClassification: typeof catalogClassification;
catalogClassificationNode: typeof catalogClassificationNode;
catalogFeed: typeof catalogFeed;
@@ -243,6 +249,7 @@ declare const fullApi: ApiFromModules<{
"httpApiV1/skillsV1": typeof httpApiV1_skillsV1;
"httpApiV1/starsV1": typeof httpApiV1_starsV1;
"httpApiV1/transfersV1": typeof httpApiV1_transfersV1;
+ "httpApiV1/trendingV1": typeof httpApiV1_trendingV1;
"httpApiV1/usersV1": typeof httpApiV1_usersV1;
"httpApiV1/whoamiV1": typeof httpApiV1_whoamiV1;
httpPreflight: typeof httpPreflight;
@@ -257,6 +264,7 @@ declare const fullApi: ApiFromModules<{
"lib/canonicalSkillSearch": typeof lib_canonicalSkillSearch;
"lib/canonicalSkillSearchBounds": typeof lib_canonicalSkillSearchBounds;
"lib/canonicalSkillSearchResponse": typeof lib_canonicalSkillSearchResponse;
+ "lib/canonicalTrending": typeof lib_canonicalTrending;
"lib/catalogClassification": typeof lib_catalogClassification;
"lib/catalogClassifier": typeof lib_catalogClassifier;
"lib/changelog": typeof lib_changelog;
diff --git a/convex/canonicalTrending.test.ts b/convex/canonicalTrending.test.ts
new file mode 100644
index 00000000..2836639f
--- /dev/null
+++ b/convex/canonicalTrending.test.ts
@@ -0,0 +1,592 @@
+///
+/* @vitest-environment edge-runtime */
+import { convexTest } from "convex-test";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { internal } from "./_generated/api";
+import schema from "./schema";
+
+const modules = import.meta.glob("./**/*.ts");
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
+function nativeCard(id: string, installs24h: number) {
+ const slug = id.replace("clawhub:", "");
+ return {
+ id,
+ source: "clawhub" as const,
+ slug,
+ displayName: slug,
+ summary: null,
+ canonicalUrl: `/patrick/skills/${slug}`,
+ links: { canonical: `/patrick/skills/${slug}`, source: null },
+ publisher: {
+ kind: "user" as const,
+ handle: "patrick",
+ displayName: "Patrick",
+ image: null,
+ official: false,
+ },
+ official: false,
+ featured: false,
+ install: { kind: "clawhub" as const, reference: `patrick/${slug}`, sourceUrl: null },
+ sourceIdentity: {
+ id,
+ owner: "patrick",
+ repo: null,
+ host: null,
+ lifetimeInstalls: null,
+ },
+ trust: {
+ visibility: "public" as const,
+ installability: "installable" as const,
+ clawHubVerdict: null,
+ upstreamScanners: null,
+ sourceFreshness: "native" as const,
+ },
+ metrics: {
+ trending24hInstalls: installs24h,
+ trending24hBookmarks: 0,
+ lifetimeInstalls: null,
+ lifetimeInstallsPeriod: "lifetime" as const,
+ updatedAt: 1_000,
+ },
+ };
+}
+
+async function insertEligibleNativeSource(t: ReturnType, slug: string) {
+ return await t.run(async (ctx) => {
+ const now = Date.now();
+ const userId = await ctx.db.insert("users", {
+ handle: "patrick",
+ displayName: "Patrick",
+ createdAt: now,
+ updatedAt: now,
+ });
+ const skillId = await ctx.db.insert("skills", {
+ slug,
+ displayName: slug,
+ ownerUserId: userId,
+ tags: {},
+ stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
+ createdAt: now,
+ updatedAt: now,
+ });
+ const versionId = await ctx.db.insert("skillVersions", {
+ skillId,
+ version: "1.0.0",
+ changelog: "Initial",
+ files: [],
+ parsed: { frontmatter: {} },
+ createdBy: userId,
+ createdAt: now,
+ });
+ const digestId = await ctx.db.insert("skillSearchDigest", {
+ skillId,
+ slug,
+ displayName: slug,
+ ownerUserId: userId,
+ ownerHandle: "patrick",
+ ownerKind: "user",
+ ownerName: "patrick",
+ ownerDisplayName: "Patrick",
+ latestVersionId: versionId,
+ latestVersionSkillId: skillId,
+ publicVersion: { status: "available", versionId },
+ tags: {},
+ stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
+ createdAt: now,
+ updatedAt: now,
+ });
+ return { digestId, skillId };
+ });
+}
+
+describe("canonical Trending snapshot storage", () => {
+ it("selects the newest completed Trending run even when no digest references it", async () => {
+ const t = convexTest(schema, modules);
+ const makeRun = (snapshotId: string, startedAt: number) => ({
+ snapshotId,
+ sourceView: "trending" as const,
+ sourceSnapshotHash: snapshotId.padEnd(64, "a").slice(0, 64),
+ status: "completed" as const,
+ sourceTotal: 0,
+ sourcePageSize: 100,
+ sourceMeasuredAt: new Date(startedAt).toISOString(),
+ page: 1,
+ offset: 0,
+ counts: {
+ observed: 0,
+ inserted: 0,
+ updated: 0,
+ unchanged: 0,
+ rejected: 0,
+ conflicts: 0,
+ detailsInserted: 0,
+ detailsUpdated: 0,
+ detailsUnchanged: 0,
+ detailsMissing: 0,
+ detailsTruncated: 0,
+ tombstoned: 0,
+ reactivated: 0,
+ scansPlanned: 0 as const,
+ scansAdmitted: 0 as const,
+ },
+ operations: { functionCalls: 1, dbReads: 1, dbWrites: 1, sourceRequests: 1, sourceBytes: 0 },
+ actor: "runtime-test",
+ reason: "canonical Trending run selection test",
+ startedAt,
+ completedAt: startedAt + 1,
+ updatedAt: startedAt + 1,
+ });
+ const { latestId } = await t.run(async (ctx) => {
+ await ctx.db.insert("skillsShMirrorRuns", makeRun("older-run", 100));
+ const insertedLatestId = await ctx.db.insert(
+ "skillsShMirrorRuns",
+ makeRun("empty-latest-run", 200),
+ );
+ return { latestId: insertedLatestId };
+ });
+
+ const result = await t.query(
+ internal.canonicalTrending.getLatestCompletedTrendingRunInternal,
+ {},
+ );
+
+ expect(result.runId).toBe(latestId);
+ });
+
+ it("keeps pagination pinned to the snapshot encoded by the cursor", async () => {
+ const t = convexTest(schema, modules);
+ const source = await insertEligibleNativeSource(t, "pagination-source");
+
+ await t.mutation(internal.canonicalTrending.startSnapshotInternal, {
+ snapshotId: "skills-1000",
+ generatedAt: 1_000,
+ expiresAt: Date.now() + 100_000,
+ windowStartDay: 40,
+ windowEndDay: 40,
+ });
+ await t.mutation(internal.canonicalTrending.writeItemsInternal, {
+ snapshotId: "skills-1000",
+ items: [
+ {
+ position: 0,
+ lane: "clawhub-trending",
+ sourceRef: { kind: "clawhub", skillId: source.skillId },
+ card: nativeCard("clawhub:one", 3),
+ },
+ {
+ position: 1,
+ lane: "skills-sh-trending",
+ sourceRef: { kind: "clawhub", skillId: source.skillId },
+ card: nativeCard("clawhub:two", 2),
+ },
+ {
+ position: 2,
+ lane: "clawhub-rising",
+ sourceRef: { kind: "clawhub", skillId: source.skillId },
+ card: nativeCard("clawhub:three", 1),
+ },
+ ],
+ });
+ await t.mutation(internal.canonicalTrending.finalizeSnapshotInternal, {
+ snapshotId: "skills-1000",
+ completedAt: 1_050,
+ totalItems: 3,
+ sourceCounts: { clawhubTrending: 1, clawhubRising: 1, skillsShTrending: 1 },
+ operations: { documentsRead: 12, documentsWritten: 4, functionCalls: 3 },
+ });
+
+ const firstResult = await t.query(internal.canonicalTrending.getPageInternal, {
+ cursor: null,
+ limit: 2,
+ });
+ expect(firstResult.status).toBe("ok");
+ if (firstResult.status !== "ok") throw new Error("Expected a ready Trending page");
+ const firstPage = firstResult.page;
+ expect(firstPage).toMatchObject({
+ kind: "skills",
+ snapshotId: "skills-1000",
+ generatedAt: "1970-01-01T00:00:01.000Z",
+ windowHours: 24,
+ rankingVersion: "skills-trending-v1",
+ items: [
+ { id: "clawhub:one", rank: 1, lane: "clawhub-trending" },
+ { id: "clawhub:two", rank: 2, lane: "skills-sh-trending" },
+ ],
+ });
+ expect(firstPage?.nextCursor).toEqual(expect.any(String));
+
+ await t.mutation(internal.canonicalTrending.startSnapshotInternal, {
+ snapshotId: "skills-2000",
+ generatedAt: 2_000,
+ expiresAt: Date.now() + 100_000,
+ windowStartDay: 41,
+ windowEndDay: 41,
+ });
+ await t.mutation(internal.canonicalTrending.writeItemsInternal, {
+ snapshotId: "skills-2000",
+ items: [
+ {
+ position: 0,
+ lane: "clawhub-trending",
+ sourceRef: { kind: "clawhub", skillId: source.skillId },
+ card: nativeCard("clawhub:new", 10),
+ },
+ ],
+ });
+ await t.mutation(internal.canonicalTrending.finalizeSnapshotInternal, {
+ snapshotId: "skills-2000",
+ completedAt: 2_050,
+ totalItems: 1,
+ sourceCounts: { clawhubTrending: 1, clawhubRising: 0, skillsShTrending: 0 },
+ operations: { documentsRead: 5, documentsWritten: 2, functionCalls: 3 },
+ });
+
+ const secondResult = await t.query(internal.canonicalTrending.getPageInternal, {
+ cursor: firstPage?.nextCursor ?? null,
+ limit: 2,
+ });
+ expect(secondResult.status).toBe("ok");
+ if (secondResult.status !== "ok") throw new Error("Expected the pinned Trending page");
+ const secondPage = secondResult.page;
+ expect(secondPage?.snapshotId).toBe("skills-1000");
+ expect(secondPage?.items).toEqual([
+ expect.objectContaining({ id: "clawhub:three", rank: 3, lane: "clawhub-rising" }),
+ ]);
+ expect(secondPage?.nextCursor).toBeNull();
+
+ await t.run(async (ctx) => {
+ await ctx.db.patch(source.digestId, { softDeletedAt: Date.now() });
+ });
+ const revokedResult = await t.query(internal.canonicalTrending.getPageInternal, {
+ cursor: firstPage.snapshotCursor,
+ limit: 2,
+ });
+ expect(revokedResult.status).toBe("ok");
+ if (revokedResult.status !== "ok") throw new Error("Expected the pinned Trending page");
+ expect(revokedResult.page.items).toEqual([]);
+ });
+
+ it("rejects an expired stable cursor before reading pruned item rows", async () => {
+ const t = convexTest(schema, modules);
+ await t.mutation(internal.canonicalTrending.startSnapshotInternal, {
+ snapshotId: "skills-expired",
+ generatedAt: 1_000,
+ expiresAt: Date.now() - 1,
+ windowStartDay: 40,
+ windowEndDay: 40,
+ });
+ await t.mutation(internal.canonicalTrending.finalizeSnapshotInternal, {
+ snapshotId: "skills-expired",
+ completedAt: 1_050,
+ totalItems: 0,
+ sourceCounts: { clawhubTrending: 0, clawhubRising: 0, skillsShTrending: 0 },
+ operations: { documentsRead: 1, documentsWritten: 2, functionCalls: 2 },
+ });
+
+ expect(
+ await t.query(internal.canonicalTrending.getPageInternal, {
+ cursor: "eyJ2IjoxLCJzIjoic2tpbGxzLWV4cGlyZWQiLCJvIjowfQ",
+ limit: 20,
+ }),
+ ).toEqual({ status: "expired" });
+ expect(
+ await t.query(internal.canonicalTrending.getPageInternal, { cursor: null, limit: 20 }),
+ ).toEqual({ status: "unavailable" });
+ });
+
+ it("returns a typed error for malformed cursors", async () => {
+ const t = convexTest(schema, modules);
+
+ expect(
+ await t.query(internal.canonicalTrending.getPageInternal, {
+ cursor: "not-a-cursor",
+ limit: 20,
+ }),
+ ).toEqual({ status: "invalid-cursor" });
+ });
+
+ it("does not read or write source state while the rollout is dark", async () => {
+ vi.stubEnv("CLAWHUB_ENV", "production");
+ vi.stubEnv("CLAWHUB_SKILLS_SH_ROLLOUT_MODE", "off");
+ const t = convexTest(schema, modules);
+
+ const result = await t.action(internal.canonicalTrending.materializeInternal, {});
+ const snapshots = await t.run(async (ctx) =>
+ ctx.db.query("canonicalTrendingSnapshots").collect(),
+ );
+
+ expect(result).toEqual({ status: "disabled" });
+ expect(snapshots).toEqual([]);
+ });
+
+ it("prunes expired snapshots independently while materialization is dark", async () => {
+ vi.stubEnv("CLAWHUB_ENV", "production");
+ vi.stubEnv("CLAWHUB_SKILLS_SH_ROLLOUT_MODE", "off");
+ const t = convexTest(schema, modules);
+ const source = await insertEligibleNativeSource(t, "cleanup-source");
+ await t.mutation(internal.canonicalTrending.startSnapshotInternal, {
+ snapshotId: "skills-expired-cleanup",
+ generatedAt: 1_000,
+ expiresAt: Date.now() - 1,
+ windowStartDay: 40,
+ windowEndDay: 40,
+ });
+ await t.mutation(internal.canonicalTrending.writeItemsInternal, {
+ snapshotId: "skills-expired-cleanup",
+ items: [
+ {
+ position: 0,
+ lane: "clawhub-trending",
+ sourceRef: { kind: "clawhub", skillId: source.skillId },
+ card: nativeCard("clawhub:old", 1),
+ },
+ ],
+ });
+
+ const result = await t.action(internal.canonicalTrending.pruneExpiredActionInternal, {});
+ const rows = await t.run(async (ctx) => ({
+ snapshots: await ctx.db.query("canonicalTrendingSnapshots").collect(),
+ items: await ctx.db.query("canonicalTrendingItems").collect(),
+ }));
+
+ expect(result).toEqual({
+ itemsDeleted: 1,
+ snapshotsDeleted: 1,
+ batches: 1,
+ continuationScheduled: false,
+ });
+ expect(rows).toEqual({ snapshots: [], items: [] });
+ });
+
+ it("materializes imported 24-hour metrics into a ready snapshot", async () => {
+ vi.stubEnv("CLAWHUB_ENV", "test");
+ vi.stubEnv("CLAWHUB_SKILLS_SH_ROLLOUT_MODE", "test");
+ const t = convexTest(schema, modules);
+ const now = Date.now();
+
+ await t.run(async (ctx) => {
+ const userId = await ctx.db.insert("users", {
+ handle: "patrick",
+ displayName: "Patrick",
+ createdAt: now,
+ updatedAt: now,
+ });
+ const skillId = await ctx.db.insert("skills", {
+ slug: "native",
+ displayName: "Native",
+ summary: "Native summary",
+ ownerUserId: userId,
+ tags: {},
+ statsInstallsAllTime: 900,
+ stats: { downloads: 1_000, installsAllTime: 900, stars: 20, versions: 1, comments: 0 },
+ createdAt: now - 1_000,
+ updatedAt: now,
+ });
+ const versionId = await ctx.db.insert("skillVersions", {
+ skillId,
+ version: "1.0.0",
+ changelog: "Initial",
+ files: [],
+ parsed: { frontmatter: {} },
+ createdBy: userId,
+ createdAt: now,
+ });
+ await ctx.db.insert("skillSearchDigest", {
+ skillId,
+ slug: "native",
+ displayName: "Native",
+ summary: "Native summary",
+ ownerUserId: userId,
+ ownerHandle: "patrick",
+ ownerKind: "user",
+ ownerName: "patrick",
+ ownerDisplayName: "Patrick",
+ latestVersionId: versionId,
+ latestVersionSkillId: skillId,
+ publicVersion: { status: "available", versionId },
+ tags: {},
+ statsInstallsAllTime: 900,
+ stats: { downloads: 1_000, installsAllTime: 900, stars: 20, versions: 1, comments: 0 },
+ createdAt: now - 1_000,
+ updatedAt: now,
+ });
+ await ctx.db.insert("rankingMetricImports", {
+ datasetVersion: "ranking-test-v1",
+ checksum: "a".repeat(64),
+ generatedAt: new Date(now).toISOString(),
+ importedAt: now,
+ startDay: 1,
+ endDay: 60,
+ targetCount: 1,
+ skillTargetCount: 1,
+ packageTargetCount: 0,
+ dailyRowCount: 1,
+ importedSkillRows: 1,
+ importedPackageRows: 0,
+ unresolvedTargets: 0,
+ skippedOverlayRows: 0,
+ });
+ await ctx.db.insert("skillDailyStats", {
+ skillId,
+ day: 60,
+ downloads: 18,
+ installs: 12,
+ bookmarks: 4,
+ rankingDatasetVersion: "ranking-test-v1",
+ rankingImportedAt: now,
+ updatedAt: now,
+ });
+ await ctx.db.insert("skillDailyStats", {
+ skillId,
+ day: 61,
+ downloads: 999,
+ installs: 999,
+ bookmarks: 999,
+ updatedAt: now + 1,
+ });
+ const trendingRunId = await ctx.db.insert("skillsShMirrorRuns", {
+ snapshotId: "skills-sh-trending-runtime",
+ sourceView: "trending",
+ sourceSnapshotHash: "b".repeat(64),
+ status: "completed",
+ sourceTotal: 1,
+ sourcePageSize: 100,
+ sourceMeasuredAt: new Date(now).toISOString(),
+ page: 1,
+ offset: 0,
+ counts: {
+ observed: 1,
+ inserted: 0,
+ updated: 1,
+ unchanged: 0,
+ rejected: 0,
+ conflicts: 0,
+ detailsInserted: 0,
+ detailsUpdated: 0,
+ detailsUnchanged: 0,
+ detailsMissing: 0,
+ detailsTruncated: 0,
+ tombstoned: 0,
+ reactivated: 0,
+ scansPlanned: 0,
+ scansAdmitted: 0,
+ },
+ operations: {
+ functionCalls: 1,
+ dbReads: 1,
+ dbWrites: 1,
+ sourceRequests: 1,
+ sourceBytes: 100,
+ },
+ actor: "runtime-test",
+ reason: "canonical Trending runtime test",
+ startedAt: now - 100,
+ completedAt: now,
+ updatedAt: now,
+ });
+ await ctx.db.insert("skillsShMirrorDigests", {
+ externalId: "patrick/repo/external",
+ sourceType: "github",
+ owner: "patrick",
+ repo: "repo",
+ slug: "external",
+ normalizedSlug: "external",
+ normalizedSlugFirstToken: "external",
+ displayName: "External",
+ normalizedDisplayName: "external",
+ normalizedDisplayNameFirstToken: "external",
+ searchSummary: "External summary",
+ searchText: "external external summary",
+ sourceUrl: "https://skills.sh/patrick/repo/external",
+ canonicalRepoUrl: "https://github.com/patrick/repo",
+ upstreamInstalls: 4_000,
+ trendingRank: 1,
+ trendingLifetimeInstalls: 4_200,
+ trendingObservedAt: now,
+ trendingSnapshotId: "skills-sh-trending-runtime",
+ trendingObservedRunId: trendingRunId,
+ upstreamScanners: {
+ genAgentTrustHub: { status: "pass" },
+ socket: { status: "pass" },
+ snyk: { status: "pass" },
+ },
+ sourceFreshnessStatus: "observed-only",
+ detailStatus: "available",
+ observationFingerprint: "c".repeat(64),
+ sourceSnapshotId: "skills-sh-trending-runtime",
+ lastObservedRunId: trendingRunId,
+ active: true,
+ publicVisible: true,
+ installable: true,
+ firstObservedAt: now - 500,
+ lastObservedAt: now,
+ createdAt: now - 500,
+ updatedAt: now,
+ });
+ });
+
+ const result = await t.action(internal.canonicalTrending.materializeInternal, {});
+ expect(result).toMatchObject({
+ status: "ready",
+ totalItems: 2,
+ sourceCounts: { clawhubTrending: 1, clawhubRising: 1, skillsShTrending: 1 },
+ sample: [
+ {
+ rank: 1,
+ lane: "clawhub-trending",
+ id: expect.stringMatching(/^clawhub:/),
+ trending24hInstalls: 12,
+ lifetimeInstalls: 900,
+ },
+ {
+ rank: 2,
+ lane: "skills-sh-trending",
+ id: "skills-sh:patrick/repo/external",
+ trending24hInstalls: null,
+ lifetimeInstalls: 4_200,
+ },
+ ],
+ });
+
+ const pageResult = await t.query(internal.canonicalTrending.getPageInternal, {
+ cursor: null,
+ limit: 20,
+ });
+ expect(pageResult.status).toBe("ok");
+ if (pageResult.status !== "ok") throw new Error("Expected a materialized Trending page");
+ const page = pageResult.page;
+ expect(page?.items).toEqual([
+ expect.objectContaining({
+ source: "clawhub",
+ rank: 1,
+ metrics: {
+ trending24hInstalls: 12,
+ trending24hBookmarks: 4,
+ lifetimeInstalls: 900,
+ lifetimeInstallsPeriod: "lifetime",
+ updatedAt: now,
+ },
+ }),
+ expect.objectContaining({
+ source: "skills-sh",
+ rank: 2,
+ install: {
+ kind: "skills-sh",
+ reference: "skills-sh:patrick/repo/external",
+ sourceUrl: "https://skills.sh/patrick/repo/external",
+ },
+ metrics: {
+ trending24hInstalls: null,
+ trending24hBookmarks: null,
+ lifetimeInstalls: 4_200,
+ lifetimeInstallsPeriod: "lifetime",
+ updatedAt: now,
+ },
+ }),
+ ]);
+ });
+});
diff --git a/convex/canonicalTrending.ts b/convex/canonicalTrending.ts
new file mode 100644
index 00000000..d6cda9d5
--- /dev/null
+++ b/convex/canonicalTrending.ts
@@ -0,0 +1,700 @@
+import { paginationOptsValidator } from "convex/server";
+import { v } from "convex/values";
+import { internal } from "./_generated/api";
+import type { Doc } from "./_generated/dataModel";
+import { internalAction, internalMutation, internalQuery } from "./_generated/server";
+import {
+ CANONICAL_TRENDING_RANKING_VERSION,
+ CANONICAL_TRENDING_WINDOW_HOURS,
+ blendCanonicalTrendingPools,
+ buildExternalCanonicalTrendingCandidate,
+ buildNativeCanonicalTrendingCandidate,
+ canonicalTrendingCardValidator,
+ canonicalTrendingSourceRefValidator,
+ decodeCanonicalTrendingCursor,
+ encodeCanonicalTrendingCursor,
+ type CanonicalTrendingMaterializationCandidate,
+} from "./lib/canonicalTrending";
+import { shouldExcludeSkillFromPublicBrowse } from "./lib/publicBrowse";
+import { getRuntimeRolloutCapabilities } from "./lib/rolloutCapabilities";
+import { isPublicSkillsShMirrorDigest } from "./lib/skillsShMirrorPublic";
+import { assertTestSeedAllowed } from "./lib/testSeed";
+
+const SOURCE_PAGE_SIZE = 250;
+const WRITE_BATCH_SIZE = 100;
+const SNAPSHOT_RETENTION_MS = 48 * 60 * 60 * 1_000;
+const RISING_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000;
+const PRUNE_BATCH_SIZE = 500;
+const PRUNE_MAX_BATCHES = 20;
+
+const internalRefs = internal as unknown as {
+ canonicalTrending: {
+ failSnapshotInternal: unknown;
+ finalizeSnapshotInternal: unknown;
+ getExternalSourcePageInternal: unknown;
+ getMetricSourcePageInternal: unknown;
+ getMetricWindowInternal: unknown;
+ getNativeSourcePageInternal: unknown;
+ getLatestCompletedTrendingRunInternal: unknown;
+ pruneExpiredInternal: unknown;
+ pruneExpiredActionInternal: unknown;
+ startSnapshotInternal: unknown;
+ writeItemsInternal: unknown;
+ };
+};
+
+async function pruneExpiredRows(
+ ctx: { runMutation: (ref: never, args: never) => Promise },
+ now: number,
+) {
+ let itemsDeleted = 0;
+ let snapshotsDeleted = 0;
+ let batches = 0;
+ for (; batches < PRUNE_MAX_BATCHES; batches += 1) {
+ const pruned = (await ctx.runMutation(
+ internalRefs.canonicalTrending.pruneExpiredInternal as never,
+ { now, batchSize: PRUNE_BATCH_SIZE } as never,
+ )) as { itemsDeleted: number; snapshotsDeleted: number; fullBatch: boolean };
+ itemsDeleted += pruned.itemsDeleted;
+ snapshotsDeleted += pruned.snapshotsDeleted;
+ if (!pruned.fullBatch) return { itemsDeleted, snapshotsDeleted, batches: batches + 1 };
+ }
+ return { itemsDeleted, snapshotsDeleted, batches };
+}
+
+const laneValidator = v.union(
+ v.literal("clawhub-trending"),
+ v.literal("clawhub-rising"),
+ v.literal("skills-sh-trending"),
+);
+
+const sourceCountsValidator = v.object({
+ clawhubTrending: v.number(),
+ clawhubRising: v.number(),
+ skillsShTrending: v.number(),
+});
+
+const operationsValidator = v.object({
+ documentsRead: v.number(),
+ documentsWritten: v.number(),
+ functionCalls: v.number(),
+});
+
+type SourcePage = {
+ page: T[];
+ isDone: boolean;
+ continueCursor: string;
+ documentsRead: number;
+};
+
+type CollectedSource = {
+ rows: T[];
+ documentsRead: number;
+ functionCalls: number;
+};
+
+async function collectSourcePages(
+ ctx: { runQuery: (ref: never, args: never) => Promise },
+ ref: unknown,
+ args: Record = {},
+) {
+ const rows: unknown[] = [];
+ let cursor: string | null = null;
+ let documentsRead = 0;
+ let functionCalls = 0;
+ do {
+ const result = (await ctx.runQuery(
+ ref as never,
+ {
+ ...args,
+ paginationOpts: { cursor, numItems: SOURCE_PAGE_SIZE },
+ } as never,
+ )) as SourcePage;
+ rows.push(...result.page);
+ documentsRead += result.documentsRead;
+ functionCalls += 1;
+ cursor = result.isDone ? null : result.continueCursor;
+ } while (cursor);
+ return { rows, documentsRead, functionCalls };
+}
+
+export const getMetricWindowInternal = internalQuery({
+ args: {},
+ handler: async (ctx) => {
+ const latestImport = await ctx.db
+ .query("rankingMetricImports")
+ .withIndex("by_imported_at")
+ .order("desc")
+ .first();
+ return latestImport
+ ? {
+ datasetVersion: latestImport.datasetVersion,
+ importedAt: latestImport.importedAt,
+ startDay: latestImport.endDay,
+ endDay: latestImport.endDay,
+ documentsRead: 1,
+ }
+ : null;
+ },
+});
+
+export const getNativeSourcePageInternal = internalQuery({
+ args: { paginationOpts: paginationOptsValidator },
+ handler: async (ctx, args) => {
+ const result = await ctx.db
+ .query("skillSearchDigest")
+ .withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
+ .paginate(args.paginationOpts);
+ return {
+ ...result,
+ page: result.page.filter(
+ (digest) =>
+ !shouldExcludeSkillFromPublicBrowse(digest) &&
+ digest.publicVersion?.status === "available",
+ ),
+ documentsRead: result.page.length,
+ };
+ },
+});
+
+export const getMetricSourcePageInternal = internalQuery({
+ args: {
+ day: v.number(),
+ datasetVersion: v.string(),
+ importedAt: v.number(),
+ paginationOpts: paginationOptsValidator,
+ },
+ handler: async (ctx, args) => {
+ const result = await ctx.db
+ .query("skillDailyStats")
+ .withIndex("by_day", (q) => q.eq("day", args.day))
+ .paginate(args.paginationOpts);
+ return {
+ ...result,
+ page: result.page.filter(
+ (row) =>
+ row.rankingDatasetVersion === args.datasetVersion &&
+ row.rankingImportedAt === args.importedAt,
+ ),
+ documentsRead: result.page.length,
+ };
+ },
+});
+
+export const getExternalSourcePageInternal = internalQuery({
+ args: { paginationOpts: paginationOptsValidator },
+ handler: async (ctx, args) => {
+ const result = await ctx.db
+ .query("skillsShMirrorDigests")
+ .withIndex("by_active_visible_installable_fresh_slug", (q) =>
+ q
+ .eq("active", true)
+ .eq("publicVisible", true)
+ .eq("installable", true)
+ .eq("sourceFreshnessStatus", "observed-only"),
+ )
+ .paginate(args.paginationOpts);
+ return {
+ ...result,
+ page: result.page.filter(
+ (digest) => isPublicSkillsShMirrorDigest(digest) && digest.trendingRank !== undefined,
+ ),
+ documentsRead: result.page.length,
+ };
+ },
+});
+
+export const getLatestCompletedTrendingRunInternal = internalQuery({
+ args: {},
+ handler: async (ctx) => {
+ const run = await ctx.db
+ .query("skillsShMirrorRuns")
+ .withIndex("by_started_at")
+ .order("desc")
+ .filter((q) =>
+ q.and(q.eq(q.field("sourceView"), "trending"), q.eq(q.field("status"), "completed")),
+ )
+ .first();
+ return { runId: run?._id ?? null, documentsRead: Number(Boolean(run)) };
+ },
+});
+
+export const startSnapshotInternal = internalMutation({
+ args: {
+ snapshotId: v.string(),
+ generatedAt: v.number(),
+ expiresAt: v.number(),
+ windowStartDay: v.number(),
+ windowEndDay: v.number(),
+ },
+ handler: async (ctx, args) => {
+ const existing = await ctx.db
+ .query("canonicalTrendingSnapshots")
+ .withIndex("by_snapshot_id", (q) => q.eq("snapshotId", args.snapshotId))
+ .unique();
+ if (existing) throw new Error("Trending snapshot already exists");
+ return await ctx.db.insert("canonicalTrendingSnapshots", {
+ snapshotId: args.snapshotId,
+ kind: "skills",
+ status: "building",
+ rankingVersion: CANONICAL_TRENDING_RANKING_VERSION,
+ generatedAt: args.generatedAt,
+ expiresAt: args.expiresAt,
+ windowHours: CANONICAL_TRENDING_WINDOW_HOURS,
+ windowStartDay: args.windowStartDay,
+ windowEndDay: args.windowEndDay,
+ writtenItems: 0,
+ });
+ },
+});
+
+export const writeItemsInternal = internalMutation({
+ args: {
+ snapshotId: v.string(),
+ items: v.array(
+ v.object({
+ position: v.number(),
+ lane: laneValidator,
+ sourceRef: canonicalTrendingSourceRefValidator,
+ card: canonicalTrendingCardValidator,
+ }),
+ ),
+ },
+ handler: async (ctx, args) => {
+ const snapshot = await ctx.db
+ .query("canonicalTrendingSnapshots")
+ .withIndex("by_snapshot_id", (q) => q.eq("snapshotId", args.snapshotId))
+ .unique();
+ if (!snapshot || snapshot.status !== "building") {
+ throw new Error("Trending snapshot is not writable");
+ }
+ for (const item of args.items) {
+ if (!Number.isSafeInteger(item.position) || item.position < 0) {
+ throw new Error("Invalid Trending position");
+ }
+ await ctx.db.insert("canonicalTrendingItems", {
+ snapshotId: args.snapshotId,
+ position: item.position,
+ lane: item.lane,
+ sourceRef: item.sourceRef,
+ card: item.card,
+ expiresAt: snapshot.expiresAt,
+ });
+ }
+ await ctx.db.patch(snapshot._id, { writtenItems: snapshot.writtenItems + args.items.length });
+ return { writtenItems: snapshot.writtenItems + args.items.length };
+ },
+});
+
+export const finalizeSnapshotInternal = internalMutation({
+ args: {
+ snapshotId: v.string(),
+ completedAt: v.number(),
+ totalItems: v.number(),
+ sourceCounts: sourceCountsValidator,
+ operations: operationsValidator,
+ },
+ handler: async (ctx, args) => {
+ const snapshot = await ctx.db
+ .query("canonicalTrendingSnapshots")
+ .withIndex("by_snapshot_id", (q) => q.eq("snapshotId", args.snapshotId))
+ .unique();
+ if (!snapshot || snapshot.status !== "building") {
+ throw new Error("Trending snapshot cannot be finalized");
+ }
+ if (snapshot.writtenItems !== args.totalItems) {
+ throw new Error("Trending snapshot item count mismatch");
+ }
+ await ctx.db.patch(snapshot._id, {
+ status: "ready",
+ completedAt: args.completedAt,
+ totalItems: args.totalItems,
+ sourceCounts: args.sourceCounts,
+ operations: args.operations,
+ });
+ return { snapshotId: args.snapshotId, status: "ready" as const };
+ },
+});
+
+export const failSnapshotInternal = internalMutation({
+ args: {
+ snapshotId: v.string(),
+ error: v.string(),
+ completedAt: v.number(),
+ },
+ handler: async (ctx, args) => {
+ const snapshot = await ctx.db
+ .query("canonicalTrendingSnapshots")
+ .withIndex("by_snapshot_id", (q) => q.eq("snapshotId", args.snapshotId))
+ .unique();
+ if (!snapshot || snapshot.status !== "building") return { changed: false };
+ await ctx.db.patch(snapshot._id, {
+ status: "failed",
+ completedAt: args.completedAt,
+ error: args.error.slice(0, 500),
+ });
+ return { changed: true };
+ },
+});
+
+export const pruneExpiredInternal = internalMutation({
+ args: { now: v.number(), batchSize: v.number() },
+ handler: async (ctx, args) => {
+ const batchSize = Math.min(Math.max(Math.trunc(args.batchSize), 1), PRUNE_BATCH_SIZE);
+ const items = await ctx.db
+ .query("canonicalTrendingItems")
+ .withIndex("by_expires_at", (q) => q.lte("expiresAt", args.now))
+ .take(batchSize);
+ for (const item of items) await ctx.db.delete(item._id);
+ const remaining = batchSize - items.length;
+ const snapshots =
+ remaining > 0
+ ? await ctx.db
+ .query("canonicalTrendingSnapshots")
+ .withIndex("by_expires_at", (q) => q.lte("expiresAt", args.now))
+ .take(remaining)
+ : [];
+ for (const snapshot of snapshots) await ctx.db.delete(snapshot._id);
+ return {
+ itemsDeleted: items.length,
+ snapshotsDeleted: snapshots.length,
+ fullBatch: items.length + snapshots.length === batchSize,
+ };
+ },
+});
+
+export const pruneExpiredActionInternal = internalAction({
+ args: {},
+ handler: async (ctx) => {
+ const result = await pruneExpiredRows(ctx, Date.now());
+ const continuationScheduled = result.batches === PRUNE_MAX_BATCHES;
+ if (continuationScheduled) {
+ await ctx.scheduler.runAfter(
+ 0,
+ internalRefs.canonicalTrending.pruneExpiredActionInternal as never,
+ {},
+ );
+ }
+ return { ...result, continuationScheduled };
+ },
+});
+
+export const materializeInternal = internalAction({
+ args: { proofSnapshotId: v.optional(v.string()) },
+ handler: async (ctx, args) => {
+ if (args.proofSnapshotId !== undefined) {
+ assertTestSeedAllowed();
+ if (!/^claw-590-proof-[0-9a-f]{40}$/.test(args.proofSnapshotId)) {
+ throw new Error("Invalid CLAW-590 proof snapshot ID");
+ }
+ }
+ if (!getRuntimeRolloutCapabilities().skillsSh.runtimeEnabled) {
+ return { status: "disabled" as const };
+ }
+
+ const startedAt = Date.now();
+ const snapshotId = args.proofSnapshotId ?? `skills-${startedAt}`;
+ let snapshotStarted = false;
+ let functionCalls = 0;
+ let documentsRead = 0;
+ let documentsWritten = 0;
+
+ try {
+ const metricWindow = (await ctx.runQuery(
+ internalRefs.canonicalTrending.getMetricWindowInternal as never,
+ {},
+ )) as {
+ datasetVersion: string;
+ importedAt: number;
+ startDay: number;
+ endDay: number;
+ documentsRead: number;
+ } | null;
+ functionCalls += 1;
+ documentsRead += metricWindow?.documentsRead ?? 0;
+ if (!metricWindow) throw new Error("No 24-hour ranking metric window is available");
+
+ const nativeSource = (await collectSourcePages(
+ ctx,
+ internalRefs.canonicalTrending.getNativeSourcePageInternal,
+ )) as CollectedSource>;
+ const metricSource = (await collectSourcePages(
+ ctx,
+ internalRefs.canonicalTrending.getMetricSourcePageInternal,
+ {
+ day: metricWindow.endDay,
+ datasetVersion: metricWindow.datasetVersion,
+ importedAt: metricWindow.importedAt,
+ },
+ )) as CollectedSource>;
+ const latestTrendingRun = (await ctx.runQuery(
+ internalRefs.canonicalTrending.getLatestCompletedTrendingRunInternal as never,
+ {},
+ )) as { runId: Doc<"skillsShMirrorRuns">["_id"] | null; documentsRead: number };
+ documentsRead += latestTrendingRun.documentsRead;
+ functionCalls += 1;
+ const externalSource = (await collectSourcePages(
+ ctx,
+ internalRefs.canonicalTrending.getExternalSourcePageInternal,
+ )) as CollectedSource>;
+ documentsRead +=
+ nativeSource.documentsRead + metricSource.documentsRead + externalSource.documentsRead;
+ functionCalls +=
+ nativeSource.functionCalls + metricSource.functionCalls + externalSource.functionCalls;
+
+ const confirmedMetricWindow = (await ctx.runQuery(
+ internalRefs.canonicalTrending.getMetricWindowInternal as never,
+ {},
+ )) as typeof metricWindow;
+ functionCalls += 1;
+ documentsRead += confirmedMetricWindow?.documentsRead ?? 0;
+ if (
+ !confirmedMetricWindow ||
+ confirmedMetricWindow.datasetVersion !== metricWindow.datasetVersion ||
+ confirmedMetricWindow.importedAt !== metricWindow.importedAt
+ ) {
+ throw new Error("24-hour ranking metric import changed during materialization");
+ }
+
+ const confirmedTrendingRun = (await ctx.runQuery(
+ internalRefs.canonicalTrending.getLatestCompletedTrendingRunInternal as never,
+ {},
+ )) as { runId: Doc<"skillsShMirrorRuns">["_id"] | null; documentsRead: number };
+ documentsRead += confirmedTrendingRun.documentsRead;
+ functionCalls += 1;
+ if (confirmedTrendingRun.runId !== latestTrendingRun.runId) {
+ throw new Error("skills.sh Trending run changed during materialization");
+ }
+ const latestTrendingRunId = latestTrendingRun.runId;
+
+ const usageBySkill = new Map(
+ metricSource.rows.map((row) => [
+ String(row.skillId),
+ {
+ installs: row.installs,
+ bookmarks: row.bookmarks ?? 0,
+ updatedAt: row.rankingImportedAt ?? row.updatedAt,
+ },
+ ]),
+ );
+ const nativeCandidates = nativeSource.rows
+ .map((digest) =>
+ buildNativeCanonicalTrendingCandidate(
+ digest,
+ usageBySkill.get(String(digest.skillId)) ?? {
+ installs: 0,
+ bookmarks: 0,
+ updatedAt: metricWindow.importedAt,
+ },
+ ),
+ )
+ .filter(
+ (candidate): candidate is CanonicalTrendingMaterializationCandidate => candidate !== null,
+ );
+ const risingCutoff = startedAt - RISING_MAX_AGE_MS;
+ const risingCandidates = nativeCandidates
+ .filter((candidate) => candidate.createdAt >= risingCutoff)
+ .map((candidate) => ({ ...candidate, lane: "clawhub-rising" as const }));
+ const externalCandidates = externalSource.rows
+ .filter((digest) => digest.trendingObservedRunId === latestTrendingRunId)
+ .map(buildExternalCanonicalTrendingCandidate)
+ .filter(
+ (candidate): candidate is CanonicalTrendingMaterializationCandidate => candidate !== null,
+ );
+ const blended = blendCanonicalTrendingPools({
+ clawhubTrending: nativeCandidates,
+ clawhubRising: risingCandidates,
+ skillsShTrending: externalCandidates,
+ });
+
+ const expiresAt = startedAt + SNAPSHOT_RETENTION_MS;
+ await ctx.runMutation(
+ internalRefs.canonicalTrending.startSnapshotInternal as never,
+ {
+ snapshotId,
+ generatedAt: startedAt,
+ expiresAt,
+ windowStartDay: metricWindow.startDay,
+ windowEndDay: metricWindow.endDay,
+ } as never,
+ );
+ snapshotStarted = true;
+ functionCalls += 1;
+ documentsWritten += 1;
+
+ for (let index = 0; index < blended.length; index += WRITE_BATCH_SIZE) {
+ const batch = blended.slice(index, index + WRITE_BATCH_SIZE);
+ await ctx.runMutation(
+ internalRefs.canonicalTrending.writeItemsInternal as never,
+ {
+ snapshotId,
+ items: batch.map((candidate, batchIndex) => ({
+ position: index + batchIndex,
+ lane: candidate.lane,
+ sourceRef: candidate.sourceRef,
+ card: candidate.card,
+ })),
+ } as never,
+ );
+ functionCalls += 1;
+ documentsWritten += batch.length + 1;
+ }
+
+ const sourceCounts = {
+ clawhubTrending: nativeCandidates.length,
+ clawhubRising: risingCandidates.length,
+ skillsShTrending: externalCandidates.length,
+ };
+ const operations = {
+ documentsRead,
+ documentsWritten: documentsWritten + 1,
+ functionCalls: functionCalls + 1,
+ };
+ await ctx.runMutation(
+ internalRefs.canonicalTrending.finalizeSnapshotInternal as never,
+ {
+ snapshotId,
+ completedAt: Date.now(),
+ totalItems: blended.length,
+ sourceCounts,
+ operations,
+ } as never,
+ );
+ functionCalls += 1;
+ documentsWritten += 1;
+
+ return {
+ status: "ready" as const,
+ snapshotId,
+ generatedAt: new Date(startedAt).toISOString(),
+ windowHours: CANONICAL_TRENDING_WINDOW_HOURS,
+ rankingVersion: CANONICAL_TRENDING_RANKING_VERSION,
+ totalItems: blended.length,
+ sourceCounts,
+ operations: {
+ documentsRead,
+ documentsWritten,
+ functionCalls,
+ },
+ durationMs: Date.now() - startedAt,
+ sample: blended.slice(0, 20).map((candidate, index) => ({
+ rank: index + 1,
+ lane: candidate.lane,
+ id: candidate.card.id,
+ displayName: candidate.card.displayName,
+ trending24hInstalls: candidate.card.metrics.trending24hInstalls,
+ lifetimeInstalls: candidate.card.metrics.lifetimeInstalls,
+ })),
+ };
+ } catch (error) {
+ if (snapshotStarted) {
+ await ctx.runMutation(
+ internalRefs.canonicalTrending.failSnapshotInternal as never,
+ {
+ snapshotId,
+ completedAt: Date.now(),
+ error:
+ error instanceof Error ? error.message : "Unknown Trending materialization failure",
+ } as never,
+ );
+ }
+ throw error;
+ }
+ },
+});
+
+export const getPageInternal = internalQuery({
+ args: {
+ cursor: v.union(v.string(), v.null()),
+ limit: v.number(),
+ },
+ handler: async (ctx, args) => {
+ if (!Number.isSafeInteger(args.limit) || args.limit < 1 || args.limit > 100) {
+ throw new Error("Invalid Trending page limit");
+ }
+ let decoded = null;
+ if (args.cursor) {
+ try {
+ decoded = decodeCanonicalTrendingCursor(args.cursor);
+ } catch {
+ return { status: "invalid-cursor" as const };
+ }
+ }
+ const now = Date.now();
+ const snapshot = decoded
+ ? await ctx.db
+ .query("canonicalTrendingSnapshots")
+ .withIndex("by_snapshot_id", (q) => q.eq("snapshotId", decoded.snapshotId))
+ .unique()
+ : await ctx.db
+ .query("canonicalTrendingSnapshots")
+ .withIndex("by_kind_and_status_and_expires_at", (q) =>
+ q.eq("kind", "skills").eq("status", "ready").gt("expiresAt", now),
+ )
+ .order("desc")
+ .first();
+ if (!snapshot && !decoded) return { status: "unavailable" as const };
+ if (
+ !snapshot ||
+ snapshot.status !== "ready" ||
+ snapshot.totalItems === undefined ||
+ snapshot.expiresAt <= now
+ ) {
+ return { status: "expired" as const };
+ }
+ const offset = decoded?.offset ?? 0;
+ const rows = await ctx.db
+ .query("canonicalTrendingItems")
+ .withIndex("by_snapshot_id_and_position", (q) =>
+ q
+ .eq("snapshotId", snapshot.snapshotId)
+ .gte("position", offset)
+ .lt("position", offset + args.limit),
+ )
+ .take(args.limit);
+ const eligibility = await Promise.all(
+ rows.map(async (row) => {
+ const sourceRef = row.sourceRef;
+ if (sourceRef.kind === "clawhub") {
+ const digest = await ctx.db
+ .query("skillSearchDigest")
+ .withIndex("by_skill", (q) => q.eq("skillId", sourceRef.skillId))
+ .unique();
+ return Boolean(
+ digest &&
+ !shouldExcludeSkillFromPublicBrowse(digest) &&
+ digest.publicVersion?.status === "available",
+ );
+ }
+ const digest = await ctx.db
+ .query("skillsShMirrorDigests")
+ .withIndex("by_external_id", (q) => q.eq("externalId", sourceRef.externalId))
+ .unique();
+ return Boolean(digest && isPublicSkillsShMirrorDigest(digest));
+ }),
+ );
+ const nextOffset = offset + rows.length;
+ return {
+ status: "ok" as const,
+ page: {
+ kind: "skills" as const,
+ snapshotId: snapshot.snapshotId,
+ snapshotCursor: encodeCanonicalTrendingCursor({
+ snapshotId: snapshot.snapshotId,
+ offset: 0,
+ }),
+ generatedAt: new Date(snapshot.generatedAt).toISOString(),
+ windowHours: snapshot.windowHours,
+ rankingVersion: snapshot.rankingVersion,
+ totalItems: snapshot.totalItems,
+ items: rows.flatMap((row, index) =>
+ eligibility[index] ? [{ ...row.card, rank: row.position + 1, lane: row.lane }] : [],
+ ),
+ nextCursor:
+ nextOffset < snapshot.totalItems
+ ? encodeCanonicalTrendingCursor({ snapshotId: snapshot.snapshotId, offset: nextOffset })
+ : null,
+ },
+ };
+ },
+});
diff --git a/convex/canonicalTrendingTestFixtures.test.ts b/convex/canonicalTrendingTestFixtures.test.ts
new file mode 100644
index 00000000..385f84c4
--- /dev/null
+++ b/convex/canonicalTrendingTestFixtures.test.ts
@@ -0,0 +1,179 @@
+///
+/* @vitest-environment edge-runtime */
+import { convexTest } from "convex-test";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { internal } from "./_generated/api";
+import schema from "./schema";
+
+const modules = import.meta.glob("./**/*.ts");
+const CONFIRM = "manage-claw-590-canonical-trending-test-proof";
+const SNAPSHOT_ID = `claw-590-proof-${"a".repeat(40)}`;
+
+beforeEach(() => {
+ vi.stubEnv("CLAWHUB_ENV", "test");
+ vi.stubEnv("CLAWHUB_DISABLE_CRONS", "1");
+ vi.stubEnv("CLAWHUB_DEPLOYMENT_NAME", "academic-chihuahua-392");
+ vi.stubEnv("CLAWHUB_SKILLS_SH_ROLLOUT_MODE", "test");
+});
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
+describe("CLAW-590 permanent Test snapshot ownership", () => {
+ it("seeds and removes an exact owned 20-row source corpus", async () => {
+ const t = convexTest(schema, modules);
+ await t.run(async (ctx) => {
+ await ctx.db.insert("rankingMetricImports", {
+ datasetVersion: "claw-590-test-dataset",
+ checksum: "claw-590-test-checksum",
+ generatedAt: new Date(1_000).toISOString(),
+ importedAt: 2_000,
+ startDay: 1,
+ endDay: 1,
+ targetCount: 0,
+ skillTargetCount: 0,
+ packageTargetCount: 0,
+ dailyRowCount: 0,
+ importedSkillRows: 0,
+ importedPackageRows: 0,
+ unresolvedTargets: 0,
+ skippedOverlayRows: 0,
+ });
+ });
+
+ await expect(
+ t.mutation(internal.canonicalTrendingTestFixtures.seedCanonicalTrendingSourceFixture, {
+ confirm: CONFIRM,
+ }),
+ ).resolves.toEqual({ ok: true, created: true, nativeCount: 12, externalCount: 8 });
+ await expect(
+ t.query(internal.canonicalTrendingTestFixtures.readCanonicalTrendingSourceFixture, {
+ confirm: CONFIRM,
+ }),
+ ).resolves.toMatchObject({
+ present: true,
+ nativeCount: 12,
+ nativePublisherCount: 6,
+ externalCount: 8,
+ scansPlanned: 0,
+ scansAdmitted: 0,
+ });
+ await expect(
+ t.mutation(internal.canonicalTrendingTestFixtures.seedCanonicalTrendingSourceFixture, {
+ confirm: CONFIRM,
+ }),
+ ).resolves.toEqual({ ok: true, created: false, nativeCount: 12, externalCount: 8 });
+
+ const materialized = await t.action(internal.canonicalTrending.materializeInternal, {
+ proofSnapshotId: SNAPSHOT_ID,
+ });
+ if (materialized.status !== "ready") throw new Error("Expected ready fixture materialization");
+ expect(materialized).toMatchObject({
+ status: "ready",
+ snapshotId: SNAPSHOT_ID,
+ totalItems: 20,
+ sourceCounts: { clawhubTrending: 12, clawhubRising: 12, skillsShTrending: 8 },
+ });
+ expect(materialized.sample.map((row) => row.lane)).toEqual([
+ "clawhub-trending",
+ "skills-sh-trending",
+ "clawhub-rising",
+ "clawhub-trending",
+ "skills-sh-trending",
+ "clawhub-trending",
+ "skills-sh-trending",
+ "clawhub-rising",
+ "clawhub-trending",
+ "skills-sh-trending",
+ "clawhub-trending",
+ "skills-sh-trending",
+ "clawhub-rising",
+ "clawhub-trending",
+ "skills-sh-trending",
+ "clawhub-trending",
+ "skills-sh-trending",
+ "clawhub-rising",
+ "clawhub-trending",
+ "skills-sh-trending",
+ ]);
+ await expect(
+ t.action(internal.canonicalTrendingTestFixtures.cleanupCanonicalTrendingProof, {
+ confirm: CONFIRM,
+ snapshotId: SNAPSHOT_ID,
+ }),
+ ).resolves.toMatchObject({ ok: true, itemsDeleted: 20, snapshotDeleted: true });
+
+ await expect(
+ t.mutation(internal.canonicalTrendingTestFixtures.cleanupCanonicalTrendingSourceFixture, {
+ confirm: CONFIRM,
+ }),
+ ).resolves.toEqual({
+ ok: true,
+ removed: true,
+ nativeDeleted: 12,
+ externalDeleted: 8,
+ usersDeleted: 6,
+ });
+ await expect(
+ t.query(internal.canonicalTrendingTestFixtures.readCanonicalTrendingSourceFixture, {
+ confirm: CONFIRM,
+ }),
+ ).resolves.toEqual({ present: false });
+ });
+
+ it("reports an absent owned snapshot without broad reads", async () => {
+ const t = convexTest(schema, modules);
+
+ await expect(
+ t.query(internal.canonicalTrendingTestFixtures.readCanonicalTrendingProof, {
+ confirm: CONFIRM,
+ snapshotId: SNAPSHOT_ID,
+ }),
+ ).resolves.toEqual({ present: false });
+ });
+
+ it("removes an exact owned snapshot and all item batches", async () => {
+ const t = convexTest(schema, modules);
+ await t.run(async (ctx) => {
+ await ctx.db.insert("canonicalTrendingSnapshots", {
+ snapshotId: SNAPSHOT_ID,
+ kind: "skills",
+ status: "failed",
+ rankingVersion: "skills-trending-v1",
+ generatedAt: 1_000,
+ completedAt: 2_000,
+ expiresAt: Date.now() + 100_000,
+ windowHours: 24,
+ windowStartDay: 1,
+ windowEndDay: 1,
+ writtenItems: 0,
+ error: "proof failure",
+ });
+ });
+
+ await expect(
+ t.action(internal.canonicalTrendingTestFixtures.cleanupCanonicalTrendingProof, {
+ confirm: CONFIRM,
+ snapshotId: SNAPSHOT_ID,
+ }),
+ ).resolves.toMatchObject({ ok: true, itemsDeleted: 0, snapshotDeleted: true, batches: 1 });
+ await expect(
+ t.query(internal.canonicalTrendingTestFixtures.readCanonicalTrendingProof, {
+ confirm: CONFIRM,
+ snapshotId: SNAPSHOT_ID,
+ }),
+ ).resolves.toEqual({ present: false });
+ });
+
+ it("rejects IDs outside the exact CLAW-590 proof namespace", async () => {
+ const t = convexTest(schema, modules);
+
+ await expect(
+ t.query(internal.canonicalTrendingTestFixtures.readCanonicalTrendingProof, {
+ confirm: CONFIRM,
+ snapshotId: "skills-123",
+ }),
+ ).rejects.toThrow("Invalid CLAW-590 proof snapshot ID");
+ });
+});
diff --git a/convex/canonicalTrendingTestFixtures.ts b/convex/canonicalTrendingTestFixtures.ts
new file mode 100644
index 00000000..3f161e6c
--- /dev/null
+++ b/convex/canonicalTrendingTestFixtures.ts
@@ -0,0 +1,719 @@
+import { v } from "convex/values";
+import { internal } from "./_generated/api";
+import type { Doc, Id } from "./_generated/dataModel";
+import {
+ internalAction,
+ internalMutation,
+ internalQuery,
+ type QueryCtx,
+} from "./_generated/server";
+import { assertTestSeedAllowed } from "./lib/testSeed";
+
+const CONFIRM = "manage-claw-590-canonical-trending-test-proof";
+const PROOF_SNAPSHOT_PATTERN = /^claw-590-proof-[0-9a-f]{40}$/;
+const CLEANUP_BATCH_SIZE = 200;
+const CLEANUP_MAX_BATCHES = 100;
+const SAMPLE_SIZE = 20;
+const SOURCE_FIXTURE_ID = "claw-590-canonical-trending-source-v1";
+const SOURCE_FIXTURE_ACTOR = "CLAW-590 Test workflow";
+const NATIVE_COUNT = 12;
+const NATIVE_PUBLISHER_COUNT = 6;
+const EXTERNAL_COUNT = 8;
+
+const internalRefs = internal as unknown as {
+ canonicalTrendingTestFixtures: {
+ cleanupCanonicalTrendingProofBatch: unknown;
+ };
+};
+
+const proofArgs = {
+ confirm: v.literal(CONFIRM),
+ snapshotId: v.string(),
+};
+
+const confirmArgs = { confirm: v.literal(CONFIRM) };
+
+function fixtureOrdinal(index: number) {
+ return String(index + 1).padStart(2, "0");
+}
+
+function nativeOwnerHandle(index: number) {
+ return `claw-590-proof-owner-${fixtureOrdinal(index % NATIVE_PUBLISHER_COUNT)}`;
+}
+
+function nativeSlug(index: number) {
+ return `claw-590-proof-native-${fixtureOrdinal(index)}`;
+}
+
+function externalOwner(index: number) {
+ return `clawhub-test-${fixtureOrdinal(index)}`;
+}
+
+function externalFixtureId(index: number) {
+ return `${externalOwner(index)}/claw-590/trending-${fixtureOrdinal(index)}`;
+}
+
+function emptySkillStats() {
+ return {
+ downloads: 0,
+ installsCurrent: 0,
+ installsAllTime: 0,
+ stars: 0,
+ versions: 1,
+ comments: 0,
+ };
+}
+
+function cleanVersionScans(now: number) {
+ return {
+ vtAnalysis: {
+ status: "clean",
+ verdict: "clean",
+ analysis: "Owned CLAW-590 permanent-Test fixture.",
+ source: SOURCE_FIXTURE_ID,
+ checkedAt: now,
+ },
+ llmAnalysis: {
+ status: "clean",
+ verdict: "clean",
+ confidence: "high",
+ summary: "Owned CLAW-590 permanent-Test fixture.",
+ model: SOURCE_FIXTURE_ID,
+ checkedAt: now,
+ },
+ staticScan: {
+ status: "clean" as const,
+ reasonCodes: [],
+ findings: [],
+ summary: "Owned CLAW-590 permanent-Test fixture.",
+ engineVersion: SOURCE_FIXTURE_ID,
+ checkedAt: now,
+ },
+ };
+}
+
+function assertOwnedUser(user: Doc<"users">, index: number) {
+ if (
+ user.handle !== nativeOwnerHandle(index) ||
+ user.name !== `CLAW-590 Proof Owner ${fixtureOrdinal(index)}`
+ ) {
+ throw new Error("CLAW-590 source fixture user ownership mismatch");
+ }
+}
+
+function assertOwnedSkill(skill: Doc<"skills">, index: number, ownerId: Id<"users">) {
+ if (
+ skill.slug !== nativeSlug(index) ||
+ skill.ownerUserId !== ownerId ||
+ skill.batch !== SOURCE_FIXTURE_ID ||
+ skill.displayName !== `CLAW-590 Native ${fixtureOrdinal(index)}` ||
+ skill.stats.versions !== 1 ||
+ !skill.latestVersionId
+ ) {
+ throw new Error("CLAW-590 source fixture skill ownership mismatch");
+ }
+}
+
+function assertOwnedVersion(
+ version: Doc<"skillVersions">,
+ skillId: Id<"skills">,
+ ownerId: Id<"users">,
+) {
+ if (
+ version.skillId !== skillId ||
+ version.createdBy !== ownerId ||
+ version.version !== "1.0.0" ||
+ version.changelog !== SOURCE_FIXTURE_ID ||
+ version.files.length !== 0 ||
+ version.vtAnalysis?.source !== SOURCE_FIXTURE_ID ||
+ version.llmAnalysis?.model !== SOURCE_FIXTURE_ID ||
+ version.staticScan?.engineVersion !== SOURCE_FIXTURE_ID
+ ) {
+ throw new Error("CLAW-590 source fixture version ownership mismatch");
+ }
+}
+
+function assertOwnedNativeDigest(
+ digest: Doc<"skillSearchDigest">,
+ index: number,
+ skillId: Id<"skills">,
+ ownerId: Id<"users">,
+ versionId: Id<"skillVersions">,
+) {
+ if (
+ digest.skillId !== skillId ||
+ digest.ownerUserId !== ownerId ||
+ digest.slug !== nativeSlug(index) ||
+ digest.ownerHandle !== nativeOwnerHandle(index) ||
+ digest.publicVersion?.status !== "available" ||
+ digest.publicVersion.versionId !== versionId ||
+ digest.latestVersionId !== versionId ||
+ digest.latestVersionSkillId !== skillId ||
+ digest.softDeletedAt !== undefined ||
+ digest.isSuspicious !== false
+ ) {
+ throw new Error("CLAW-590 source fixture native digest ownership mismatch");
+ }
+}
+
+function assertOwnedDailyStat(stat: Doc<"skillDailyStats">, index: number, skillId: Id<"skills">) {
+ if (
+ stat.skillId !== skillId ||
+ stat.installs !== 100_000 - index ||
+ stat.bookmarks !== 10_000 - index ||
+ !stat.rankingDatasetVersion ||
+ stat.rankingImportedAt === undefined
+ ) {
+ throw new Error("CLAW-590 source fixture metric ownership mismatch");
+ }
+}
+
+function assertOwnedRun(run: Doc<"skillsShMirrorRuns">) {
+ if (
+ run.snapshotId !== SOURCE_FIXTURE_ID ||
+ run.sourceView !== "trending" ||
+ run.sourceSnapshotHash !== SOURCE_FIXTURE_ID ||
+ run.status !== "completed" ||
+ run.actor !== SOURCE_FIXTURE_ACTOR ||
+ run.counts.scansPlanned !== 0 ||
+ run.counts.scansAdmitted !== 0
+ ) {
+ throw new Error("CLAW-590 source fixture run ownership mismatch");
+ }
+}
+
+function assertOwnedExternalDigest(
+ digest: Doc<"skillsShMirrorDigests">,
+ index: number,
+ runId: Id<"skillsShMirrorRuns">,
+) {
+ const id = externalFixtureId(index);
+ if (
+ digest.externalId !== id ||
+ digest.owner !== externalOwner(index) ||
+ digest.repo !== "claw-590" ||
+ digest.slug !== `trending-${fixtureOrdinal(index)}` ||
+ digest.trendingRank !== index + 1 ||
+ digest.trendingObservedRunId !== runId ||
+ digest.sourceSnapshotId !== SOURCE_FIXTURE_ID ||
+ digest.observationFingerprint !== `${SOURCE_FIXTURE_ID}-${fixtureOrdinal(index)}` ||
+ !digest.active ||
+ !digest.publicVisible ||
+ !digest.installable ||
+ digest.sourceFreshnessStatus !== "observed-only" ||
+ digest.tombstonedAt !== undefined
+ ) {
+ throw new Error("CLAW-590 source fixture external digest ownership mismatch");
+ }
+}
+
+async function findOwnedRun(ctx: Pick) {
+ return await ctx.db
+ .query("skillsShMirrorRuns")
+ .withIndex("by_source_view_and_status_and_source_snapshot_hash", (q) =>
+ q
+ .eq("sourceView", "trending")
+ .eq("status", "completed")
+ .eq("sourceSnapshotHash", SOURCE_FIXTURE_ID),
+ )
+ .unique();
+}
+
+async function readOwnedSourceFixture(ctx: Pick) {
+ const users = [];
+ for (let index = 0; index < NATIVE_PUBLISHER_COUNT; index += 1) {
+ users.push(
+ await ctx.db
+ .query("users")
+ .withIndex("handle", (q) => q.eq("handle", nativeOwnerHandle(index)))
+ .unique(),
+ );
+ }
+ const skills = [];
+ for (let index = 0; index < NATIVE_COUNT; index += 1) {
+ skills.push(
+ await ctx.db
+ .query("skills")
+ .withIndex("by_slug", (q) => q.eq("slug", nativeSlug(index)))
+ .unique(),
+ );
+ }
+ const externalDigests = [];
+ for (let index = 0; index < EXTERNAL_COUNT; index += 1) {
+ externalDigests.push(
+ await ctx.db
+ .query("skillsShMirrorDigests")
+ .withIndex("by_external_id", (q) => q.eq("externalId", externalFixtureId(index)))
+ .unique(),
+ );
+ }
+ const run = await findOwnedRun(ctx);
+ const roots = [...users, ...skills, ...externalDigests, run];
+ if (roots.every((row) => row === null)) return null;
+ if (roots.some((row) => row === null)) {
+ throw new Error("CLAW-590 source fixture has partial root state");
+ }
+
+ const checkedUsers = users as Doc<"users">[];
+ const checkedSkills = skills as Doc<"skills">[];
+ const checkedExternalDigests = externalDigests as Doc<"skillsShMirrorDigests">[];
+ const checkedRun = run as Doc<"skillsShMirrorRuns">;
+ checkedUsers.forEach(assertOwnedUser);
+ assertOwnedRun(checkedRun);
+ checkedExternalDigests.forEach((digest, index) =>
+ assertOwnedExternalDigest(digest, index, checkedRun._id),
+ );
+
+ const native = [];
+ for (const [index, skill] of checkedSkills.entries()) {
+ const owner = checkedUsers[index % NATIVE_PUBLISHER_COUNT]!;
+ assertOwnedSkill(skill, index, owner._id);
+ const [version, digest, stats] = await Promise.all([
+ ctx.db.get(skill.latestVersionId!),
+ ctx.db
+ .query("skillSearchDigest")
+ .withIndex("by_skill", (q) => q.eq("skillId", skill._id))
+ .unique(),
+ ctx.db
+ .query("skillDailyStats")
+ .withIndex("by_skill_day", (q) => q.eq("skillId", skill._id))
+ .collect(),
+ ]);
+ if (!version || !digest || stats.length !== 1) {
+ throw new Error("CLAW-590 source fixture has partial native state");
+ }
+ assertOwnedVersion(version, skill._id, owner._id);
+ assertOwnedNativeDigest(digest, index, skill._id, owner._id, version._id);
+ assertOwnedDailyStat(stats[0]!, index, skill._id);
+ native.push({ owner, skill, version, digest, stat: stats[0]! });
+ }
+
+ return {
+ users: checkedUsers,
+ native,
+ run: checkedRun,
+ externalDigests: checkedExternalDigests,
+ };
+}
+
+function assertProofSnapshotId(snapshotId: string) {
+ if (!PROOF_SNAPSHOT_PATTERN.test(snapshotId)) {
+ throw new Error("Invalid CLAW-590 proof snapshot ID");
+ }
+}
+
+function assertOwnedSnapshot(snapshot: Doc<"canonicalTrendingSnapshots">, snapshotId: string) {
+ if (
+ snapshot.snapshotId !== snapshotId ||
+ snapshot.kind !== "skills" ||
+ snapshot.rankingVersion !== "skills-trending-v1" ||
+ snapshot.windowHours !== 24
+ ) {
+ throw new Error("CLAW-590 proof snapshot ownership mismatch");
+ }
+}
+
+export const seedCanonicalTrendingSourceFixture = internalMutation({
+ args: confirmArgs,
+ handler: async (ctx) => {
+ assertTestSeedAllowed();
+ const existing = await readOwnedSourceFixture(ctx);
+ if (existing) {
+ return {
+ ok: true as const,
+ created: false as const,
+ nativeCount: existing.native.length,
+ externalCount: existing.externalDigests.length,
+ };
+ }
+
+ const metricWindow = await ctx.db
+ .query("rankingMetricImports")
+ .withIndex("by_imported_at")
+ .order("desc")
+ .first();
+ if (!metricWindow) throw new Error("CLAW-590 source fixture requires a ranking metric import");
+
+ const now = Date.now();
+ const users: Id<"users">[] = [];
+ for (let index = 0; index < NATIVE_PUBLISHER_COUNT; index += 1) {
+ users.push(
+ await ctx.db.insert("users", {
+ handle: nativeOwnerHandle(index),
+ name: `CLAW-590 Proof Owner ${fixtureOrdinal(index)}`,
+ displayName: `CLAW-590 Proof Owner ${fixtureOrdinal(index)}`,
+ role: "user",
+ createdAt: now,
+ updatedAt: now,
+ }),
+ );
+ }
+
+ for (let index = 0; index < NATIVE_COUNT; index += 1) {
+ const ownerUserId = users[index % NATIVE_PUBLISHER_COUNT]!;
+ const slug = nativeSlug(index);
+ const displayName = `CLAW-590 Native ${fixtureOrdinal(index)}`;
+ const skillId = await ctx.db.insert("skills", {
+ slug,
+ displayName,
+ summary: "Owned synthetic native candidate for permanent-Test Trending proof.",
+ ownerUserId,
+ tags: {},
+ batch: SOURCE_FIXTURE_ID,
+ statsDownloads: 0,
+ statsStars: 0,
+ statsInstallsCurrent: 0,
+ statsInstallsAllTime: 1_000 + index,
+ stats: emptySkillStats(),
+ createdAt: now - index,
+ updatedAt: now - index,
+ });
+ const versionId = await ctx.db.insert("skillVersions", {
+ skillId,
+ version: "1.0.0",
+ publicationStatus: "published",
+ changelog: SOURCE_FIXTURE_ID,
+ files: [],
+ parsed: { frontmatter: {} },
+ createdBy: ownerUserId,
+ createdAt: now - index,
+ ...cleanVersionScans(now),
+ });
+ await ctx.db.patch(skillId, {
+ latestVersionId: versionId,
+ latestVersionSummary: {
+ version: "1.0.0",
+ createdAt: now - index,
+ changelog: SOURCE_FIXTURE_ID,
+ },
+ tags: { latest: versionId },
+ });
+ await ctx.db.insert("skillSearchDigest", {
+ skillId,
+ slug,
+ normalizedSlug: slug,
+ normalizedSlugFirstToken: "claw",
+ displayName,
+ normalizedDisplayName: displayName.toLowerCase(),
+ normalizedDisplayNameFirstToken: "claw",
+ summary: "Owned synthetic native candidate for permanent-Test Trending proof.",
+ ownerUserId,
+ ownerHandle: nativeOwnerHandle(index),
+ ownerKind: "user",
+ ownerName: `CLAW-590 Proof Owner ${fixtureOrdinal(index % NATIVE_PUBLISHER_COUNT)}`,
+ ownerDisplayName: `CLAW-590 Proof Owner ${fixtureOrdinal(index % NATIVE_PUBLISHER_COUNT)}`,
+ latestVersionId: versionId,
+ latestVersionSkillId: skillId,
+ publicVersion: { status: "available", versionId },
+ latestVersionSummary: {
+ version: "1.0.0",
+ createdAt: now - index,
+ changelog: SOURCE_FIXTURE_ID,
+ },
+ tags: { latest: versionId },
+ statsDownloads: 0,
+ statsStars: 0,
+ statsInstallsCurrent: 0,
+ statsInstallsAllTime: 1_000 + index,
+ stats: emptySkillStats(),
+ isSuspicious: false,
+ createdAt: now - index,
+ updatedAt: now - index,
+ });
+ await ctx.db.insert("skillDailyStats", {
+ skillId,
+ day: metricWindow.endDay,
+ downloads: 100_000 - index,
+ installs: 100_000 - index,
+ bookmarks: 10_000 - index,
+ rankingDatasetVersion: metricWindow.datasetVersion,
+ rankingImportedAt: metricWindow.importedAt,
+ updatedAt: metricWindow.importedAt,
+ });
+ }
+
+ const runId = await ctx.db.insert("skillsShMirrorRuns", {
+ snapshotId: SOURCE_FIXTURE_ID,
+ sourceView: "trending",
+ sourceSnapshotHash: SOURCE_FIXTURE_ID,
+ sourceCaptureWrites: 0,
+ status: "completed",
+ sourceTotal: EXTERNAL_COUNT,
+ sourcePageSize: EXTERNAL_COUNT,
+ sourceMeasuredAt: new Date(now).toISOString(),
+ sourceDurationMs: 0,
+ page: 1,
+ offset: EXTERNAL_COUNT,
+ counts: {
+ observed: EXTERNAL_COUNT,
+ inserted: EXTERNAL_COUNT,
+ updated: 0,
+ unchanged: 0,
+ rejected: 0,
+ quarantined: 0,
+ quarantinedPreserved: 0,
+ conflicts: 0,
+ detailsInserted: 0,
+ detailsUpdated: 0,
+ detailsUnchanged: 0,
+ detailsMissing: EXTERNAL_COUNT,
+ detailsTruncated: 0,
+ tombstoned: 0,
+ reactivated: 0,
+ scansPlanned: 0,
+ scansAdmitted: 0,
+ },
+ operations: {
+ functionCalls: 1,
+ dbReads: 0,
+ dbWrites: EXTERNAL_COUNT + 1,
+ sourceRequests: 0,
+ sourceBytes: 0,
+ },
+ actor: SOURCE_FIXTURE_ACTOR,
+ reason: "Owned synthetic source corpus for CLAW-590 permanent-Test Trending proof.",
+ startedAt: now,
+ completedAt: now,
+ updatedAt: now,
+ });
+ for (let index = 0; index < EXTERNAL_COUNT; index += 1) {
+ const id = externalFixtureId(index);
+ const slug = `trending-${fixtureOrdinal(index)}`;
+ await ctx.db.insert("skillsShMirrorDigests", {
+ externalId: id,
+ sourceType: "github",
+ upstreamSourceType: "github",
+ owner: externalOwner(index),
+ repo: "claw-590",
+ slug,
+ normalizedSlug: slug,
+ normalizedSlugFirstToken: "trending",
+ displayName: `CLAW-590 External ${fixtureOrdinal(index)}`,
+ normalizedDisplayName: `claw-590 external ${fixtureOrdinal(index)}`,
+ normalizedDisplayNameFirstToken: "claw",
+ searchSummary: "Owned synthetic skills.sh candidate for permanent-Test Trending proof.",
+ searchText: `claw 590 external trending ${fixtureOrdinal(index)}`,
+ sourceUrl: `https://skills.sh/${id}`,
+ canonicalRepoUrl: "https://github.com/clawhub-test/claw-590",
+ githubPath: `skills/${slug}`,
+ githubCommit: "0".repeat(40),
+ sourceContentHash: "0".repeat(64),
+ upstreamInstalls: 50_000 - index,
+ trendingRank: index + 1,
+ trendingLifetimeInstalls: 50_000 - index,
+ trendingObservedAt: now,
+ trendingSnapshotId: SOURCE_FIXTURE_ID,
+ trendingObservedRunId: runId,
+ upstreamScanners: {
+ genAgentTrustHub: { status: "unavailable" },
+ socket: { status: "unavailable" },
+ snyk: { status: "unavailable" },
+ },
+ sourceFreshnessStatus: "observed-only",
+ detailStatus: "missing",
+ observationFingerprint: `${SOURCE_FIXTURE_ID}-${fixtureOrdinal(index)}`,
+ sourceSnapshotId: SOURCE_FIXTURE_ID,
+ lastObservedRunId: runId,
+ active: true,
+ publicVisible: true,
+ installable: true,
+ firstObservedAt: now,
+ lastObservedAt: now,
+ createdAt: now,
+ updatedAt: now,
+ });
+ }
+
+ return {
+ ok: true as const,
+ created: true as const,
+ nativeCount: NATIVE_COUNT,
+ externalCount: EXTERNAL_COUNT,
+ };
+ },
+});
+
+export const readCanonicalTrendingSourceFixture = internalQuery({
+ args: confirmArgs,
+ handler: async (ctx) => {
+ assertTestSeedAllowed();
+ const fixture = await readOwnedSourceFixture(ctx);
+ if (!fixture) return { present: false as const };
+ return {
+ present: true as const,
+ fixtureId: SOURCE_FIXTURE_ID,
+ nativeCount: fixture.native.length,
+ nativePublisherCount: fixture.users.length,
+ externalCount: fixture.externalDigests.length,
+ runId: fixture.run._id,
+ scansPlanned: fixture.run.counts.scansPlanned,
+ scansAdmitted: fixture.run.counts.scansAdmitted,
+ };
+ },
+});
+
+export const cleanupCanonicalTrendingSourceFixture = internalMutation({
+ args: confirmArgs,
+ handler: async (ctx) => {
+ assertTestSeedAllowed();
+ const fixture = await readOwnedSourceFixture(ctx);
+ if (!fixture) return { ok: true as const, removed: false as const };
+
+ for (const digest of fixture.externalDigests) {
+ const [details, facets] = await Promise.all([
+ ctx.db
+ .query("skillsShMirrorDetails")
+ .withIndex("by_digest_id", (q) => q.eq("digestId", digest._id))
+ .take(1),
+ ctx.db
+ .query("skillsShMirrorFacets")
+ .withIndex("by_digest_id_and_kind_and_term", (q) => q.eq("digestId", digest._id))
+ .take(1),
+ ]);
+ if (details.length > 0 || facets.length > 0) {
+ throw new Error("CLAW-590 source fixture cleanup refused dependent mirror rows");
+ }
+ await ctx.db.delete(digest._id);
+ }
+ const conflicts = await ctx.db
+ .query("skillsShMirrorConflicts")
+ .withIndex("by_run_id", (q) => q.eq("runId", fixture.run._id))
+ .take(1);
+ if (conflicts.length > 0) {
+ throw new Error("CLAW-590 source fixture cleanup refused dependent conflict rows");
+ }
+
+ for (const row of fixture.native) {
+ await ctx.db.delete(row.stat._id);
+ await ctx.db.delete(row.digest._id);
+ await ctx.db.delete(row.version._id);
+ await ctx.db.delete(row.skill._id);
+ }
+ for (const user of fixture.users) await ctx.db.delete(user._id);
+ await ctx.db.delete(fixture.run._id);
+ return {
+ ok: true as const,
+ removed: true as const,
+ nativeDeleted: fixture.native.length,
+ externalDeleted: fixture.externalDigests.length,
+ usersDeleted: fixture.users.length,
+ };
+ },
+});
+
+export const readCanonicalTrendingProof = internalQuery({
+ args: proofArgs,
+ handler: async (ctx, args) => {
+ assertTestSeedAllowed();
+ assertProofSnapshotId(args.snapshotId);
+ const snapshot = await ctx.db
+ .query("canonicalTrendingSnapshots")
+ .withIndex("by_snapshot_id", (q) => q.eq("snapshotId", args.snapshotId))
+ .unique();
+ const items = await ctx.db
+ .query("canonicalTrendingItems")
+ .withIndex("by_snapshot_id_and_position", (q) => q.eq("snapshotId", args.snapshotId))
+ .take(SAMPLE_SIZE);
+ if (!snapshot) {
+ if (items.length > 0) throw new Error("CLAW-590 proof snapshot has orphaned items");
+ return { present: false as const };
+ }
+ assertOwnedSnapshot(snapshot, args.snapshotId);
+ const sample = await Promise.all(
+ items.map(async (item) => {
+ if (item.sourceRef.kind === "clawhub") {
+ const skillId = item.sourceRef.skillId;
+ const digest = await ctx.db
+ .query("skillSearchDigest")
+ .withIndex("by_skill", (q) => q.eq("skillId", skillId))
+ .unique();
+ if (!digest) throw new Error("CLAW-590 proof native source is missing");
+ return {
+ rank: item.position + 1,
+ id: item.card.id,
+ lane: item.lane,
+ publisherKey: String(digest.ownerPublisherId ?? digest.ownerUserId),
+ upstreamRank: null,
+ metrics: item.card.metrics,
+ };
+ }
+ const externalId = item.sourceRef.externalId;
+ const digest = await ctx.db
+ .query("skillsShMirrorDigests")
+ .withIndex("by_external_id", (q) => q.eq("externalId", externalId))
+ .unique();
+ if (!digest) throw new Error("CLAW-590 proof external source is missing");
+ return {
+ rank: item.position + 1,
+ id: item.card.id,
+ lane: item.lane,
+ publisherKey: digest.owner ?? digest.sourceHost ?? digest.externalId,
+ upstreamRank: digest.trendingRank ?? null,
+ metrics: item.card.metrics,
+ };
+ }),
+ );
+ return {
+ present: true as const,
+ snapshotId: snapshot.snapshotId,
+ status: snapshot.status,
+ generatedAt: snapshot.generatedAt,
+ completedAt: snapshot.completedAt ?? null,
+ totalItems: snapshot.totalItems ?? null,
+ writtenItems: snapshot.writtenItems,
+ sourceCounts: snapshot.sourceCounts ?? null,
+ operations: snapshot.operations ?? null,
+ sample,
+ };
+ },
+});
+
+export const cleanupCanonicalTrendingProofBatch = internalMutation({
+ args: proofArgs,
+ handler: async (ctx, args) => {
+ assertTestSeedAllowed();
+ assertProofSnapshotId(args.snapshotId);
+ const snapshot = await ctx.db
+ .query("canonicalTrendingSnapshots")
+ .withIndex("by_snapshot_id", (q) => q.eq("snapshotId", args.snapshotId))
+ .unique();
+ const items = await ctx.db
+ .query("canonicalTrendingItems")
+ .withIndex("by_snapshot_id_and_position", (q) => q.eq("snapshotId", args.snapshotId))
+ .take(CLEANUP_BATCH_SIZE);
+ if (!snapshot) {
+ if (items.length > 0) throw new Error("CLAW-590 cleanup found orphaned proof items");
+ return { done: true as const, itemsDeleted: 0, snapshotDeleted: false };
+ }
+ assertOwnedSnapshot(snapshot, args.snapshotId);
+ for (const item of items) await ctx.db.delete(item._id);
+ const done = items.length < CLEANUP_BATCH_SIZE;
+ if (done) await ctx.db.delete(snapshot._id);
+ return { done, itemsDeleted: items.length, snapshotDeleted: done };
+ },
+});
+
+export const cleanupCanonicalTrendingProof = internalAction({
+ args: proofArgs,
+ handler: async (ctx, args) => {
+ assertTestSeedAllowed();
+ assertProofSnapshotId(args.snapshotId);
+ let itemsDeleted = 0;
+ for (let batch = 1; batch <= CLEANUP_MAX_BATCHES; batch += 1) {
+ const result = (await ctx.runMutation(
+ internalRefs.canonicalTrendingTestFixtures.cleanupCanonicalTrendingProofBatch as never,
+ args as never,
+ )) as { done: boolean; itemsDeleted: number; snapshotDeleted: boolean };
+ itemsDeleted += result.itemsDeleted;
+ if (result.done) {
+ return {
+ ok: true as const,
+ itemsDeleted,
+ snapshotDeleted: result.snapshotDeleted,
+ batches: batch,
+ };
+ }
+ }
+ throw new Error("CLAW-590 proof cleanup exceeded its bounded batch limit");
+ },
+});
diff --git a/convex/crons.test.ts b/convex/crons.test.ts
index 5fcec64c..9572e230 100644
--- a/convex/crons.test.ts
+++ b/convex/crons.test.ts
@@ -17,6 +17,8 @@ const mocks = vi.hoisted(() => {
const authRefreshTokensPruneRef = Symbol("auth-refresh-tokens-prune");
const publisherInvitesPruneRef = Symbol("publisher-invites-prune");
const promotionsFeedPublishRef = Symbol("promotions-feed-publish");
+ const canonicalTrendingMaterializeRef = Symbol("canonical-trending-materialize");
+ const canonicalTrendingPruneRef = Symbol("canonical-trending-prune");
const prepublicationQueueHealthRef = Symbol("prepublication-queue-health");
const securityScanExpiredLeaseRecoveryRef = Symbol("security-scan-expired-lease-recovery");
const securityScanDispatchWatchdogRef = Symbol("security-scan-dispatch-watchdog");
@@ -36,6 +38,8 @@ const mocks = vi.hoisted(() => {
authRefreshTokensPruneRef,
publisherInvitesPruneRef,
promotionsFeedPublishRef,
+ canonicalTrendingMaterializeRef,
+ canonicalTrendingPruneRef,
prepublicationQueueHealthRef,
securityScanExpiredLeaseRecoveryRef,
securityScanDispatchWatchdogRef,
@@ -50,6 +54,10 @@ vi.mock("convex/server", () => ({
vi.mock("./_generated/api", () => ({
internal: {
+ canonicalTrending: {
+ materializeInternal: mocks.canonicalTrendingMaterializeRef,
+ pruneExpiredActionInternal: mocks.canonicalTrendingPruneRef,
+ },
githubSkillSyncNode: { syncGitHubSkillSourcesInternal: mocks.githubSkillSyncRef },
leaderboards: { rebuildTrendingLeaderboardAction: Symbol("trending-leaderboard") },
packageLeaderboards: {
@@ -164,6 +172,28 @@ describe("crons", () => {
);
});
+ it("materializes the canonical Trending snapshot hourly", async () => {
+ await import("./crons");
+
+ expect(mocks.interval).toHaveBeenCalledWith(
+ "canonical-trending-snapshot",
+ { hours: 1 },
+ mocks.canonicalTrendingMaterializeRef,
+ {},
+ );
+ });
+
+ it("prunes canonical Trending snapshots independently each hour", async () => {
+ await import("./crons");
+
+ expect(mocks.interval).toHaveBeenCalledWith(
+ "canonical-trending-prune",
+ { hours: 1 },
+ mocks.canonicalTrendingPruneRef,
+ {},
+ );
+ });
+
it("prunes expired skill scan requests in bounded continuation batches", async () => {
await import("./crons");
diff --git a/convex/crons.ts b/convex/crons.ts
index 085adb9b..78251da7 100644
--- a/convex/crons.ts
+++ b/convex/crons.ts
@@ -26,6 +26,20 @@ if (process.env.CLAWHUB_DISABLE_CRONS !== "1" && process.env.CLAWHUB_PREVIEW !==
{ limit: 200 },
);
+ crons.interval(
+ "canonical-trending-snapshot",
+ { hours: 1 },
+ internal.canonicalTrending.materializeInternal,
+ {},
+ );
+
+ crons.interval(
+ "canonical-trending-prune",
+ { hours: 1 },
+ internal.canonicalTrending.pruneExpiredActionInternal,
+ {},
+ );
+
crons.interval(
"package-trending-leaderboard",
{ minutes: 60 },
diff --git a/convex/http.test.ts b/convex/http.test.ts
index 2f7075de..982519f4 100644
--- a/convex/http.test.ts
+++ b/convex/http.test.ts
@@ -1,6 +1,6 @@
import { ApiRoutes, LegacyApiRoutes } from "clawhub-schema";
/* @vitest-environment node */
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it } from "vitest";
import { vi } from "vitest";
import type { ActionCtx } from "./_generated/server";
import http from "./http";
@@ -93,6 +93,28 @@ async function expectRouteUsesIpBucket({
}
describe("HTTP route rate limit defaults", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("keeps the dark canonical Trending route ahead of rate limiting", async () => {
+ vi.stubEnv("CLAWHUB_ENV", "production");
+ vi.stubEnv("CLAWHUB_SKILLS_SH_ROLLOUT_MODE", "off");
+ const route = http.lookup(ApiRoutes.trending, "GET");
+ if (!route) throw new Error("Expected canonical Trending route");
+ const [action] = route;
+ const { ctx, runMutation } = makeDeniedRateLimitCtx();
+
+ const response = await (action as unknown as WrappedHttpAction)._handler(
+ ctx,
+ new Request(`https://example.com${ApiRoutes.trending}`),
+ );
+
+ expect(response.status).toBe(404);
+ expect(response.headers.get("cache-control")).toBe("no-store");
+ expect(runMutation).not.toHaveBeenCalled();
+ });
+
it("registers package version downloads behind the download limit", async () => {
await expectRouteUsesIpBucket({
path: "/api/v1/packages/demo/versions/1.0.0/download",
diff --git a/convex/http.ts b/convex/http.ts
index 8f81d7ba..cd9d76e4 100644
--- a/convex/http.ts
+++ b/convex/http.ts
@@ -24,6 +24,7 @@ import {
listPackagesV1Http,
listPluginsV1Http,
listSkillsV1Http,
+ trendingV1Http,
mintPublishTokenV1Http,
npmMirrorGetHttp,
packagesDeleteRouterV1Http,
@@ -120,6 +121,12 @@ http.route({
handler: listSkillsV1Http,
});
+http.route({
+ path: ApiRoutes.trending,
+ method: "GET",
+ handler: trendingV1Http,
+});
+
http.route({
pathPrefix: `${ApiRoutes.skillScans}/`,
method: "GET",
diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts
index a83c9082..951cb89d 100644
--- a/convex/httpApiV1.handlers.test.ts
+++ b/convex/httpApiV1.handlers.test.ts
@@ -2451,6 +2451,46 @@ describe("httpApiV1 handlers", () => {
}
});
+ it("deprecates native-only Trending with the canonical successor link", async () => {
+ vi.stubEnv("CLAWHUB_ENV", "test");
+ vi.stubEnv("CLAWHUB_SKILLS_SH_ROLLOUT_MODE", "test");
+ const runQuery = vi.fn(async (_query: unknown, args: Record) => {
+ if ("limit" in args) return { items: [], nextCursor: null };
+ return null;
+ });
+ const runMutation = vi.fn().mockResolvedValue(okRate());
+
+ const response = await __handlers.listSkillsV1Handler(
+ makeCtx({ runQuery, runMutation }),
+ new Request("https://example.com/api/v1/skills?sort=trending"),
+ );
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get("deprecation")).toBe("true");
+ expect(response.headers.get("link")).toBe(
+ '; rel="successor-version"',
+ );
+ });
+
+ it("does not advertise the dark canonical Trending successor", async () => {
+ vi.stubEnv("CLAWHUB_ENV", "production");
+ vi.stubEnv("CLAWHUB_SKILLS_SH_ROLLOUT_MODE", "off");
+ const runQuery = vi.fn(async (_query: unknown, args: Record) => {
+ if ("limit" in args) return { items: [], nextCursor: null };
+ return null;
+ });
+ const runMutation = vi.fn().mockResolvedValue(okRate());
+
+ const response = await __handlers.listSkillsV1Handler(
+ makeCtx({ runQuery, runMutation }),
+ new Request("https://example.com/api/v1/skills?sort=trending"),
+ );
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get("deprecation")).toBeNull();
+ expect(response.headers.get("link")).toBeNull();
+ });
+
it("lists skills rejects invalid sort", async () => {
const runQuery = vi.fn();
const runMutation = vi.fn().mockResolvedValue(okRate());
diff --git a/convex/httpApiV1.ts b/convex/httpApiV1.ts
index 701eff7f..d93c213a 100644
--- a/convex/httpApiV1.ts
+++ b/convex/httpApiV1.ts
@@ -49,6 +49,7 @@ import {
} from "./httpApiV1/skillsV1";
import { starsDeleteRouterV1Handler, starsPostRouterV1Handler } from "./httpApiV1/starsV1";
import { transfersGetRouterV1Handler } from "./httpApiV1/transfersV1";
+import { trendingV1Handler } from "./httpApiV1/trendingV1";
import {
banAppealContextV1Handler,
usersGetRouterV1Handler,
@@ -82,6 +83,7 @@ export const promotionsFeedV1Http = httpAction(promotionsFeedV1Handler);
export const searchSkillsV1Http = httpAction(searchSkillsV1Handler);
export const resolveSkillVersionV1Http = httpAction(resolveSkillVersionV1Handler);
export const listSkillsV1Http = httpAction(listSkillsV1Handler);
+export const trendingV1Http = httpAction(trendingV1Handler);
export const skillsGetRouterV1Http = httpAction(skillsGetRouterV1Handler);
export const publishSkillV1Http = httpAction(publishSkillV1Handler);
export const skillSecurityVerdictsV1Http = httpAction(skillSecurityVerdictsV1Handler);
@@ -132,6 +134,7 @@ export const __handlers = {
searchSkillsV1Handler,
resolveSkillVersionV1Handler,
listSkillsV1Handler,
+ trendingV1Handler,
skillsGetRouterV1Handler,
publishSkillV1Handler,
skillSecurityVerdictsV1Handler,
diff --git a/convex/httpApiV1/skillsV1.ts b/convex/httpApiV1/skillsV1.ts
index 7419c5e9..6ad4e3e6 100644
--- a/convex/httpApiV1/skillsV1.ts
+++ b/convex/httpApiV1/skillsV1.ts
@@ -1517,7 +1517,14 @@ export async function listSkillsV1Handler(ctx: ActionCtx, request: Request) {
: null,
}));
- return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers);
+ const responseHeaders =
+ sort === "trending" && getRuntimeRolloutCapabilities().skillsSh.runtimeEnabled
+ ? mergeHeaders(rate.headers, {
+ Deprecation: "true",
+ Link: `<${ApiRoutes.trending}?kind=skills>; rel="successor-version"`,
+ })
+ : rate.headers;
+ return json({ items, nextCursor: result.nextCursor ?? null }, 200, responseHeaders);
}
async function describeOwnerVisibleSkillState(
diff --git a/convex/httpApiV1/trendingV1.test.ts b/convex/httpApiV1/trendingV1.test.ts
new file mode 100644
index 00000000..6c8f8218
--- /dev/null
+++ b/convex/httpApiV1/trendingV1.test.ts
@@ -0,0 +1,115 @@
+/* @vitest-environment node */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("../lib/httpRateLimit", () => ({
+ applyRateLimit: vi.fn(async () => ({ ok: true, headers: { "x-rate-limit": "ok" } })),
+}));
+
+const { applyRateLimit } = await import("../lib/httpRateLimit");
+const { trendingV1Handler } = await import("./trendingV1");
+
+beforeEach(() => {
+ vi.stubEnv("CLAWHUB_ENV", "test");
+ vi.stubEnv("CLAWHUB_SKILLS_SH_ROLLOUT_MODE", "test");
+ vi.mocked(applyRateLimit).mockClear();
+});
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
+describe("canonical Trending HTTP API", () => {
+ it("stays dark before rate limiting while the skills.sh rollout is disabled", async () => {
+ vi.stubEnv("CLAWHUB_SKILLS_SH_ROLLOUT_MODE", "off");
+ const runQuery = vi.fn();
+
+ const response = await trendingV1Handler(
+ { runQuery } as never,
+ new Request("https://clawhub.ai/api/v1/trending?kind=skills"),
+ );
+
+ expect(response.status).toBe(404);
+ expect(response.headers.get("cache-control")).toBe("no-store");
+ expect(applyRateLimit).not.toHaveBeenCalled();
+ expect(runQuery).not.toHaveBeenCalled();
+ });
+
+ it("returns the materialized snapshot envelope without reordering cards", async () => {
+ const page = {
+ kind: "skills",
+ snapshotId: "snap_123",
+ snapshotCursor: "snapshot-cursor",
+ generatedAt: "2026-07-25T16:00:00.000Z",
+ windowHours: 24,
+ rankingVersion: "skills-trending-v1",
+ items: [
+ { id: "clawhub:skills:1", source: "clawhub", displayName: "Native" },
+ { id: "skills-sh:owner/repo/skill", source: "skills-sh", displayName: "External" },
+ ],
+ nextCursor: "next-cursor",
+ };
+ const runQuery = vi.fn(async () => ({ status: "ok", page }));
+
+ const response = await trendingV1Handler(
+ { runQuery } as never,
+ new Request(
+ "https://academic-chihuahua-392.convex.site/api/v1/trending?kind=skills&limit=2&cursor=current-cursor",
+ ),
+ );
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get("x-rate-limit")).toBe("ok");
+ expect(runQuery).toHaveBeenCalledWith(expect.anything(), {
+ cursor: "current-cursor",
+ limit: 2,
+ });
+ expect(await response.json()).toEqual(page);
+ });
+
+ it("rejects unsupported kinds and invalid limits before querying a snapshot", async () => {
+ const runQuery = vi.fn();
+
+ const unsupported = await trendingV1Handler(
+ { runQuery } as never,
+ new Request("https://clawhub.ai/api/v1/trending?kind=plugins"),
+ );
+ const invalidLimit = await trendingV1Handler(
+ { runQuery } as never,
+ new Request("https://clawhub.ai/api/v1/trending?kind=skills&limit=0"),
+ );
+
+ expect(unsupported.status).toBe(400);
+ expect(invalidLimit.status).toBe(400);
+ expect(runQuery).not.toHaveBeenCalled();
+ });
+
+ it("returns 503 until the first ready snapshot exists", async () => {
+ const response = await trendingV1Handler(
+ { runQuery: vi.fn(async () => ({ status: "unavailable" })) } as never,
+ new Request("https://clawhub.ai/api/v1/trending?kind=skills"),
+ );
+
+ expect(response.status).toBe(503);
+ });
+
+ it("returns 410 when a stable cursor references a pruned snapshot", async () => {
+ const response = await trendingV1Handler(
+ {
+ runQuery: vi.fn(async () => ({ status: "expired" })),
+ } as never,
+ new Request("https://clawhub.ai/api/v1/trending?cursor=expired"),
+ );
+
+ expect(response.status).toBe(410);
+ });
+
+ it("returns 400 when the page query rejects a malformed cursor", async () => {
+ const response = await trendingV1Handler(
+ { runQuery: vi.fn(async () => ({ status: "invalid-cursor" })) } as never,
+ new Request("https://clawhub.ai/api/v1/trending?cursor=malformed"),
+ );
+
+ expect(response.status).toBe(400);
+ });
+});
diff --git a/convex/httpApiV1/trendingV1.ts b/convex/httpApiV1/trendingV1.ts
new file mode 100644
index 00000000..7ad74cee
--- /dev/null
+++ b/convex/httpApiV1/trendingV1.ts
@@ -0,0 +1,54 @@
+import { internal } from "../_generated/api";
+import type { ActionCtx } from "../_generated/server";
+import { applyRateLimit } from "../lib/httpRateLimit";
+import { getRuntimeRolloutCapabilities } from "../lib/rolloutCapabilities";
+import { json, text } from "./shared";
+
+const internalRefs = internal as unknown as {
+ canonicalTrending: {
+ getPageInternal: unknown;
+ };
+};
+
+const DEFAULT_TRENDING_LIMIT = 20;
+const MAX_TRENDING_LIMIT = 100;
+
+type TrendingPageQueryResult =
+ | { status: "ok"; page: unknown }
+ | { status: "unavailable" }
+ | { status: "invalid-cursor" }
+ | { status: "expired" };
+
+function parseLimit(value: string | null) {
+ if (value === null) return DEFAULT_TRENDING_LIMIT;
+ const parsed = Number(value);
+ if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > MAX_TRENDING_LIMIT) return null;
+ return parsed;
+}
+
+export async function trendingV1Handler(ctx: ActionCtx, request: Request) {
+ if (!getRuntimeRolloutCapabilities().skillsSh.runtimeEnabled) {
+ return text("Not found", 404, { "cache-control": "no-store" });
+ }
+
+ const rate = await applyRateLimit(ctx, request, "read");
+ if (!rate.ok) return rate.response;
+
+ const url = new URL(request.url);
+ const kind = url.searchParams.get("kind")?.trim() || "skills";
+ if (kind !== "skills") return text("Unsupported trending kind", 400, rate.headers);
+ const limit = parseLimit(url.searchParams.get("limit"));
+ if (limit === null) return text("Invalid limit", 400, rate.headers);
+ const cursor = url.searchParams.get("cursor")?.trim() || null;
+
+ const result = (await ctx.runQuery(
+ internalRefs.canonicalTrending.getPageInternal as never,
+ { cursor, limit } as never,
+ )) as TrendingPageQueryResult;
+ if (result.status === "unavailable") {
+ return text("Trending snapshot unavailable", 503, rate.headers);
+ }
+ if (result.status === "invalid-cursor") return text("Invalid cursor format", 400, rate.headers);
+ if (result.status === "expired") return text("Trending snapshot expired", 410, rate.headers);
+ return json(result.page, 200, rate.headers);
+}
diff --git a/convex/lib/canonicalTrending.test.ts b/convex/lib/canonicalTrending.test.ts
new file mode 100644
index 00000000..fb8908ee
--- /dev/null
+++ b/convex/lib/canonicalTrending.test.ts
@@ -0,0 +1,308 @@
+import { describe, expect, it } from "vitest";
+import {
+ blendCanonicalTrendingPools,
+ buildExternalCanonicalTrendingCandidate,
+ buildNativeCanonicalTrendingCandidate,
+ decodeCanonicalTrendingCursor,
+ encodeCanonicalTrendingCursor,
+ sortCanonicalTrendingPools,
+ type CanonicalTrendingCandidate,
+} from "./canonicalTrending";
+
+function candidate(
+ identity: string,
+ lane: CanonicalTrendingCandidate["lane"],
+ overrides: Partial = {},
+): CanonicalTrendingCandidate {
+ return {
+ identity,
+ lane,
+ publisherKey: identity,
+ installs24h: 0,
+ bookmarks24h: 0,
+ createdAt: 0,
+ updatedAt: 0,
+ upstreamRank: null,
+ ...overrides,
+ };
+}
+
+describe("canonical Trending ordering", () => {
+ it("interleaves complete pools as a continuous 40/20/40 feed", () => {
+ const result = blendCanonicalTrendingPools({
+ clawhubTrending: Array.from({ length: 4 }, (_, index) =>
+ candidate(`c-${index + 1}`, "clawhub-trending"),
+ ),
+ clawhubRising: Array.from({ length: 2 }, (_, index) =>
+ candidate(`r-${index + 1}`, "clawhub-rising"),
+ ),
+ skillsShTrending: Array.from({ length: 4 }, (_, index) =>
+ candidate(`s-${index + 1}`, "skills-sh-trending"),
+ ),
+ });
+
+ expect(result.map((entry) => entry.identity)).toEqual([
+ "c-1",
+ "s-1",
+ "r-1",
+ "c-2",
+ "s-2",
+ "c-3",
+ "s-3",
+ "r-2",
+ "c-4",
+ "s-4",
+ ]);
+ });
+
+ it("backfills exhausted Rising capacity from ClawHub Trending", () => {
+ const result = blendCanonicalTrendingPools({
+ clawhubTrending: Array.from({ length: 6 }, (_, index) =>
+ candidate(`c-${index + 1}`, "clawhub-trending"),
+ ),
+ clawhubRising: [],
+ skillsShTrending: Array.from({ length: 4 }, (_, index) =>
+ candidate(`s-${index + 1}`, "skills-sh-trending"),
+ ),
+ });
+
+ expect(result.slice(0, 5).map((entry) => entry.identity)).toEqual([
+ "c-1",
+ "s-1",
+ "c-2",
+ "c-3",
+ "s-2",
+ ]);
+ expect(result).toHaveLength(10);
+ });
+
+ it("deduplicates native overlap and defers capped publishers beyond the first 20", () => {
+ const clawhubTrending = [
+ candidate("shared", "clawhub-trending", { publisherKey: "alpha", installs24h: 30 }),
+ candidate("alpha-2", "clawhub-trending", { publisherKey: "alpha", installs24h: 29 }),
+ candidate("alpha-3", "clawhub-trending", { publisherKey: "alpha", installs24h: 28 }),
+ ...Array.from({ length: 18 }, (_, index) =>
+ candidate(`c-${index + 1}`, "clawhub-trending", {
+ publisherKey: `c-${index + 1}`,
+ installs24h: 27 - index,
+ }),
+ ),
+ ];
+ const clawhubRising = [
+ candidate("shared", "clawhub-rising", { publisherKey: "alpha" }),
+ ...Array.from({ length: 10 }, (_, index) =>
+ candidate(`r-${index + 1}`, "clawhub-rising", { publisherKey: `r-${index + 1}` }),
+ ),
+ ];
+ const skillsShTrending = Array.from({ length: 20 }, (_, index) =>
+ candidate(`s-${index + 1}`, "skills-sh-trending", { publisherKey: `s-${index + 1}` }),
+ );
+
+ const result = blendCanonicalTrendingPools({
+ clawhubTrending,
+ clawhubRising,
+ skillsShTrending,
+ });
+
+ expect(result.filter((entry) => entry.identity === "shared")).toHaveLength(1);
+ expect(result.slice(0, 20).filter((entry) => entry.publisherKey === "alpha")).toHaveLength(2);
+ expect(result.findIndex((entry) => entry.identity === "alpha-3")).toBeGreaterThanOrEqual(20);
+ expect(result).toHaveLength(51);
+ });
+
+ it("completes an undersized feed after cap-compliant alternatives are exhausted", () => {
+ const result = blendCanonicalTrendingPools({
+ clawhubTrending: [
+ candidate("alpha-1", "clawhub-trending", { publisherKey: "alpha" }),
+ candidate("alpha-2", "clawhub-trending", { publisherKey: "alpha" }),
+ candidate("alpha-3", "clawhub-trending", { publisherKey: "alpha" }),
+ ],
+ clawhubRising: [],
+ skillsShTrending: [],
+ });
+
+ expect(result.map((entry) => entry.identity)).toEqual(["alpha-1", "alpha-2", "alpha-3"]);
+ });
+
+ it("sorts native metrics and preserves exact skills.sh upstream rank", () => {
+ const pools = sortCanonicalTrendingPools({
+ clawhubTrending: [
+ candidate("c-low", "clawhub-trending", { installs24h: 2, bookmarks24h: 9 }),
+ candidate("c-high-bookmarks", "clawhub-trending", {
+ installs24h: 3,
+ bookmarks24h: 5,
+ }),
+ candidate("c-high", "clawhub-trending", { installs24h: 3, bookmarks24h: 1 }),
+ ],
+ clawhubRising: [
+ candidate("r-old", "clawhub-rising", {
+ installs24h: 1,
+ bookmarks24h: 1,
+ createdAt: 10,
+ }),
+ candidate("r-new", "clawhub-rising", {
+ installs24h: 1,
+ bookmarks24h: 1,
+ createdAt: 20,
+ }),
+ ],
+ skillsShTrending: [
+ candidate("s-3", "skills-sh-trending", { upstreamRank: 3 }),
+ candidate("s-1", "skills-sh-trending", { upstreamRank: 1 }),
+ candidate("s-2", "skills-sh-trending", { upstreamRank: 2 }),
+ ],
+ });
+
+ expect(pools.clawhubTrending.map((entry) => entry.identity)).toEqual([
+ "c-high-bookmarks",
+ "c-high",
+ "c-low",
+ ]);
+ expect(pools.clawhubRising.map((entry) => entry.identity)).toEqual(["r-new", "r-old"]);
+ expect(pools.skillsShTrending.map((entry) => entry.identity)).toEqual(["s-1", "s-2", "s-3"]);
+ });
+});
+
+describe("canonical Trending cursors", () => {
+ it("round-trips a stable snapshot position and rejects malformed cursors", () => {
+ const cursor = encodeCanonicalTrendingCursor({ snapshotId: "snapshot-123", offset: 40 });
+
+ expect(decodeCanonicalTrendingCursor(cursor)).toEqual({
+ snapshotId: "snapshot-123",
+ offset: 40,
+ });
+ expect(() => decodeCanonicalTrendingCursor("not-a-cursor")).toThrow("Invalid cursor format");
+ });
+});
+
+describe("canonical Trending cards", () => {
+ it("keeps native 24-hour metrics separate from lifetime installs", () => {
+ const result = buildNativeCanonicalTrendingCandidate(
+ {
+ skillId: "skills:native" as never,
+ slug: "native",
+ displayName: "Native",
+ summary: "Native summary",
+ ownerUserId: "users:patrick" as never,
+ ownerPublisherId: undefined,
+ ownerHandle: "patrick",
+ ownerKind: "user",
+ ownerName: "patrick",
+ ownerDisplayName: "Patrick",
+ ownerImage: undefined,
+ badges: undefined,
+ installKind: undefined,
+ githubScanStatus: undefined,
+ moderationVerdict: "clean",
+ statsInstallsAllTime: 900,
+ stats: { downloads: 1_000, stars: 20, versions: 1, comments: 0 },
+ createdAt: 100,
+ updatedAt: 200,
+ },
+ { installs: 12, bookmarks: 4, updatedAt: 300 },
+ );
+
+ expect(result?.card.metrics).toEqual({
+ trending24hInstalls: 12,
+ trending24hBookmarks: 4,
+ lifetimeInstalls: 900,
+ lifetimeInstallsPeriod: "lifetime",
+ updatedAt: 300,
+ });
+ });
+
+ it("excludes native rows without a routable public owner handle", () => {
+ const result = buildNativeCanonicalTrendingCandidate(
+ {
+ skillId: "skills:native" as never,
+ slug: "native",
+ displayName: "Native",
+ summary: undefined,
+ ownerUserId: "users:patrick" as never,
+ ownerPublisherId: undefined,
+ ownerHandle: undefined,
+ ownerKind: "user",
+ ownerName: "patrick",
+ ownerDisplayName: "Patrick",
+ ownerImage: undefined,
+ badges: undefined,
+ installKind: undefined,
+ githubScanStatus: undefined,
+ moderationVerdict: "clean",
+ statsInstallsAllTime: 0,
+ stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
+ createdAt: 100,
+ updatedAt: 200,
+ },
+ { installs: 1, bookmarks: 0, updatedAt: 300 },
+ );
+
+ expect(result).toBeNull();
+ });
+
+ it("preserves an unknown native lifetime install count as null", () => {
+ const result = buildNativeCanonicalTrendingCandidate(
+ {
+ skillId: "skills:native" as never,
+ slug: "native",
+ displayName: "Native",
+ summary: undefined,
+ ownerUserId: "users:patrick" as never,
+ ownerPublisherId: undefined,
+ ownerHandle: "patrick",
+ ownerKind: "user",
+ ownerName: "patrick",
+ ownerDisplayName: "Patrick",
+ ownerImage: undefined,
+ badges: undefined,
+ installKind: undefined,
+ githubScanStatus: undefined,
+ moderationVerdict: "clean",
+ statsInstallsAllTime: undefined,
+ stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
+ createdAt: 100,
+ updatedAt: 200,
+ },
+ { installs: 1, bookmarks: 0, updatedAt: 300 },
+ );
+
+ expect(result?.card.metrics).toMatchObject({
+ trending24hInstalls: 1,
+ lifetimeInstalls: null,
+ updatedAt: 300,
+ });
+ });
+
+ it("preserves skills.sh rank while leaving unavailable 24-hour metrics null", () => {
+ const result = buildExternalCanonicalTrendingCandidate({
+ externalId: "patrick/repo/external",
+ owner: "patrick",
+ repo: "repo",
+ sourceHost: undefined,
+ slug: "external",
+ displayName: "External",
+ searchSummary: "External summary",
+ sourceUrl: "https://skills.sh/patrick/repo/external",
+ upstreamInstalls: 4_000,
+ trendingRank: 7,
+ trendingLifetimeInstalls: 4_200,
+ trendingObservedAt: 300,
+ upstreamScanners: {
+ genAgentTrustHub: { status: "pass" },
+ socket: { status: "pass" },
+ snyk: { status: "pass" },
+ },
+ firstObservedAt: 100,
+ lastObservedAt: 250,
+ });
+
+ expect(result).toMatchObject({ upstreamRank: 7 });
+ expect(result?.card.metrics).toEqual({
+ trending24hInstalls: null,
+ trending24hBookmarks: null,
+ lifetimeInstalls: 4_200,
+ lifetimeInstallsPeriod: "lifetime",
+ updatedAt: 300,
+ });
+ });
+});
diff --git a/convex/lib/canonicalTrending.ts b/convex/lib/canonicalTrending.ts
new file mode 100644
index 00000000..d84e40b5
--- /dev/null
+++ b/convex/lib/canonicalTrending.ts
@@ -0,0 +1,444 @@
+import { type Infer, v } from "convex/values";
+import type { Doc } from "../_generated/dataModel";
+
+export const CANONICAL_TRENDING_RANKING_VERSION = "skills-trending-v1";
+export const CANONICAL_TRENDING_WINDOW_HOURS = 24;
+export const CANONICAL_TRENDING_FIRST_PAGE_SIZE = 20;
+export const CANONICAL_TRENDING_PUBLISHER_CAP = 2;
+
+const canonicalTrendingUpstreamScannerValidator = v.object({
+ status: v.string(),
+ sourceCheckedAt: v.optional(v.string()),
+ sourceUrl: v.optional(v.string()),
+});
+
+export const canonicalTrendingCardValidator = v.object({
+ id: v.string(),
+ source: v.union(v.literal("clawhub"), v.literal("skills-sh")),
+ slug: v.string(),
+ displayName: v.string(),
+ summary: v.union(v.string(), v.null()),
+ canonicalUrl: v.string(),
+ links: v.object({
+ canonical: v.string(),
+ source: v.union(v.string(), v.null()),
+ }),
+ publisher: v.union(
+ v.object({
+ kind: v.union(v.literal("user"), v.literal("org")),
+ handle: v.union(v.string(), v.null()),
+ displayName: v.union(v.string(), v.null()),
+ image: v.union(v.string(), v.null()),
+ official: v.boolean(),
+ }),
+ v.null(),
+ ),
+ official: v.boolean(),
+ featured: v.boolean(),
+ install: v.object({
+ kind: v.union(v.literal("clawhub"), v.literal("github"), v.literal("skills-sh")),
+ reference: v.string(),
+ sourceUrl: v.union(v.string(), v.null()),
+ }),
+ sourceIdentity: v.object({
+ id: v.string(),
+ owner: v.union(v.string(), v.null()),
+ repo: v.union(v.string(), v.null()),
+ host: v.union(v.string(), v.null()),
+ lifetimeInstalls: v.union(v.number(), v.null()),
+ }),
+ trust: v.object({
+ visibility: v.literal("public"),
+ installability: v.literal("installable"),
+ clawHubVerdict: v.union(v.string(), v.null()),
+ upstreamScanners: v.union(
+ v.object({
+ genAgentTrustHub: canonicalTrendingUpstreamScannerValidator,
+ socket: canonicalTrendingUpstreamScannerValidator,
+ snyk: canonicalTrendingUpstreamScannerValidator,
+ }),
+ v.null(),
+ ),
+ sourceFreshness: v.union(v.literal("native"), v.literal("observed-only")),
+ }),
+ metrics: v.object({
+ trending24hInstalls: v.union(v.number(), v.null()),
+ trending24hBookmarks: v.union(v.number(), v.null()),
+ lifetimeInstalls: v.union(v.number(), v.null()),
+ lifetimeInstallsPeriod: v.literal("lifetime"),
+ updatedAt: v.number(),
+ }),
+});
+
+export const canonicalTrendingSourceRefValidator = v.union(
+ v.object({ kind: v.literal("clawhub"), skillId: v.id("skills") }),
+ v.object({ kind: v.literal("skills-sh"), externalId: v.string() }),
+);
+
+export type CanonicalTrendingCard = Infer;
+
+export type CanonicalTrendingMaterializationCandidate = CanonicalTrendingCandidate & {
+ card: CanonicalTrendingCard;
+ sourceRef: Infer;
+};
+
+type NativeTrendingDigest = Pick<
+ Doc<"skillSearchDigest">,
+ | "skillId"
+ | "slug"
+ | "displayName"
+ | "summary"
+ | "ownerUserId"
+ | "ownerPublisherId"
+ | "ownerHandle"
+ | "ownerKind"
+ | "ownerName"
+ | "ownerDisplayName"
+ | "ownerImage"
+ | "badges"
+ | "installKind"
+ | "githubScanStatus"
+ | "moderationVerdict"
+ | "statsInstallsAllTime"
+ | "stats"
+ | "createdAt"
+ | "updatedAt"
+>;
+
+type ExternalTrendingDigest = Pick<
+ Doc<"skillsShMirrorDigests">,
+ | "externalId"
+ | "owner"
+ | "repo"
+ | "sourceHost"
+ | "slug"
+ | "displayName"
+ | "searchSummary"
+ | "sourceUrl"
+ | "upstreamInstalls"
+ | "trendingRank"
+ | "trendingLifetimeInstalls"
+ | "trendingObservedAt"
+ | "upstreamScanners"
+ | "firstObservedAt"
+ | "lastObservedAt"
+>;
+
+export function buildNativeCanonicalTrendingCandidate(
+ digest: NativeTrendingDigest,
+ usage: { installs: number; bookmarks: number; updatedAt: number },
+): CanonicalTrendingMaterializationCandidate | null {
+ const ownerHandle = digest.ownerHandle?.trim();
+ if (!ownerHandle) return null;
+ const official = Boolean(digest.badges?.official);
+ const canonicalUrl = `/${encodeURIComponent(ownerHandle)}/skills/${encodeURIComponent(digest.slug)}`;
+ const identity = `clawhub:${String(digest.skillId)}`;
+ const lifetimeInstalls = digest.statsInstallsAllTime ?? digest.stats.installsAllTime ?? null;
+ return {
+ identity,
+ lane: "clawhub-trending",
+ publisherKey: String(digest.ownerPublisherId ?? digest.ownerUserId),
+ installs24h: Math.max(0, usage.installs),
+ bookmarks24h: Math.max(0, usage.bookmarks),
+ createdAt: digest.createdAt,
+ updatedAt: digest.updatedAt,
+ upstreamRank: null,
+ sourceRef: { kind: "clawhub", skillId: digest.skillId },
+ card: {
+ id: identity,
+ source: "clawhub",
+ slug: digest.slug,
+ displayName: digest.displayName,
+ summary: digest.summary ?? null,
+ canonicalUrl,
+ links: { canonical: canonicalUrl, source: null },
+ publisher: {
+ kind: digest.ownerKind ?? "user",
+ handle: ownerHandle,
+ displayName: digest.ownerDisplayName ?? digest.ownerName ?? ownerHandle,
+ image: digest.ownerImage ?? null,
+ official,
+ },
+ official,
+ featured: Boolean(digest.badges?.highlighted),
+ install: {
+ kind: digest.installKind === "github" ? "github" : "clawhub",
+ reference: `${ownerHandle}/${digest.slug}`,
+ sourceUrl: null,
+ },
+ sourceIdentity: {
+ id: String(digest.skillId),
+ owner: ownerHandle,
+ repo: null,
+ host: null,
+ lifetimeInstalls,
+ },
+ trust: {
+ visibility: "public",
+ installability: "installable",
+ clawHubVerdict: digest.githubScanStatus ?? digest.moderationVerdict ?? null,
+ upstreamScanners: null,
+ sourceFreshness: "native",
+ },
+ metrics: {
+ trending24hInstalls: Math.max(0, usage.installs),
+ trending24hBookmarks: Math.max(0, usage.bookmarks),
+ lifetimeInstalls,
+ lifetimeInstallsPeriod: "lifetime",
+ updatedAt: usage.updatedAt,
+ },
+ },
+ };
+}
+
+export function buildExternalCanonicalTrendingCandidate(
+ digest: ExternalTrendingDigest,
+): CanonicalTrendingMaterializationCandidate | null {
+ if (!Number.isSafeInteger(digest.trendingRank) || (digest.trendingRank ?? 0) < 1) return null;
+ const encodedIdentity = digest.externalId
+ .split("/")
+ .map((segment) => encodeURIComponent(segment))
+ .join("/");
+ const canonicalUrl = `/skills-sh/${encodedIdentity}`;
+ const identity = `skills-sh:${digest.externalId}`;
+ const lifetimeInstalls = digest.trendingLifetimeInstalls ?? digest.upstreamInstalls;
+ return {
+ identity,
+ lane: "skills-sh-trending",
+ publisherKey: digest.owner ?? digest.sourceHost ?? digest.externalId,
+ installs24h: 0,
+ bookmarks24h: 0,
+ createdAt: digest.firstObservedAt,
+ updatedAt: digest.trendingObservedAt ?? digest.lastObservedAt,
+ upstreamRank: digest.trendingRank ?? null,
+ sourceRef: { kind: "skills-sh", externalId: digest.externalId },
+ card: {
+ id: identity,
+ source: "skills-sh",
+ slug: digest.slug,
+ displayName: digest.displayName,
+ summary: digest.searchSummary ?? null,
+ canonicalUrl,
+ links: { canonical: canonicalUrl, source: digest.sourceUrl },
+ publisher: null,
+ official: false,
+ featured: false,
+ install: {
+ kind: "skills-sh",
+ reference: identity,
+ sourceUrl: digest.sourceUrl,
+ },
+ sourceIdentity: {
+ id: digest.externalId,
+ owner: digest.owner ?? null,
+ repo: digest.repo ?? null,
+ host: digest.sourceHost ?? null,
+ lifetimeInstalls,
+ },
+ trust: {
+ visibility: "public",
+ installability: "installable",
+ clawHubVerdict: null,
+ upstreamScanners: digest.upstreamScanners,
+ sourceFreshness: "observed-only",
+ },
+ metrics: {
+ trending24hInstalls: null,
+ trending24hBookmarks: null,
+ lifetimeInstalls,
+ lifetimeInstallsPeriod: "lifetime",
+ updatedAt: digest.trendingObservedAt ?? digest.lastObservedAt,
+ },
+ },
+ };
+}
+
+export type CanonicalTrendingLane = "clawhub-trending" | "clawhub-rising" | "skills-sh-trending";
+
+export type CanonicalTrendingCandidate = {
+ identity: string;
+ lane: CanonicalTrendingLane;
+ publisherKey: string;
+ installs24h: number;
+ bookmarks24h: number;
+ createdAt: number;
+ updatedAt: number;
+ upstreamRank: number | null;
+};
+
+export type CanonicalTrendingPools = {
+ clawhubTrending: T[];
+ clawhubRising: T[];
+ skillsShTrending: T[];
+};
+
+const WEIGHTED_CYCLE: CanonicalTrendingLane[] = [
+ "clawhub-trending",
+ "skills-sh-trending",
+ "clawhub-rising",
+ "clawhub-trending",
+ "skills-sh-trending",
+];
+
+const SLOT_FALLBACKS: Record = {
+ "clawhub-trending": ["clawhub-trending", "skills-sh-trending", "clawhub-rising"],
+ "clawhub-rising": ["clawhub-rising", "clawhub-trending", "skills-sh-trending"],
+ "skills-sh-trending": ["skills-sh-trending", "clawhub-trending", "clawhub-rising"],
+};
+
+type PoolState = {
+ queue: T[];
+ deferred: T[];
+};
+
+function compareNumberDesc(left: number, right: number) {
+ return right - left;
+}
+
+function compareIdentity(left: CanonicalTrendingCandidate, right: CanonicalTrendingCandidate) {
+ return left.identity.localeCompare(right.identity);
+}
+
+export function sortCanonicalTrendingPools(
+ pools: CanonicalTrendingPools,
+): CanonicalTrendingPools {
+ return {
+ clawhubTrending: [...pools.clawhubTrending].sort(
+ (left, right) =>
+ compareNumberDesc(left.installs24h, right.installs24h) ||
+ compareNumberDesc(left.bookmarks24h, right.bookmarks24h) ||
+ compareNumberDesc(left.updatedAt, right.updatedAt) ||
+ compareIdentity(left, right),
+ ),
+ clawhubRising: [...pools.clawhubRising].sort(
+ (left, right) =>
+ compareNumberDesc(left.installs24h, right.installs24h) ||
+ compareNumberDesc(left.bookmarks24h, right.bookmarks24h) ||
+ compareNumberDesc(left.createdAt, right.createdAt) ||
+ compareIdentity(left, right),
+ ),
+ skillsShTrending: [...pools.skillsShTrending].sort(
+ (left, right) =>
+ (left.upstreamRank ?? Number.MAX_SAFE_INTEGER) -
+ (right.upstreamRank ?? Number.MAX_SAFE_INTEGER) || compareIdentity(left, right),
+ ),
+ };
+}
+
+export function blendCanonicalTrendingPools(
+ input: CanonicalTrendingPools,
+ options: {
+ firstPageSize?: number;
+ publisherCap?: number;
+ } = {},
+) {
+ const firstPageSize = options.firstPageSize ?? CANONICAL_TRENDING_FIRST_PAGE_SIZE;
+ const publisherCap = options.publisherCap ?? CANONICAL_TRENDING_PUBLISHER_CAP;
+ const sorted = sortCanonicalTrendingPools(input);
+ const pools: Record> = {
+ "clawhub-trending": { queue: sorted.clawhubTrending, deferred: [] },
+ "clawhub-rising": { queue: sorted.clawhubRising, deferred: [] },
+ "skills-sh-trending": { queue: sorted.skillsShTrending, deferred: [] },
+ };
+ const publisherCounts = new Map();
+ const seen = new Set();
+ const result: T[] = [];
+ let cycleIndex = 0;
+ let capReleased = firstPageSize === 0;
+
+ const releaseDeferred = () => {
+ if (capReleased) return;
+ capReleased = true;
+ for (const pool of Object.values(pools)) {
+ pool.queue = [...pool.deferred, ...pool.queue];
+ pool.deferred = [];
+ }
+ };
+
+ const takeFromLane = (lane: CanonicalTrendingLane) => {
+ const pool = pools[lane];
+ while (pool.queue.length > 0) {
+ const next = pool.queue.shift()!;
+ if (seen.has(next.identity)) continue;
+ if (!capReleased && (publisherCounts.get(next.publisherKey) ?? 0) >= publisherCap) {
+ pool.deferred.push(next);
+ continue;
+ }
+ return next;
+ }
+ return null;
+ };
+
+ while (true) {
+ if (!capReleased && result.length >= firstPageSize) releaseDeferred();
+
+ const preferredLane = WEIGHTED_CYCLE[cycleIndex % WEIGHTED_CYCLE.length];
+ cycleIndex += 1;
+ let next: T | null = null;
+ for (const lane of SLOT_FALLBACKS[preferredLane]) {
+ next = takeFromLane(lane);
+ if (next) break;
+ }
+
+ if (!next) {
+ const deferredCount = Object.values(pools).reduce(
+ (total, pool) => total + pool.deferred.length,
+ 0,
+ );
+ if (!capReleased && deferredCount > 0) {
+ // A complete feed and a strict first-page cap cannot both be satisfied
+ // when every remaining row belongs to an already-capped publisher.
+ // Preserve the cap while alternatives exist, then release deterministically.
+ releaseDeferred();
+ continue;
+ }
+ break;
+ }
+
+ seen.add(next.identity);
+ publisherCounts.set(next.publisherKey, (publisherCounts.get(next.publisherKey) ?? 0) + 1);
+ result.push(next);
+ }
+
+ return result;
+}
+
+export type CanonicalTrendingCursor = {
+ snapshotId: string;
+ offset: number;
+};
+
+export function encodeCanonicalTrendingCursor(cursor: CanonicalTrendingCursor) {
+ if (!cursor.snapshotId || !/^[A-Za-z0-9:_-]+$/.test(cursor.snapshotId)) {
+ throw new Error("Invalid snapshot ID");
+ }
+ if (!Number.isSafeInteger(cursor.offset) || cursor.offset < 0) {
+ throw new Error("Invalid cursor offset");
+ }
+ return btoa(JSON.stringify({ v: 1, s: cursor.snapshotId, o: cursor.offset }))
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/g, "");
+}
+
+export function decodeCanonicalTrendingCursor(value: string): CanonicalTrendingCursor {
+ try {
+ const padded = value
+ .replace(/-/g, "+")
+ .replace(/_/g, "/")
+ .padEnd(Math.ceil(value.length / 4) * 4, "=");
+ const parsed = JSON.parse(atob(padded)) as { v?: unknown; s?: unknown; o?: unknown };
+ if (
+ parsed.v !== 1 ||
+ typeof parsed.s !== "string" ||
+ !/^[A-Za-z0-9:_-]+$/.test(parsed.s) ||
+ !Number.isSafeInteger(parsed.o) ||
+ (parsed.o as number) < 0
+ ) {
+ throw new Error("invalid");
+ }
+ return { snapshotId: parsed.s, offset: parsed.o as number };
+ } catch {
+ throw new Error("Invalid cursor format");
+ }
+}
diff --git a/convex/lib/httpRouteRateLimit.ts b/convex/lib/httpRouteRateLimit.ts
index ce3cfb4f..bc319309 100644
--- a/convex/lib/httpRouteRateLimit.ts
+++ b/convex/lib/httpRouteRateLimit.ts
@@ -50,6 +50,9 @@ function resolveDefaultRouteRateLimit(spec: RouteSpec, request: Request): RouteR
const routedPath = getRoutedPath(spec);
if (authMetadataPaths.has(routedPath)) return { kind: "none" };
+ // The Trending handler owns its rollout gate before rate limiting so the
+ // dark route remains indistinguishable from an absent endpoint.
+ if (routedPath === ApiRoutes.trending) return { kind: "none" };
if (routedPath === ApiRoutes.publishTokenMint) return { kind: "trustedPublish" };
if (routedPath === ApiRoutes.skillsExport || routedPath === ApiRoutes.pluginsExport) {
diff --git a/convex/lib/retentionPolicy.ts b/convex/lib/retentionPolicy.ts
index ef640e24..e975e61f 100644
--- a/convex/lib/retentionPolicy.ts
+++ b/convex/lib/retentionPolicy.ts
@@ -196,6 +196,24 @@ export const RETENTION_POLICIES = {
rankingMetricImports: permanent(
"Versioned Test ranking import provenance is retained until explicit cleanup.",
),
+ canonicalTrendingSnapshots: ephemeral(
+ "Canonical Trending headers remain briefly available for stable cursor pagination.",
+ {
+ expirationField: "expiresAt",
+ expirationIndex: "by_expires_at",
+ prune: "canonicalTrending.pruneExpiredActionInternal",
+ retention: "Forty-eight hours after snapshot generation.",
+ },
+ ),
+ canonicalTrendingItems: ephemeral(
+ "Materialized Trending cards expire with their snapshot header.",
+ {
+ expirationField: "expiresAt",
+ expirationIndex: "by_expires_at",
+ prune: "canonicalTrending.pruneExpiredActionInternal",
+ retention: "Forty-eight hours after snapshot generation.",
+ },
+ ),
skillStatEvents: ephemeral(
"Skill stat event log is retained only after both consumers pass it.",
{
diff --git a/convex/schema.ts b/convex/schema.ts
index 475539d3..1d6317c6 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -2,6 +2,10 @@ import { authTables } from "@convex-dev/auth/server";
import { createClawManifestSummarySchema } from "clawhub-schema";
import { defineSchema, defineTable } from "convex/server";
import { type GenericValidator, v } from "convex/values";
+import {
+ canonicalTrendingCardValidator,
+ canonicalTrendingSourceRefValidator,
+} from "./lib/canonicalTrending";
import { EMBEDDING_DIMENSIONS } from "./lib/embeddings";
const PLATFORM_SKILL_LICENSE = "MIT-0" as const;
@@ -2623,6 +2627,54 @@ const rankingMetricImports = defineTable({
.index("by_dataset_version", ["datasetVersion"])
.index("by_imported_at", ["importedAt"]);
+const canonicalTrendingSnapshots = defineTable({
+ snapshotId: v.string(),
+ kind: v.literal("skills"),
+ status: v.union(v.literal("building"), v.literal("ready"), v.literal("failed")),
+ rankingVersion: v.string(),
+ generatedAt: v.number(),
+ completedAt: v.optional(v.number()),
+ expiresAt: v.number(),
+ windowHours: v.number(),
+ windowStartDay: v.number(),
+ windowEndDay: v.number(),
+ writtenItems: v.number(),
+ totalItems: v.optional(v.number()),
+ sourceCounts: v.optional(
+ v.object({
+ clawhubTrending: v.number(),
+ clawhubRising: v.number(),
+ skillsShTrending: v.number(),
+ }),
+ ),
+ operations: v.optional(
+ v.object({
+ documentsRead: v.number(),
+ documentsWritten: v.number(),
+ functionCalls: v.number(),
+ }),
+ ),
+ error: v.optional(v.string()),
+})
+ .index("by_snapshot_id", ["snapshotId"])
+ .index("by_kind_and_status_and_expires_at", ["kind", "status", "expiresAt"])
+ .index("by_expires_at", ["expiresAt"]);
+
+const canonicalTrendingItems = defineTable({
+ snapshotId: v.string(),
+ position: v.number(),
+ lane: v.union(
+ v.literal("clawhub-trending"),
+ v.literal("clawhub-rising"),
+ v.literal("skills-sh-trending"),
+ ),
+ sourceRef: canonicalTrendingSourceRefValidator,
+ card: canonicalTrendingCardValidator,
+ expiresAt: v.number(),
+})
+ .index("by_snapshot_id_and_position", ["snapshotId", "position"])
+ .index("by_expires_at", ["expiresAt"]);
+
const skillStatEvents = defineTable({
skillId: v.id("skills"),
kind: v.union(
@@ -4096,6 +4148,8 @@ export default defineSchema({
skillStatBackfillState,
globalStats,
rankingMetricImports,
+ canonicalTrendingSnapshots,
+ canonicalTrendingItems,
skillStatEvents,
skillStatUpdateCursors,
skillStatDocSyncLeases,
diff --git a/package.json b/package.json
index 4631a648..d228b31d 100644
--- a/package.json
+++ b/package.json
@@ -88,6 +88,7 @@
"test:pw:publish-lifecycle": "bun run test:pw:local-auth -- --project=chromium e2e/local-auth/publish-skill-lifecycle.pw.test.ts",
"test:ui-contract": "vitest run src/__tests__/ui-design-contract.test.ts src/__tests__/header.test.tsx src/__tests__/home-route.test.tsx src/components/Footer.test.tsx src/lib/theme.test.tsx src/routes/-settings.test.tsx",
"test:watch": "vitest",
+ "trending:prove-test": "bun scripts/canonical-trending/prove-test.ts",
"validate:public-corpus": "bun scripts/public-corpus/validate-public-corpus.ts",
"verify:convex-contract": "bun scripts/verify-convex-contract.ts"
},
diff --git a/packages/clawhub/src/schema/routes.ts b/packages/clawhub/src/schema/routes.ts
index 6ae9d275..bd072871 100644
--- a/packages/clawhub/src/schema/routes.ts
+++ b/packages/clawhub/src/schema/routes.ts
@@ -17,6 +17,7 @@ export const ApiRoutes = {
download: "/api/v1/download",
publishTokenMint: "/api/v1/publish/token/mint",
skills: "/api/v1/skills",
+ trending: "/api/v1/trending",
skillsSh: "/api/v1/skills-sh",
skillScans: "/api/v1/skills/-/scan",
packages: "/api/v1/packages",
diff --git a/packages/schema/dist/routes.d.ts b/packages/schema/dist/routes.d.ts
index 7a90d432..6bff2dc0 100644
--- a/packages/schema/dist/routes.d.ts
+++ b/packages/schema/dist/routes.d.ts
@@ -16,6 +16,7 @@ export declare const ApiRoutes: {
readonly download: "/api/v1/download";
readonly publishTokenMint: "/api/v1/publish/token/mint";
readonly skills: "/api/v1/skills";
+ readonly trending: "/api/v1/trending";
readonly skillsSh: "/api/v1/skills-sh";
readonly skillScans: "/api/v1/skills/-/scan";
readonly plugins: "/api/v1/plugins";
diff --git a/packages/schema/dist/routes.js b/packages/schema/dist/routes.js
index 42f1504b..3f099612 100644
--- a/packages/schema/dist/routes.js
+++ b/packages/schema/dist/routes.js
@@ -16,6 +16,7 @@ export const ApiRoutes = {
download: "/api/v1/download",
publishTokenMint: "/api/v1/publish/token/mint",
skills: "/api/v1/skills",
+ trending: "/api/v1/trending",
skillsSh: "/api/v1/skills-sh",
skillScans: "/api/v1/skills/-/scan",
plugins: "/api/v1/plugins",
diff --git a/packages/schema/dist/routes.js.map b/packages/schema/dist/routes.js.map
index d4178461..9f61b8d5 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,MAAM,EAAE,gBAAgB;IACxB,QAAQ,EAAE,mBAAmB;IAC7B,UAAU,EAAE,uBAAuB;IACnC,OAAO,EAAE,iBAAiB;IAC1B,aAAa,EAAE,wBAAwB;IACvC,QAAQ,EAAE,kBAAkB;IAC5B,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,MAAM,EAAE,gBAAgB;IACxB,QAAQ,EAAE,kBAAkB;IAC5B,QAAQ,EAAE,mBAAmB;IAC7B,UAAU,EAAE,uBAAuB;IACnC,OAAO,EAAE,iBAAiB;IAC1B,aAAa,EAAE,wBAAwB;IACvC,QAAQ,EAAE,kBAAkB;IAC5B,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
diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts
index 3ba59a0e..28387d88 100644
--- a/packages/schema/src/routes.ts
+++ b/packages/schema/src/routes.ts
@@ -17,6 +17,7 @@ export const ApiRoutes = {
download: "/api/v1/download",
publishTokenMint: "/api/v1/publish/token/mint",
skills: "/api/v1/skills",
+ trending: "/api/v1/trending",
skillsSh: "/api/v1/skills-sh",
skillScans: "/api/v1/skills/-/scan",
plugins: "/api/v1/plugins",
diff --git a/scripts/canonical-trending/proof-contract.test.ts b/scripts/canonical-trending/proof-contract.test.ts
new file mode 100644
index 00000000..6ce54ad7
--- /dev/null
+++ b/scripts/canonical-trending/proof-contract.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from "vitest";
+import {
+ assertFirstPageContract,
+ EXPECTED_FIRST_20_LANES,
+ latencySummary,
+ type ProofSampleRow,
+} from "./proof-contract";
+
+function sampleRow(index: number): ProofSampleRow {
+ const lane = EXPECTED_FIRST_20_LANES[index]!;
+ const external = lane === "skills-sh-trending";
+ return {
+ rank: index + 1,
+ id: `row-${index + 1}`,
+ lane,
+ publisherKey: `publisher-${Math.floor(index / 2)}`,
+ upstreamRank: external ? index + 1 : null,
+ metrics: {
+ trending24hInstalls: external ? null : 20 - index,
+ trending24hBookmarks: external ? null : index,
+ lifetimeInstalls: 100 + index,
+ lifetimeInstallsPeriod: "lifetime",
+ },
+ };
+}
+
+describe("CLAW-590 permanent Test proof contract", () => {
+ it("accepts the continuous blend, cap, order, and metric provenance", () => {
+ expect(assertFirstPageContract(Array.from({ length: 20 }, (_, index) => sampleRow(index))))
+ .toMatchInlineSnapshot(`
+ {
+ "laneCounts": {
+ "clawhub-rising": 4,
+ "clawhub-trending": 8,
+ "skills-sh-trending": 8,
+ },
+ "maximumPublisherCount": 2,
+ "skillsShUpstreamRanks": [
+ 2,
+ 5,
+ 7,
+ 10,
+ 12,
+ 15,
+ 17,
+ 20,
+ ],
+ }
+ `);
+ });
+
+ it("rejects publisher-cap and external metric provenance violations", () => {
+ const publisherViolation = Array.from({ length: 20 }, (_, index) => sampleRow(index));
+ publisherViolation[4]!.publisherKey = publisherViolation[0]!.publisherKey;
+ expect(() => assertFirstPageContract(publisherViolation)).toThrow("Publisher cap exceeded");
+
+ const metricViolation = Array.from({ length: 20 }, (_, index) => sampleRow(index));
+ metricViolation[1]!.metrics.trending24hInstalls = 12;
+ expect(() => assertFirstPageContract(metricViolation)).toThrow(
+ "skills.sh 24-hour metrics were inferred",
+ );
+ });
+
+ it("summarizes observed latency without imposing an unstated SLO", () => {
+ expect(latencySummary([3, 1, 2])).toEqual({
+ samplesMs: [3, 1, 2],
+ medianMs: 2,
+ p95Ms: 3,
+ });
+ });
+});
diff --git a/scripts/canonical-trending/proof-contract.ts b/scripts/canonical-trending/proof-contract.ts
new file mode 100644
index 00000000..b49814cc
--- /dev/null
+++ b/scripts/canonical-trending/proof-contract.ts
@@ -0,0 +1,99 @@
+export const EXPECTED_FIRST_20_LANES = [
+ "clawhub-trending",
+ "skills-sh-trending",
+ "clawhub-rising",
+ "clawhub-trending",
+ "skills-sh-trending",
+ "clawhub-trending",
+ "skills-sh-trending",
+ "clawhub-rising",
+ "clawhub-trending",
+ "skills-sh-trending",
+ "clawhub-trending",
+ "skills-sh-trending",
+ "clawhub-rising",
+ "clawhub-trending",
+ "skills-sh-trending",
+ "clawhub-trending",
+ "skills-sh-trending",
+ "clawhub-rising",
+ "clawhub-trending",
+ "skills-sh-trending",
+] as const;
+
+type ProofMetrics = {
+ trending24hInstalls: number | null;
+ trending24hBookmarks: number | null;
+ lifetimeInstalls: number | null;
+ lifetimeInstallsPeriod: string;
+};
+
+export type ProofSampleRow = {
+ rank: number;
+ id: string;
+ lane: string;
+ publisherKey: string;
+ upstreamRank: number | null;
+ metrics: ProofMetrics;
+};
+
+export function assertFirstPageContract(sample: ProofSampleRow[]) {
+ if (sample.length !== EXPECTED_FIRST_20_LANES.length) {
+ throw new Error(`Expected a 20-row Trending sample, received ${sample.length}`);
+ }
+ const publisherCounts = new Map();
+ const upstreamRanks: number[] = [];
+ for (const [index, row] of sample.entries()) {
+ if (row.rank !== index + 1) throw new Error(`Unexpected rank at sample offset ${index}`);
+ if (row.lane !== EXPECTED_FIRST_20_LANES[index]) {
+ throw new Error(`40/20/40 lane mismatch at rank ${row.rank}`);
+ }
+ const publisherCount = (publisherCounts.get(row.publisherKey) ?? 0) + 1;
+ publisherCounts.set(row.publisherKey, publisherCount);
+ if (publisherCount > 2) throw new Error(`Publisher cap exceeded by ${row.publisherKey}`);
+ if (row.metrics.lifetimeInstallsPeriod !== "lifetime") {
+ throw new Error(`Lifetime metric period is mislabeled for ${row.id}`);
+ }
+ if (row.lane === "skills-sh-trending") {
+ if (row.metrics.trending24hInstalls !== null || row.metrics.trending24hBookmarks !== null) {
+ throw new Error(`skills.sh 24-hour metrics were inferred for ${row.id}`);
+ }
+ if (typeof row.metrics.lifetimeInstalls !== "number") {
+ throw new Error(`skills.sh lifetime installs are missing for ${row.id}`);
+ }
+ if (!Number.isSafeInteger(row.upstreamRank) || (row.upstreamRank ?? 0) < 1) {
+ throw new Error(`skills.sh upstream rank is missing for ${row.id}`);
+ }
+ upstreamRanks.push(row.upstreamRank as number);
+ } else if (
+ typeof row.metrics.trending24hInstalls !== "number" ||
+ typeof row.metrics.trending24hBookmarks !== "number"
+ ) {
+ throw new Error(`Native 24-hour metrics are missing for ${row.id}`);
+ }
+ }
+ if (upstreamRanks.some((rank, index) => index > 0 && rank <= upstreamRanks[index - 1]!)) {
+ throw new Error("skills.sh upstream order was not preserved");
+ }
+ return {
+ laneCounts: Object.fromEntries(
+ [...new Set(EXPECTED_FIRST_20_LANES)].map((lane) => [
+ lane,
+ sample.filter((row) => row.lane === lane).length,
+ ]),
+ ),
+ maximumPublisherCount: Math.max(...publisherCounts.values()),
+ skillsShUpstreamRanks: upstreamRanks,
+ };
+}
+
+export function latencySummary(samples: number[]) {
+ if (samples.length === 0) throw new Error("Latency samples are required");
+ const sorted = [...samples].sort((a, b) => a - b);
+ const percentile = (ratio: number) => sorted[Math.ceil(sorted.length * ratio) - 1] ?? sorted[0]!;
+ return {
+ samplesMs: samples.map((value) => Number(value.toFixed(2))),
+ medianMs: Number(percentile(0.5).toFixed(2)),
+ p95Ms: Number(percentile(0.95).toFixed(2)),
+ };
+}
diff --git a/scripts/canonical-trending/prove-test.ts b/scripts/canonical-trending/prove-test.ts
new file mode 100644
index 00000000..008fd8b1
--- /dev/null
+++ b/scripts/canonical-trending/prove-test.ts
@@ -0,0 +1,213 @@
+#!/usr/bin/env bun
+
+import { execFile } from "node:child_process";
+import { mkdir, writeFile } from "node:fs/promises";
+import { dirname, resolve } from "node:path";
+import { promisify } from "node:util";
+import { assertFirstPageContract, latencySummary, type ProofSampleRow } from "./proof-contract";
+
+const OUTPUT_PATH = resolve("proof/claw-590/canonical-trending-test-proof.json");
+const ACTIVE_PATH = resolve("proof/claw-590/active-snapshot.json");
+const SAMPLE_COUNT = 3;
+const PAGE_LIMIT = 20;
+const MAX_PAGES = 1_000;
+const CONFIRM = "manage-claw-590-canonical-trending-test-proof";
+const execFileAsync = promisify(execFile);
+
+function requireEnv(name: string) {
+ const value = process.env[name]?.trim();
+ if (!value) throw new Error(`${name} is required`);
+ return value;
+}
+
+const deploySha = requireEnv("DEPLOY_SHA");
+if (!/^[0-9a-f]{40}$/.test(deploySha)) throw new Error("DEPLOY_SHA must be a full commit SHA");
+const siteUrl = requireEnv("TEST_SITE_URL").replace(/\/$/, "");
+const bypass = process.env.VERCEL_AUTOMATION_BYPASS_SECRET?.trim();
+const snapshotId = `claw-590-proof-${deploySha}`;
+
+type ApiItem = {
+ id: string;
+ rank: number;
+ lane: string;
+ metrics: {
+ trending24hInstalls: number | null;
+ trending24hBookmarks: number | null;
+ lifetimeInstalls: number | null;
+ lifetimeInstallsPeriod: string;
+ };
+};
+
+type ApiPage = {
+ kind: string;
+ snapshotId: string;
+ snapshotCursor: string;
+ generatedAt: string;
+ windowHours: number;
+ rankingVersion: string;
+ totalItems: number;
+ items: ApiItem[];
+ nextCursor: string | null;
+};
+
+async function convexRun(functionName: string, args: Record) {
+ const { stdout } = await execFileAsync(
+ "bunx",
+ ["convex", "run", "--no-push", functionName, JSON.stringify(args)],
+ { env: process.env, maxBuffer: 32 * 1024 * 1024 },
+ );
+ return JSON.parse(stdout.trim()) as Record;
+}
+
+async function fetchPage(cursor: string | null) {
+ const url = new URL("/api/v1/trending", siteUrl);
+ url.searchParams.set("kind", "skills");
+ url.searchParams.set("limit", String(PAGE_LIMIT));
+ if (cursor) url.searchParams.set("cursor", cursor);
+ const startedAt = performance.now();
+ const response = await fetch(url, {
+ headers: bypass ? { "x-vercel-protection-bypass": bypass } : undefined,
+ });
+ const elapsedMs = performance.now() - startedAt;
+ const text = await response.text();
+ if (!response.ok) throw new Error(`Trending API returned HTTP ${response.status}: ${text}`);
+ return { elapsedMs, page: JSON.parse(text) as ApiPage };
+}
+
+function orderedIds(page: ApiPage) {
+ return page.items.map((item) => item.id);
+}
+
+await mkdir(dirname(ACTIVE_PATH), { recursive: true });
+await writeFile(
+ ACTIVE_PATH,
+ `${JSON.stringify({ armed: true, snapshotId, deploySha }, null, 2)}\n`,
+);
+
+const materialization = await convexRun("canonicalTrending:materializeInternal", {
+ proofSnapshotId: snapshotId,
+});
+if (materialization.status !== "ready" || materialization.snapshotId !== snapshotId) {
+ throw new Error(`Unexpected materialization result: ${JSON.stringify(materialization)}`);
+}
+
+const firstSamples = [];
+for (let index = 0; index < SAMPLE_COUNT; index += 1) firstSamples.push(await fetchPage(null));
+const firstPage = firstSamples[0]!.page;
+for (const sample of firstSamples) {
+ if (
+ sample.page.snapshotId !== snapshotId ||
+ sample.page.snapshotCursor !== firstPage.snapshotCursor ||
+ sample.page.nextCursor !== firstPage.nextCursor ||
+ JSON.stringify(orderedIds(sample.page)) !== JSON.stringify(orderedIds(firstPage))
+ ) {
+ throw new Error("Trending first-page cursor or order changed between samples");
+ }
+}
+
+const allItems = [...firstPage.items];
+let cursor = firstPage.nextCursor;
+let pages = 1;
+while (cursor) {
+ if (pages >= MAX_PAGES) throw new Error("Trending pagination exceeded its proof bound");
+ const result = await fetchPage(cursor);
+ if (result.page.snapshotId !== snapshotId)
+ throw new Error("Trending pagination changed snapshot");
+ allItems.push(...result.page.items);
+ cursor = result.page.nextCursor;
+ pages += 1;
+}
+if (allItems.length !== firstPage.totalItems) {
+ throw new Error(`Trending pagination returned ${allItems.length}/${firstPage.totalItems} items`);
+}
+if (new Set(allItems.map((item) => item.id)).size !== allItems.length) {
+ throw new Error("Trending pagination returned duplicate identities");
+}
+if (allItems.some((item, index) => item.rank !== index + 1)) {
+ throw new Error("Trending pagination returned unstable or discontinuous ranks");
+}
+
+const readback = await convexRun("canonicalTrendingTestFixtures:readCanonicalTrendingProof", {
+ confirm: CONFIRM,
+ snapshotId,
+});
+if (readback.present !== true || readback.status !== "ready") {
+ throw new Error(`Owned snapshot readback failed: ${JSON.stringify(readback)}`);
+}
+const sample = readback.sample as ProofSampleRow[];
+const publicSample = firstPage.items.map((item, index): ProofSampleRow => {
+ const internalRow = sample[index];
+ if (!internalRow) throw new Error(`Public API returned an unexpected row at rank ${item.rank}`);
+ return {
+ rank: item.rank,
+ id: item.id,
+ lane: item.lane,
+ publisherKey: internalRow.publisherKey,
+ upstreamRank: internalRow.upstreamRank,
+ metrics: item.metrics,
+ };
+});
+if (
+ JSON.stringify(
+ publicSample.map(({ rank, id, lane, metrics }) => ({ rank, id, lane, metrics })),
+ ) !== JSON.stringify(sample.map(({ rank, id, lane, metrics }) => ({ rank, id, lane, metrics })))
+) {
+ throw new Error("Public API first-page fields differ from the owned snapshot readback");
+}
+const assertions = assertFirstPageContract(publicSample);
+const materializedSample = materialization.sample as Array<{ id: string }>;
+if (
+ JSON.stringify(materializedSample.map((row) => row.id)) !== JSON.stringify(orderedIds(firstPage))
+) {
+ throw new Error("Public API first page differs from the regenerated materialization sample");
+}
+
+const proof = {
+ generatedAt: new Date().toISOString(),
+ target: {
+ environment: "permanent Test",
+ deploySha,
+ siteUrl,
+ convexDeployment: "academic-chihuahua-392",
+ },
+ materialization: {
+ snapshotId,
+ durationMs: materialization.durationMs,
+ totalItems: materialization.totalItems,
+ sourceCounts: materialization.sourceCounts,
+ operations: materialization.operations,
+ regeneratedFirst20: materializedSample,
+ },
+ publicApi: {
+ samples: SAMPLE_COUNT,
+ latency: latencySummary(firstSamples.map((sampleResult) => sampleResult.elapsedMs)),
+ snapshotCursor: firstPage.snapshotCursor,
+ generatedAt: firstPage.generatedAt,
+ windowHours: firstPage.windowHours,
+ rankingVersion: firstPage.rankingVersion,
+ totalItems: firstPage.totalItems,
+ pages,
+ first20: firstPage.items,
+ },
+ assertions: {
+ ...assertions,
+ stableSnapshot: true,
+ stableCursor: true,
+ stableOrder: true,
+ completePagination: true,
+ duplicateIdentities: 0,
+ lifetimeMetricsLabeled: true,
+ skillsShTrending24hInstalls: assertions.skillsShUpstreamRanks.every(
+ (_, index) =>
+ publicSample.filter((row) => row.lane === "skills-sh-trending")[index]?.metrics
+ .trending24hInstalls === null,
+ )
+ ? null
+ : "unexpected-non-null",
+ },
+ readback,
+};
+
+await mkdir(dirname(OUTPUT_PATH), { recursive: true });
+await writeFile(OUTPUT_PATH, `${JSON.stringify(proof, null, 2)}\n`);
+console.log(JSON.stringify({ ok: true, outputPath: OUTPUT_PATH, snapshotId }));
diff --git a/src/__tests__/deploy-test-workflow.test.ts b/src/__tests__/deploy-test-workflow.test.ts
index 5943fc7e..68606b7c 100644
--- a/src/__tests__/deploy-test-workflow.test.ts
+++ b/src/__tests__/deploy-test-workflow.test.ts
@@ -111,6 +111,8 @@ describe("Test deploy workflow", () => {
expect(revision).toContain("deploy-claw-577-to-permanent-test");
expect(revision).toContain("refs/heads/pe/claw-583-mirrored-search-journey");
expect(revision).toContain("deploy-claw-583-to-permanent-test");
+ expect(revision).toContain("refs/heads/pe/claw-590-trending-snapshot");
+ expect(revision).toContain("deploy-claw-590-to-permanent-test");
expect(revision).toContain("${{ inputs.expected_sha }}");
expect(revision).toContain("${{ github.event.pull_request.head.sha }}");
expect(revision).toContain("${{ github.event.pull_request.head.repo.full_name }}");
@@ -307,4 +309,44 @@ describe("Test deploy workflow", () => {
expect(upload?.with?.path).toContain("proof/claw-583/external-detail.png");
expect(upload?.with?.path).toContain("claw583-cleanup-readback.json");
});
+
+ it("runs the CLAW-590 materialization proof only for its exact guarded branch", async () => {
+ const workflow = await readWorkflow();
+ const job = workflow.jobs?.["claw590-canonical-trending-proof"];
+ const proofStep = job?.steps?.find(
+ (candidate) =>
+ candidate.name === "Prove canonical Trending materialization and API in permanent Test",
+ );
+ const upload = job?.steps?.find(
+ (candidate) => candidate.name === "Upload permanent Test canonical Trending proof",
+ );
+ const run = proofStep?.run ?? "";
+
+ expect(job?.needs).toBe("deploy-test");
+ expect(job?.if).toContain("github.ref == 'refs/heads/pe/claw-590-trending-snapshot'");
+ expect(job?.if).toContain("inputs.branch_test_confirm == 'deploy-claw-590-to-permanent-test'");
+ expect(job?.if).toContain("inputs.expected_sha == needs.deploy-test.outputs.deploy_sha");
+ expect(job?.environment?.name).toBe("Test");
+ expect(job?.steps?.[0]?.with?.ref).toBe("${{ inputs.expected_sha }}");
+ expect(proofStep?.env?.DEPLOY_SHA).toBe("${{ needs.deploy-test.outputs.deploy_sha }}");
+ expect(run).toContain("appMeta:getDeploymentInfo");
+ expect(run).toContain("bun run trending:prove-test");
+ expect(run).toContain("trap cleanup EXIT");
+ expect(run).toContain("canonicalTrendingTestFixtures:cleanupCanonicalTrendingProof");
+ expect(run).toContain("canonicalTrendingTestFixtures:readCanonicalTrendingProof");
+ expect(run).toContain("canonicalTrendingTestFixtures:seedCanonicalTrendingSourceFixture");
+ expect(run).toContain("canonicalTrendingTestFixtures:cleanupCanonicalTrendingSourceFixture");
+ expect(run).toContain("canonicalTrendingTestFixtures:readCanonicalTrendingSourceFixture");
+ expect(run).toContain("claw590-recovery-readback.json");
+ expect(run).toContain("claw590-source-cleanup-readback.json");
+ expect(run).toContain("snapshot cleanup incomplete; source retained");
+ expect(run).toContain("documentsRead > 0");
+ expect(run).toContain('laneCounts["skills-sh-trending"] == 8');
+ expect(upload?.uses).toBe("actions/upload-artifact@v7");
+ expect(upload?.with?.name).toBe("claw590-canonical-trending-proof");
+ expect(upload?.with?.["if-no-files-found"]).toBe("error");
+ expect(upload?.with?.path).toContain("proof/claw-590/canonical-trending-test-proof.json");
+ expect(upload?.with?.path).toContain("claw590-cleanup-readback.json");
+ expect(upload?.with?.path).toContain("claw590-source-cleanup-readback.json");
+ });
});