fix(skills): read cached versions and cancel conversion reads

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-28 17:34:21 -05:00
1 parent 62553671a0
commit 1eb90d80be
4 files changed
+119 -40

No files matched your search

@@ -438,6 +438,22 @@ describe.skipIf(!support.supported)("external skill packages", () => {
expect((await f.resolve()).every(entry => entry.sourceStatus === "available")).toBe(true); expect((await f.resolve()).every(entry => entry.sourceStatus === "available")).toBe(true);
}); });
it("reads verified cached versions offline while independently checking durable bytes and rejecting corrupt files", async () => {
const f = await fixture();
await f.store.files(f.companyId, f.id);
const read = vi.spyOn(storage, "getObject").mockRejectedValue(new Error("storage offline"));
const saved = f.skills[0]!;
expect((await f.service.getVersion(f.companyId, saved.skill.id, saved.version.id))!.fileInventory)
.toEqual([{ path: "SKILL.md", kind: "skill", content: files[0]!.content }]);
expect(await f.store.versionFiles({ ...saved.version, packageFileMap: null })).toEqual(files);
expect(read).not.toHaveBeenCalled();
await expect(f.store.verifyDurable(f.companyId, f.id)).rejects.toThrow("storage offline");
const target = path.join(skillPackagePaths(f.companyId, f.id).directory, files[0]!.path);
await fs.chmod(target, 0o600);
await fs.writeFile(target, "x".repeat(Buffer.byteLength(files[0]!.content)));
await expect(f.store.versionFiles(saved.version)).rejects.toThrow("storage offline");
});
it("retries expired upload claims with fresh keys and only deletes the abandoned attempt", async () => { it("retries expired upload claims with fresh keys and only deletes the abandoned attempt", async () => {
const companyId = await company(); const companyId = await company();
const store = skillPackageStore(db, storage); const store = skillPackageStore(db, storage);
@@ -57,9 +57,9 @@ export function skillPackageMaintenance(db: Db, storage?: StorageService) {
if (row.repositorySnapshotId) await migrateSnapshots({ id: row.repositorySnapshotId, apply: true, clearLegacy: true, signal: options.signal }); if (row.repositorySnapshotId) await migrateSnapshots({ id: row.repositorySnapshotId, apply: true, clearLegacy: true, signal: options.signal });
let packageId = row.packageSnapshotId; let packageId = row.packageSnapshotId;
let fileMap = row.packageFileMap; let fileMap = row.packageFileMap;
if (packageId && skillSnapshotHash(await store.versionFiles(row)) !== skillSnapshotHash(files)) throw new Error(`Legacy version differs from its package: ${row.id}`); if (packageId && skillSnapshotHash(await store.versionFiles(row, true, options.signal)) !== skillSnapshotHash(files)) throw new Error(`Legacy version differs from its package: ${row.id}`);
if (!packageId && row.repositorySnapshotId && row.repositorySkillPath) { if (!packageId && row.repositorySnapshotId && row.repositorySkillPath) {
const manifest = await store.manifest(row.companyId, row.repositorySnapshotId); const manifest = await store.manifest(row.companyId, row.repositorySnapshotId, options.signal);
const byPath = new Map(manifest.files.map(file => [file.path, file])); const byPath = new Map(manifest.files.map(file => [file.path, file]));
const mappings = files.map(file => ({ path: file.path, kind: file.kind, encoding: file.encoding ?? "utf8" as const, const mappings = files.map(file => ({ path: file.path, kind: file.kind, encoding: file.encoding ?? "utf8" as const,
sourcePath: file.path === "SKILL.md" ? row.repositorySkillPath! : path.posix.join(path.posix.dirname(row.repositorySkillPath!), file.path) })); sourcePath: file.path === "SKILL.md" ? row.repositorySkillPath! : path.posix.join(path.posix.dirname(row.repositorySkillPath!), file.path) }));
@@ -71,7 +71,7 @@ export function skillPackageMaintenance(db: Db, storage?: StorageService) {
packageId ??= await store.prepare(row.companyId, files, true, options.signal); packageId ??= await store.prepare(row.companyId, files, true, options.signal);
const legacyMetadata = new Map(files.map(file => [file.path, file])); const legacyMetadata = new Map(files.map(file => [file.path, file]));
fileMap = fileMap ? fileMap.map(file => ({ ...file, kind: legacyMetadata.get(file.path)?.kind ?? file.kind, fileMap = fileMap ? fileMap.map(file => ({ ...file, kind: legacyMetadata.get(file.path)?.kind ?? file.kind,
encoding: legacyMetadata.get(file.path)?.encoding ?? "utf8" })) : skillVersionFileMap(files, await store.manifest(row.companyId, packageId)); encoding: legacyMetadata.get(file.path)?.encoding ?? "utf8" })) : skillVersionFileMap(files, await store.manifest(row.companyId, packageId, options.signal));
const receipt = await store.descriptor(row.companyId, packageId); const receipt = await store.descriptor(row.companyId, packageId);
if (!receipt.storage || !await store.verifyDurable(row.companyId, packageId, options.signal)) throw new Error(`Package must be migrated and verified first: ${packageId}`); if (!receipt.storage || !await store.verifyDurable(row.companyId, packageId, options.signal)) throw new Error(`Package must be migrated and verified first: ${packageId}`);
await db.update(versions).set({ packageSnapshotId: packageId, packageFileMap: fileMap, ...(options.clearLegacy ? { fileInventory: [], migrationAt: null } : {}) }) await db.update(versions).set({ packageSnapshotId: packageId, packageFileMap: fileMap, ...(options.clearLegacy ? { fileInventory: [], migrationAt: null } : {}) })
+63 -35
View File
@@ -182,12 +182,14 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService) {
if (legacy.storage) { row.storage = legacy.storage; row.upload = legacy.upload; } if (legacy.storage) { row.storage = legacy.storage; row.upload = legacy.upload; }
return legacy.files; return legacy.files;
} }
async function manifestFor(row: Descriptor): Promise<SkillPackageManifest> { async function manifestFor(row: Descriptor, cancellation?: AbortSignal): Promise<SkillPackageManifest> {
cancellation?.throwIfAborted();
const paths = skillPackagePaths(row.companyId, row.id); const paths = skillPackagePaths(row.companyId, row.id);
const key = `${paths.manifest}:${row.storage?.manifestSha256 ?? row.contentHash}`; const key = `${paths.manifest}:${row.storage?.manifestSha256 ?? row.contentHash}`;
const cached = manifests.get(key); const cached = manifests.get(key);
if (cached) return cached.manifest; if (cached) return cached.manifest;
return flight(db, `manifest:${key}`, async () => { const load = async () => {
const signal = cancellation ? AbortSignal.any([cancellation, AbortSignal.timeout(180_000)]) : AbortSignal.timeout(180_000);
await ensureSkillCacheDirectory(paths.parent, true); await ensureSkillCacheDirectory(paths.parent, true);
// Legacy manifests have no independently stored checksum. Derive them once // Legacy manifests have no independently stored checksum. Derive them once
// per process from the authoritative row until the operator migrates it. // per process from the authoritative row until the operator migrates it.
@@ -205,12 +207,12 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService) {
} }
if (row.storage) { if (row.storage) {
if (!bytes || packageSha256(bytes) !== row.storage.manifestSha256) { if (!bytes || packageSha256(bytes) !== row.storage.manifestSha256) {
const signal = AbortSignal.timeout(180_000);
try { try {
if (row.storage.provider !== storage().provider) throw new Error("Skill package storage provider changed"); if (row.storage.provider !== storage().provider) throw new Error("Skill package storage provider changed");
bytes = await withPackageIo(() => readBoundedObject(storage(), row.companyId, row.storage!.manifestKey, bytes = await withPackageIo(() => readBoundedObject(storage(), row.companyId, row.storage!.manifestKey,
row.storage!.manifestBytes, row.storage!.manifestSha256, signal), signal); row.storage!.manifestBytes, row.storage!.manifestSha256, signal), signal);
} catch (error) { } catch (error) {
signal.throwIfAborted();
const [legacy] = await db.select({ files: snapshots.fileInventory }).from(snapshots).where(scope(row.companyId, row.id)); const [legacy] = await db.select({ files: snapshots.fileInventory }).from(snapshots).where(scope(row.companyId, row.id));
const saved = legacy?.files.length ? skillPackageManifestBytes(skillPackageManifest(legacy.files)) : null; const saved = legacy?.files.length ? skillPackageManifestBytes(skillPackageManifest(legacy.files)) : null;
if (saved && packageSha256(saved) === row.storage!.manifestSha256) bytes = saved; if (saved && packageSha256(saved) === row.storage!.manifestSha256) bytes = saved;
@@ -222,9 +224,10 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService) {
bytes = skillPackageManifestBytes((await restoreRecipe(row)).manifest); bytes = skillPackageManifestBytes((await restoreRecipe(row)).manifest);
} else if (!bytes) { } else if (!bytes) {
const files = await legacyFiles(row); const files = await legacyFiles(row);
if (row.storage) return manifestFor(row); if (row.storage) return manifestFor(row, cancellation);
bytes = skillPackageManifestBytes(skillPackageManifest(files)); bytes = skillPackageManifestBytes(skillPackageManifest(files));
} }
signal.throwIfAborted();
if (bytes.length > MAX_SKILL_MANIFEST_BYTES) throw new Error("Skill manifest exceeds its byte bound"); if (bytes.length > MAX_SKILL_MANIFEST_BYTES) throw new Error("Skill manifest exceeds its byte bound");
const manifest = validateSkillPackageManifest(JSON.parse(bytes.toString("utf8"))); const manifest = validateSkillPackageManifest(JSON.parse(bytes.toString("utf8")));
if (row.storage && (manifest.contentHash !== row.contentHash || manifest.files.length !== row.storage.fileCount if (row.storage && (manifest.contentHash !== row.contentHash || manifest.files.length !== row.storage.fileCount
@@ -235,7 +238,10 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService) {
finally { await fs.rm(temp, { force: true }); } finally { await fs.rm(temp, { force: true }); }
rememberManifest(key, manifest, bytes.length); rememberManifest(key, manifest, bytes.length);
return manifest; return manifest;
}); };
// Optional work must neither inherit an uncancellable interactive read nor
// cancel a shared read that another caller still needs.
return cancellation ? load() : flight(db, `manifest:${key}`, load);
} }
async function restoreInto(row: Descriptor, manifest: SkillPackageManifest, staging: string, async function restoreInto(row: Descriptor, manifest: SkillPackageManifest, staging: string,
archivePath: string, signal: AbortSignal, allowRecovery = true) { archivePath: string, signal: AbortSignal, allowRecovery = true) {
@@ -279,10 +285,11 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService) {
// Admin reads and retention verification own a scratch directory. They never // Admin reads and retention verification own a scratch directory. They never
// wait for or hold the lock used to prepare and pin an agent's warm files. // wait for or hold the lock used to prepare and pin an agent's warm files.
async function withIsolatedPackage<T>(companyId: string, id: string, async function withIsolatedPackage<T>(companyId: string, id: string,
consume: (root: string, manifest: SkillPackageManifest) => Promise<T>, allowRecovery = true) { consume: (root: string, manifest: SkillPackageManifest) => Promise<T>, allowRecovery = true, cancellation?: AbortSignal) {
const signal = cancellation ? AbortSignal.any([cancellation, AbortSignal.timeout(180_000)]) : AbortSignal.timeout(180_000);
signal.throwIfAborted();
const row = await descriptor(companyId, id); const row = await descriptor(companyId, id);
const manifest = await manifestFor(row); const manifest = await manifestFor(row, signal);
const signal = AbortSignal.timeout(180_000);
return withPackageIo(async () => { return withPackageIo(async () => {
const parent = skillPackagePaths(companyId, id).parent; const parent = skillPackagePaths(companyId, id).parent;
await ensureSkillCacheDirectory(parent, true); await ensureSkillCacheDirectory(parent, true);
@@ -433,10 +440,11 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService) {
async function verifyDurable(companyId: string, id: string, cancellation?: AbortSignal, async function verifyDurable(companyId: string, id: string, cancellation?: AbortSignal,
validate?: (root: string, manifest: SkillPackageManifest) => Promise<boolean>): Promise<boolean> { validate?: (root: string, manifest: SkillPackageManifest) => Promise<boolean>): Promise<boolean> {
const action = async () => { const action = async () => {
cancellation?.throwIfAborted();
const row = await descriptor(companyId, id); const row = await descriptor(companyId, id);
if (!row.storage) { if (!row.storage) {
await manifestFor(row); await manifestFor(row, cancellation);
if (!row.storage) return validate ? withIsolatedPackage(companyId, id, validate, false) : true; if (!row.storage) return validate ? withIsolatedPackage(companyId, id, validate, false, cancellation) : true;
} }
if (row.storage.provider !== storage().provider) throw new Error("Skill package storage provider changed; migrate its objects before switching storage"); if (row.storage.provider !== storage().provider) throw new Error("Skill package storage provider changed; migrate its objects before switching storage");
const signal = cancellation ? AbortSignal.any([cancellation, AbortSignal.timeout(180_000)]) : AbortSignal.timeout(180_000); const signal = cancellation ? AbortSignal.any([cancellation, AbortSignal.timeout(180_000)]) : AbortSignal.timeout(180_000);
@@ -464,18 +472,41 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService) {
}; };
const context = readScope.getStore(); const context = readScope.getStore();
const key = `${companyId}:${id}`; const key = `${companyId}:${id}`;
if (context?.db !== db || validate) return action(); if (context?.db !== db || validate || cancellation) return action();
if (!context.durable.has(key)) context.durable.set(key, action()); if (!context.durable.has(key)) context.durable.set(key, action());
return context.durable.get(key)!; return context.durable.get(key)!;
} }
async function versionFiles(version: StoredSkillVersion, allowRecovery = true): Promise<CompanySkillVersionFileInventoryEntry[]> { async function readPackageFile(root: string, entry: SkillPackageManifest["files"][number], signal?: AbortSignal) {
signal?.throwIfAborted();
const filename = path.join(root, entry.path);
await ensureSkillCacheDirectory(path.dirname(filename));
const handle = await fs.open(filename, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const stat = await handle.stat();
if (!stat.isFile() || stat.size !== entry.size) throw new Error("Invalid cached skill file");
const bytes = Buffer.alloc(entry.size);
let offset = 0;
while (offset < bytes.length) {
signal?.throwIfAborted();
const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset);
if (!bytesRead) throw new Error("Invalid cached skill file");
offset += bytesRead;
}
if (packageSha256(bytes) !== entry.sha256) throw new Error("Invalid cached skill file");
return bytes;
} finally { await handle.close(); }
}
async function versionFiles(version: StoredSkillVersion, allowRecovery = true, cancellation?: AbortSignal): Promise<CompanySkillVersionFileInventoryEntry[]> {
cancellation?.throwIfAborted();
if (!version.packageSnapshotId) { if (!version.packageSnapshotId) {
const [row] = await db.select({ ...skillVersionMetadataColumns, files: companySkillVersions.fileInventory }).from(companySkillVersions).where(and( const [row] = await db.select({ ...skillVersionMetadataColumns, files: companySkillVersions.fileInventory }).from(companySkillVersions).where(and(
eq(companySkillVersions.companyId, version.companyId), eq(companySkillVersions.id, version.id))); eq(companySkillVersions.companyId, version.companyId), eq(companySkillVersions.id, version.id)));
if (row?.packageSnapshotId) return versionFiles(row, allowRecovery); if (row?.packageSnapshotId) return versionFiles(row, allowRecovery, cancellation);
return row?.files ?? []; return row?.files ?? [];
} }
return withIsolatedPackage(version.companyId, version.packageSnapshotId, async (root, manifest) => { const id = version.packageSnapshotId;
const manifest = await manifestFor(await descriptor(version.companyId, id), cancellation);
const read = async (root: string) => {
const byPath = new Map(manifest.files.map(file => [file.path, file])); const byPath = new Map(manifest.files.map(file => [file.path, file]));
const result: CompanySkillVersionFileInventoryEntry[] = []; const result: CompanySkillVersionFileInventoryEntry[] = [];
for (const mapping of version.packageFileMap ?? manifest.files.map(file => ({ ...file, sourcePath: file.path }))) { for (const mapping of version.packageFileMap ?? manifest.files.map(file => ({ ...file, sourcePath: file.path }))) {
@@ -485,11 +516,22 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService) {
if (!file) throw new Error("Skill version references a missing package file"); if (!file) throw new Error("Skill version references a missing package file");
const encoding = mapping.encoding ?? file.encoding; const encoding = mapping.encoding ?? file.encoding;
result.push({ path: mapping.path, kind: mapping.kind, result.push({ path: mapping.path, kind: mapping.kind,
content: (await fs.readFile(path.join(root, file.path))).toString(encoding), content: (await readPackageFile(root, file, cancellation)).toString(encoding),
...(encoding === "base64" ? { encoding } : {}), ...(file.executable ? { executable: true } : {}) }); ...(encoding === "base64" ? { encoding } : {}), ...(file.executable ? { executable: true } : {}) });
} }
return result; return result;
}, allowRecovery); };
// Verify bytes as they are read, without holding the warm preparation lock.
// A descriptor survives eviction; a missing/racing file falls back to storage.
const root = skillPackagePaths(version.companyId, id).directory;
try {
const result = await read(root);
touchSkillPackage(root);
return result;
} catch {
cancellation?.throwIfAborted();
return withIsolatedPackage(version.companyId, id, read, allowRecovery, cancellation);
}
} }
async function readVersionFile(version: StoredSkillVersion, relativePath: string, allowRecovery = true) { async function readVersionFile(version: StoredSkillVersion, relativePath: string, allowRecovery = true) {
assertSkillSnapshotPath(relativePath); assertSkillSnapshotPath(relativePath);
@@ -501,24 +543,10 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService) {
const entry = manifest.files.find(file => file.path === (mapping?.sourcePath ?? relativePath)); const entry = manifest.files.find(file => file.path === (mapping?.sourcePath ?? relativePath));
if (!entry) return undefined; if (!entry) return undefined;
const read = async (root: string) => { const read = async (root: string) => {
const filename = path.join(root, entry.path); const bytes = await readPackageFile(root, entry);
await ensureSkillCacheDirectory(path.dirname(filename)); const encoding = mapping?.encoding ?? entry.encoding;
const handle = await fs.open(filename, constants.O_RDONLY | constants.O_NOFOLLOW); return { path: relativePath, kind: mapping?.kind ?? entry.kind, content: bytes.toString(encoding),
try { encoding, executable: entry.executable };
const stat = await handle.stat();
if (!stat.isFile() || stat.size !== entry.size) throw new Error("Invalid cached skill file");
const bytes = Buffer.alloc(entry.size);
let offset = 0;
while (offset < bytes.length) {
const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset);
if (!bytesRead) throw new Error("Invalid cached skill file");
offset += bytesRead;
}
if (packageSha256(bytes) !== entry.sha256) throw new Error("Invalid cached skill file");
const encoding = mapping?.encoding ?? entry.encoding;
return { path: relativePath, kind: mapping?.kind ?? entry.kind, content: bytes.toString(encoding),
encoding, executable: entry.executable };
} finally { await handle.close(); }
}; };
// Immutable cache files can be read without the preparation lock. An open // Immutable cache files can be read without the preparation lock. An open
// descriptor survives eviction; missing/racing files fall back to storage. // descriptor survives eviction; missing/racing files fall back to storage.
@@ -538,5 +566,5 @@ export function skillPackageStore(db: Db, suppliedStorage?: StorageService) {
return row?.files ?? []; return row?.files ?? [];
} }
return { descriptor, prepare, bindRestore, files, version, prefetch, versionFiles, versionInventory, readVersionFile, withPackage, withIsolatedPackage, verifyDurable, return { descriptor, prepare, bindRestore, files, version, prefetch, versionFiles, versionInventory, readVersionFile, withPackage, withIsolatedPackage, verifyDurable,
manifest: async (companyId: string, id: string) => manifestFor(await descriptor(companyId, id)) }; manifest: async (companyId: string, id: string, cancellation?: AbortSignal) => manifestFor(await descriptor(companyId, id), cancellation) };
} }
+37 -2
View File
@@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { companies, companySkillRepositorySnapshots as snapshots, skillPackageDeletions, createDb } from "@paperclipai/db"; import { companies, companySkills, companySkillVersions as versions, companySkillRepositorySnapshots as snapshots, skillPackageDeletions, createDb } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../__tests__/helpers/embedded-postgres.js";
import { createStorageService } from "../storage/service.js"; import { createStorageService } from "../storage/service.js";
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js"; import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
@@ -103,12 +103,47 @@ describe.skipIf(!support.supported)("automatic skill package work", () => {
it("reports a safe conversion failure reason without logging the legacy payload", async () => { it("reports a safe conversion failure reason without logging the legacy payload", async () => {
const companyId = await company(); const companyId = await company();
const legacy = [{ ...files[0]!, content: "private payload" }]; const legacy = [{ ...files[0]!, content: "private payload" }];
const [row] = await db.insert(snapshots).values({ companyId, contentHash: skillSnapshotHash(files), fileInventory: legacy }).returning(); const [row] = await db.insert(snapshots).values({ companyId, contentHash: skillSnapshotHash(files), fileInventory: legacy, migrationAt: new Date(0) }).returning();
const warning = vi.spyOn(logger, "warn").mockImplementation(() => {}); const warning = vi.spyOn(logger, "warn").mockImplementation(() => {});
await skillPackageWork(db, storage).tick(new AbortController().signal); await skillPackageWork(db, storage).tick(new AbortController().signal);
expect(warning).toHaveBeenCalledWith({ packageId: row!.id, failure: { reason: "legacy_hash_mismatch", errorCode: "unknown" } }, expect.any(String)); expect(warning).toHaveBeenCalledWith({ packageId: row!.id, failure: { reason: "legacy_hash_mismatch", errorCode: "unknown" } }, expect.any(String));
expect(JSON.stringify(warning.mock.calls)).not.toContain("private payload"); expect(JSON.stringify(warning.mock.calls)).not.toContain("private payload");
}); });
it.each(["manifest.json", "files.tar.gz"])("stops conversion during a stalled %s read and retains legacy bytes", async object => {
const companyId = await company();
const packageId = await skillPackageStore(db, storage).prepare(companyId, files);
const [skill] = await db.insert(companySkills).values({ companyId, key: "legacy", slug: "legacy", name: "Legacy", markdown: files[0]!.content }).returning();
const [version] = await db.insert(versions).values({ companyId, companySkillId: skill!.id, revisionNumber: 1, fileInventory: files, migrationAt: new Date(0),
...(object === "manifest.json" ? { repositorySnapshotId: packageId, repositorySkillPath: "SKILL.md" } : { packageSnapshotId: packageId }) }).returning();
const get = storage.getObject.bind(storage);
let signalStarted!: () => void;
const started = new Promise<void>(resolve => { signalStarted = resolve; });
let pendingSignal: AbortSignal | undefined;
let releasePending: () => void = () => {};
vi.spyOn(storage, "getObject").mockImplementation(async (companyId, key, options) => {
if (!key.endsWith(object)) return get(companyId, key, options);
pendingSignal = options?.signal;
signalStarted();
return new Promise<never>((_resolve, reject) => {
releasePending = () => reject(new Error("Test released stalled read"));
pendingSignal?.addEventListener("abort", () => reject(pendingSignal!.reason), { once: true });
});
});
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, canReconcile: () => true, onError: () => {} });
skillPackageWork(db, storage).start(coordinator);
await started;
const stopping = coordinator.stop();
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([stopping, new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => reject(new Error("Conversion did not stop promptly")), 1_000);
})]);
expect(pendingSignal?.aborted).toBe(true);
const [saved] = await db.select().from(versions).where(eq(versions.id, version!.id));
expect(saved!.fileInventory).toEqual(files);
expect(saved!.migrationAt).not.toBeNull();
} finally { clearTimeout(timeout); releasePending(); await stopping; }
});
it.each([ it.each([
["deleteObject", "stop"], ["headObject", "stop"], ["deleteObject", "deadline"], ["headObject", "deadline"], ["deleteObject", "stop"], ["headObject", "stop"], ["deleteObject", "deadline"], ["headObject", "deadline"],
] as const)("releases a stalled %s on %s and retains its deletion receipt", async (method, cancellation) => { ] as const)("releases a stalled %s on %s and retains its deletion receipt", async (method, cancellation) => {