diff --git a/convex/canonicalTrending.test.ts b/convex/canonicalTrending.test.ts index e5bc50a1..095f67f2 100644 --- a/convex/canonicalTrending.test.ts +++ b/convex/canonicalTrending.test.ts @@ -829,6 +829,15 @@ describe("canonical Trending snapshot storage", () => { .unique(); if (!control) throw new Error("catalog control missing"); await ctx.db.patch(control._id, { mirrorPublicVisibilityEnabled: true }); + const mirrorControl = await ctx.db + .query("skillsShMirrorControls") + .withIndex("by_key", (q) => q.eq("key", "global")) + .unique(); + if (!mirrorControl) throw new Error("mirror control missing"); + await ctx.db.patch(mirrorControl._id, { + activationLockToken: "native-only-lock", + activationLockedAt: now, + }); }); const pageResult = await t.query(internal.canonicalTrending.getPageInternal, { cursor: null, @@ -866,5 +875,21 @@ describe("canonical Trending snapshot storage", () => { }, }), ]); + + const nativeOnly = await t.action(internal.canonicalTrending.materializeInternal, { + activationLockToken: "native-only-lock", + skillsShMode: "native-only", + }); + expect(nativeOnly).toMatchObject({ + status: "ready", + totalItems: 1, + sourceCounts: { clawhubTrending: 1, clawhubRising: 1, skillsShTrending: 0 }, + }); + await expect( + t.query(internal.canonicalTrending.getPageInternal, { cursor: null, limit: 20 }), + ).resolves.toMatchObject({ + status: "ok", + page: { items: [{ source: "clawhub" }] }, + }); }); }); diff --git a/convex/canonicalTrending.ts b/convex/canonicalTrending.ts index fc73216c..e3d06ad5 100644 --- a/convex/canonicalTrending.ts +++ b/convex/canonicalTrending.ts @@ -215,7 +215,10 @@ export const getExternalSourcePageInternal = internalQuery({ }); export const getMaterializationModeInternal = internalQuery({ - args: { activationLockToken: v.optional(v.string()) }, + args: { + activationLockToken: v.optional(v.string()), + skillsShMode: v.optional(v.literal("native-only")), + }, handler: async (ctx, args) => { const control = await ctx.db .query("skillsShMirrorControls") @@ -331,6 +334,7 @@ export const finalizeSnapshotInternal = internalMutation({ totalItems: v.number(), sourceCounts: sourceCountsValidator, operations: operationsValidator, + activationLockToken: v.optional(v.string()), }, handler: async (ctx, args) => { const snapshot = await ctx.db @@ -343,6 +347,17 @@ export const finalizeSnapshotInternal = internalMutation({ if (snapshot.writtenItems !== args.totalItems) { throw new Error("Trending snapshot item count mismatch"); } + const mirrorControl = await ctx.db + .query("skillsShMirrorControls") + .withIndex("by_key", (q) => q.eq("key", "global")) + .unique(); + if (args.activationLockToken) { + if (mirrorControl?.activationLockToken !== args.activationLockToken) { + throw new Error("skills.sh activation lock changed before Trending publication"); + } + } else if (mirrorControl?.activationLockToken) { + throw new Error("skills.sh activation started before Trending publication"); + } await ctx.db.patch(snapshot._id, { status: "ready", completedAt: args.completedAt, @@ -421,6 +436,7 @@ export const materializeInternal = internalAction({ args: { proofSnapshotId: v.optional(v.string()), activationLockToken: v.optional(v.string()), + skillsShMode: v.optional(v.literal("native-only")), }, handler: async (ctx, args) => { if (args.proofSnapshotId !== undefined) { @@ -429,6 +445,9 @@ export const materializeInternal = internalAction({ throw new Error("Invalid CLAW-590 proof snapshot ID"); } } + if (args.skillsShMode === "native-only" && !args.activationLockToken) { + throw new Error("native-only Trending materialization requires a visibility lock"); + } const startedAt = Date.now(); const snapshotId = args.proofSnapshotId ?? `skills-${startedAt}`; let snapshotStarted = false; @@ -439,7 +458,10 @@ export const materializeInternal = internalAction({ try { await ctx.runQuery( internalRefs.canonicalTrending.getMaterializationModeInternal as never, - { activationLockToken: args.activationLockToken } as never, + { + activationLockToken: args.activationLockToken, + skillsShMode: args.skillsShMode, + } as never, ); functionCalls += 1; type HourlyWindow = { @@ -493,7 +515,10 @@ export const materializeInternal = internalAction({ documentsRead: 0, functionCalls: 0, }; - if (getRuntimeRolloutCapabilities().skillsSh.runtimeEnabled) { + if ( + args.skillsShMode !== "native-only" && + getRuntimeRolloutCapabilities().skillsSh.runtimeEnabled + ) { const candidateRun = (await ctx.runQuery( internalRefs.canonicalTrending.getLatestCompletedTrendingRunInternal as never, {}, @@ -608,6 +633,7 @@ export const materializeInternal = internalAction({ totalItems: blended.length, sourceCounts, operations, + activationLockToken: args.activationLockToken, } as never, ); functionCalls += 1; diff --git a/convex/httpApiV1/skillsShCatalogV1.ts b/convex/httpApiV1/skillsShCatalogV1.ts index 7ef0ad5b..ea5f60ba 100644 --- a/convex/httpApiV1/skillsShCatalogV1.ts +++ b/convex/httpApiV1/skillsShCatalogV1.ts @@ -72,6 +72,8 @@ const internalRefs = internal as unknown as { storeSourcePageInternal: unknown; }; skillsShMirrorVisibility: { + deactivateAndMaterializeInternal: unknown; + prepareNativeTrendingInternal: unknown; setPublicGateInternal: unknown; verifyAndActivateInternal: unknown; }; @@ -688,6 +690,36 @@ export async function skillsShCatalogTestV1Handler(ctx: ActionCtx, request: Requ rate.headers, ); } + if (operation === "mirror-prepare-native-trending") { + return json( + await runActionRef( + ctx, + internalRefs.skillsShMirrorVisibility.prepareNativeTrendingInternal, + { + actor, + reason: requireString(body, "reason"), + confirm: requireString(body, "confirm"), + }, + ), + 200, + rate.headers, + ); + } + if (operation === "mirror-deactivate-native-trending") { + return json( + await runActionRef( + ctx, + internalRefs.skillsShMirrorVisibility.deactivateAndMaterializeInternal, + { + actor, + reason: requireString(body, "reason"), + confirm: requireString(body, "confirm"), + }, + ), + 200, + rate.headers, + ); + } if (operation === "mirror-public-gate") { return json( await runMutationRef(ctx, internalRefs.skillsShMirrorVisibility.setPublicGateInternal, { diff --git a/convex/skillsShMirrorVisibility.test.ts b/convex/skillsShMirrorVisibility.test.ts index 30db0b2a..52bf0c8a 100644 --- a/convex/skillsShMirrorVisibility.test.ts +++ b/convex/skillsShMirrorVisibility.test.ts @@ -347,6 +347,51 @@ describe("skills.sh mirror visibility operations", () => { reason: "CLAW-603 verification test", updatedAt: now - 2_000, }); + const nativeOwnerId = await ctx.db.insert("users", { + handle: "native-owner", + displayName: "Native owner", + createdAt: now - 10_000, + updatedAt: now, + }); + const nativeSkillId = await ctx.db.insert("skills", { + slug: "native-ready", + displayName: "Native ready", + summary: "Native canonical Trending fixture", + ownerUserId: nativeOwnerId, + tags: {}, + statsInstallsAllTime: 900, + stats: { downloads: 1_000, installsAllTime: 900, stars: 20, versions: 1, comments: 0 }, + createdAt: now - 10_000, + updatedAt: now, + }); + const nativeVersionId = await ctx.db.insert("skillVersions", { + skillId: nativeSkillId, + version: "1.0.0", + changelog: "Initial", + files: [], + parsed: { frontmatter: {} }, + createdBy: nativeOwnerId, + createdAt: now - 10_000, + }); + await ctx.db.insert("skillSearchDigest", { + skillId: nativeSkillId, + slug: "native-ready", + displayName: "Native ready", + summary: "Native canonical Trending fixture", + ownerUserId: nativeOwnerId, + ownerHandle: "native-owner", + ownerKind: "user", + ownerName: "Native owner", + ownerDisplayName: "Native owner", + latestVersionId: nativeVersionId, + latestVersionSkillId: nativeSkillId, + publicVersion: { status: "available", versionId: nativeVersionId }, + tags: {}, + statsInstallsAllTime: 900, + stats: { downloads: 1_000, installsAllTime: 900, stars: 20, versions: 1, comments: 0 }, + createdAt: now - 10_000, + updatedAt: now, + }); await ctx.db.insert("skillHourlyStatStates", { key: "canonical_trending", liveStartedAt: now - 3_600_000, @@ -357,6 +402,16 @@ describe("skills.sh mirror visibility operations", () => { lastProcessedEventCreationTime: 100, updatedAt: now, }); + await ctx.db.insert("skillHourlyStats", { + skillId: nativeSkillId, + hour: window.endHour, + generation: 0, + downloads: 18, + installs: 12, + bookmarks: 4, + updatedAt: now, + expiresAt: now + 72 * 3_600_000, + }); }); await expect( @@ -402,6 +457,69 @@ describe("skills.sh mirror visibility operations", () => { })); expect(activationState.mirrorControl?.activationLockToken).toBeUndefined(); expect(activationState.snapshots).toHaveLength(1); + await expect( + t.query(internal.canonicalTrending.getPageInternal, { cursor: null, limit: 20 }), + ).resolves.toMatchObject({ + status: "ok", + page: { items: [{ source: "clawhub" }, { source: "skills-sh" }] }, + }); + + await t.run(async (ctx) => { + const state = await ctx.db + .query("skillHourlyStatStates") + .withIndex("by_key", (q) => q.eq("key", "canonical_trending")) + .unique(); + if (!state) throw new Error("hourly state missing"); + await ctx.db.delete(state._id); + }); + await expect( + t.action(internal.skillsShMirrorVisibility.deactivateAndMaterializeInternal, { + actor: "codex-test", + reason: "CLAW-603 fail-closed rollback", + confirm: "deactivate-skills-sh-public-test", + }), + ).rejects.toThrow("native-only canonical Trending did not become ready"); + await expect( + t.query(internal.canonicalTrending.getPageInternal, { cursor: null, limit: 20 }), + ).resolves.toMatchObject({ + status: "ok", + page: { items: [{ source: "clawhub" }] }, + }); + await t.run(async (ctx) => { + await ctx.db.insert("skillHourlyStatStates", { + key: "canonical_trending", + liveStartedAt: now - 3_600_000, + eventBackfillThroughCreationTime: 100, + activeGeneration: 1, + backfillCompletedAt: now - 1_000, + lastAggregationCompletedAt: window.endAt + 1, + lastProcessedEventCreationTime: 100, + updatedAt: now, + }); + }); + + await expect( + t.action(internal.skillsShMirrorVisibility.deactivateAndMaterializeInternal, { + actor: "codex-test", + reason: "CLAW-603 native-only rollback", + confirm: "deactivate-skills-sh-public-test", + }), + ).resolves.toMatchObject({ + ok: true, + enabled: false, + nativeTrending: { + status: "ready", + sourceCounts: { clawhubTrending: 1, clawhubRising: 1, skillsShTrending: 0 }, + }, + scansPlanned: 0, + scansAdmitted: 0, + }); + await expect( + t.query(internal.canonicalTrending.getPageInternal, { cursor: null, limit: 20 }), + ).resolves.toMatchObject({ + status: "ok", + page: { items: [{ source: "clawhub" }] }, + }); }); it("reclaims an abandoned activation lock after its bounded lease", async () => { @@ -465,6 +583,39 @@ describe("skills.sh mirror visibility operations", () => { }); }); + it("prepares native-only publication without enabling a missing mirror control", async () => { + const t = convexTest(schema, modules); + await t.mutation(internal.skillsShMirrorVisibility.beginNativeTrendingInternal, { + actor: "codex-test", + reason: "prepare native-only Trending", + confirm: "deactivate-skills-sh-public-test", + lockToken: "native-only-lock", + }); + await expect( + t.run(async (ctx) => + ctx.db + .query("skillsShMirrorControls") + .withIndex("by_key", (q) => q.eq("key", "global")) + .unique(), + ), + ).resolves.toMatchObject({ + enabled: false, + paused: true, + maxRowsPerRun: 0, + maxRowsPerBatch: 0, + maxDetailBytes: 0, + activationLockToken: "native-only-lock", + }); + await expect( + t.mutation(internal.skillsShMirrorVisibility.beginActivationInternal, { + actor: "codex-test", + reason: "must wait for native-only snapshot", + confirm: "activate-skills-sh-public-test", + lockToken: "activation-lock", + }), + ).rejects.toThrow("skills.sh mirror control is not active"); + }); + it("rejects a completed Trending run older than the selected leaderboard import", async () => { const t = convexTest(schema, modules); await t.run(async (ctx) => { @@ -533,11 +684,11 @@ describe("skills.sh mirror visibility operations", () => { }; }); - await t.mutation(internal.skillsShMirrorVisibility.setPublicGateInternal, { - enabled: false, + await t.mutation(internal.skillsShMirrorVisibility.beginDeactivationInternal, { actor: "codex-test", reason: "systemic rollback", confirm: "deactivate-skills-sh-public-test", + lockToken: "native-rollback-lock", }); const control = await t.run(async (ctx) => ctx.db @@ -545,8 +696,8 @@ describe("skills.sh mirror visibility operations", () => { .withIndex("by_key", (q) => q.eq("key", "global")) .unique(), ); - expect(control?.activationLockToken).toBeUndefined(); - expect(control?.activationLockedAt).toBeUndefined(); + expect(control?.activationLockToken).toBe("native-rollback-lock"); + expect(control?.activationLockedAt).toEqual(expect.any(Number)); expect(control?.activationLeaderboardRunId).toBeUndefined(); expect(control?.activationTrendingRunId).toBeUndefined(); await expect( diff --git a/convex/skillsShMirrorVisibility.ts b/convex/skillsShMirrorVisibility.ts index 06043305..ab555496 100644 --- a/convex/skillsShMirrorVisibility.ts +++ b/convex/skillsShMirrorVisibility.ts @@ -21,8 +21,11 @@ const internalRefs = internal as unknown as { canonicalTrending: { materializeInternal: unknown }; skillsShMirrorVisibility: { beginActivationInternal: unknown; + beginDeactivationInternal: unknown; + beginNativeTrendingInternal: unknown; finalizeActivationInternal: unknown; releaseActivationInternal: unknown; + setPublicGateInternal: unknown; }; }; @@ -298,6 +301,26 @@ function publicGateValue(args: { enabled: boolean; actor: string; reason: string }; } +function disabledMirrorControl(args: { + actor: string; + reason: string; + now: number; + lockToken: string; +}) { + return { + enabled: false, + paused: true, + maxRowsPerRun: 0, + maxRowsPerBatch: 0, + maxDetailBytes: 0, + activationLockToken: args.lockToken, + activationLockedAt: args.now, + updatedBy: args.actor, + reason: args.reason, + updatedAt: args.now, + }; +} + async function writePublicGate( ctx: Pick, args: { enabled: boolean; actor: string; reason: string; now: number }, @@ -365,6 +388,205 @@ export const setPublicGateInternal = internalMutation({ }, }); +export const beginDeactivationInternal = internalMutation({ + args: { + actor: v.string(), + reason: v.string(), + confirm: v.string(), + lockToken: v.string(), + }, + handler: async (ctx, args) => { + const environment = assertSkillsShPublicVisibilityMutationAllowed({ + activate: false, + confirm: args.confirm, + }); + const actor = args.actor.trim(); + const reason = args.reason.trim(); + const lockToken = args.lockToken.trim(); + if (!actor || !reason || !lockToken) { + throw new Error("skills.sh deactivation actor, reason, and lock token are required"); + } + const now = Date.now(); + const mirrorControl = await ctx.db + .query("skillsShMirrorControls") + .withIndex("by_key", (q) => q.eq("key", CONTROL_KEY)) + .unique(); + await writePublicGate(ctx, { enabled: false, actor, reason, now }); + if (mirrorControl) { + await ctx.db.patch(mirrorControl._id, { + activationLockToken: lockToken, + activationLockedAt: now, + activationLeaderboardRunId: undefined, + activationTrendingRunId: undefined, + updatedBy: actor, + reason, + updatedAt: now, + }); + } else { + await ctx.db.insert("skillsShMirrorControls", { + key: CONTROL_KEY, + ...disabledMirrorControl({ actor, reason, now, lockToken }), + }); + } + return { + ok: true as const, + environment, + enabled: false as const, + updatedAt: now, + scansPlanned: 0 as const, + scansAdmitted: 0 as const, + }; + }, +}); + +export const beginNativeTrendingInternal = internalMutation({ + args: { + actor: v.string(), + reason: v.string(), + confirm: v.string(), + lockToken: v.string(), + }, + handler: async (ctx, args) => { + const environment = assertSkillsShPublicVisibilityMutationAllowed({ + activate: false, + confirm: args.confirm, + }); + const actor = args.actor.trim(); + const reason = args.reason.trim(); + const lockToken = args.lockToken.trim(); + if (!actor || !reason || !lockToken) { + throw new Error("skills.sh native Trending actor, reason, and lock token are required"); + } + const now = Date.now(); + const [catalogControl, mirrorControl] = await Promise.all([ + ctx.db + .query("skillsShCatalogControls") + .withIndex("by_key", (q) => q.eq("key", CONTROL_KEY)) + .unique(), + ctx.db + .query("skillsShMirrorControls") + .withIndex("by_key", (q) => q.eq("key", CONTROL_KEY)) + .unique(), + ]); + if (catalogControl?.mirrorPublicVisibilityEnabled === true) { + throw new Error("skills.sh native Trending preflight requires a closed public gate"); + } + if ( + mirrorControl?.activationLockToken && + mirrorControl.activationLockToken !== lockToken && + mirrorControl.activationLockedAt !== undefined && + mirrorControl.activationLockedAt > now - ACTIVATION_LOCK_LEASE_MS + ) { + throw new Error("another skills.sh public activation is in progress"); + } + if (mirrorControl) { + await ctx.db.patch(mirrorControl._id, { + activationLockToken: lockToken, + activationLockedAt: now, + activationLeaderboardRunId: undefined, + activationTrendingRunId: undefined, + updatedBy: actor, + reason, + updatedAt: now, + }); + } else { + await ctx.db.insert("skillsShMirrorControls", { + key: CONTROL_KEY, + ...disabledMirrorControl({ actor, reason, now, lockToken }), + }); + } + return { environment, lockToken }; + }, +}); + +async function materializeNativeTrending(ctx: Pick, lockToken: string) { + const nativeTrending = (await ctx.runAction( + internalRefs.canonicalTrending.materializeInternal as never, + { activationLockToken: lockToken, skillsShMode: "native-only" } as never, + )) as { + status: "ready" | "unavailable"; + snapshotId?: string; + sourceCounts?: { clawhubTrending: number; clawhubRising: number; skillsShTrending: number }; + }; + if (nativeTrending.status !== "ready" || nativeTrending.sourceCounts?.skillsShTrending !== 0) { + throw new Error("native-only canonical Trending did not become ready"); + } + return nativeTrending; +} + +async function materializeNativeTrendingWithLock( + ctx: Pick, + args: { actor: string; reason: string; confirm: string }, +) { + const lockToken = `skills-sh-native-trending:${crypto.randomUUID()}`; + const locked = (await ctx.runMutation( + internalRefs.skillsShMirrorVisibility.beginNativeTrendingInternal as never, + { ...args, lockToken } as never, + )) as { environment: "test" | "production" }; + try { + return { + environment: locked.environment, + nativeTrending: await materializeNativeTrending(ctx, lockToken), + }; + } finally { + await ctx.runMutation( + internalRefs.skillsShMirrorVisibility.releaseActivationInternal as never, + { lockToken } as never, + ); + } +} + +export const prepareNativeTrendingInternal = internalAction({ + args: { + actor: v.string(), + reason: v.string(), + confirm: v.string(), + }, + handler: async (ctx, args) => { + const { environment, nativeTrending } = await materializeNativeTrendingWithLock(ctx, args); + return { + ok: true as const, + environment, + nativeTrending, + scansPlanned: 0 as const, + scansAdmitted: 0 as const, + }; + }, +}); + +export const deactivateAndMaterializeInternal = internalAction({ + args: { + actor: v.string(), + reason: v.string(), + confirm: v.string(), + }, + handler: async (ctx, args) => { + const lockToken = `skills-sh-native-trending:${crypto.randomUUID()}`; + // Close the gate and replace any activation lock atomically so an + // in-flight or newly starting activation cannot reopen the lane. + const deactivation = (await ctx.runMutation( + internalRefs.skillsShMirrorVisibility.beginDeactivationInternal as never, + { ...args, lockToken } as never, + )) as { + ok: true; + environment: "test" | "production"; + enabled: false; + updatedAt: number; + scansPlanned: 0; + scansAdmitted: 0; + }; + try { + const nativeTrending = await materializeNativeTrending(ctx, lockToken); + return { ...deactivation, nativeTrending }; + } finally { + await ctx.runMutation( + internalRefs.skillsShMirrorVisibility.releaseActivationInternal as never, + { lockToken } as never, + ); + } + }, +}); + export const beginActivationInternal = internalMutation({ args: { actor: v.string(), diff --git a/scripts/skills-sh-catalog/sync.test.ts b/scripts/skills-sh-catalog/sync.test.ts index 1558b47c..dbd7ee69 100644 --- a/scripts/skills-sh-catalog/sync.test.ts +++ b/scripts/skills-sh-catalog/sync.test.ts @@ -45,6 +45,17 @@ function completedRun(sourceView: "leaderboard" | "trending", scansPlanned = 0) }; } +function nativeTrendingPreparation() { + return response({ + ok: true, + nativeTrending: { + status: "ready", + snapshotId: "skills-native-before-import", + sourceCounts: { clawhubTrending: 1, clawhubRising: 1, skillsShTrending: 0 }, + }, + }); +} + describe("skills.sh synchronization runner", () => { it("refreshes GitHub OIDC authorization before the cached token expires", async () => { let now = 1_000_000; @@ -80,7 +91,9 @@ describe("skills.sh synchronization runner", () => { operations.push(String(body.operation)); switch (body.operation) { case "status": - return response({ runs: [] }); + return response({ runs: [], invariants: { publicVisible: false } }); + case "prepare-native-trending": + return nativeTrendingPreparation(); case "configure": return response({ ok: true, enabled: body.enabled }); case "start": @@ -121,6 +134,12 @@ describe("skills.sh synchronization runner", () => { }), ).resolves.toMatchObject({ ok: true, + nativeBefore: { + nativeTrending: { + status: "ready", + sourceCounts: { skillsShTrending: 0 }, + }, + }, leaderboard: { status: "completed" }, trending: { status: "completed" }, activation: { activated: true }, @@ -130,6 +149,7 @@ describe("skills.sh synchronization runner", () => { expect(operations).toEqual([ "status", "configure", + "prepare-native-trending", "start", "step", "start-trending", @@ -163,8 +183,11 @@ describe("skills.sh synchronization runner", () => { startedAt: 1, }, ], + invariants: { publicVisible: false }, }) - : response({ runs: [] }); + : response({ runs: [], invariants: { publicVisible: true } }); + case "prepare-native-trending": + return nativeTrendingPreparation(); case "configure": return response({ ok: true, enabled: body.enabled }); case "step": @@ -193,6 +216,7 @@ describe("skills.sh synchronization runner", () => { expect(operations).toEqual([ "status", "configure", + "prepare-native-trending", "step", "start-trending", "verify-activate", @@ -224,7 +248,9 @@ describe("skills.sh synchronization runner", () => { ); switch (body.operation) { case "status": - return response({ runs: [] }); + return response({ runs: [], invariants: { publicVisible: false } }); + case "prepare-native-trending": + return nativeTrendingPreparation(); case "configure": return response({ ok: true, enabled: body.enabled }); case "start": @@ -273,6 +299,7 @@ describe("skills.sh synchronization runner", () => { expect(requests).toEqual([ "status", "configure", + "prepare-native-trending", "start", "step:9:50", "step:9:50", @@ -303,7 +330,9 @@ describe("skills.sh synchronization runner", () => { const body = JSON.parse(String(init.body)) as Record; switch (body.operation) { case "status": - return response({ runs: [] }); + return response({ runs: [], invariants: { publicVisible: false } }); + case "prepare-native-trending": + return nativeTrendingPreparation(); case "configure": return response({ ok: true, enabled: body.enabled }); case "start": @@ -346,7 +375,10 @@ describe("skills.sh synchronization runner", () => { const fetchImpl = vi.fn(async (_url: string, init: RequestInit) => { const body = JSON.parse(String(init.body)) as Record; operations.push(String(body.operation)); - if (body.operation === "status") return response({ runs: [] }); + if (body.operation === "status") { + return response({ runs: [], invariants: { publicVisible: false } }); + } + if (body.operation === "prepare-native-trending") return nativeTrendingPreparation(); if (body.operation === "configure") return response({ ok: true }); if (body.operation === "start") { return response({ @@ -371,7 +403,15 @@ describe("skills.sh synchronization runner", () => { fetchImpl, }), ).rejects.toThrow("scheduled a ClawHub scan"); - expect(operations).toEqual(["status", "configure", "start", "step", "deactivate", "configure"]); + expect(operations).toEqual([ + "status", + "configure", + "prepare-native-trending", + "start", + "step", + "deactivate", + "configure", + ]); }); it("closes the skills.sh lane when server-side activation verification fails", async () => { @@ -379,7 +419,10 @@ describe("skills.sh synchronization runner", () => { const fetchImpl = vi.fn(async (_url: string, init: RequestInit) => { const body = JSON.parse(String(init.body)) as Record; operations.push(String(body.operation)); - if (body.operation === "status") return response({ runs: [] }); + if (body.operation === "status") { + return response({ runs: [], invariants: { publicVisible: false } }); + } + if (body.operation === "prepare-native-trending") return nativeTrendingPreparation(); if (body.operation === "configure") return response({ ok: true }); if (body.operation === "start") return response(completedRun("leaderboard")); if (body.operation === "start-trending") return response(completedRun("trending")); @@ -401,6 +444,7 @@ describe("skills.sh synchronization runner", () => { expect(operations).toEqual([ "status", "configure", + "prepare-native-trending", "start", "start-trending", "verify-activate", @@ -414,7 +458,9 @@ describe("skills.sh synchronization runner", () => { const fetchImpl = vi.fn(async (_url: string, init: RequestInit) => { const body = JSON.parse(String(init.body)) as Record; operations.push(String(body.operation)); - if (body.operation === "status") return response({ runs: [] }); + if (body.operation === "status") { + return response({ runs: [], invariants: { publicVisible: true } }); + } if (body.operation === "configure") return response({ ok: true }); if (body.operation === "start") return response({ error: "upstream unavailable" }, 503); throw new Error(`unexpected operation ${String(body.operation)}`); diff --git a/scripts/skills-sh-catalog/sync.ts b/scripts/skills-sh-catalog/sync.ts index b27133ff..c148cb20 100644 --- a/scripts/skills-sh-catalog/sync.ts +++ b/scripts/skills-sh-catalog/sync.ts @@ -266,11 +266,29 @@ export async function runSkillsShSync(options: { const startedAt = Date.now(); const before = await call({ operation: "status" }); + const publicVisible = (before.invariants as Record | undefined)?.publicVisible; + if (typeof publicVisible !== "boolean") { + throw new Error("skills.sh mirror status is missing the public visibility invariant"); + } const recoverable = findRecoverableMirrorRun(before) as | (MirrorRun & { sourceView?: "leaderboard" | "trending" }) | null; + let nativeBefore: Record | null = null; await call({ operation: "configure", enabled: true, reason: options.reason }); try { + nativeBefore = publicVisible + ? null + : await call({ + operation: "prepare-native-trending", + reason: `${options.reason} native-only preflight`, + }); + if (nativeBefore) { + const nativeTrending = nativeBefore.nativeTrending as Record | undefined; + const sourceCounts = nativeTrending?.sourceCounts as Record | undefined; + if (nativeTrending?.status !== "ready" || sourceCounts?.skillsShTrending !== 0) { + throw new Error("native-only canonical Trending preflight did not become ready"); + } + } const recoveredSourceView = recoverable?.sourceView ?? "leaderboard"; const recovered = recoverable ? await completeRun(recoverable, recoveredSourceView) : null; const leaderboard = @@ -298,6 +316,7 @@ export async function runSkillsShSync(options: { completedAt: new Date().toISOString(), durationMs: Date.now() - startedAt, before, + ...(nativeBefore ? { nativeBefore } : {}), ...(recovered ? { recovered } : {}), leaderboard, trending, diff --git a/server/routes/ops/skills-sh/mirror-test.post.ts b/server/routes/ops/skills-sh/mirror-test.post.ts index e732e8aa..80b39ad4 100644 --- a/server/routes/ops/skills-sh/mirror-test.post.ts +++ b/server/routes/ops/skills-sh/mirror-test.post.ts @@ -49,6 +49,7 @@ type MirrorRequest = { operation?: | "configure" | "verify-activate" + | "prepare-native-trending" | "deactivate" | "start" | "start-trending" @@ -295,11 +296,19 @@ export function createSkillsShMirrorRoute(target: keyof typeof ROUTE_CONFIG) { }), ); } + if (operation === "prepare-native-trending") { + return jsonResponse( + await callConvexOperator(authorization, { + operation: "mirror-prepare-native-trending", + reason: requireString(body.reason, "reason"), + confirm: config.deactivateConfirm, + }), + ); + } if (operation === "deactivate") { return jsonResponse( await callConvexOperator(authorization, { - operation: "mirror-public-gate", - enabled: false, + operation: "mirror-deactivate-native-trending", reason: requireString(body.reason, "reason"), confirm: config.deactivateConfirm, }), diff --git a/server/skillsShMirrorProductionRoute.test.ts b/server/skillsShMirrorProductionRoute.test.ts index ede2d337..744c96dd 100644 --- a/server/skillsShMirrorProductionRoute.test.ts +++ b/server/skillsShMirrorProductionRoute.test.ts @@ -197,6 +197,12 @@ describe("skills.sh production mirror route", () => { vi.stubGlobal("fetch", convexFetch); const handler = (await import("./routes/ops/skills-sh/mirror.post")).default; + readBodyMock.mockResolvedValueOnce({ + operation: "prepare-native-trending", + reason: "native-only production preflight", + }); + expect(((await handler({} as never)) as Response).status).toBe(200); + readBodyMock.mockResolvedValueOnce({ operation: "verify-activate", reason: "verified initial production import", @@ -210,14 +216,18 @@ describe("skills.sh production mirror route", () => { expect(((await handler({} as never)) as Response).status).toBe(200); expect(forwarded).toEqual([ + { + operation: "mirror-prepare-native-trending", + reason: "native-only production preflight", + confirm: "deactivate-skills-sh-public-production", + }, { operation: "mirror-verify-activate", reason: "verified initial production import", confirm: "activate-skills-sh-public-production", }, { - operation: "mirror-public-gate", - enabled: false, + operation: "mirror-deactivate-native-trending", reason: "systemic production rollback", confirm: "deactivate-skills-sh-public-production", },