From 5a1d2fe7bccf1e8414482b4df475be02ad71931a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 30 Jul 2026 18:19:59 +0800 Subject: [PATCH] fix: repair stale package latest pointers (#3303) --- convex/lib/packageArtifacts.ts | 38 +++++++ convex/maintenance.test.ts | 174 +++++++++++++++++++++++++++++++++ convex/maintenance.ts | 166 +++++++++++++++++++++++++++++++ convex/packages.ts | 48 ++------- 4 files changed, 386 insertions(+), 40 deletions(-) diff --git a/convex/lib/packageArtifacts.ts b/convex/lib/packageArtifacts.ts index aa7e89df..c2be59c5 100644 --- a/convex/lib/packageArtifacts.ts +++ b/convex/lib/packageArtifacts.ts @@ -1,3 +1,4 @@ +import type { PackageArtifactSummary } from "clawhub-schema"; import type { Doc } from "../_generated/dataModel"; type PackageReleaseArtifactHashFields = Pick< @@ -5,6 +6,20 @@ type PackageReleaseArtifactHashFields = Pick< "artifactKind" | "clawpackSha256" | "sha256hash" >; +type PackageReleaseArtifactSummaryFields = Pick< + Doc<"packageReleases">, + | "artifactKind" + | "clawpackSha256" + | "sha256hash" + | "clawpackSize" + | "clawpackFormat" + | "npmIntegrity" + | "npmShasum" + | "npmTarballName" + | "npmUnpackedSize" + | "npmFileCount" +>; + export function getPackageReleaseArtifactSha256( release: PackageReleaseArtifactHashFields, ): string | null { @@ -13,3 +28,26 @@ export function getPackageReleaseArtifactSha256( } return release.sha256hash ?? null; } + +export function summarizePackageReleaseArtifact( + release: PackageReleaseArtifactSummaryFields, +): PackageArtifactSummary { + if (release.artifactKind === "npm-pack") { + return { + kind: "npm-pack", + sha256: getPackageReleaseArtifactSha256(release) ?? undefined, + size: release.clawpackSize, + format: release.clawpackFormat ?? "tgz", + npmIntegrity: release.npmIntegrity, + npmShasum: release.npmShasum, + npmTarballName: release.npmTarballName, + npmUnpackedSize: release.npmUnpackedSize, + npmFileCount: release.npmFileCount, + }; + } + return { + kind: "legacy-zip", + sha256: getPackageReleaseArtifactSha256(release) ?? undefined, + format: "zip", + }; +} diff --git a/convex/maintenance.test.ts b/convex/maintenance.test.ts index 50474a32..2f06714e 100644 --- a/convex/maintenance.test.ts +++ b/convex/maintenance.test.ts @@ -83,6 +83,7 @@ const { applySkillLineageCycleRepairInternalHandler, inspectSkillLineageCycleInternalHandler, nominateEmptySkillSpammersInternalHandler, + repairPackageLatestPointerHandler, repairLegacyPluginSkillSpectorBatchInternalHandler, repairLegacyPublisherOwnershipForUserHandler, repairSkillLineageCyclesInternalHandler, @@ -2006,3 +2007,176 @@ describe("maintenance empty skill nominations", () => { ]); }); }); + +function makePackageLatestPointerRepairDb() { + const pkg = { + _id: "packages:kitchen-sink", + name: "@openclaw/kitchen-sink", + normalizedName: "@openclaw/kitchen-sink", + family: "code-plugin", + tags: { latest: "packageReleases:old" }, + latestReleaseId: "packageReleases:old", + }; + const releases = [ + { + _id: "packageReleases:old", + packageId: pkg._id, + version: "0.2.11", + publicationStatus: "published", + changelog: "old", + summary: "old summary", + icon: "old-icon", + distTags: ["latest"], + compatibility: {}, + verification: {}, + runtimeId: "old-runtime", + sourceRepo: "https://github.com/openclaw/kitchen-sink", + artifactKind: "npm-pack", + clawpackSha256: "old-sha", + clawpackSize: 10, + clawpackFormat: "tgz", + createdAt: 100, + }, + { + _id: "packageReleases:new", + packageId: pkg._id, + version: "0.2.12", + publicationStatus: "published", + changelog: "new", + summary: "new summary", + icon: "new-icon", + distTags: [], + compatibility: {}, + verification: {}, + runtimeId: "new-runtime", + sourceRepo: "https://github.com/openclaw/kitchen-sink", + artifactKind: "npm-pack", + clawpackSha256: "new-sha", + clawpackSize: 20, + clawpackFormat: "tgz", + createdAt: 200, + }, + { + _id: "packageReleases:pending", + packageId: pkg._id, + version: "0.2.13", + publicationStatus: "pending", + changelog: "pending", + distTags: [], + compatibility: {}, + verification: {}, + artifactKind: "npm-pack", + clawpackSha256: "pending-sha", + createdAt: 300, + }, + ]; + const patch = vi.fn(); + const insert = vi.fn(); + const query = vi.fn((table: string) => ({ + withIndex: (_index: string, applyIndex: (q: QueryEq) => unknown) => { + const q = { eq: vi.fn() } as unknown as QueryEq; + vi.mocked(q.eq).mockReturnValue(q); + applyIndex(q); + if (table === "packages") { + return { unique: async () => pkg }; + } + if (table === "packageReleases") { + return { collect: async () => releases }; + } + throw new Error(`Unexpected table: ${table}`); + }, + })); + + return { db: { query, patch, insert }, pkg, releases, patch, insert }; +} + +describe("maintenance package latest pointer repair", () => { + it("dry-runs the canonical published release without writing", async () => { + const fixture = makePackageLatestPointerRepairDb(); + + const result = await repairPackageLatestPointerHandler(fixture as never, { + name: "@openclaw/kitchen-sink", + }); + + expect(result).toMatchObject({ + dryRun: true, + confirmRequired: "repair-package-latest-pointer-2026-07-30", + previousLatestVersion: "0.2.11", + selectedVersion: "0.2.12", + eligibleReleaseCount: 2, + releaseTagChanges: [ + { + releaseId: "packageReleases:old", + version: "0.2.11", + action: "remove", + }, + { + releaseId: "packageReleases:new", + version: "0.2.12", + action: "add", + }, + ], + }); + expect(fixture.patch).not.toHaveBeenCalled(); + expect(fixture.insert).not.toHaveBeenCalled(); + }); + + it("requires the exact confirmation token before applying", async () => { + const fixture = makePackageLatestPointerRepairDb(); + + await expect( + repairPackageLatestPointerHandler(fixture as never, { + name: "@openclaw/kitchen-sink", + dryRun: false, + confirm: "wrong", + }), + ).rejects.toThrow('Pass confirm="repair-package-latest-pointer-2026-07-30" to apply.'); + expect(fixture.patch).not.toHaveBeenCalled(); + }); + + it("repoints the package, normalizes release tags, and audits the repair", async () => { + const fixture = makePackageLatestPointerRepairDb(); + + const result = await repairPackageLatestPointerHandler(fixture as never, { + name: "@openclaw/kitchen-sink", + dryRun: false, + confirm: "repair-package-latest-pointer-2026-07-30", + }); + + expect(result.selectedVersion).toBe("0.2.12"); + expect(fixture.patch).toHaveBeenCalledWith("packageReleases:old", { distTags: [] }); + expect(fixture.patch).toHaveBeenCalledWith("packageReleases:new", { + distTags: ["latest"], + }); + expect(fixture.patch).toHaveBeenCalledWith( + fixture.pkg._id, + expect.objectContaining({ + tags: { latest: "packageReleases:new" }, + latestReleaseId: "packageReleases:new", + summary: "new summary", + icon: "new-icon", + runtimeId: "new-runtime", + scanStatus: "not-run", + latestVersionSummary: expect.objectContaining({ + version: "0.2.12", + artifact: expect.objectContaining({ + kind: "npm-pack", + sha256: "new-sha", + }), + }), + }), + ); + expect(fixture.insert).toHaveBeenCalledWith( + "auditLogs", + expect.objectContaining({ + action: "package.latest_pointer.repair", + targetType: "package", + targetId: fixture.pkg._id, + metadata: expect.objectContaining({ + previousLatestVersion: "0.2.11", + selectedVersion: "0.2.12", + }), + }), + ); + }); +}); diff --git a/convex/maintenance.ts b/convex/maintenance.ts index afa9961b..f0c24f21 100644 --- a/convex/maintenance.ts +++ b/convex/maintenance.ts @@ -1,4 +1,5 @@ import { ConvexError, v } from "convex/values"; +import semver from "semver"; import { internal } from "./_generated/api"; import type { Doc, Id } from "./_generated/dataModel"; import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server"; @@ -10,7 +11,10 @@ import { syncPackageSearchDigestForPackageId, } from "./functions"; import { assertRole, requireUserFromAction } from "./lib/access"; +import { summarizePackageReleaseArtifact } from "./lib/packageArtifacts"; +import { normalizePackageName } from "./lib/packageRegistry"; import { extractPackageDigestFields } from "./lib/packageSearchDigest"; +import { resolvePackageReleaseScanStatus } from "./lib/packageSecurity"; import { derivePersonalPublisherHandle, ensurePersonalPublisherForUser, @@ -46,6 +50,7 @@ const PUBLISHER_ABUSE_SIGNAL_SMOKE_OWNER_KEY = const PUBLISHER_ABUSE_SIGNAL_SMOKE_CONFIRM = "create-publisher-abuse-hermit-digest-smoke-2026-07-03" as const; const SKILL_LINEAGE_CYCLE_REPAIR_CONFIRM = "repair-skill-lineage-cycles-2026-07-23" as const; +const PACKAGE_LATEST_POINTER_REPAIR_CONFIRM = "repair-package-latest-pointer-2026-07-30" as const; const legacyPluginSkillSpectorRepairFamilyValidator = v.union( v.literal("code-plugin"), v.literal("bundle-plugin"), @@ -3273,6 +3278,167 @@ export const repairLegacyPublisherOwnershipForUser = internalMutation({ handler: repairLegacyPublisherOwnershipForUserHandler, }); +function isPublishedPackageRelease(release: Doc<"packageReleases">) { + return ( + !release.softDeletedAt && + release.ownerDeletedAt === undefined && + (release.publicationStatus === undefined || release.publicationStatus === "published") + ); +} + +function comparePackageLatestCandidates( + family: Doc<"packages">["family"], + a: Doc<"packageReleases">, + b: Doc<"packageReleases">, +) { + if (family === "bundle-plugin") { + if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt; + return a._id.localeCompare(b._id); + } + const aSemver = semver.valid(a.version); + const bSemver = semver.valid(b.version); + if (aSemver && bSemver) return semver.compare(aSemver, bSemver); + if (aSemver) return 1; + if (bSemver) return -1; + if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt; + return a._id.localeCompare(b._id); +} + +export async function repairPackageLatestPointerHandler( + ctx: Pick, + args: { + name: string; + dryRun?: boolean; + confirm?: string; + }, +) { + const dryRun = args.dryRun !== false; + if (!dryRun && args.confirm !== PACKAGE_LATEST_POINTER_REPAIR_CONFIRM) { + throw new ConvexError(`Pass confirm="${PACKAGE_LATEST_POINTER_REPAIR_CONFIRM}" to apply.`); + } + + const normalizedName = normalizePackageName(args.name); + const pkg = await ctx.db + .query("packages") + .withIndex("by_name", (q) => q.eq("normalizedName", normalizedName)) + .unique(); + if (!pkg || pkg.softDeletedAt) { + throw new ConvexError(`Active package not found: ${normalizedName}`); + } + + const releases = await ctx.db + .query("packageReleases") + .withIndex("by_package", (q) => q.eq("packageId", pkg._id)) + .collect(); + const eligibleReleases = releases.filter(isPublishedPackageRelease); + const selectedRelease = eligibleReleases.reduce | null>( + (best, release) => + !best || comparePackageLatestCandidates(pkg.family, best, release) < 0 ? release : best, + null, + ); + if (!selectedRelease) { + throw new ConvexError(`No published releases found for package: ${normalizedName}`); + } + + const releaseTagChanges = releases.flatMap((release) => { + const hadLatest = release.distTags.includes("latest"); + const shouldHaveLatest = release._id === selectedRelease._id; + return hadLatest === shouldHaveLatest + ? [] + : [ + { + releaseId: release._id, + version: release.version, + action: shouldHaveLatest ? ("add" as const) : ("remove" as const), + }, + ]; + }); + const previousLatestRelease = pkg.latestReleaseId + ? (releases.find((release) => release._id === pkg.latestReleaseId) ?? null) + : null; + + const result = { + dryRun, + confirmRequired: dryRun ? PACKAGE_LATEST_POINTER_REPAIR_CONFIRM : undefined, + packageId: pkg._id, + packageName: pkg.name, + normalizedName, + family: pkg.family, + releasesScanned: releases.length, + eligibleReleaseCount: eligibleReleases.length, + previousLatestReleaseId: pkg.latestReleaseId, + previousLatestVersion: previousLatestRelease?.version, + selectedReleaseId: selectedRelease._id, + selectedVersion: selectedRelease.version, + releaseTagChanges, + }; + if (dryRun) return result; + + for (const change of releaseTagChanges) { + const release = releases.find((candidate) => candidate._id === change.releaseId); + if (!release) continue; + await ctx.db.patch(release._id, { + distTags: + change.action === "add" + ? [...release.distTags, "latest"] + : release.distTags.filter((tag) => tag !== "latest"), + }); + } + + const now = Date.now(); + const packagePatch: Partial> = { + tags: { ...pkg.tags, latest: selectedRelease._id }, + latestReleaseId: selectedRelease._id, + latestVersionSummary: { + version: selectedRelease.version, + createdAt: selectedRelease.createdAt, + changelog: selectedRelease.changelog, + icon: selectedRelease.icon, + compatibility: selectedRelease.compatibility, + verification: selectedRelease.verification, + artifact: summarizePackageReleaseArtifact(selectedRelease), + }, + summary: selectedRelease.summary, + icon: selectedRelease.icon, + sourceRepo: selectedRelease.sourceRepo ?? selectedRelease.verification?.sourceRepo, + runtimeId: selectedRelease.runtimeId, + compatibility: selectedRelease.compatibility, + verification: selectedRelease.verification, + scanStatus: resolvePackageReleaseScanStatus(selectedRelease), + updatedAt: now, + }; + // The wrapped internal mutation DB runs the package trigger in functions.ts, + // which refreshes package search digests from this patched document. + await ctx.db.patch(pkg._id, packagePatch); + await ctx.db.insert("auditLogs", { + action: "package.latest_pointer.repair", + targetType: "package", + targetId: pkg._id, + metadata: { + normalizedName, + previousLatestReleaseId: pkg.latestReleaseId, + previousLatestVersion: previousLatestRelease?.version, + selectedReleaseId: selectedRelease._id, + selectedVersion: selectedRelease.version, + releaseTagChanges, + }, + createdAt: now, + }); + + return result; +} + +// One-off production repair. Run dry first, then pass the exact confirmation token. +// npx convex run maintenance:repairPackageLatestPointer '{"name":"@scope/package"}' --prod +export const repairPackageLatestPointer = internalMutation({ + args: { + name: v.string(), + dryRun: v.optional(v.boolean()), + confirm: v.optional(v.string()), + }, + handler: repairPackageLatestPointerHandler, +}); + function clampInt(value: number, min: number, max: number) { const rounded = Math.trunc(value); if (!Number.isFinite(rounded)) return min; diff --git a/convex/packages.ts b/convex/packages.ts index 0463a5a2..12561f6a 100644 --- a/convex/packages.ts +++ b/convex/packages.ts @@ -68,7 +68,10 @@ import { normalizeGitHubRepository } from "./lib/githubActionsOidc"; import { readGlobalPublicPluginsCount } from "./lib/globalStats"; import { toDayKey } from "./lib/leaderboards"; import { isOfficialPublisher } from "./lib/officialPublishers"; -import { getPackageReleaseArtifactSha256 } from "./lib/packageArtifacts"; +import { + getPackageReleaseArtifactSha256, + summarizePackageReleaseArtifact, +} from "./lib/packageArtifacts"; import { assertPackageVersion, derivePluginManifestSummary, @@ -1216,7 +1219,7 @@ function toPublicPackage( latestRelease === undefined ? pkg.latestVersionSummary?.artifact : isPublishedPackageRelease(latestRelease) - ? packageArtifactSummary(latestRelease) + ? summarizePackageReleaseArtifact(latestRelease) : undefined, clawManifestSummary: pkg.family === "claw" && isPublishedPackageRelease(latestRelease) @@ -1345,41 +1348,6 @@ async function paginatePublishedPackageReleases( return { page, isDone, continueCursor: isDone ? "" : continueCursor }; } -function packageArtifactSummary( - release: Pick< - Doc<"packageReleases">, - | "artifactKind" - | "clawpackSha256" - | "sha256hash" - | "clawpackSize" - | "clawpackFormat" - | "npmIntegrity" - | "npmShasum" - | "npmTarballName" - | "npmUnpackedSize" - | "npmFileCount" - >, -): PackageArtifactSummary { - if (release.artifactKind === "npm-pack") { - return { - kind: "npm-pack", - sha256: getPackageReleaseArtifactSha256(release) ?? undefined, - size: release.clawpackSize, - format: release.clawpackFormat ?? "tgz", - npmIntegrity: release.npmIntegrity, - npmShasum: release.npmShasum, - npmTarballName: release.npmTarballName, - npmUnpackedSize: release.npmUnpackedSize, - npmFileCount: release.npmFileCount, - }; - } - return { - kind: "legacy-zip", - sha256: getPackageReleaseArtifactSha256(release) ?? undefined, - format: "zip", - }; -} - function digestMatchesFilters( digest: PackageDigestLike, args: { @@ -5772,7 +5740,7 @@ function packageLatestSummaryFromRelease(release: Doc<"packageReleases"> | null) icon: release.icon, compatibility: release.compatibility, verification: release.verification, - artifact: packageArtifactSummary(release), + artifact: summarizePackageReleaseArtifact(release), } : undefined; } @@ -5873,7 +5841,7 @@ async function restorePackageDoc( icon: nextLatest.icon, compatibility: nextLatest.compatibility, verification: nextLatest.verification, - artifact: packageArtifactSummary(nextLatest), + artifact: summarizePackageReleaseArtifact(nextLatest), } : undefined, summary: nextLatest?.summary, @@ -10921,7 +10889,7 @@ export const insertReleaseInternal = internalMutation({ icon: args.icon, compatibility: args.compatibility, verification: releaseVerification, - artifact: packageArtifactSummary(args), + artifact: summarizePackageReleaseArtifact(args), } : pkg.latestVersionSummary, tags: nextTags,