fix: remove registry backup index writes

This commit is contained in:
Patrick Erichsen
2026-06-15 20:36:39 -07:00
committed by GitHub
parent 1b66b84e58
commit 6feaa0974a
9 changed files with 241 additions and 1623 deletions
+33
View File
@@ -405,6 +405,7 @@ describe("httpApiV1 handlers", () => {
body: JSON.stringify({
handle: "Target",
slugs: ["a", "b"],
versionsBySlug: { a: "1.0.0", b: "1.1.0" },
forceOverwriteSquatter: true,
}),
}),
@@ -415,10 +416,42 @@ describe("httpApiV1 handlers", () => {
ownerHandle: "target",
ownerUserId: "users:target",
slugs: ["a", "b"],
versionsBySlug: { a: "1.0.0", b: "1.1.0" },
forceOverwriteSquatter: true,
});
});
it("users/restore requires a backup version for every slug", async () => {
const runAction = vi.fn();
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
return { ok: true };
});
const runQuery = vi.fn();
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
const response = await __handlers.usersPostRouterV1Handler(
makeCtx({ runQuery, runAction, runMutation }),
new Request("https://example.com/api/v1/users/restore", {
method: "POST",
body: JSON.stringify({
handle: "Target",
slugs: ["a", "b"],
versionsBySlug: { a: "1.0.0" },
forceOverwriteSquatter: true,
}),
}),
);
expect(response.status).toBe(400);
expect(await response.text()).toBe("Missing backup version for slug b");
expect(runQuery).not.toHaveBeenCalled();
expect(runAction).not.toHaveBeenCalled();
});
it("skills export allows authenticated non-admin users at the key rate limit", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:actor",
+16 -1
View File
@@ -557,7 +557,7 @@ export async function usersGetRouterV1Handler(ctx: ActionCtx, request: Request)
/**
* POST /api/v1/users/restore
* Admin-only: restore skills from registry artifact backup for a user.
* Body: { handle: string, slugs: string[], forceOverwriteSquatter?: boolean }
* Body: { handle: string, slugs: string[], versionsBySlug: Record<string, string>, forceOverwriteSquatter?: boolean }
*/
async function handleAdminRestore(
ctx: ActionCtx,
@@ -575,6 +575,20 @@ async function handleAdminRestore(
if (slugs.length === 0) return text("Missing slugs array", 400, headers);
if (slugs.length > 100) return text("Too many slugs (max 100)", 400, headers);
const versionsBySlug =
payload.versionsBySlug && typeof payload.versionsBySlug === "object"
? Object.fromEntries(
Object.entries(payload.versionsBySlug).filter(
(entry): entry is [string, string] =>
typeof entry[0] === "string" && typeof entry[1] === "string",
),
)
: undefined;
if (!versionsBySlug) return text("Missing versionsBySlug", 400, headers);
const missingVersionSlug = slugs.find((slug) => !versionsBySlug[slug]?.trim());
if (missingVersionSlug) {
return text(`Missing backup version for slug ${missingVersionSlug}`, 400, headers);
}
const forceOverwriteSquatter = Boolean(payload.forceOverwriteSquatter);
const targetUser = await ctx.runQuery(api.users.getByHandle, { handle });
@@ -588,6 +602,7 @@ async function handleAdminRestore(
ownerHandle: handle,
ownerUserId: targetUser._id,
slugs,
versionsBySlug,
forceOverwriteSquatter,
},
);
+3 -338
View File
@@ -6,7 +6,6 @@ import {
backupSkillVersionToObjectStorage,
buildPackageReleaseBackupManifest,
buildSkillVersionBackupManifest,
fetchSkillBackupIndex,
getRegistryArtifactBackupSettings,
readRegistryArtifactBackupObject,
} from "./registryArtifactBackup";
@@ -79,7 +78,6 @@ describe("registry artifact backup settings", () => {
expect(manifest).toMatchObject({
skillRoot: "skills/openclaw-team/demo-skill",
versionRoot: "skills/openclaw-team/demo-skill/1%2E2%2E3",
indexPath: "skills/openclaw-team/demo-skill/_index.json",
metaPath: "skills/openclaw-team/demo-skill/1%2E2%2E3/_meta.json",
fileObjects: [
{
@@ -153,7 +151,6 @@ describe("registry artifact backup settings", () => {
artifactPath:
"packages/openclaw-team/%40openclaw%2Fdemo-plugin/1%2E2%2E3/demo-plugin-1.2.3.tgz",
metaPath: "packages/openclaw-team/%40openclaw%2Fdemo-plugin/1%2E2%2E3/_meta.json",
indexPath: "packages/openclaw-team/%40openclaw%2Fdemo-plugin/_index.json",
meta: {
kind: "packageRelease",
restore: {
@@ -195,181 +192,6 @@ describe("registry artifact backup settings", () => {
).toThrow("Invalid package backup artifact filename");
});
it("keeps skill index latest pointers on the greatest semver version without an explicit latest", () => {
const backport = buildSkillVersionBackupManifest({
root: "skills",
ownerHandle: "OpenClaw Team",
versionId: "skillVersions:demo-1" as Id<"skillVersions">,
slug: "demo-skill",
displayName: "Demo Skill",
version: "1.0.0",
publishedAt: 1_900_000_000_000,
files: [],
});
const index = __registryArtifactBackupTestInternals.buildSkillIndexFile(backport, {
kind: "skill",
owner: "openclaw-team",
slug: "demo-skill",
displayName: "Demo Skill",
latest: {
version: "2.0.0",
publishedAt: 1_800_000_000_000,
versionId: "skillVersions:demo-2" as Id<"skillVersions">,
path: "skills/openclaw-team/demo-skill/2%2E0%2E0/_meta.json",
},
versions: [],
});
expect(index.latest.version).toBe("2.0.0");
expect(index.versions.map((version) => version.version)).toEqual(["2.0.0", "1.0.0"]);
});
it("preserves explicit skill latest pointers after a rollback", () => {
const rolledBackLatest = buildSkillVersionBackupManifest({
root: "skills",
ownerHandle: "OpenClaw Team",
versionId: "skillVersions:demo-1" as Id<"skillVersions">,
slug: "demo-skill",
displayName: "Demo Skill",
version: "1.0.0",
isLatest: true,
publishedAt: 1_700_000_000_000,
files: [],
});
const index = __registryArtifactBackupTestInternals.buildSkillIndexFile(rolledBackLatest, {
kind: "skill",
owner: "openclaw-team",
slug: "demo-skill",
displayName: "Demo Skill",
latest: {
version: "2.0.0",
isLatest: true,
publishedAt: 1_800_000_000_000,
versionId: "skillVersions:demo-2" as Id<"skillVersions">,
path: "skills/openclaw-team/demo-skill/2%2E0%2E0/_meta.json",
},
versions: [],
});
expect(index.latest).toMatchObject({
version: "1.0.0",
isLatest: true,
versionId: "skillVersions:demo-1",
});
expect(index.versions.find((version) => version.version === "2.0.0")?.isLatest).toBe(false);
});
it("keeps package index latest pointers on explicit latest release markers", () => {
const backport = buildPackageReleaseBackupManifest({
root: "packages",
ownerHandle: "OpenClaw Team",
packageId: "packages:demo" as Id<"packages">,
releaseId: "packageReleases:demo-1" as Id<"packageReleases">,
packageName: "@openclaw/demo-plugin",
normalizedName: "@openclaw/demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
version: "1.0.0",
isLatest: false,
publishedAt: 1_900_000_000_000,
artifactKind: "npm-pack",
artifactSha256: "sha256:artifact",
artifactSize: 42,
artifactFormat: "tgz",
files: [],
});
const index = __registryArtifactBackupTestInternals.buildPackageIndexFile(backport, {
kind: "package",
owner: "openclaw-team",
packageName: "@openclaw/demo-plugin",
normalizedName: "@openclaw/demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
latest: {
version: "2.0.0",
isLatest: true,
publishedAt: 1_800_000_000_000,
packageId: "packages:demo" as Id<"packages">,
releaseId: "packageReleases:demo-2" as Id<"packageReleases">,
path: "packages/openclaw-team/%40openclaw%2Fdemo-plugin/2%2E0%2E0/_meta.json",
},
versions: [],
});
expect(index.latest).toMatchObject({
version: "2.0.0",
isLatest: true,
releaseId: "packageReleases:demo-2",
});
});
it("keeps full version catalogs in skill and package indexes", () => {
const skillBackup = buildSkillVersionBackupManifest({
root: "skills",
ownerHandle: "OpenClaw Team",
versionId: "skillVersions:demo-new" as Id<"skillVersions">,
slug: "demo-skill",
displayName: "Demo Skill",
version: "1001.0.0",
publishedAt: 1_800_000_001_000,
files: [],
});
const packageBackup = buildPackageReleaseBackupManifest({
root: "packages",
ownerHandle: "OpenClaw Team",
packageId: "packages:demo" as Id<"packages">,
releaseId: "packageReleases:demo-new" as Id<"packageReleases">,
packageName: "@openclaw/demo-plugin",
normalizedName: "@openclaw/demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
version: "1001.0.0",
publishedAt: 1_800_000_001_000,
files: [],
});
const existingSkillVersions = Array.from({ length: 1001 }, (_, index) => ({
version: `${index}.0.0`,
publishedAt: 1_800_000_000_000 - index,
versionId: `skillVersions:demo-${index}` as Id<"skillVersions">,
path: `skills/openclaw-team/demo-skill/${index}%2E0%2E0/_meta.json`,
}));
const existingPackageVersions = Array.from({ length: 1001 }, (_, index) => ({
version: `${index}.0.0`,
publishedAt: 1_800_000_000_000 - index,
packageId: "packages:demo" as Id<"packages">,
releaseId: `packageReleases:demo-${index}` as Id<"packageReleases">,
path: `packages/openclaw-team/%40openclaw%2Fdemo-plugin/${index}%2E0%2E0/_meta.json`,
}));
const skillIndex = __registryArtifactBackupTestInternals.buildSkillIndexFile(skillBackup, {
kind: "skill",
owner: "openclaw-team",
slug: "demo-skill",
displayName: "Demo Skill",
latest: existingSkillVersions[0]!,
versions: existingSkillVersions,
});
const packageIndex = __registryArtifactBackupTestInternals.buildPackageIndexFile(
packageBackup,
{
kind: "package",
owner: "openclaw-team",
packageName: "@openclaw/demo-plugin",
normalizedName: "@openclaw/demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
latest: existingPackageVersions[0]!,
versions: existingPackageVersions,
},
);
expect(skillIndex.versions).toHaveLength(1002);
expect(packageIndex.versions).toHaveLength(1002);
});
it("uses lossless path encoding to avoid package and version collisions", () => {
expect(__registryArtifactBackupTestInternals.encodeBackupPathSegment("@openclaw/demo")).toBe(
"%40openclaw%2Fdemo",
@@ -421,28 +243,11 @@ describe("registry artifact backup settings", () => {
]);
});
it("reads skill indexes and object bytes from object storage", async () => {
it("reads object bytes from object storage", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (url: URL | string, init?: RequestInit) => {
const key = objectKey(String(url));
if (init?.method === "GET" && key === "skills/openclaw-team/demo-skill/_index.json") {
return response(
200,
JSON.stringify({
kind: "skill",
owner: "openclaw-team",
slug: "demo-skill",
displayName: "Demo Skill",
latest: {
version: "1.2.3",
publishedAt: 1_700_000_000_000,
path: "skills/openclaw-team/demo-skill/1%2E2%2E3/_meta.json",
},
versions: [],
}),
);
}
if (
init?.method === "GET" &&
key === "skills/openclaw-team/demo-skill/1%2E2%2E3/SKILL.md"
@@ -453,17 +258,15 @@ describe("registry artifact backup settings", () => {
}),
);
const index = await fetchSkillBackupIndex(makeContext(), "OpenClaw Team", "demo-skill");
const bytes = await readRegistryArtifactBackupObject(
makeContext(),
"skills/openclaw-team/demo-skill/1%2E2%2E3/SKILL.md",
);
expect(index?.latest.version).toBe("1.2.3");
expect(Buffer.from(bytes!).toString("utf8")).toBe("hello skill");
});
it("writes skill files, version metadata, and the skill index to object storage", async () => {
it("writes skill files and version metadata to object storage", async () => {
const calls: Array<{ method: string; url: string; body: string }> = [];
vi.stubGlobal(
"fetch",
@@ -471,7 +274,6 @@ describe("registry artifact backup settings", () => {
const method = init?.method ?? "GET";
const body = await requestBodyText(init?.body);
calls.push({ method, url: String(url), body });
if (method === "GET") return response(404, "");
return response(200, "");
}),
);
@@ -502,141 +304,15 @@ describe("registry artifact backup settings", () => {
expect(calls.map((call) => [call.method, objectKey(call.url)])).toEqual([
["PUT", "skills/openclaw-team/demo-skill/1%2E2%2E3/SKILL.md"],
["PUT", "skills/openclaw-team/demo-skill/1%2E2%2E3/_meta.json"],
["GET", "skills/openclaw-team/demo-skill/_index.json"],
["PUT", "skills/openclaw-team/demo-skill/_index.json"],
]);
expect(JSON.parse(calls[1].body)).toMatchObject({
kind: "skillVersion",
version: "1.2.3",
metadata: { files: [{ path: "SKILL.md", sha256: "sha256:skill" }] },
});
expect(JSON.parse(calls[3].body)).toMatchObject({
kind: "skill",
latest: { version: "1.2.3" },
versions: [{ version: "1.2.3" }],
});
});
it("retries skill index writes when another backup updates the index first", async () => {
const calls: Array<{ method: string; url: string; body: string; ifMatch?: string }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: URL | string, init?: RequestInit) => {
const method = init?.method ?? "GET";
const key = objectKey(String(url));
const body = await requestBodyText(init?.body);
calls.push({
method,
url: String(url),
body,
ifMatch: headerValue(init?.headers, "if-match"),
});
if (method === "GET" && key === "skills/openclaw-team/demo-skill/_index.json") {
const indexGetCount = calls.filter(
(call) =>
call.method === "GET" &&
objectKey(call.url) === "skills/openclaw-team/demo-skill/_index.json",
).length;
if (indexGetCount === 1) {
return response(
200,
JSON.stringify({
kind: "skill",
owner: "openclaw-team",
slug: "demo-skill",
displayName: "Demo Skill",
latest: {
version: "1.0.0",
publishedAt: 1_600_000_000_000,
path: "skills/openclaw-team/demo-skill/1%2E0%2E0/_meta.json",
},
versions: [
{
version: "1.0.0",
publishedAt: 1_600_000_000_000,
path: "skills/openclaw-team/demo-skill/1%2E0%2E0/_meta.json",
},
],
}),
{ etag: '"old-index"' },
);
}
return response(
200,
JSON.stringify({
kind: "skill",
owner: "openclaw-team",
slug: "demo-skill",
displayName: "Demo Skill",
latest: {
version: "2.0.0",
publishedAt: 1_800_000_000_000,
path: "skills/openclaw-team/demo-skill/2%2E0%2E0/_meta.json",
},
versions: [
{
version: "2.0.0",
publishedAt: 1_800_000_000_000,
path: "skills/openclaw-team/demo-skill/2%2E0%2E0/_meta.json",
},
{
version: "1.0.0",
publishedAt: 1_600_000_000_000,
path: "skills/openclaw-team/demo-skill/1%2E0%2E0/_meta.json",
},
],
}),
{ etag: '"new-index"' },
);
}
if (method === "PUT" && key === "skills/openclaw-team/demo-skill/_index.json") {
return headerValue(init?.headers, "if-match") === '"old-index"'
? response(412, "precondition failed")
: response(200, "");
}
return response(200, "");
}),
);
await backupSkillVersionToObjectStorage(
makeStorageCtx({ "storage:skill": "hello skill" }) as never,
{
root: "skills",
ownerHandle: "OpenClaw Team",
versionId: "skillVersions:demo-1.2" as Id<"skillVersions">,
slug: "demo-skill",
displayName: "Demo Skill",
version: "1.2.3",
publishedAt: 1_700_000_000_000,
files: [
{
path: "SKILL.md",
size: 11,
storageId: "storage:skill" as Id<"_storage">,
sha256: "sha256:skill",
contentType: "text/markdown",
},
],
},
makeContext(),
);
const indexPuts = calls.filter(
(call) =>
call.method === "PUT" &&
objectKey(call.url) === "skills/openclaw-team/demo-skill/_index.json",
);
expect(indexPuts.map((call) => call.ifMatch)).toEqual(['"old-index"', '"new-index"']);
expect(JSON.parse(indexPuts[1].body)).toMatchObject({
latest: { version: "2.0.0" },
versions: [{ version: "2.0.0" }, { version: "1.2.3" }, { version: "1.0.0" }],
});
});
it("writes package artifacts, version metadata, and the package index to object storage", async () => {
it("writes package artifacts and version metadata to object storage", async () => {
const calls: Array<{ method: string; url: string; body: string }> = [];
vi.stubGlobal(
"fetch",
@@ -644,7 +320,6 @@ describe("registry artifact backup settings", () => {
const method = init?.method ?? "GET";
const body = await requestBodyText(init?.body);
calls.push({ method, url: String(url), body });
if (method === "GET") return response(404, "");
return response(200, "");
}),
);
@@ -674,17 +349,11 @@ describe("registry artifact backup settings", () => {
expect(calls.map((call) => [call.method, objectKey(call.url)])).toEqual([
["PUT", "packages/openclaw-team/%40openclaw%2Fdemo-plugin/1%2E2%2E3/demo-plugin-1.2.3.tgz"],
["PUT", "packages/openclaw-team/%40openclaw%2Fdemo-plugin/1%2E2%2E3/_meta.json"],
["GET", "packages/openclaw-team/%40openclaw%2Fdemo-plugin/_index.json"],
["PUT", "packages/openclaw-team/%40openclaw%2Fdemo-plugin/_index.json"],
]);
expect(JSON.parse(calls[1].body)).toMatchObject({
kind: "packageRelease",
artifact: { path: "demo-plugin-1.2.3.tgz", sha256: "sha256:artifact" },
});
expect(JSON.parse(calls[3].body)).toMatchObject({
kind: "package",
latest: { version: "1.2.3", releaseId: "packageReleases:demo-1" },
});
});
});
@@ -743,7 +412,3 @@ async function requestBodyText(body: BodyInit | null | undefined) {
if (!body) return "";
return Buffer.from(await new Response(body).arrayBuffer()).toString("utf8");
}
function headerValue(headers: HeadersInit | undefined, name: string) {
return new Headers(headers).get(name) ?? undefined;
}
+3 -335
View File
@@ -1,7 +1,6 @@
"use node";
import { createHash, createHmac } from "node:crypto";
import semver from "semver";
import type { Id } from "../_generated/dataModel";
import type { ActionCtx } from "../_generated/server";
import { validateFilePath } from "./skillZip";
@@ -9,10 +8,6 @@ import { validateFilePath } from "./skillZip";
const DEFAULT_SKILLS_ROOT = "skills";
const DEFAULT_PACKAGES_ROOT = "packages";
const META_FILENAME = "_meta.json";
const INDEX_FILENAME = "_index.json";
const MAX_INDEX_WRITE_ATTEMPTS = 5;
const MIN_INDEX_WRITE_RETRY_DELAY_MS = 25;
const MAX_INDEX_WRITE_RETRY_DELAY_MS = 250;
type BackupFile = {
path: string;
@@ -64,47 +59,6 @@ type PackageBackupParams = {
files: Array<{ path: string; size: number; sha256: string }>;
};
type IndexWriteOptions = {
withIndexWrite?: <T>(indexPath: string, write: () => Promise<T>) => Promise<T>;
};
type VersionIndexEntry = {
version: string;
isLatest?: boolean;
publishedAt: number;
path: string;
};
type SkillIndexEntry = VersionIndexEntry & {
skillId?: Id<"skills">;
versionId?: Id<"skillVersions">;
};
type PackageIndexEntry = VersionIndexEntry & {
packageId: Id<"packages">;
releaseId: Id<"packageReleases">;
};
type SkillIndexFile = {
kind: "skill";
owner: string;
slug: string;
displayName: string;
latest: SkillIndexEntry;
versions: SkillIndexEntry[];
};
type PackageIndexFile = {
kind: "package";
owner: string;
packageName: string;
normalizedName: string;
displayName: string;
family: PackageBackupParams["family"];
latest: PackageIndexEntry;
versions: PackageIndexEntry[];
};
export type RegistryArtifactBackupContext = RegistryArtifactBackupSettings;
export type RegistryArtifactBackupSettings = {
@@ -155,7 +109,6 @@ export async function backupSkillVersionToObjectStorage(
ctx: Pick<ActionCtx, "storage">,
params: SkillBackupParams & { root?: string },
context: RegistryArtifactBackupContext = getRegistryArtifactBackupContext(),
options: IndexWriteOptions = {},
) {
const planned = buildSkillVersionBackupManifest({
root: params.root ?? context.skillsRoot,
@@ -170,19 +123,12 @@ export async function backupSkillVersionToObjectStorage(
}
await putJsonObject(context, planned.metaPath, planned.meta);
await writeMergedJsonIndex(
context,
planned.indexPath,
(existingIndex: SkillIndexFile | null) => buildSkillIndexFile(planned, existingIndex),
options,
);
}
export async function backupPackageReleaseToObjectStorage(
ctx: Pick<ActionCtx, "storage">,
params: PackageBackupParams & { artifactStorageId: Id<"_storage">; root?: string },
context: RegistryArtifactBackupContext = getRegistryArtifactBackupContext(),
options: IndexWriteOptions = {},
) {
const planned = buildPackageReleaseBackupManifest({
root: params.root ?? context.packagesRoot,
@@ -194,86 +140,6 @@ export async function backupPackageReleaseToObjectStorage(
});
await putJsonObject(context, planned.metaPath, planned.meta);
await writeMergedJsonIndex(
context,
planned.indexPath,
(existingIndex: PackageIndexFile | null) => buildPackageIndexFile(planned, existingIndex),
options,
);
}
export async function repairSkillVersionBackupIndex(
_ctx: Pick<ActionCtx, "storage">,
params: SkillBackupParams & { root?: string },
context: RegistryArtifactBackupContext = getRegistryArtifactBackupContext(),
options: IndexWriteOptions = {},
) {
await repairSkillVersionBackupIndexes(_ctx, [params], context, options);
}
export async function repairSkillVersionBackupIndexes(
_ctx: Pick<ActionCtx, "storage">,
params: Array<SkillBackupParams & { root?: string }>,
context: RegistryArtifactBackupContext = getRegistryArtifactBackupContext(),
options: IndexWriteOptions = {},
) {
if (params.length === 0) return;
const planned = params.map((item) =>
buildSkillVersionBackupManifest({
root: item.root ?? context.skillsRoot,
...item,
}),
);
const [first, ...rest] = planned;
if (!first) return;
const indexPath = sharedIndexPath(planned.map((item) => item.indexPath));
await writeMergedJsonIndex(
context,
indexPath,
(existingIndex: SkillIndexFile | null) =>
rest.reduce(
(nextIndex, plannedItem) => buildSkillIndexFile(plannedItem, nextIndex),
buildSkillIndexFile(first, existingIndex),
),
options,
);
}
export async function repairPackageReleaseBackupIndex(
_ctx: Pick<ActionCtx, "storage">,
params: PackageBackupParams & { root?: string },
context: RegistryArtifactBackupContext = getRegistryArtifactBackupContext(),
options: IndexWriteOptions = {},
) {
await repairPackageReleaseBackupIndexes(_ctx, [params], context, options);
}
export async function repairPackageReleaseBackupIndexes(
_ctx: Pick<ActionCtx, "storage">,
params: Array<PackageBackupParams & { root?: string }>,
context: RegistryArtifactBackupContext = getRegistryArtifactBackupContext(),
options: IndexWriteOptions = {},
) {
if (params.length === 0) return;
const planned = params.map((item) =>
buildPackageReleaseBackupManifest({
root: item.root ?? context.packagesRoot,
...item,
}),
);
const [first, ...rest] = planned;
if (!first) return;
const indexPath = sharedIndexPath(planned.map((item) => item.indexPath));
await writeMergedJsonIndex(
context,
indexPath,
(existingIndex: PackageIndexFile | null) =>
rest.reduce(
(nextIndex, plannedItem) => buildPackageIndexFile(plannedItem, nextIndex),
buildPackageIndexFile(first, existingIndex),
),
options,
);
}
export async function fetchSkillVersionBackupMeta(
@@ -289,41 +155,6 @@ export async function fetchSkillVersionBackupMeta(
return getJsonObject<ReturnType<typeof buildSkillVersionBackupManifest>["meta"]>(context, path);
}
async function writeMergedJsonIndex<T>(
context: RegistryArtifactBackupContext,
indexPath: string,
buildNext: (existing: T | null) => T,
options: IndexWriteOptions,
) {
const write = () => putMergedJsonIndex(context, indexPath, buildNext);
if (options.withIndexWrite) {
return options.withIndexWrite(indexPath, write);
}
return write();
}
export async function fetchSkillBackupIndex(
context: RegistryArtifactBackupContext,
ownerHandle: string,
slug: string,
) {
const owner = normalizeOwner(ownerHandle);
const path = `${context.skillsRoot}/${owner}/${slug}/${INDEX_FILENAME}`;
return getJsonObject<SkillIndexFile>(context, path);
}
export async function fetchPackageBackupIndex(
context: RegistryArtifactBackupContext,
ownerHandle: string,
normalizedName: string,
) {
const owner = normalizeOwner(ownerHandle);
const path = `${context.packagesRoot}/${owner}/${encodeBackupPathSegment(
normalizedName,
)}/${INDEX_FILENAME}`;
return getJsonObject<PackageIndexFile>(context, path);
}
export async function fetchPackageReleaseBackupMeta(
context: RegistryArtifactBackupContext,
ownerHandle: string,
@@ -356,7 +187,6 @@ export function buildSkillVersionBackupManifest(params: SkillBackupParams & { ro
const skillRoot = `${params.root}/${owner}/${params.slug}`;
const versionRoot = `${skillRoot}/${versionSegment}`;
const metaPath = `${versionRoot}/${META_FILENAME}`;
const indexPath = `${skillRoot}/${INDEX_FILENAME}`;
const files = params.files.map((file) => {
if (!validateFilePath(file.path)) {
throw new Error(`Invalid skill backup file path: ${file.path}`);
@@ -393,7 +223,6 @@ export function buildSkillVersionBackupManifest(params: SkillBackupParams & { ro
skillRoot,
versionRoot,
metaPath,
indexPath,
fileObjects,
meta,
};
@@ -449,113 +278,11 @@ export function buildPackageReleaseBackupManifest(params: PackageBackupParams &
releaseRoot,
artifactPath: `${releaseRoot}/${artifactFileName}`,
metaPath: `${releaseRoot}/${META_FILENAME}`,
indexPath: `${packageRoot}/${INDEX_FILENAME}`,
meta,
};
}
function buildSkillIndexFile(
planned: ReturnType<typeof buildSkillVersionBackupManifest>,
existing: SkillIndexFile | null,
): SkillIndexFile {
const nextVersion: SkillIndexEntry = {
version: planned.meta.version,
isLatest: planned.meta.isLatest,
publishedAt: planned.meta.publishedAt,
skillId: planned.meta.restore.skillId,
versionId: planned.meta.restore.versionId,
path: planned.metaPath,
};
const byVersion = new Map<string, SkillIndexEntry>();
for (const entry of [nextVersion, existing?.latest, ...(existing?.versions ?? [])]) {
if (entry && !byVersion.has(entry.version)) byVersion.set(entry.version, entry);
}
const mergedVersions = Array.from(byVersion.values());
const explicitLatest = nextVersion.isLatest
? nextVersion
: mergedVersions.find((entry) => entry.isLatest);
const versions = mergedVersions
.map((entry) => ({
...entry,
isLatest: explicitLatest ? entry.version === explicitLatest.version : entry.isLatest,
}))
.sort(compareSkillIndexEntriesForLatest);
const latest = explicitLatest
? (versions.find((entry) => entry.version === explicitLatest.version) ?? explicitLatest)
: (versions[0] ?? nextVersion);
return {
kind: "skill",
owner: planned.meta.owner,
slug: planned.meta.slug,
displayName: planned.meta.displayName,
latest,
versions,
};
}
function sharedIndexPath(paths: string[]) {
const [first, ...rest] = paths;
if (!first || rest.some((path) => path !== first)) {
throw new Error("Registry artifact backup bulk index repair received mixed roots");
}
return first;
}
function compareSkillIndexEntriesForLatest(left: SkillIndexEntry, right: SkillIndexEntry) {
const leftValid = semver.valid(left.version);
const rightValid = semver.valid(right.version);
if (leftValid && rightValid) return semver.rcompare(leftValid, rightValid);
if (leftValid) return -1;
if (rightValid) return 1;
return right.publishedAt - left.publishedAt;
}
function buildPackageIndexFile(
planned: ReturnType<typeof buildPackageReleaseBackupManifest>,
existing: PackageIndexFile | null,
): PackageIndexFile {
const nextVersion: PackageIndexEntry = {
version: planned.meta.version,
isLatest: planned.meta.isLatest,
publishedAt: planned.meta.publishedAt,
packageId: planned.meta.restore.packageId,
releaseId: planned.meta.restore.releaseId,
path: planned.metaPath,
};
const byRelease = new Map<string, PackageIndexEntry>();
for (const entry of [nextVersion, existing?.latest, ...(existing?.versions ?? [])]) {
if (entry && !byRelease.has(entry.releaseId)) byRelease.set(entry.releaseId, entry);
}
const mergedVersions = Array.from(byRelease.values());
const explicitLatest = nextVersion.isLatest
? nextVersion
: mergedVersions.find((entry) => entry.isLatest);
const versions = mergedVersions
.map((entry) => ({
...entry,
isLatest: explicitLatest ? entry.releaseId === explicitLatest.releaseId : entry.isLatest,
}))
.sort((a, b) => b.publishedAt - a.publishedAt);
const latest = explicitLatest
? (versions.find((entry) => entry.releaseId === explicitLatest.releaseId) ?? explicitLatest)
: (versions[0] ?? nextVersion);
return {
kind: "package",
owner: planned.meta.owner,
packageName: planned.meta.packageName,
normalizedName: planned.meta.normalizedName,
displayName: planned.meta.displayName,
family: planned.meta.family,
latest,
versions,
};
}
export const __registryArtifactBackupTestInternals = {
buildPackageIndexFile,
buildSkillIndexFile,
encodeBackupPathSegment,
};
@@ -614,63 +341,13 @@ async function putJsonObject(context: RegistryArtifactBackupContext, key: string
}
async function getJsonObject<T>(context: RegistryArtifactBackupContext, key: string) {
const result = await getJsonObjectForUpdate(context, key);
return result.value as T | null;
}
async function getJsonObjectForUpdate(context: RegistryArtifactBackupContext, key: string) {
const response = await signedFetch(context, "GET", key);
if (response.status === 404) return { value: null, etag: null };
if (response.status === 404) return null;
if (!response.ok) {
const body = await response.text();
throw new Error(`Registry artifact backup GET ${key} failed: ${body}`);
}
return {
value: (await response.json()) as unknown,
etag: response.headers.get("etag"),
};
}
async function putMergedJsonIndex<T>(
context: RegistryArtifactBackupContext,
key: string,
buildNext: (existing: T | null) => T,
) {
for (let attempt = 1; attempt <= MAX_INDEX_WRITE_ATTEMPTS; attempt++) {
const existing = await getJsonObjectForUpdate(context, key);
const existingValue = existing.value as T | null;
if (existingValue && !existing.etag) {
throw new Error(`Registry artifact backup GET ${key} missing ETag`);
}
const result = await putObject(
context,
key,
`${JSON.stringify(buildNext(existingValue), null, 2)}\n`,
{
contentType: "application/json; charset=utf-8",
ifMatch: existing.etag ?? undefined,
ifNoneMatch: existingValue ? undefined : "*",
allowPreconditionFailed: true,
},
);
if (result === "ok") return;
await sleep(indexWriteRetryDelayMs(attempt));
}
throw new Error(`Registry artifact backup index ${key} changed too frequently`);
}
function indexWriteRetryDelayMs(attempt: number) {
const base = Math.min(
MAX_INDEX_WRITE_RETRY_DELAY_MS,
MIN_INDEX_WRITE_RETRY_DELAY_MS * 2 ** attempt,
);
return base + Math.floor(Math.random() * MIN_INDEX_WRITE_RETRY_DELAY_MS);
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
return (await response.json()) as T;
}
async function putObject(
@@ -679,20 +356,13 @@ async function putObject(
body: string | Uint8Array,
options: {
contentType?: string;
ifMatch?: string;
ifNoneMatch?: string;
allowPreconditionFailed?: boolean;
} = {},
) {
const response = await signedFetch(context, "PUT", key, body, options);
if (options.allowPreconditionFailed && response.status === 412) {
return "preconditionFailed" as const;
}
if (!response.ok) {
const responseBody = await response.text();
throw new Error(`Registry artifact backup PUT ${key} failed: ${responseBody}`);
}
return "ok" as const;
}
async function signedFetch(
@@ -700,7 +370,7 @@ async function signedFetch(
method: "GET" | "PUT",
key: string,
body?: string | Uint8Array,
options: { contentType?: string; ifMatch?: string; ifNoneMatch?: string } = {},
options: { contentType?: string } = {},
) {
const now = new Date();
const bodyBytes = body === undefined ? new Uint8Array() : toBytes(body);
@@ -711,8 +381,6 @@ async function signedFetch(
headers.set("x-amz-content-sha256", payloadHash);
headers.set("x-amz-date", amzDate(now));
if (options.contentType) headers.set("content-type", options.contentType);
if (options.ifMatch) headers.set("if-match", options.ifMatch);
if (options.ifNoneMatch) headers.set("if-none-match", options.ifNoneMatch);
headers.set(
"authorization",
authorizationHeader(context, method, url, headers, payloadHash, now),
+11 -538
View File
@@ -7,8 +7,6 @@ import {
getRegistryArtifactBackupPageInternal,
getPackageRegistryArtifactBackupPageInternal,
releaseRegistryArtifactBackupRetryLeaseHandler,
releaseRegistryArtifactBackupIndexLeaseHandler,
tryAcquireRegistryArtifactBackupIndexLeaseHandler,
tryAcquireRegistryArtifactBackupRetryLeaseHandler,
} from "./registryArtifactBackups";
import {
@@ -19,35 +17,13 @@ import {
} from "./registryArtifactBackupsNode";
const registryBackupMocks = vi.hoisted(() => {
const normalizeOwner = (value: string) =>
value
.trim()
.toLowerCase()
.replace(/^@+/, "")
.replace(/[^a-z0-9._-]/g, "-")
.replace(/-+/g, "-")
.replace(/^[._-]+|[._-]+$/g, "") || "unknown";
const encodeBackupPathSegment = (value: string) =>
encodeURIComponent(value.trim()).replace(/\./g, "%2E");
return {
backupPackageReleaseToObjectStorage: vi.fn(),
backupSkillVersionToObjectStorage: vi.fn(),
buildPackageReleaseBackupManifest: vi.fn((params) => ({
indexPath: `${params.root}/${normalizeOwner(params.ownerHandle)}/${encodeBackupPathSegment(params.normalizedName || params.packageName)}/_index.json`,
})),
buildSkillVersionBackupManifest: vi.fn((params) => ({
indexPath: `${params.root}/${normalizeOwner(params.ownerHandle)}/${params.slug}/_index.json`,
})),
fetchPackageBackupIndex: vi.fn(),
fetchPackageReleaseBackupMeta: vi.fn(),
fetchSkillBackupIndex: vi.fn(),
fetchSkillVersionBackupMeta: vi.fn(),
getRegistryArtifactBackupContext: vi.fn(),
isRegistryArtifactBackupConfigured: vi.fn(),
repairPackageReleaseBackupIndex: vi.fn(),
repairPackageReleaseBackupIndexes: vi.fn(),
repairSkillVersionBackupIndex: vi.fn(),
repairSkillVersionBackupIndexes: vi.fn(),
};
});
@@ -81,92 +57,13 @@ beforeEach(() => {
};
registryBackupMocks.getRegistryArtifactBackupContext.mockReturnValue(backupContext);
registryBackupMocks.isRegistryArtifactBackupConfigured.mockReturnValue(true);
registryBackupMocks.backupSkillVersionToObjectStorage.mockImplementation(
async (
_ctx: unknown,
params: { root?: string; ownerHandle: string; slug: string },
context: typeof backupContext,
options?: {
withIndexWrite?: (indexPath: string, write: () => Promise<void>) => Promise<void>;
},
) => {
const manifest = registryBackupMocks.buildSkillVersionBackupManifest({
root: params.root ?? context.skillsRoot,
...params,
});
await options?.withIndexWrite?.(manifest.indexPath, async () => undefined);
},
);
registryBackupMocks.backupPackageReleaseToObjectStorage.mockImplementation(
async (
_ctx: unknown,
params: {
root?: string;
ownerHandle: string;
normalizedName: string;
packageName: string;
},
context: typeof backupContext,
options?: {
withIndexWrite?: (indexPath: string, write: () => Promise<void>) => Promise<void>;
},
) => {
const manifest = registryBackupMocks.buildPackageReleaseBackupManifest({
root: params.root ?? context.packagesRoot,
...params,
});
await options?.withIndexWrite?.(manifest.indexPath, async () => undefined);
},
);
registryBackupMocks.repairSkillVersionBackupIndexes.mockImplementation(
async (
_ctx: unknown,
params: Array<{ root?: string; ownerHandle: string; slug: string }>,
context: typeof backupContext,
options?: {
withIndexWrite?: (indexPath: string, write: () => Promise<void>) => Promise<void>;
},
) => {
const first = params[0];
if (!first) return;
const manifest = registryBackupMocks.buildSkillVersionBackupManifest({
root: first.root ?? context.skillsRoot,
...first,
});
await options?.withIndexWrite?.(manifest.indexPath, async () => undefined);
},
);
registryBackupMocks.repairPackageReleaseBackupIndexes.mockImplementation(
async (
_ctx: unknown,
params: Array<{
root?: string;
ownerHandle: string;
normalizedName: string;
packageName: string;
}>,
context: typeof backupContext,
options?: {
withIndexWrite?: (indexPath: string, write: () => Promise<void>) => Promise<void>;
},
) => {
const first = params[0];
if (!first) return;
const manifest = registryBackupMocks.buildPackageReleaseBackupManifest({
root: first.root ?? context.packagesRoot,
...first,
});
await options?.withIndexWrite?.(manifest.indexPath, async () => undefined);
},
);
registryBackupMocks.backupSkillVersionToObjectStorage.mockResolvedValue(undefined);
registryBackupMocks.backupPackageReleaseToObjectStorage.mockResolvedValue(undefined);
});
function retryLeaseRunMutation() {
return vi.fn(async (_ref, args) => {
if (args && typeof args === "object" && "token" in args) {
if ("indexPath" in args) {
return { acquired: true, released: true };
}
return { acquired: true, released: true };
}
return undefined;
@@ -225,8 +122,6 @@ describe("publish-time registry artifact backups", () => {
ownerHandle: "alice",
isLatest: false,
}),
expect.anything(),
expect.anything(),
);
});
@@ -290,8 +185,6 @@ describe("publish-time registry artifact backups", () => {
displayName: "Current Package",
isLatest: false,
}),
expect.anything(),
expect.anything(),
);
});
});
@@ -769,7 +662,7 @@ describe("processRegistryArtifactBackupRetriesInternalHandler", () => {
expect(runQuery.mock.calls[0]?.[1]).toMatchObject({ ignoreNextRunAt: true });
});
it("serializes retry artifact backups with a per-index lease", async () => {
it("backs up retry artifacts without acquiring per-index leases", async () => {
const jobs = [makeSkillBackupJob("demo", "skillVersions:demo")];
const skill = {
...makeSkill("skills:demo", "demo-skill"),
@@ -799,18 +692,7 @@ describe("processRegistryArtifactBackupRetriesInternalHandler", () => {
expect(result.stats.retryJobsSucceeded).toBe(1);
expect(registryBackupMocks.backupSkillVersionToObjectStorage).toHaveBeenCalledOnce();
const indexLeaseCalls = runMutation.mock.calls.filter(
(call) => call[1]?.indexPath === "skills/alice/demo-skill/_index.json",
);
expect(indexLeaseCalls.map((call) => call[1])).toEqual([
expect.objectContaining({
indexPath: "skills/alice/demo-skill/_index.json",
ttlMs: 5 * 60 * 1000,
}),
expect.objectContaining({
indexPath: "skills/alice/demo-skill/_index.json",
}),
]);
expect(runMutation.mock.calls.some((call) => "indexPath" in (call[1] ?? {}))).toBe(false);
});
it("drains retry jobs without scanning the historical registry", async () => {
@@ -960,7 +842,7 @@ describe("processRegistryArtifactBackupRetriesInternalHandler", () => {
);
});
it("repairs the index without reuploading skill files when retry metadata already exists", async () => {
it("marks skill retries succeeded without reuploading when version metadata already exists", async () => {
const dueJob = {
_id: "registryArtifactBackupJobs:demo",
targetKind: "skillVersion",
@@ -1018,21 +900,13 @@ describe("processRegistryArtifactBackupRetriesInternalHandler", () => {
expect(result.stats.retryJobsSucceeded).toBe(1);
expect(registryBackupMocks.backupSkillVersionToObjectStorage).not.toHaveBeenCalled();
expect(registryBackupMocks.repairSkillVersionBackupIndexes).toHaveBeenCalledWith(
expect.anything(),
[
expect.objectContaining({
slug: "demo-skill",
version: "1.0.0",
ownerHandle: "alice",
}),
],
expect.anything(),
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ jobId: "registryArtifactBackupJobs:demo" }),
);
});
it("repairs multiple retry index misses for the same skill root with one index write", async () => {
it("marks multiple skill retries succeeded from matching version metadata", async () => {
const jobs = [
makeSkillBackupJob("demo-1", "skillVersions:demo-1"),
makeSkillBackupJob("demo-2", "skillVersions:demo-2"),
@@ -1078,19 +952,10 @@ describe("processRegistryArtifactBackupRetriesInternalHandler", () => {
expect(result.stats.retryJobsSucceeded).toBe(2);
expect(result.stats.retryJobsFailed).toBe(0);
expect(registryBackupMocks.backupSkillVersionToObjectStorage).not.toHaveBeenCalled();
expect(registryBackupMocks.repairSkillVersionBackupIndexes).toHaveBeenCalledOnce();
expect(registryBackupMocks.repairSkillVersionBackupIndexes).toHaveBeenCalledWith(
expect.anything(),
[
expect.objectContaining({ ownerHandle: "alice", slug: "demo-skill", version: "1.0.0" }),
expect.objectContaining({ ownerHandle: "alice", slug: "demo-skill", version: "1.1.0" }),
],
expect.anything(),
expect.anything(),
);
expect(registryBackupMocks.fetchSkillVersionBackupMeta).toHaveBeenCalledTimes(2);
});
it("repairs multiple retry index misses for the same package root with one index write", async () => {
it("marks package retries succeeded from matching version metadata", async () => {
const jobs = [
makePackageBackupJob("demo-1", "packageReleases:demo-1"),
makePackageBackupJob("demo-2", "packageReleases:demo-2"),
@@ -1143,314 +1008,7 @@ describe("processRegistryArtifactBackupRetriesInternalHandler", () => {
expect(result.stats.retryJobsSucceeded).toBe(2);
expect(result.stats.retryJobsFailed).toBe(0);
expect(registryBackupMocks.backupPackageReleaseToObjectStorage).not.toHaveBeenCalled();
expect(registryBackupMocks.repairPackageReleaseBackupIndexes).toHaveBeenCalledOnce();
expect(registryBackupMocks.repairPackageReleaseBackupIndexes).toHaveBeenCalledWith(
expect.anything(),
[
expect.objectContaining({
ownerHandle: "alice",
normalizedName: "@openclaw/demo",
version: "1.0.0",
}),
expect.objectContaining({
ownerHandle: "alice",
normalizedName: "@openclaw/demo",
version: "1.1.0",
}),
],
expect.anything(),
expect.anything(),
);
});
it("marks skill index retries succeeded when the version is already indexed", async () => {
const jobs = [
makeSkillBackupJob("demo-1", "skillVersions:demo-1"),
makeSkillBackupJob("demo-2", "skillVersions:demo-2"),
];
const versions = new Map([
["skillVersions:demo-1", makeSkillVersion("skillVersions:demo-1", "skills:demo", "1.0.0")],
["skillVersions:demo-2", makeSkillVersion("skillVersions:demo-2", "skills:demo", "1.1.0")],
]);
const skill = {
...makeSkill("skills:demo", "demo-skill"),
latestVersionId: "skillVersions:demo-2",
};
const owner = {
_id: "users:owner",
handle: "alice",
deletedAt: undefined,
deactivatedAt: undefined,
};
const runQuery = vi.fn(async (_ref, args) => {
if ("limit" in args) return jobs;
if (args.versionId) return versions.get(args.versionId) ?? null;
if (args.skillId === "skills:demo") return skill;
if (args.userId === "users:owner") return owner;
if ("staleAfterMs" in args) return { stale: 0, exhausted: 0 };
throw new Error(`unexpected query ${JSON.stringify(args)}`);
});
const versionIdsByVersion = new Map([
["1.0.0", "skillVersions:demo-1"],
["1.1.0", "skillVersions:demo-2"],
]);
registryBackupMocks.fetchSkillVersionBackupMeta.mockImplementation(
async (_context, _ownerHandle, _slug, version) => ({
version,
restore: { versionId: versionIdsByVersion.get(version) },
}),
);
registryBackupMocks.fetchSkillBackupIndex.mockResolvedValueOnce({
latest: { version: "1.1.0", versionId: "skillVersions:demo-2", isLatest: true },
versions: [
{ version: "1.0.0", versionId: "skillVersions:demo-1", isLatest: false },
{ version: "1.1.0", versionId: "skillVersions:demo-2", isLatest: true },
],
});
const result = await processRegistryArtifactBackupRetriesInternalHandler(
{ runQuery, runMutation: retryLeaseRunMutation() } as never,
{},
);
expect(result.stats.retryJobsSucceeded).toBe(2);
expect(result.stats.retryJobsFailed).toBe(0);
expect(registryBackupMocks.repairSkillVersionBackupIndexes).not.toHaveBeenCalled();
});
it("repairs skill index retries when the indexed version has stale latest state", async () => {
const jobs = [makeSkillBackupJob("demo", "skillVersions:demo")];
const skill = {
...makeSkill("skills:demo", "demo-skill"),
latestVersionId: "skillVersions:demo",
};
const owner = {
_id: "users:owner",
handle: "alice",
deletedAt: undefined,
deactivatedAt: undefined,
};
const runQuery = vi.fn(async (_ref, args) => {
if ("limit" in args) return jobs;
if (args.versionId) return makeSkillVersion("skillVersions:demo", "skills:demo", "1.0.0");
if (args.skillId === "skills:demo") return skill;
if (args.userId === "users:owner") return owner;
if ("staleAfterMs" in args) return { stale: 0, exhausted: 0 };
throw new Error(`unexpected query ${JSON.stringify(args)}`);
});
registryBackupMocks.fetchSkillVersionBackupMeta.mockResolvedValue({
version: "1.0.0",
restore: { versionId: "skillVersions:demo" },
});
registryBackupMocks.fetchSkillBackupIndex.mockResolvedValueOnce({
latest: { version: "0.9.0", versionId: "skillVersions:old", isLatest: true },
versions: [{ version: "1.0.0", versionId: "skillVersions:demo", isLatest: false }],
});
const result = await processRegistryArtifactBackupRetriesInternalHandler(
{ runQuery, runMutation: retryLeaseRunMutation() } as never,
{},
);
expect(result.stats.retryJobsSucceeded).toBe(1);
expect(result.stats.retryJobsFailed).toBe(0);
expect(registryBackupMocks.repairSkillVersionBackupIndexes).toHaveBeenCalledOnce();
});
it("keeps indexed skill success marker failures isolated", async () => {
const jobs = [makeSkillBackupJob("demo", "skillVersions:demo")];
const skill = {
...makeSkill("skills:demo", "demo-skill"),
latestVersionId: "skillVersions:demo",
};
const owner = {
_id: "users:owner",
handle: "alice",
deletedAt: undefined,
deactivatedAt: undefined,
};
const runQuery = vi.fn(async (_ref, args) => {
if ("limit" in args) return jobs;
if (args.versionId) return makeSkillVersion("skillVersions:demo", "skills:demo", "1.0.0");
if (args.skillId === "skills:demo") return skill;
if (args.userId === "users:owner") return owner;
if ("staleAfterMs" in args) return { stale: 0, exhausted: 0 };
throw new Error(`unexpected query ${JSON.stringify(args)}`);
});
const runMutation = vi.fn(async (_ref, args) => {
if (args && typeof args === "object" && "token" in args) {
return { acquired: true, released: true };
}
if (args && typeof args === "object" && "jobId" in args) {
throw new Error("status patch failed");
}
return undefined;
});
registryBackupMocks.fetchSkillVersionBackupMeta.mockResolvedValue({
version: "1.0.0",
restore: { versionId: "skillVersions:demo" },
});
registryBackupMocks.fetchSkillBackupIndex.mockResolvedValueOnce({
latest: { version: "1.0.0", versionId: "skillVersions:demo", isLatest: true },
versions: [{ version: "1.0.0", versionId: "skillVersions:demo", isLatest: true }],
});
const result = await processRegistryArtifactBackupRetriesInternalHandler(
{ runQuery, runMutation } as never,
{},
);
expect(result.stats.retryJobsSucceeded).toBe(0);
expect(result.stats.retryJobsFailed).toBe(1);
expect(registryBackupMocks.repairSkillVersionBackupIndexes).not.toHaveBeenCalled();
expect(runMutation).toHaveBeenCalledTimes(4);
});
it("marks package index retries succeeded when the release is already indexed", async () => {
const jobs = [
makePackageBackupJob("demo-1", "packageReleases:demo-1"),
makePackageBackupJob("demo-2", "packageReleases:demo-2"),
];
const releases = new Map([
[
"packageReleases:demo-1",
makePackageRelease("packageReleases:demo-1", "packages:demo", "1.0.0"),
],
[
"packageReleases:demo-2",
makePackageRelease("packageReleases:demo-2", "packages:demo", "1.1.0"),
],
]);
const pkg = {
...makePackage("packages:demo", "@openclaw/demo"),
latestReleaseId: "packageReleases:demo-2",
};
const owner = {
_id: "users:owner",
handle: "alice",
deletedAt: undefined,
deactivatedAt: undefined,
};
const runQuery = vi.fn(async (_ref, args) => {
if ("limit" in args) return jobs;
if (args.releaseId) return releases.get(args.releaseId) ?? null;
if (args.packageId === "packages:demo") return pkg;
if (args.userId === "users:owner") return owner;
if ("staleAfterMs" in args) return { stale: 0, exhausted: 0 };
throw new Error(`unexpected query ${JSON.stringify(args)}`);
});
const releaseIdsByVersion = new Map([
["1.0.0", "packageReleases:demo-1"],
["1.1.0", "packageReleases:demo-2"],
]);
const shaByVersion = new Map([
["1.0.0", "sha:packageReleases:demo-1"],
["1.1.0", "sha:packageReleases:demo-2"],
]);
registryBackupMocks.fetchPackageReleaseBackupMeta.mockImplementation(
async (_context, _ownerHandle, _normalizedName, version) => ({
restore: { releaseId: releaseIdsByVersion.get(version) },
artifact: { sha256: shaByVersion.get(version) },
}),
);
registryBackupMocks.fetchPackageBackupIndex.mockResolvedValueOnce({
latest: { version: "1.1.0", releaseId: "packageReleases:demo-2", isLatest: true },
versions: [
{ version: "1.0.0", releaseId: "packageReleases:demo-1", isLatest: false },
{ version: "1.1.0", releaseId: "packageReleases:demo-2", isLatest: true },
],
});
const result = await processRegistryArtifactBackupRetriesInternalHandler(
{ runQuery, runMutation: retryLeaseRunMutation() } as never,
{},
);
expect(result.stats.retryJobsSucceeded).toBe(2);
expect(result.stats.retryJobsFailed).toBe(0);
expect(registryBackupMocks.repairPackageReleaseBackupIndexes).not.toHaveBeenCalled();
});
it("marks skill index retries failed when the index lookup fails", async () => {
const jobs = [makeSkillBackupJob("demo", "skillVersions:demo")];
const skill = makeSkill("skills:demo", "demo-skill");
const owner = {
_id: "users:owner",
handle: "alice",
deletedAt: undefined,
deactivatedAt: undefined,
};
const runQuery = vi.fn(async (_ref, args) => {
if ("limit" in args) return jobs;
if (args.versionId) return makeSkillVersion("skillVersions:demo", "skills:demo", "1.0.0");
if (args.skillId === "skills:demo") return skill;
if (args.userId === "users:owner") return owner;
if ("staleAfterMs" in args) return { stale: 0, exhausted: 0 };
throw new Error(`unexpected query ${JSON.stringify(args)}`);
});
const runMutation = retryLeaseRunMutation();
registryBackupMocks.fetchSkillVersionBackupMeta.mockResolvedValue({
version: "1.0.0",
restore: { versionId: "skillVersions:demo" },
});
registryBackupMocks.fetchSkillBackupIndex.mockRejectedValueOnce(new Error("R2 index read"));
const result = await processRegistryArtifactBackupRetriesInternalHandler(
{ runQuery, runMutation } as never,
{},
);
expect(result.stats.retryJobsSucceeded).toBe(0);
expect(result.stats.retryJobsFailed).toBe(1);
expect(registryBackupMocks.repairSkillVersionBackupIndexes).not.toHaveBeenCalled();
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
jobId: "registryArtifactBackupJobs:demo",
error: "R2 index read",
}),
);
});
it("marks package index retries failed when the index lookup fails", async () => {
const jobs = [makePackageBackupJob("demo", "packageReleases:demo")];
const pkg = makePackage("packages:demo", "@openclaw/demo");
const owner = {
_id: "users:owner",
handle: "alice",
deletedAt: undefined,
deactivatedAt: undefined,
};
const runQuery = vi.fn(async (_ref, args) => {
if ("limit" in args) return jobs;
if (args.releaseId) return makePackageRelease("packageReleases:demo", "packages:demo");
if (args.packageId === "packages:demo") return pkg;
if (args.userId === "users:owner") return owner;
if ("staleAfterMs" in args) return { stale: 0, exhausted: 0 };
throw new Error(`unexpected query ${JSON.stringify(args)}`);
});
const runMutation = retryLeaseRunMutation();
registryBackupMocks.fetchPackageReleaseBackupMeta.mockResolvedValue({
restore: { releaseId: "packageReleases:demo" },
artifact: { sha256: "sha:packageReleases:demo" },
});
registryBackupMocks.fetchPackageBackupIndex.mockRejectedValueOnce(new Error("R2 index read"));
const result = await processRegistryArtifactBackupRetriesInternalHandler(
{ runQuery, runMutation } as never,
{},
);
expect(result.stats.retryJobsSucceeded).toBe(0);
expect(result.stats.retryJobsFailed).toBe(1);
expect(registryBackupMocks.repairPackageReleaseBackupIndexes).not.toHaveBeenCalled();
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
jobId: "registryArtifactBackupJobs:demo",
error: "R2 index read",
}),
);
expect(registryBackupMocks.fetchPackageReleaseBackupMeta).toHaveBeenCalledTimes(2);
});
it("processes different retry roots in parallel while keeping one root sequential", async () => {
@@ -2009,91 +1567,6 @@ describe("registry artifact backup jobs", () => {
});
});
it("acquires a registry artifact backup index lease with an index-scoped key", async () => {
const now = 1_700_000_000_000;
const insert = vi.fn();
const ctx = {
db: {
query: vi.fn(() => ({
withIndex: vi.fn(() => ({ unique: vi.fn().mockResolvedValue(null) })),
})),
insert,
patch: vi.fn(),
},
};
const result = await tryAcquireRegistryArtifactBackupIndexLeaseHandler(ctx as never, {
indexPath: "skills/alice/demo/_index.json",
now,
token: "index-token",
ttlMs: 60_000,
});
expect(result).toEqual({ acquired: true });
expect(insert).toHaveBeenCalledWith("registryArtifactBackupSyncState", {
key: "index:skills/alice/demo/_index.json",
cursor: "index-token",
updatedAt: now,
});
});
it("refuses a fresh registry artifact backup index lease", async () => {
const now = 1_700_000_000_000;
const existing = {
_id: "registryArtifactBackupSyncState:index",
key: "index:skills/alice/demo/_index.json",
cursor: "other-token",
updatedAt: now - 1_000,
};
const patch = vi.fn();
const ctx = {
db: {
query: vi.fn(() => ({
withIndex: vi.fn(() => ({ unique: vi.fn().mockResolvedValue(existing) })),
})),
insert: vi.fn(),
patch,
},
};
const result = await tryAcquireRegistryArtifactBackupIndexLeaseHandler(ctx as never, {
indexPath: "skills/alice/demo/_index.json",
now,
token: "index-token",
ttlMs: 60_000,
});
expect(result).toEqual({ acquired: false, holderUpdatedAt: existing.updatedAt });
expect(patch).not.toHaveBeenCalled();
});
it("releases only the matching registry artifact backup index lease token", async () => {
const now = 1_700_000_000_000;
const existing = {
_id: "registryArtifactBackupSyncState:index",
key: "index:skills/alice/demo/_index.json",
cursor: "index-token",
updatedAt: now - 1_000,
};
const deleteDoc = vi.fn();
const ctx = {
db: {
query: vi.fn(() => ({
withIndex: vi.fn(() => ({ unique: vi.fn().mockResolvedValue(existing) })),
})),
delete: deleteDoc,
},
};
const result = await releaseRegistryArtifactBackupIndexLeaseHandler(ctx as never, {
indexPath: "skills/alice/demo/_index.json",
token: "index-token",
});
expect(result).toEqual({ released: true });
expect(deleteDoc).toHaveBeenCalledWith("registryArtifactBackupSyncState:index");
});
it("upserts package release backup failures into a retryable backlog", async () => {
const now = 1_700_000_000_000;
const existing = {
-70
View File
@@ -12,7 +12,6 @@ const MAX_BATCH_SIZE = 200;
const SYNC_STATE_KEY = "default";
const PACKAGE_SYNC_STATE_KEY = "packageReleases";
const RETRY_LEASE_KEY = "retryLease";
const INDEX_LEASE_KEY_PREFIX = "index:";
const MAX_BACKUP_JOB_ERROR_LENGTH = 4000;
const DEFAULT_BACKUP_HEALTH_SAMPLE_LIMIT = 500;
const MAX_BACKUP_HEALTH_SAMPLE_LIMIT = 1000;
@@ -21,8 +20,6 @@ const MAX_BACKUP_JOB_LIMIT = 500;
const DEFAULT_BACKUP_JOB_REPAIR_ATTEMPTS = 16;
const DEFAULT_RETRY_LEASE_TTL_MS = 20 * 60 * 1000;
const MAX_RETRY_LEASE_TTL_MS = 60 * 60 * 1000;
const DEFAULT_INDEX_LEASE_TTL_MS = 5 * 60 * 1000;
const MAX_INDEX_LEASE_TTL_MS = 30 * 60 * 1000;
type BackupPageItem =
| {
@@ -413,69 +410,6 @@ export const releaseRegistryArtifactBackupRetryLeaseInternal = internalMutation(
handler: releaseRegistryArtifactBackupRetryLeaseHandler,
});
export async function tryAcquireRegistryArtifactBackupIndexLeaseHandler(
ctx: Pick<MutationCtx, "db">,
args: { indexPath: string; now?: number; token: string; ttlMs?: number },
) {
const now = args.now ?? Date.now();
const ttlMs = clampInt(args.ttlMs ?? DEFAULT_INDEX_LEASE_TTL_MS, 1_000, MAX_INDEX_LEASE_TTL_MS);
const key = registryArtifactBackupIndexLeaseKey(args.indexPath);
const state = await ctx.db
.query("registryArtifactBackupSyncState")
.withIndex("by_key", (q) => q.eq("key", key))
.unique();
if (state?.cursor && state.updatedAt + ttlMs > now) {
return { acquired: false as const, holderUpdatedAt: state.updatedAt };
}
if (!state) {
await ctx.db.insert("registryArtifactBackupSyncState", {
key,
cursor: args.token,
updatedAt: now,
});
return { acquired: true as const };
}
await ctx.db.patch(state._id, {
cursor: args.token,
updatedAt: now,
});
return { acquired: true as const };
}
export const tryAcquireRegistryArtifactBackupIndexLeaseInternal = internalMutation({
args: {
indexPath: v.string(),
now: v.optional(v.number()),
token: v.string(),
ttlMs: v.optional(v.number()),
},
handler: tryAcquireRegistryArtifactBackupIndexLeaseHandler,
});
export async function releaseRegistryArtifactBackupIndexLeaseHandler(
ctx: Pick<MutationCtx, "db">,
args: { indexPath: string; token: string },
) {
const state = await ctx.db
.query("registryArtifactBackupSyncState")
.withIndex("by_key", (q) => q.eq("key", registryArtifactBackupIndexLeaseKey(args.indexPath)))
.unique();
if (!state || state.cursor !== args.token) return { released: false as const };
await ctx.db.delete(state._id);
return { released: true as const };
}
export const releaseRegistryArtifactBackupIndexLeaseInternal = internalMutation({
args: {
indexPath: v.string(),
token: v.string(),
},
handler: releaseRegistryArtifactBackupIndexLeaseHandler,
});
const registryArtifactBackupTargetKindValidator = v.union(
v.literal("skillVersion"),
v.literal("packageRelease"),
@@ -724,10 +658,6 @@ function truncateBackupJobError(error: string | undefined) {
return error.slice(0, MAX_BACKUP_JOB_ERROR_LENGTH);
}
function registryArtifactBackupIndexLeaseKey(indexPath: string) {
return `${INDEX_LEASE_KEY_PREFIX}${indexPath}`;
}
function retryDelayMs(attempts: number) {
const minutes = Math.min(60, 2 ** Math.min(attempts, 6));
return minutes * 60 * 1000;
+10 -274
View File
@@ -9,14 +9,10 @@ import { isPublicSkillDoc } from "./lib/globalStats";
import {
backupPackageReleaseToObjectStorage,
backupSkillVersionToObjectStorage,
fetchPackageBackupIndex,
fetchPackageReleaseBackupMeta,
fetchSkillBackupIndex,
fetchSkillVersionBackupMeta,
getRegistryArtifactBackupContext,
isRegistryArtifactBackupConfigured,
repairPackageReleaseBackupIndexes,
repairSkillVersionBackupIndexes,
type RegistryArtifactBackupContext,
} from "./lib/registryArtifactBackup";
@@ -32,9 +28,6 @@ const UNKNOWN_SKILL_ARTIFACT_BYTES = 50 * 1024 * 1024;
const MAX_PARALLEL_RETRY_ARTIFACT_BYTES = UNKNOWN_PACKAGE_ARTIFACT_BYTES;
const STALE_BACKUP_JOB_MS = 24 * 60 * 60 * 1000;
const RETRY_LEASE_TTL_MS = 20 * 60 * 1000;
const INDEX_LEASE_TTL_MS = 5 * 60 * 1000;
const INDEX_LEASE_RETRY_DELAY_MS = 250;
const INDEX_LEASE_MAX_WAIT_MS = 30_000;
type BackupPageItem =
| {
@@ -159,7 +152,7 @@ export const backupSkillForPublishInternal = internalAction({
if (args.versionId && !item) {
return { skipped: true as const };
}
await backupSkillVersionWithIndexLease(ctx, item ?? args);
await backupSkillVersionToObjectStorage(ctx, item ?? args);
return { skipped: false as const };
} catch (error) {
if (args.versionId) {
@@ -219,7 +212,7 @@ export const backupPackageForPublishInternal = internalAction({
if (!item) {
return { skipped: true as const };
}
await backupPackageReleaseWithIndexLease(ctx, item);
await backupPackageReleaseToObjectStorage(ctx, item);
return { skipped: false as const };
} catch (error) {
await ctx.runMutation(
@@ -314,7 +307,7 @@ export async function seedRegistryArtifactBackupsInternalHandler(
}
if (!dryRun) {
await backupSkillVersionWithIndexLease(
await backupSkillVersionToObjectStorage(
ctx,
{
skillId: item.skillId,
@@ -437,7 +430,7 @@ async function syncPackageReleaseBackups(
continue;
}
if (!dryRun) {
await backupPackageReleaseWithIndexLease(ctx, item, context);
await backupPackageReleaseToObjectStorage(ctx, item, context);
stats.packagesBackedUp += 1;
}
} catch (error) {
@@ -527,10 +520,6 @@ type RetryJobWorkItem =
estimatedBytes: number;
};
type ArtifactRetryJobWorkItem =
| Extract<RetryJobWorkItem, { kind: "packageRelease" }>
| Extract<RetryJobWorkItem, { kind: "skillVersion" }>;
type RetryJobGroup = {
estimatedBytes: number;
items: RetryJobWorkItem[];
@@ -598,8 +587,6 @@ async function processRetryJobGroup(
group: RetryJobGroup,
) {
const result = { processed: 0, succeeded: 0, failed: 0 };
const packageIndexRepairs: Array<Extract<RetryJobWorkItem, { kind: "packageRelease" }>> = [];
const skillIndexRepairs: Array<Extract<RetryJobWorkItem, { kind: "skillVersion" }>> = [];
for (const workItem of group.items) {
result.processed += 1;
try {
@@ -610,17 +597,19 @@ async function processRetryJobGroup(
result.succeeded += 1;
} else if (workItem.kind === "packageRelease") {
if (await hasMatchingPackageReleaseMeta(context, workItem.item)) {
packageIndexRepairs.push(workItem);
await markRetryJobSucceeded(ctx, workItem.job);
result.succeeded += 1;
} else {
await backupPackageReleaseWithIndexLease(ctx, workItem.item, context);
await backupPackageReleaseToObjectStorage(ctx, workItem.item, context);
await markRetryJobSucceeded(ctx, workItem.job);
result.succeeded += 1;
}
} else {
if (await hasMatchingSkillVersionMeta(context, workItem.item)) {
skillIndexRepairs.push(workItem);
await markRetryJobSucceeded(ctx, workItem.job);
result.succeeded += 1;
} else {
await backupSkillVersionWithIndexLease(ctx, workItem.item, context);
await backupSkillVersionToObjectStorage(ctx, workItem.item, context);
await markRetryJobSucceeded(ctx, workItem.job);
result.succeeded += 1;
}
@@ -637,200 +626,9 @@ async function processRetryJobGroup(
);
}
}
await flushPackageIndexRepairs(ctx, context, packageIndexRepairs, result);
await flushSkillIndexRepairs(ctx, context, skillIndexRepairs, result);
return result;
}
async function flushPackageIndexRepairs(
ctx: ActionCtx,
context: RegistryArtifactBackupContext,
workItems: Array<Extract<RetryJobWorkItem, { kind: "packageRelease" }>>,
result: { succeeded: number; failed: number },
) {
if (workItems.length === 0) return;
let splitItems: {
indexed: Array<Extract<RetryJobWorkItem, { kind: "packageRelease" }>>;
missing: Array<Extract<RetryJobWorkItem, { kind: "packageRelease" }>>;
};
try {
splitItems = await splitPackageIndexRepairItems(context, workItems);
} catch (error) {
result.failed += workItems.length;
await markRetryJobsFailed(ctx, workItems, error);
return;
}
const { indexed, missing } = splitItems;
await markIndexedRetryJobsSucceeded(ctx, indexed, result);
if (missing.length === 0) return;
try {
await repairPackageReleaseBackupIndexes(
ctx,
missing.map((workItem) => workItem.item),
context,
{
withIndexWrite: (indexPath, write) =>
withRegistryArtifactBackupIndexLease(ctx, indexPath, write),
},
);
for (const workItem of missing) {
await markRetryJobSucceeded(ctx, workItem.job);
}
result.succeeded += missing.length;
} catch (error) {
result.failed += missing.length;
await markRetryJobsFailed(ctx, missing, error);
}
}
async function flushSkillIndexRepairs(
ctx: ActionCtx,
context: RegistryArtifactBackupContext,
workItems: Array<Extract<RetryJobWorkItem, { kind: "skillVersion" }>>,
result: { succeeded: number; failed: number },
) {
if (workItems.length === 0) return;
let splitItems: {
indexed: Array<Extract<RetryJobWorkItem, { kind: "skillVersion" }>>;
missing: Array<Extract<RetryJobWorkItem, { kind: "skillVersion" }>>;
};
try {
splitItems = await splitSkillIndexRepairItems(context, workItems);
} catch (error) {
result.failed += workItems.length;
await markRetryJobsFailed(ctx, workItems, error);
return;
}
const { indexed, missing } = splitItems;
await markIndexedRetryJobsSucceeded(ctx, indexed, result);
if (missing.length === 0) return;
try {
await repairSkillVersionBackupIndexes(
ctx,
missing.map((workItem) => workItem.item),
context,
{
withIndexWrite: (indexPath, write) =>
withRegistryArtifactBackupIndexLease(ctx, indexPath, write),
},
);
for (const workItem of missing) {
await markRetryJobSucceeded(ctx, workItem.job);
}
result.succeeded += missing.length;
} catch (error) {
result.failed += missing.length;
await markRetryJobsFailed(ctx, missing, error);
}
}
async function splitPackageIndexRepairItems(
context: RegistryArtifactBackupContext,
workItems: Array<Extract<RetryJobWorkItem, { kind: "packageRelease" }>>,
) {
const first = workItems[0];
const index = first
? await fetchPackageBackupIndex(context, first.item.ownerHandle, first.item.normalizedName)
: null;
const indexed: Array<Extract<RetryJobWorkItem, { kind: "packageRelease" }>> = [];
const missing: Array<Extract<RetryJobWorkItem, { kind: "packageRelease" }>> = [];
for (const workItem of workItems) {
const present = packageIndexEntryMatchesRetry(index, workItem.item);
(present ? indexed : missing).push(workItem);
}
return { indexed, missing };
}
async function splitSkillIndexRepairItems(
context: RegistryArtifactBackupContext,
workItems: Array<Extract<RetryJobWorkItem, { kind: "skillVersion" }>>,
) {
const first = workItems[0];
const index = first
? await fetchSkillBackupIndex(context, first.item.ownerHandle, first.item.slug)
: null;
const indexed: Array<Extract<RetryJobWorkItem, { kind: "skillVersion" }>> = [];
const missing: Array<Extract<RetryJobWorkItem, { kind: "skillVersion" }>> = [];
for (const workItem of workItems) {
const present = skillIndexEntryMatchesRetry(index, workItem.item);
(present ? indexed : missing).push(workItem);
}
return { indexed, missing };
}
function packageIndexEntryMatchesRetry(
index: Awaited<ReturnType<typeof fetchPackageBackupIndex>>,
item: Extract<RetryJobWorkItem, { kind: "packageRelease" }>["item"],
) {
const entry = index?.versions.find(
(candidate) => candidate.releaseId === item.releaseId && candidate.version === item.version,
);
if (!entry) return false;
if (typeof item.isLatest !== "boolean") return true;
if (entry.isLatest !== item.isLatest) return false;
const latestMatches = Boolean(
index && index.latest.releaseId === item.releaseId && index.latest.version === item.version,
);
return item.isLatest ? latestMatches : !latestMatches;
}
function skillIndexEntryMatchesRetry(
index: Awaited<ReturnType<typeof fetchSkillBackupIndex>>,
item: Extract<RetryJobWorkItem, { kind: "skillVersion" }>["item"],
) {
const entry = index?.versions.find(
(candidate) => candidate.versionId === item.versionId && candidate.version === item.version,
);
if (!entry) return false;
if (typeof item.isLatest !== "boolean") return true;
if (entry.isLatest !== item.isLatest) return false;
const latestMatches = Boolean(
index && index.latest.versionId === item.versionId && index.latest.version === item.version,
);
return item.isLatest ? latestMatches : !latestMatches;
}
async function markIndexedRetryJobsSucceeded(
ctx: ActionCtx,
workItems: ArtifactRetryJobWorkItem[],
result: { succeeded: number; failed: number },
) {
for (const workItem of workItems) {
try {
await markRetryJobSucceeded(ctx, workItem.job);
result.succeeded += 1;
} catch (error) {
result.failed += 1;
try {
await markRetryJobsFailed(ctx, [workItem], error);
} catch (markFailedError) {
console.error("Registry artifact backup retry status update failed", markFailedError);
}
}
}
}
async function markRetryJobsFailed(
ctx: ActionCtx,
workItems: ArtifactRetryJobWorkItem[],
error: unknown,
) {
for (const workItem of workItems) {
await ctx.runMutation(
internal.registryArtifactBackups.markRegistryArtifactBackupJobFailedInternal,
{
jobId: workItem.job._id,
error: errorMessage(error),
maxAttempts: MAX_RETRY_REPAIR_ATTEMPTS,
},
);
}
}
async function markRetryJobSucceeded(ctx: ActionCtx, job: Doc<"registryArtifactBackupJobs">) {
await ctx.runMutation(
internal.registryArtifactBackups.markRegistryArtifactBackupJobSucceededInternal,
@@ -905,68 +703,6 @@ function chunkRetryJobGroups(groups: RetryJobGroup[]) {
return chunks;
}
async function backupSkillVersionWithIndexLease(
ctx: ActionCtx,
item: Parameters<typeof backupSkillVersionToObjectStorage>[1],
context: RegistryArtifactBackupContext = getRegistryArtifactBackupContext(),
) {
await backupSkillVersionToObjectStorage(ctx, item, context, {
withIndexWrite: (indexPath, write) =>
withRegistryArtifactBackupIndexLease(ctx, indexPath, write),
});
}
async function backupPackageReleaseWithIndexLease(
ctx: ActionCtx,
item: Parameters<typeof backupPackageReleaseToObjectStorage>[1],
context: RegistryArtifactBackupContext = getRegistryArtifactBackupContext(),
) {
await backupPackageReleaseToObjectStorage(ctx, item, context, {
withIndexWrite: (indexPath, write) =>
withRegistryArtifactBackupIndexLease(ctx, indexPath, write),
});
}
async function withRegistryArtifactBackupIndexLease<T>(
ctx: ActionCtx,
indexPath: string,
run: () => Promise<T>,
) {
const token = `index-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const deadline = Date.now() + INDEX_LEASE_MAX_WAIT_MS;
while (true) {
const lease = (await ctx.runMutation(
internal.registryArtifactBackups.tryAcquireRegistryArtifactBackupIndexLeaseInternal,
{
indexPath,
token,
ttlMs: INDEX_LEASE_TTL_MS,
},
)) as { acquired: boolean };
if (lease.acquired) break;
if (Date.now() >= deadline) {
throw new Error(`Registry artifact backup index ${indexPath} is busy`);
}
await sleep(INDEX_LEASE_RETRY_DELAY_MS);
}
try {
return await run();
} finally {
await ctx.runMutation(
internal.registryArtifactBackups.releaseRegistryArtifactBackupIndexLeaseInternal,
{
indexPath,
token,
},
);
}
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getPackageBackupItemForRelease(
ctx: ActionCtx,
releaseId: Id<"packageReleases">,
+65 -11
View File
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { restoreSkillFromBackup } from "./registryArtifactRestore";
const registryBackupMocks = vi.hoisted(() => ({
fetchSkillBackupIndex: vi.fn(),
fetchSkillVersionBackupMeta: vi.fn(),
getRegistryArtifactBackupContext: vi.fn(),
isRegistryArtifactBackupConfigured: vi.fn(),
@@ -54,6 +53,7 @@ describe("restoreSkillFromBackup", () => {
ownerHandle: "alice",
ownerUserId: "users:owner",
slug: "demo-skill",
version: "1.0.0",
},
);
@@ -62,13 +62,70 @@ describe("restoreSkillFromBackup", () => {
status: "error",
detail: "Existing skill is not public; restore blocked",
});
expect(registryBackupMocks.fetchSkillBackupIndex).not.toHaveBeenCalled();
expect(registryBackupMocks.fetchSkillVersionBackupMeta).not.toHaveBeenCalled();
});
it("requires an explicit version before any forced slug eviction", async () => {
const runMutation = vi.fn();
const result = await restoreHandler(
{
runQuery: vi.fn().mockResolvedValueOnce({ _id: "users:admin", role: "admin" }),
runMutation,
} as never,
{
actorUserId: "users:admin",
ownerHandle: "alice",
ownerUserId: "users:owner",
slug: "demo-skill",
forceOverwriteSquatter: true,
},
);
expect(result).toEqual({
slug: "demo-skill",
status: "no_backup",
detail: "Restore requires an explicit backup version",
});
expect(runMutation).not.toHaveBeenCalled();
expect(registryBackupMocks.fetchSkillVersionBackupMeta).not.toHaveBeenCalled();
});
it("validates the requested version before forced slug eviction", async () => {
registryBackupMocks.fetchSkillVersionBackupMeta.mockResolvedValueOnce(null);
const runMutation = vi.fn();
const result = await restoreHandler(
{
runQuery: vi
.fn()
.mockResolvedValueOnce({ _id: "users:admin", role: "admin" })
.mockResolvedValueOnce({
_id: "skills:squatter",
ownerUserId: "users:other",
slug: "demo-skill",
softDeletedAt: undefined,
moderationStatus: "active",
}),
runMutation,
} as never,
{
actorUserId: "users:admin",
ownerHandle: "alice",
ownerUserId: "users:owner",
slug: "demo-skill",
version: "typo-version",
forceOverwriteSquatter: true,
},
);
expect(result).toEqual({
slug: "demo-skill",
status: "no_backup",
detail: "No version backup found",
});
expect(runMutation).not.toHaveBeenCalled();
});
it("reactivates the same owner's soft-deleted skill row without republishing a duplicate version", async () => {
registryBackupMocks.fetchSkillBackupIndex.mockResolvedValueOnce({
latest: { version: "1.0.0" },
});
registryBackupMocks.fetchSkillVersionBackupMeta.mockResolvedValueOnce({
version: "1.0.0",
displayName: "Demo Skill",
@@ -114,6 +171,7 @@ describe("restoreSkillFromBackup", () => {
ownerHandle: "alice",
ownerUserId: "users:owner",
slug: "demo-skill",
version: "1.0.0",
},
);
@@ -148,9 +206,6 @@ describe("restoreSkillFromBackup", () => {
});
it("fails restore when a manifest file is missing from backup storage", async () => {
registryBackupMocks.fetchSkillBackupIndex.mockResolvedValueOnce({
latest: { version: "1.0.0" },
});
registryBackupMocks.fetchSkillVersionBackupMeta.mockResolvedValueOnce({
version: "1.0.0",
displayName: "Demo Skill",
@@ -174,6 +229,7 @@ describe("restoreSkillFromBackup", () => {
ownerHandle: "alice",
ownerUserId: "users:owner",
slug: "demo-skill",
version: "1.0.0",
},
);
@@ -187,9 +243,6 @@ describe("restoreSkillFromBackup", () => {
});
it("fails restore when a manifest file checksum does not match backup storage", async () => {
registryBackupMocks.fetchSkillBackupIndex.mockResolvedValueOnce({
latest: { version: "1.0.0" },
});
registryBackupMocks.fetchSkillVersionBackupMeta.mockResolvedValueOnce({
version: "1.0.0",
displayName: "Demo Skill",
@@ -215,6 +268,7 @@ describe("restoreSkillFromBackup", () => {
ownerHandle: "alice",
ownerUserId: "users:owner",
slug: "demo-skill",
version: "1.0.0",
},
);
+100 -56
View File
@@ -8,7 +8,6 @@ import { assertAdmin } from "./lib/access";
import { guessContentTypeForPath } from "./lib/contentTypes";
import { isPublicSkillDoc } from "./lib/globalStats";
import {
fetchSkillBackupIndex,
fetchSkillVersionBackupMeta,
getRegistryArtifactBackupContext,
isRegistryArtifactBackupConfigured,
@@ -32,6 +31,19 @@ type BulkRestoreResult = {
totalErrors: number;
};
type SkillBackupMeta = NonNullable<Awaited<ReturnType<typeof fetchSkillVersionBackupMeta>>>;
type VerifiedSkillBackup = {
meta: SkillBackupMeta;
files: Array<{
path: string;
size: number;
sha256: string;
contentType: string;
content: Uint8Array;
}>;
};
/**
* Admin-only: restore a single skill from registry artifact backup.
* Reads backed-up objects and re-creates the skill in the database.
@@ -42,6 +54,7 @@ export const restoreSkillFromBackup = internalAction({
ownerHandle: v.string(),
ownerUserId: v.id("users"),
slug: v.string(),
version: v.optional(v.string()),
forceOverwriteSquatter: v.optional(v.boolean()),
},
handler: async (ctx, args): Promise<RestoreResult> => {
@@ -59,6 +72,80 @@ export const restoreSkillFromBackup = internalAction({
}
const backupContext = getRegistryArtifactBackupContext();
if (!args.version) {
return {
slug: args.slug,
status: "no_backup",
detail: "Restore requires an explicit backup version",
};
}
let verifiedBackup: VerifiedSkillBackup | null = null;
const loadVerifiedBackup = async (): Promise<VerifiedSkillBackup | RestoreResult> => {
if (verifiedBackup) return verifiedBackup;
const meta = await fetchSkillVersionBackupMeta(
backupContext,
args.ownerHandle,
args.slug,
args.version!,
);
if (!meta) {
return { slug: args.slug, status: "no_backup", detail: "No version backup found" };
}
const backupFiles = meta.metadata.files;
if (backupFiles.length === 0) {
return { slug: args.slug, status: "no_backup", detail: "Backup has no files" };
}
const owner = normalizeOwner(args.ownerHandle);
const files: VerifiedSkillBackup["files"] = [];
for (const file of backupFiles) {
if (!validateFilePath(file.path)) {
return { slug: args.slug, status: "error", detail: "Backup contains unsafe file path" };
}
const fileContent = await readRegistryArtifactBackupObject(
backupContext,
`${backupContext.skillsRoot}/${owner}/${args.slug}/${encodeBackupPathSegment(
meta.version,
)}/${file.path}`,
);
if (!fileContent) {
return {
slug: args.slug,
status: "error",
detail: `Backup missing file ${file.path}`,
};
}
if (fileContent.byteLength !== file.size) {
return {
slug: args.slug,
status: "error",
detail: `Backup file size mismatch for ${file.path}`,
};
}
const sha256 = await sha256Hex(fileContent);
if (sha256 !== file.sha256) {
return {
slug: args.slug,
status: "error",
detail: `Backup file checksum mismatch for ${file.path}`,
};
}
files.push({
path: file.path,
size: fileContent.byteLength,
sha256,
contentType: file.contentType ?? guessContentTypeForPath(file.path),
content: fileContent,
});
}
verifiedBackup = { meta, files };
return verifiedBackup;
};
// Check if skill already exists in the DB
const existingSkill = (await ctx.runQuery(
@@ -97,6 +184,8 @@ export const restoreSkillFromBackup = internalAction({
detail: `Slug occupied by another user. Set forceOverwriteSquatter=true to reclaim.`,
};
} else {
const backup = await loadVerifiedBackup();
if ("status" in backup) return backup;
// Free the slug in-transaction by renaming the squatter, then enqueue cleanup.
await ctx.runMutation(
internal.registryArtifactRestoreMutations.evictSquatterSkillForRestoreInternal,
@@ -109,25 +198,9 @@ export const restoreSkillFromBackup = internalAction({
}
}
const index = await fetchSkillBackupIndex(backupContext, args.ownerHandle, args.slug);
if (!index?.latest?.version) {
return { slug: args.slug, status: "no_backup", detail: "No backup index found" };
}
const meta = await fetchSkillVersionBackupMeta(
backupContext,
args.ownerHandle,
args.slug,
index.latest.version,
);
if (!meta) {
return { slug: args.slug, status: "no_backup", detail: "No version backup found" };
}
const backupFiles = meta.metadata.files;
if (backupFiles.length === 0) {
return { slug: args.slug, status: "no_backup", detail: "Backup has no files" };
}
const backup = verifiedBackup ?? (await loadVerifiedBackup());
if ("status" in backup) return backup;
const { meta } = backup;
// Download and store each file in Convex storage
const storedFiles: Array<{
@@ -138,47 +211,16 @@ export const restoreSkillFromBackup = internalAction({
contentType: string;
}> = [];
const owner = normalizeOwner(args.ownerHandle);
for (const file of backupFiles) {
if (!validateFilePath(file.path)) {
return { slug: args.slug, status: "error", detail: "Backup contains unsafe file path" };
}
const fileContent = await readRegistryArtifactBackupObject(
backupContext,
`${backupContext.skillsRoot}/${owner}/${args.slug}/${encodeBackupPathSegment(
meta.version,
)}/${file.path}`,
);
if (!fileContent) {
return { slug: args.slug, status: "error", detail: `Backup missing file ${file.path}` };
}
if (fileContent.byteLength !== file.size) {
return {
slug: args.slug,
status: "error",
detail: `Backup file size mismatch for ${file.path}`,
};
}
const sha256 = await sha256Hex(fileContent);
if (sha256 !== file.sha256) {
return {
slug: args.slug,
status: "error",
detail: `Backup file checksum mismatch for ${file.path}`,
};
}
const contentType = file.contentType ?? guessContentTypeForPath(file.path);
const blob = new Blob([Buffer.from(fileContent)], { type: contentType });
for (const file of backup.files) {
const blob = new Blob([Buffer.from(file.content)], { type: file.contentType });
const storageId = await ctx.storage.store(blob);
storedFiles.push({
path: file.path,
size: fileContent.byteLength,
size: file.size,
storageId,
sha256,
contentType,
sha256: file.sha256,
contentType: file.contentType,
});
}
@@ -251,6 +293,7 @@ export const restoreUserSkillsFromBackup = internalAction({
ownerHandle: v.string(),
ownerUserId: v.id("users"),
slugs: v.array(v.string()),
versionsBySlug: v.optional(v.record(v.string(), v.string())),
forceOverwriteSquatter: v.optional(v.boolean()),
},
handler: async (ctx, args): Promise<BulkRestoreResult> => {
@@ -266,6 +309,7 @@ export const restoreUserSkillsFromBackup = internalAction({
ownerHandle: args.ownerHandle,
ownerUserId: args.ownerUserId,
slug,
version: args.versionsBySlug?.[slug],
forceOverwriteSquatter: args.forceOverwriteSquatter,
})) as RestoreResult;