mirror of
https://github.com/langgenius/dify.git
synced 2026-09-02 22:36:15 +08:00
3307 lines
111 KiB
TypeScript
3307 lines
111 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
|
|
import type { ObjectStorageAdapter, Source } from "@knowledge/core";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import type { SourceActiveDocumentInventoryItem } from "./logical-document-repository";
|
|
import type {
|
|
MaterializeSourceDocumentsInput,
|
|
MaterializedSourceDocument,
|
|
SourceDocumentInput,
|
|
SourceDocumentMaterializer,
|
|
} from "./source-document-materializer";
|
|
import { createSourceWorkflowDocumentAssetId } from "./source-document-workflow-ownership";
|
|
import type { PublishSourceLogicalRevisionInput } from "./source-logical-revision-publisher";
|
|
import {
|
|
type NewSourceWorkflowRun,
|
|
type SourceBulkWorkflowItem,
|
|
SourceWorkflowError,
|
|
type SourceWorkflowRun,
|
|
} from "./source-product-workflow";
|
|
import { createInMemorySourceProductWorkflowRepository } from "./source-product-workflow-memory-repository";
|
|
import {
|
|
createObjectStorageSourceWorkflowContentStore,
|
|
createSourceProductWorkflowRuntime,
|
|
} from "./source-product-workflow-runtime";
|
|
|
|
const tenantId = "tenant-source-runtime";
|
|
const knowledgeSpaceId = "space-source-runtime";
|
|
const nowIso = "2026-07-14T12:00:00.000Z";
|
|
const nowMs = Date.parse(nowIso);
|
|
const capabilityGrantId = "grant-source-runtime";
|
|
|
|
describe("source-product workflow staged content store", () => {
|
|
it("writes, reads, and deletes only content in the hashed run scope", async () => {
|
|
const body = new TextEncoder().encode("staged source body");
|
|
const contentHash = createHash("sha256").update(body).digest("hex");
|
|
const storage = fakeObjectStorage({
|
|
listResults: [
|
|
{
|
|
nextCursor: "next-page",
|
|
objects: [
|
|
{ key: "staged-object-a", metadata: {}, sizeBytes: 1 },
|
|
{ key: "staged-object-b", metadata: {}, sizeBytes: 1 },
|
|
],
|
|
},
|
|
{ objects: [] },
|
|
],
|
|
storedBody: body,
|
|
});
|
|
const store = createObjectStorageSourceWorkflowContentStore({
|
|
maxCleanupBatchSize: 2,
|
|
maxObjectBytes: body.byteLength,
|
|
storage,
|
|
});
|
|
|
|
const key = await store.put({
|
|
body,
|
|
contentHash,
|
|
knowledgeSpaceId: "space/with/slash",
|
|
pageId: "page/with/slash",
|
|
runId: "run/with/slash",
|
|
tenantId: "tenant/with/slash",
|
|
});
|
|
|
|
expect(key).toMatch(
|
|
/^__knowledge-source-workflows\/[a-f0-9]{64}\/[a-f0-9]{64}\/[a-f0-9]{64}\/[a-f0-9]{64}-[a-f0-9]{64}\.bin$/,
|
|
);
|
|
expect(storage.putObject).toHaveBeenCalledWith({
|
|
body,
|
|
contentType: "application/octet-stream",
|
|
key,
|
|
metadata: { contentHash, lifecycle: "source-workflow-staging", runId: "run/with/slash" },
|
|
});
|
|
await expect(
|
|
store.get({
|
|
contentObjectKey: key,
|
|
knowledgeSpaceId: "space/with/slash",
|
|
runId: "run/with/slash",
|
|
tenantId: "tenant/with/slash",
|
|
}),
|
|
).resolves.toEqual(body);
|
|
await expect(
|
|
store.deleteRun({
|
|
knowledgeSpaceId: "space/with/slash",
|
|
limit: 2,
|
|
runId: "run/with/slash",
|
|
tenantId: "tenant/with/slash",
|
|
}),
|
|
).resolves.toEqual({ deleted: 2, hasMore: true });
|
|
await expect(
|
|
store.deleteRun({
|
|
knowledgeSpaceId: "space/with/slash",
|
|
limit: 1,
|
|
runId: "run/with/slash",
|
|
tenantId: "tenant/with/slash",
|
|
}),
|
|
).resolves.toEqual({ deleted: 0, hasMore: false });
|
|
expect(storage.deleteObject.mock.calls.map(([deletedKey]) => deletedKey)).toEqual([
|
|
"staged-object-a",
|
|
"staged-object-b",
|
|
]);
|
|
});
|
|
|
|
it("rejects oversized, corrupt, cross-scope, and invalid cleanup requests", async () => {
|
|
const body = new TextEncoder().encode("body");
|
|
const contentHash = createHash("sha256").update(body).digest("hex");
|
|
const storage = fakeObjectStorage({ storedBody: null });
|
|
const store = createObjectStorageSourceWorkflowContentStore({
|
|
maxCleanupBatchSize: 2,
|
|
maxObjectBytes: body.byteLength,
|
|
storage,
|
|
});
|
|
|
|
await expect(
|
|
store.put({
|
|
body: new Uint8Array(body.byteLength + 1),
|
|
contentHash,
|
|
knowledgeSpaceId,
|
|
pageId: "page-a",
|
|
runId: "run-a",
|
|
tenantId,
|
|
}),
|
|
).rejects.toMatchObject({ code: "SOURCE_WORKFLOW_CONTENT_TOO_LARGE" });
|
|
await expect(
|
|
store.put({
|
|
body,
|
|
contentHash: "0".repeat(64),
|
|
knowledgeSpaceId,
|
|
pageId: "page-a",
|
|
runId: "run-a",
|
|
tenantId,
|
|
}),
|
|
).rejects.toMatchObject({ code: "SOURCE_WORKFLOW_CONTENT_HASH_MISMATCH" });
|
|
await expect(
|
|
store.get({
|
|
contentObjectKey: "outside/the/run/scope",
|
|
knowledgeSpaceId,
|
|
runId: "run-a",
|
|
tenantId,
|
|
}),
|
|
).rejects.toMatchObject({ code: "SOURCE_WORKFLOW_CONTENT_SCOPE_MISMATCH" });
|
|
|
|
for (const limit of [0, 3, 1.5, Number.NaN]) {
|
|
await expect(
|
|
store.deleteRun({ knowledgeSpaceId, limit, runId: "run-a", tenantId }),
|
|
).rejects.toMatchObject({ code: "SOURCE_WORKFLOW_CLEANUP_LIMIT_INVALID" });
|
|
}
|
|
expect(storage.putObject).not.toHaveBeenCalled();
|
|
expect(storage.getObject).not.toHaveBeenCalled();
|
|
expect(storage.listObjects).not.toHaveBeenCalled();
|
|
|
|
const defaultStorage = fakeObjectStorage({ storedBody: null });
|
|
const defaultStore = createObjectStorageSourceWorkflowContentStore({ storage: defaultStorage });
|
|
const defaultKey = await defaultStore.put({
|
|
body,
|
|
contentHash,
|
|
knowledgeSpaceId,
|
|
pageId: "page-default",
|
|
runId: "run-default",
|
|
tenantId,
|
|
});
|
|
await expect(
|
|
defaultStore.get({
|
|
contentObjectKey: defaultKey,
|
|
knowledgeSpaceId,
|
|
runId: "run-default",
|
|
tenantId,
|
|
}),
|
|
).resolves.toBeNull();
|
|
await expect(
|
|
defaultStore.deleteRun({ knowledgeSpaceId, limit: 101, runId: "run-default", tenantId }),
|
|
).rejects.toMatchObject({ code: "SOURCE_WORKFLOW_CLEANUP_LIMIT_INVALID" });
|
|
});
|
|
});
|
|
|
|
describe("source-product workflow provider imports", () => {
|
|
it("stages a crawl preview, imports the frozen selection, and drains staged content", async () => {
|
|
let source = sourceRecord("crawl-preview-source", {
|
|
metadata: { preview: true },
|
|
status: "disabled",
|
|
type: "web",
|
|
});
|
|
const sources = {
|
|
get: vi.fn(async () => source),
|
|
update: vi.fn(
|
|
async (input: {
|
|
readonly metadata?: Source["metadata"];
|
|
readonly status?: Source["status"];
|
|
}) => {
|
|
source = {
|
|
...source,
|
|
...(input.metadata ? { metadata: input.metadata } : {}),
|
|
...(input.status ? { status: input.status } : {}),
|
|
version: source.version + 1,
|
|
};
|
|
return source;
|
|
},
|
|
),
|
|
};
|
|
const pages = [
|
|
{
|
|
content: "First page body",
|
|
description: "First description",
|
|
sourceUrl: "https://example.test/first",
|
|
title: "First title",
|
|
},
|
|
{
|
|
content: "Second page body",
|
|
sourceUrl: "https://example.test/second",
|
|
},
|
|
];
|
|
const bodies = new Map<string, Uint8Array>();
|
|
const deleteRun = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ deleted: 1, hasMore: true })
|
|
.mockResolvedValueOnce({ deleted: 1, hasMore: false });
|
|
const contentStore = {
|
|
deleteRun,
|
|
get: vi.fn(
|
|
async ({ contentObjectKey }: { readonly contentObjectKey: string }) =>
|
|
bodies.get(contentObjectKey) ?? null,
|
|
),
|
|
put: vi.fn(
|
|
async ({ body, pageId }: { readonly body: Uint8Array; readonly pageId: string }) => {
|
|
const key = `staged/${pageId}`;
|
|
bodies.set(key, body);
|
|
return key;
|
|
},
|
|
),
|
|
} as unknown as Parameters<typeof createSourceProductWorkflowRuntime>[0]["contentStore"];
|
|
const run = {
|
|
...runRecord(source.id),
|
|
id: "run-crawl-preview",
|
|
idempotencyKey: "crawl-preview",
|
|
kind: "crawl-preview" as const,
|
|
payload: {},
|
|
};
|
|
const fixture = await createFixture({
|
|
contentStore,
|
|
inventory: [],
|
|
maxCleanupBatchesPerRun: 2,
|
|
run,
|
|
source,
|
|
sources: sources as never,
|
|
websiteCrawl: { crawl: vi.fn(async () => ({ pages })) },
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
progressCompleted: 2,
|
|
progressTotal: 2,
|
|
state: "preview_ready",
|
|
});
|
|
const staged = await fixture.repository.listCrawlPages({ limit: 10, runId: run.id });
|
|
expect(staged.items).toHaveLength(2);
|
|
expect(staged.items).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ description: "First description", title: "First title" }),
|
|
expect.not.objectContaining({ description: expect.anything(), title: expect.anything() }),
|
|
]),
|
|
);
|
|
|
|
await fixture.repository.selectCrawlPages({
|
|
accessChannel: "interactive",
|
|
idempotencyKey: "crawl-selection",
|
|
now: nowIso,
|
|
pageIds: staged.items.map((page) => page.pageId),
|
|
permissionSnapshotId: "permission-source",
|
|
permissionSnapshotRevision: 1,
|
|
requestedBySubjectId: "editor-source",
|
|
runId: run.id,
|
|
});
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
progressCompleted: 2,
|
|
progressTotal: 2,
|
|
state: "completed",
|
|
});
|
|
expect(fixture.publish).toHaveBeenCalledTimes(2);
|
|
expect(deleteRun).toHaveBeenCalledTimes(2);
|
|
expect(source).toMatchObject({ metadata: { preview: false }, status: "active", version: 2 });
|
|
});
|
|
|
|
it("advances materialization ownership generation when an explicit retry must avoid tombstones", async () => {
|
|
const ownerships: NonNullable<
|
|
MaterializeSourceDocumentsInput["workflowExecution"]
|
|
>["items"][number][] = [];
|
|
const materializer: SourceDocumentMaterializer = {
|
|
compensate: vi.fn(async () => undefined),
|
|
materialize: vi.fn(async ({ documents, workflowExecution }) => {
|
|
const ownership = workflowExecution?.items[0];
|
|
if (!ownership) throw new Error("workflow ownership is missing");
|
|
ownerships.push(ownership);
|
|
if (ownerships.length === 1) throw new Error("first materialization failed");
|
|
const documentAssetId = createSourceWorkflowDocumentAssetId(ownership);
|
|
return {
|
|
documents: documents.map((document: SourceDocumentInput) => ({
|
|
documentAssetId,
|
|
documentAssetVersion: 1,
|
|
filename: document.filename,
|
|
mimeType: document.mimeType,
|
|
objectKey: `objects/${documentAssetId}`,
|
|
sizeBytes: document.body.byteLength,
|
|
workflowOwnership: ownership,
|
|
})),
|
|
failed: [],
|
|
};
|
|
}),
|
|
};
|
|
const fixture = await createSelectedCrawlFixture({ materializer });
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 0, failed: 1 });
|
|
await fixture.repository.retry({
|
|
accessChannel: "interactive",
|
|
now: nowIso,
|
|
permissionSnapshotId: "permission-retry",
|
|
permissionSnapshotRevision: 1,
|
|
requestedBySubjectId: "editor-source",
|
|
runId: fixture.run.id,
|
|
});
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
|
|
expect(ownerships).toHaveLength(2);
|
|
const [firstOwnership, secondOwnership] = ownerships;
|
|
if (!firstOwnership || !secondOwnership) throw new Error("retry ownership is missing");
|
|
expect(firstOwnership.runId).toBe(secondOwnership.runId);
|
|
expect(secondOwnership.itemKey).toBe(`1:${firstOwnership.itemKey}`);
|
|
expect(createSourceWorkflowDocumentAssetId(firstOwnership)).not.toBe(
|
|
createSourceWorkflowDocumentAssetId(secondOwnership),
|
|
);
|
|
});
|
|
|
|
it("imports online-document records with and without optional identity metadata", async () => {
|
|
const source = sourceRecord("online-document-import-source", {
|
|
connectionId: "connection-document-import",
|
|
type: "connector",
|
|
});
|
|
const getPageContent = vi.fn(
|
|
async ({ page }: { readonly page: { readonly pageId: string } }) => ({
|
|
content: `content:${page.pageId}`,
|
|
}),
|
|
);
|
|
const resolve = vi.fn(async ({ source: unresolved }: { readonly source: Source }) => ({
|
|
...unresolved,
|
|
uri: "https://resolved.example.test",
|
|
}));
|
|
const fixture = await createFixture({
|
|
inventory: [],
|
|
onlineDocuments: { getPageContent },
|
|
run: {
|
|
...runRecord(source.id),
|
|
id: "run-online-document-import",
|
|
idempotencyKey: "online-document-import",
|
|
kind: "online-document-import",
|
|
payload: {
|
|
items: [
|
|
{
|
|
etag: "etag-a",
|
|
name: "Page A",
|
|
pageId: "page-a",
|
|
providerItemId: "provider-page-a",
|
|
type: "page",
|
|
workspaceId: "workspace-a",
|
|
},
|
|
{
|
|
pageId: "page-b",
|
|
providerItemId: "provider-page-b",
|
|
type: "database",
|
|
workspaceId: "workspace-b",
|
|
},
|
|
],
|
|
},
|
|
progressTotal: 2,
|
|
},
|
|
source,
|
|
sourceConnections: { resolve },
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
expect(resolve).toHaveBeenCalledOnce();
|
|
expect(getPageContent).toHaveBeenCalledTimes(2);
|
|
expect(fixture.publish).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
etag: "etag-a",
|
|
providerCoordinate: {
|
|
kind: "online-document",
|
|
pageId: "page-a",
|
|
type: "page",
|
|
workspaceId: "workspace-a",
|
|
},
|
|
providerItemId: "provider-page-a",
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
expect(fixture.publish).toHaveBeenCalledWith(
|
|
expect.not.objectContaining({ etag: expect.anything() }),
|
|
expect.any(Object),
|
|
);
|
|
expect(fixture.sourceUpdates).toHaveBeenCalledOnce();
|
|
const frozenMetadata = fixture.sourceUpdates.mock.calls[0]?.[0].metadata;
|
|
const frozenSelection = frozenMetadata?.__knowledgeFsProviderSelection;
|
|
expect(frozenSelection).toMatchObject({
|
|
coordinateHashes: [
|
|
expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
],
|
|
identityHashes: [
|
|
expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
expect.stringMatching(/^[a-f0-9]{64}$/),
|
|
],
|
|
kind: "online-document",
|
|
version: 1,
|
|
});
|
|
expect(JSON.stringify(frozenSelection)).not.toContain("provider-page");
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
progressCompleted: 2,
|
|
state: "completed",
|
|
});
|
|
});
|
|
|
|
it("imports online-drive files with bounded optional bucket, etag, and MIME metadata", async () => {
|
|
const source = sourceRecord("online-drive-import-source", { type: "connector" });
|
|
const download = vi.fn(
|
|
async ({ file }: { readonly file: { readonly bucket?: string; readonly id: string } }) => ({
|
|
body: new TextEncoder().encode(`${file.bucket ?? "root"}:${file.id}`),
|
|
}),
|
|
);
|
|
const fixture = await createFixture({
|
|
inventory: [],
|
|
onlineDrive: { download },
|
|
run: {
|
|
...runRecord(source.id),
|
|
id: "run-online-drive-import",
|
|
idempotencyKey: "online-drive-import",
|
|
kind: "online-drive-import",
|
|
payload: {
|
|
items: [
|
|
{
|
|
bucket: "bucket-a",
|
|
etag: "etag-file-a",
|
|
id: "file-a",
|
|
mimeType: "text/plain",
|
|
name: "A.txt",
|
|
providerItemId: "provider-file-a",
|
|
},
|
|
{
|
|
id: "file-b",
|
|
name: "B.bin",
|
|
providerItemId: "provider-file-b",
|
|
},
|
|
],
|
|
},
|
|
progressTotal: 2,
|
|
},
|
|
source,
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
expect(download).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({ file: { bucket: "bucket-a", id: "file-a" } }),
|
|
);
|
|
expect(download).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({ file: { id: "file-b" } }),
|
|
);
|
|
expect(fixture.publish).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
etag: "etag-file-a",
|
|
mimeType: "text/plain",
|
|
providerCoordinate: {
|
|
bucket: "bucket-a",
|
|
id: "file-a",
|
|
kind: "online-drive",
|
|
mimeType: "text/plain",
|
|
name: "A.txt",
|
|
},
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
expect(fixture.publish).toHaveBeenCalledWith(
|
|
expect.objectContaining({ mimeType: "application/octet-stream" }),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("rejects a later durable import that expands the first frozen provider selection", async () => {
|
|
const allowedProviderItemId = JSON.stringify(["workspace-a", "page-a"]);
|
|
const expandedProviderItemId = JSON.stringify(["workspace-a", "page-b"]);
|
|
const getPageContent = vi.fn();
|
|
const source = sourceRecord("frozen-online-document-import-source", {
|
|
metadata: frozenSelectionMetadata("online-document", [allowedProviderItemId]),
|
|
});
|
|
const fixture = await createFixture({
|
|
inventory: [],
|
|
onlineDocuments: { getPageContent },
|
|
run: providerRun(source.id, "online-document-import", {
|
|
items: [
|
|
{
|
|
pageId: "page-b",
|
|
providerItemId: expandedProviderItemId,
|
|
type: "page",
|
|
workspaceId: "workspace-a",
|
|
},
|
|
],
|
|
}),
|
|
source,
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 0, failed: 1 });
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
lastErrorCode: "SOURCE_PROVIDER_SELECTION_FROZEN",
|
|
state: "failed",
|
|
});
|
|
expect(getPageContent).not.toHaveBeenCalled();
|
|
expect(fixture.sourceUpdates).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects distinct providerItemIds that alias the same provider coordinate", async () => {
|
|
const getPageContent = vi.fn();
|
|
const source = sourceRecord("duplicate-online-document-coordinate", {});
|
|
const fixture = await createFixture({
|
|
inventory: [],
|
|
onlineDocuments: { getPageContent },
|
|
run: providerRun(source.id, "online-document-import", {
|
|
items: [
|
|
{
|
|
pageId: "page-a",
|
|
providerItemId: "opaque-provider-a",
|
|
type: "page",
|
|
workspaceId: "workspace-a",
|
|
},
|
|
{
|
|
pageId: "page-a",
|
|
providerItemId: "opaque-provider-b",
|
|
type: "page",
|
|
workspaceId: "workspace-a",
|
|
},
|
|
],
|
|
}),
|
|
source,
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 0, failed: 1 });
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
lastErrorCode: "SOURCE_PROVIDER_SELECTION_DUPLICATE",
|
|
state: "failed",
|
|
});
|
|
expect(getPageContent).not.toHaveBeenCalled();
|
|
expect(fixture.sourceUpdates).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("records zero-result previews without staging provider content", async () => {
|
|
const source = sourceRecord("empty-crawl-preview", { type: "web" });
|
|
const put = vi.fn();
|
|
const fixture = await createFixture({
|
|
contentStore: { put } as never,
|
|
inventory: [],
|
|
run: providerRun(source.id, "crawl-preview", {}),
|
|
source,
|
|
websiteCrawl: { crawl: vi.fn(async () => ({ pages: [] })) },
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
await expect(fixture.getRun()).resolves.toMatchObject({ state: "zero_results" });
|
|
expect(put).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails closed for unavailable providers, invalid payloads, and bounded result limits", async () => {
|
|
const source = sourceRecord("provider-failure-source", { type: "connector" });
|
|
const scenarios: readonly {
|
|
readonly expectedCode: string;
|
|
readonly fixture: Parameters<typeof createFixture>[0];
|
|
}[] = [
|
|
{
|
|
expectedCode: "SOURCE_CRAWL_PROVIDER_UNAVAILABLE",
|
|
fixture: {
|
|
inventory: [],
|
|
run: providerRun(source.id, "crawl-preview", {}),
|
|
source,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_ONLINE_DOCUMENT_UNAVAILABLE",
|
|
fixture: {
|
|
inventory: [],
|
|
run: providerRun(source.id, "online-document-import", {
|
|
items: [
|
|
{
|
|
pageId: "page-a",
|
|
providerItemId: "provider-a",
|
|
type: "page",
|
|
workspaceId: "workspace-a",
|
|
},
|
|
],
|
|
}),
|
|
source,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_ONLINE_DRIVE_UNAVAILABLE",
|
|
fixture: {
|
|
inventory: [],
|
|
run: providerRun(source.id, "online-drive-import", {
|
|
items: [{ id: "file-a", name: "A", providerItemId: "provider-a" }],
|
|
}),
|
|
source,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_CRAWL_PROVIDER_UNAVAILABLE",
|
|
fixture: {
|
|
inventory: [],
|
|
run: providerRun(source.id, "crawl-preview", {}),
|
|
source,
|
|
websiteCrawl: { crawl: vi.fn(() => undefined as never) },
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_ONLINE_DOCUMENT_UNAVAILABLE",
|
|
fixture: {
|
|
inventory: [],
|
|
onlineDocuments: { getPageContent: vi.fn(() => undefined as never) },
|
|
run: providerRun(source.id, "online-document-import", {
|
|
items: [
|
|
{
|
|
pageId: "empty-response-page",
|
|
providerItemId: "empty-response-provider-page",
|
|
type: "page",
|
|
workspaceId: "empty-response-workspace",
|
|
},
|
|
],
|
|
}),
|
|
source,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_ONLINE_DRIVE_UNAVAILABLE",
|
|
fixture: {
|
|
inventory: [],
|
|
onlineDrive: { download: vi.fn(() => undefined as never) },
|
|
run: providerRun(source.id, "online-drive-import", {
|
|
items: [
|
|
{
|
|
id: "empty-response-file",
|
|
name: "Empty.bin",
|
|
providerItemId: "empty-response-provider-file",
|
|
},
|
|
],
|
|
}),
|
|
source,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_WORKFLOW_PAYLOAD_INVALID",
|
|
fixture: {
|
|
inventory: [],
|
|
onlineDocuments: { getPageContent: vi.fn() },
|
|
run: providerRun(source.id, "online-document-import", { items: [] }),
|
|
source,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_WORKFLOW_PAYLOAD_INVALID",
|
|
fixture: {
|
|
inventory: [],
|
|
onlineDocuments: { getPageContent: vi.fn() },
|
|
run: providerRun(source.id, "online-document-import", { items: [null] as never }),
|
|
source,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_WORKFLOW_PAYLOAD_INVALID",
|
|
fixture: {
|
|
inventory: [],
|
|
onlineDocuments: { getPageContent: vi.fn() },
|
|
run: providerRun(source.id, "online-document-import", {
|
|
items: [
|
|
{
|
|
pageId: " ",
|
|
providerItemId: "provider-a",
|
|
type: "page",
|
|
workspaceId: "workspace-a",
|
|
},
|
|
],
|
|
}),
|
|
source,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_WORKFLOW_PAYLOAD_INVALID",
|
|
fixture: {
|
|
inventory: [],
|
|
run: providerRun(source.id, "crawl-preview", { selectedPageIds: [1] }),
|
|
source,
|
|
websiteCrawl: { crawl: vi.fn() },
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_CRAWL_RESULT_LIMIT_EXCEEDED",
|
|
fixture: {
|
|
inventory: [],
|
|
maxCrawlPages: 1,
|
|
run: providerRun(source.id, "crawl-preview", {}),
|
|
source,
|
|
websiteCrawl: {
|
|
crawl: vi.fn(async () => ({
|
|
pages: [
|
|
{ content: "A", sourceUrl: "https://example.test/a" },
|
|
{ content: "B", sourceUrl: "https://example.test/b" },
|
|
],
|
|
})),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_SYNC_LIMIT_INVALID",
|
|
fixture: {
|
|
inventory: [],
|
|
maxSyncItems: 0,
|
|
source,
|
|
},
|
|
},
|
|
];
|
|
|
|
for (const scenario of scenarios) {
|
|
const fixture = await createFixture(scenario.fixture);
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 0, failed: 1 });
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
lastErrorCode: scenario.expectedCode,
|
|
state: "failed",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("compensates every materialized document when a provider batch is partial", async () => {
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
const source = sourceRecord("partial-import-source", { type: "connector" });
|
|
const documents: readonly MaterializedSourceDocument[] = [
|
|
{
|
|
documentAssetId: "partial-asset",
|
|
documentAssetVersion: 1,
|
|
filename: "Partial.md",
|
|
mimeType: "text/markdown",
|
|
sizeBytes: 7,
|
|
},
|
|
];
|
|
const compensate = vi.fn(async () => undefined);
|
|
const fixture = await createFixture({
|
|
inventory: [],
|
|
materializer: {
|
|
compensate,
|
|
materialize: vi.fn(async () => ({
|
|
documents,
|
|
failed: [{ code: "PARSE_FAILED", error: "parse failed", filename: "Partial.md" }],
|
|
})),
|
|
},
|
|
onlineDocuments: {
|
|
getPageContent: vi.fn(async () => ({ content: "partial" })),
|
|
},
|
|
run: providerRun(source.id, "online-document-import", {
|
|
items: [
|
|
{
|
|
pageId: "partial-page",
|
|
providerItemId: "partial-provider-item",
|
|
type: "page",
|
|
workspaceId: "partial-workspace",
|
|
},
|
|
],
|
|
}),
|
|
source,
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 0, failed: 1 });
|
|
expect(compensate).toHaveBeenCalledWith(
|
|
expect.objectContaining({ documents, sourceId: source.id }),
|
|
);
|
|
expect(fixture.publish).not.toHaveBeenCalled();
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
lastErrorCode: "PARSE_FAILED",
|
|
lastErrorMessage: "parse failed",
|
|
state: "failed",
|
|
});
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
"Source workflow document materialization failed",
|
|
expect.objectContaining({
|
|
errorCode: "PARSE_FAILED",
|
|
filename: "Partial.md",
|
|
sourceId: source.id,
|
|
}),
|
|
);
|
|
consoleError.mockRestore();
|
|
});
|
|
|
|
it("rejects missing logical-revision ownership proofs and compensates exact writes", async () => {
|
|
const source = sourceRecord("proof-import-source", { type: "connector" });
|
|
const prooflessDocument: MaterializedSourceDocument = {
|
|
documentAssetId: "proofless-asset",
|
|
documentAssetVersion: 1,
|
|
filename: "Proofless.md",
|
|
mimeType: "text/markdown",
|
|
sizeBytes: 9,
|
|
};
|
|
for (const documents of [[], [prooflessDocument]] as const) {
|
|
const compensate = vi.fn(async () => undefined);
|
|
const fixture = await createFixture({
|
|
inventory: [],
|
|
materializer: {
|
|
compensate,
|
|
materialize: vi.fn(async () => ({ documents, failed: [] })),
|
|
},
|
|
onlineDocuments: { getPageContent: vi.fn(async () => ({ content: "proofless" })) },
|
|
run: providerRun(source.id, "online-document-import", {
|
|
items: [
|
|
{
|
|
pageId: "proof-page",
|
|
providerItemId: "proof-provider-item",
|
|
type: "page",
|
|
workspaceId: "proof-workspace",
|
|
},
|
|
],
|
|
}),
|
|
source,
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 0, failed: 1 });
|
|
expect(compensate).toHaveBeenCalledWith(
|
|
expect.objectContaining({ documents, sourceId: source.id }),
|
|
);
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
lastErrorCode: "SOURCE_LOGICAL_REVISION_PROOF_MISSING",
|
|
state: "failed",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("logs logical-revision publication failures and persists a specific workflow error", async () => {
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
const source = sourceRecord("publication-failure-source", { type: "connector" });
|
|
const fixture = await createFixture({
|
|
inventory: [],
|
|
onlineDocuments: { getPageContent: vi.fn(async () => ({ content: "content" })) },
|
|
publishError: new Error("Source compilation terminated as failed: embedding unavailable"),
|
|
run: providerRun(source.id, "online-document-import", {
|
|
items: [
|
|
{
|
|
pageId: "publication-page",
|
|
providerItemId: "publication-provider-item",
|
|
type: "page",
|
|
workspaceId: "publication-workspace",
|
|
},
|
|
],
|
|
}),
|
|
source,
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 0, failed: 1 });
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
lastErrorCode: "SOURCE_DOCUMENT_COMPILATION_FAILED",
|
|
lastErrorMessage: "Source document compilation failed",
|
|
state: "failed",
|
|
});
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
"Source workflow logical revision publication failed",
|
|
expect.objectContaining({
|
|
error: "Source compilation terminated as failed: embedding unavailable",
|
|
sourceId: source.id,
|
|
}),
|
|
);
|
|
consoleError.mockRestore();
|
|
});
|
|
|
|
it("runs the explicit crawl-import kind and cleans an empty frozen selection", async () => {
|
|
const deleteRun = vi.fn(async () => ({ deleted: 0, hasMore: false }));
|
|
const source = sourceRecord("empty-crawl-import", { type: "web" });
|
|
const fixture = await createFixture({
|
|
contentStore: { deleteRun } as never,
|
|
inventory: [],
|
|
run: providerRun(source.id, "crawl-import", { selectedPageIds: [] }),
|
|
source,
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
expect(deleteRun).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("fails a selected URL crawl import when the second crawl does not contain every URL", async () => {
|
|
const source = sourceRecord("selected-url-crawl-import", { type: "web" });
|
|
const fixture = await createFixture({
|
|
contentStore: {
|
|
deleteRun: vi.fn(async () => ({ deleted: 0, hasMore: false })),
|
|
get: vi.fn(async () => null),
|
|
put: vi.fn(async () => "staged/selected-url"),
|
|
},
|
|
inventory: [],
|
|
run: providerRun(source.id, "crawl-import", {
|
|
selectedSourceUrls: ["https://example.test/selected", "https://example.test/missing"],
|
|
}),
|
|
source,
|
|
websiteCrawl: {
|
|
crawl: vi.fn(async () => ({
|
|
pages: [
|
|
{
|
|
content: "selected",
|
|
sourceUrl: "https://example.test/selected",
|
|
title: "Selected",
|
|
},
|
|
],
|
|
})),
|
|
},
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 0, failed: 1 });
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
lastErrorCode: "SOURCE_CRAWL_PAGE_NOT_FOUND",
|
|
state: "failed",
|
|
});
|
|
});
|
|
|
|
it("fails a selected URL crawl import when canonical matching is ambiguous", async () => {
|
|
const source = sourceRecord("ambiguous-crawl-import", { type: "web" });
|
|
const fixture = await createFixture({
|
|
contentStore: {
|
|
deleteRun: vi.fn(async () => ({ deleted: 0, hasMore: false })),
|
|
get: vi.fn(async () => null),
|
|
put: vi.fn(async () => "staged/ambiguous"),
|
|
},
|
|
inventory: [],
|
|
run: providerRun(source.id, "crawl-import", {
|
|
selectedSourceUrls: ["https://example.test/selected#preview"],
|
|
}),
|
|
source,
|
|
websiteCrawl: {
|
|
crawl: vi.fn(async () => ({
|
|
pages: [
|
|
{ content: "one", sourceUrl: "https://example.test/selected", title: "One" },
|
|
{ content: "two", sourceUrl: "https://example.test/selected/", title: "Two" },
|
|
],
|
|
})),
|
|
},
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 0, failed: 1 });
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
lastErrorCode: "SOURCE_CRAWL_PAGE_AMBIGUOUS",
|
|
state: "failed",
|
|
});
|
|
});
|
|
|
|
it("imports only the selected URLs from the second crawl", async () => {
|
|
const source = sourceRecord("selected-url-crawl-success", {
|
|
metadata: { preview: true },
|
|
status: "disabled",
|
|
type: "web",
|
|
});
|
|
const bodies = new Map<string, Uint8Array>();
|
|
const materialize = vi.fn(
|
|
async ({ documents, workflowExecution }: MaterializeSourceDocumentsInput) => ({
|
|
documents: documents.map((document, index) => ({
|
|
documentAssetId: `asset-${index}`,
|
|
documentAssetVersion: 1,
|
|
filename: document.filename,
|
|
mimeType: document.mimeType,
|
|
sizeBytes: document.body.byteLength,
|
|
workflowOwnership: workflowExecution?.items[index],
|
|
})),
|
|
failed: [],
|
|
}),
|
|
);
|
|
const fixture = await createFixture({
|
|
contentStore: {
|
|
deleteRun: vi.fn(async () => ({ deleted: bodies.size, hasMore: false })),
|
|
get: vi.fn(async ({ contentObjectKey }) => bodies.get(contentObjectKey) ?? null),
|
|
put: vi.fn(async ({ body, pageId }) => {
|
|
const key = `staged/${pageId}`;
|
|
bodies.set(key, body);
|
|
return key;
|
|
}),
|
|
},
|
|
inventory: [],
|
|
materializer: {
|
|
compensate: vi.fn(async () => undefined),
|
|
materialize,
|
|
},
|
|
run: providerRun(source.id, "crawl-import", {
|
|
selectedSourceUrls: ["HTTPS://EXAMPLE.TEST:443/selected/#preview"],
|
|
}),
|
|
source,
|
|
websiteCrawl: {
|
|
crawl: vi.fn(async () => ({
|
|
pages: [
|
|
{
|
|
content: "selected body",
|
|
sourceUrl: "https://example.test/selected/",
|
|
title: "Selected",
|
|
},
|
|
{
|
|
content: "unselected body",
|
|
sourceUrl: "https://example.test/unselected",
|
|
title: "Unselected",
|
|
},
|
|
],
|
|
})),
|
|
},
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
expect(materialize).toHaveBeenCalledOnce();
|
|
expect(materialize.mock.calls[0]?.[0].documents).toEqual([
|
|
expect.objectContaining({ filename: expect.stringMatching(/^Selected-.*\.md$/) }),
|
|
]);
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
progressCompleted: 1,
|
|
progressTotal: 1,
|
|
state: "completed",
|
|
});
|
|
});
|
|
|
|
it("fails bounded crawl imports for invalid cleanup, incomplete cleanup, and missing content", async () => {
|
|
const invalidCleanup = await createSelectedCrawlFixture({ maxCleanupBatchesPerRun: 0 });
|
|
await expect(invalidCleanup.runtime.tick()).resolves.toMatchObject({ failed: 1 });
|
|
await expect(invalidCleanup.getRun()).resolves.toMatchObject({
|
|
lastErrorCode: "SOURCE_WORKFLOW_CLEANUP_BATCHES_INVALID",
|
|
state: "failed",
|
|
});
|
|
|
|
const incompleteCleanup = await createSelectedCrawlFixture({
|
|
cleanupHasMore: true,
|
|
maxCleanupBatchesPerRun: 1,
|
|
});
|
|
await expect(incompleteCleanup.runtime.tick()).resolves.toMatchObject({ failed: 1 });
|
|
await expect(incompleteCleanup.getRun()).resolves.toMatchObject({
|
|
lastErrorCode: "SOURCE_WORKFLOW_CLEANUP_INCOMPLETE",
|
|
state: "failed",
|
|
});
|
|
|
|
const missingContent = await createSelectedCrawlFixture({ contentAvailable: false });
|
|
await expect(missingContent.runtime.tick()).resolves.toMatchObject({ failed: 1 });
|
|
await expect(missingContent.getRun()).resolves.toMatchObject({
|
|
lastErrorCode: "SOURCE_WORKFLOW_CONTENT_MISSING",
|
|
state: "failed",
|
|
});
|
|
});
|
|
|
|
it("compensates unchanged and failed logical revision publications", async () => {
|
|
const source = sourceRecord("publication-compensation-source", {
|
|
metadata: { remoteDeletionPolicy: "retain" },
|
|
type: "connector",
|
|
});
|
|
const run = providerRun(source.id, "online-document-import", {
|
|
items: [
|
|
{
|
|
pageId: "publication-page",
|
|
providerItemId: "publication-provider-item",
|
|
type: "page",
|
|
workspaceId: "publication-workspace",
|
|
},
|
|
],
|
|
});
|
|
const unchanged = await createFixture({
|
|
inventory: [],
|
|
onlineDocuments: { getPageContent: vi.fn(async () => ({ content: "unchanged" })) },
|
|
publishKind: "unchanged",
|
|
run,
|
|
source,
|
|
});
|
|
|
|
await expect(unchanged.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
expect(unchanged.publish).toHaveBeenCalledWith(
|
|
expect.objectContaining({ remoteDeletionPolicy: "retain" }),
|
|
expect.any(Object),
|
|
);
|
|
expect(unchanged.materializer.compensate).toHaveBeenCalledTimes(1);
|
|
|
|
const failed = await createFixture({
|
|
inventory: [],
|
|
onlineDocuments: { getPageContent: vi.fn(async () => ({ content: "failed" })) },
|
|
publishError: new Error("publication failed"),
|
|
run,
|
|
source,
|
|
});
|
|
await expect(failed.runtime.tick()).resolves.toMatchObject({ completed: 0, failed: 1 });
|
|
expect(failed.materializer.compensate).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("source-product workflow runtime sync", () => {
|
|
it("starts idempotently and allows every stop handle to drain the shared lane", async () => {
|
|
const fixture = await createFixture({
|
|
intervalMs: 60_000,
|
|
inventory: [],
|
|
source: sourceRecord("runtime-lifecycle", { type: "web" }),
|
|
websiteCrawl: { crawl: vi.fn(async () => ({ pages: [] })) },
|
|
});
|
|
|
|
const stopFirst = fixture.runtime.start();
|
|
const stopSecond = fixture.runtime.start();
|
|
await stopSecond();
|
|
await stopFirst();
|
|
await fixture.runtime.stop();
|
|
});
|
|
|
|
it("publishes new website content and tombstones remote-missing logical documents", async () => {
|
|
const newUrl = "https://example.test/new";
|
|
const newProviderItemId = createHash("sha256").update(newUrl, "utf8").digest("hex");
|
|
const source = sourceRecord("website", { type: "web" });
|
|
const fixture = await createFixture({
|
|
inventory: [inventoryItem("missing-page", "website")],
|
|
source,
|
|
websiteCrawl: {
|
|
crawl: vi.fn(async () => ({
|
|
pages: [{ content: "new content", sourceUrl: newUrl, title: "New page" }],
|
|
})),
|
|
},
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
expect(fixture.publish).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
providerItemId: newProviderItemId,
|
|
providerKind: "website",
|
|
sourceId: source.id,
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
expect(fixture.markRemoteMissing).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
documentId: "document-missing-page",
|
|
policy: "tombstone",
|
|
providerItemId: "missing-page",
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
progressCompleted: 2,
|
|
progressTotal: 2,
|
|
state: "completed",
|
|
});
|
|
});
|
|
|
|
it("uses deterministic website fallbacks for missing and filesystem-unsafe titles", async () => {
|
|
const fixture = await createFixture({
|
|
inventory: [],
|
|
source: sourceRecord("website-filename-fallbacks", { type: "web" }),
|
|
websiteCrawl: {
|
|
crawl: vi.fn(async () => ({
|
|
pages: [
|
|
{ content: "untitled", sourceUrl: "https://example.test/untitled" },
|
|
{
|
|
content: "unsafe title",
|
|
sourceUrl: "https://example.test/unsafe-title",
|
|
title: "!!!",
|
|
},
|
|
],
|
|
})),
|
|
},
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
expect(fixture.publish).toHaveBeenCalledTimes(2);
|
|
expect(fixture.materializer.materialize).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
documents: expect.arrayContaining([
|
|
expect.objectContaining({ filename: expect.stringMatching(/^provider-item-/) }),
|
|
]),
|
|
}),
|
|
);
|
|
expect(fixture.publish).toHaveBeenCalledWith(
|
|
expect.objectContaining({ title: "https://example.test/untitled" }),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("derives remote-missing provider kinds from online inventories", async () => {
|
|
const scenarios: readonly {
|
|
readonly inventory: SourceActiveDocumentInventoryItem;
|
|
readonly onlineDocuments?: object;
|
|
readonly onlineDrive?: object;
|
|
readonly source: Source;
|
|
}[] = [
|
|
{
|
|
inventory: inventoryItem("missing-document", "online-document"),
|
|
onlineDocuments: { listPages: vi.fn(async () => ({ workspaces: [] })) },
|
|
source: sourceRecord("missing-online-document", {
|
|
metadata: { providerKind: "online-document" },
|
|
}),
|
|
},
|
|
{
|
|
inventory: inventoryItem("missing-drive-file", "online-drive"),
|
|
onlineDrive: { browse: vi.fn(async () => ({ buckets: [] })) },
|
|
source: sourceRecord("missing-online-drive", {
|
|
metadata: { providerKind: "online-drive" },
|
|
}),
|
|
},
|
|
];
|
|
|
|
for (const scenario of scenarios) {
|
|
const fixture = await createFixture({
|
|
inventory: [scenario.inventory],
|
|
...(scenario.onlineDocuments ? { onlineDocuments: scenario.onlineDocuments } : {}),
|
|
...(scenario.onlineDrive ? { onlineDrive: scenario.onlineDrive } : {}),
|
|
source: scenario.source,
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
expect(fixture.markRemoteMissing).toHaveBeenCalledWith(
|
|
expect.objectContaining({ providerItemId: scenario.inventory.providerItemId }),
|
|
expect.any(Object),
|
|
);
|
|
}
|
|
});
|
|
|
|
it("resumes a capability workflow without reading a legacy permission snapshot", async () => {
|
|
const source = sourceRecord("capability-website", {
|
|
permissionScope: ["team:camera"],
|
|
type: "web",
|
|
});
|
|
const fixture = await createFixture({
|
|
capability: true,
|
|
inventory: [],
|
|
source,
|
|
websiteCrawl: {
|
|
crawl: vi.fn(async () => ({
|
|
pages: [
|
|
{
|
|
content: "capability content",
|
|
sourceUrl: "https://example.test/capability",
|
|
title: "Capability page",
|
|
},
|
|
],
|
|
})),
|
|
},
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
expect(fixture.revalidatePermissionSnapshot).not.toHaveBeenCalled();
|
|
expect(fixture.assertPublicationAllowed).toHaveBeenCalledWith({
|
|
grantId: capabilityGrantId,
|
|
knowledgeSpaceId,
|
|
tenantId,
|
|
});
|
|
expect(fixture.publish).toHaveBeenCalledWith(
|
|
expect.objectContaining({ capabilityGrantId }),
|
|
expect.any(Object),
|
|
);
|
|
const publication = fixture.publish.mock.calls[0]?.[0];
|
|
expect(publication).not.toHaveProperty("permissionSnapshot");
|
|
expect(publication).not.toHaveProperty("requestedBySubjectId");
|
|
});
|
|
|
|
it("does not expand an empty connected-source inventory into a provider-wide import", async () => {
|
|
const source = sourceRecord("online-document", {
|
|
connectionId: "connection-document",
|
|
metadata: {},
|
|
});
|
|
const listPages = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
nextCursor: "page-2",
|
|
workspaces: [
|
|
{
|
|
pages: [{ lastEditedTime: "v1", pageId: "page-a", pageName: "Page A", type: "page" }],
|
|
workspaceId: "workspace-a",
|
|
},
|
|
],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
workspaces: [
|
|
{
|
|
pages: [{ lastEditedTime: "v2", pageId: "page-b", pageName: "Page B", type: "page" }],
|
|
workspaceId: "workspace-a",
|
|
},
|
|
],
|
|
});
|
|
const fixture = await createFixture({
|
|
inventory: [],
|
|
onlineDocuments: {
|
|
getPageContent: vi.fn(async ({ page }) => ({
|
|
content: `content:${page.pageId}`,
|
|
pageId: page.pageId,
|
|
workspaceId: page.workspaceId,
|
|
})),
|
|
listPages,
|
|
},
|
|
source,
|
|
sourceConnections: {
|
|
get: vi.fn(async () => ({ providerId: "notion-provider" })),
|
|
resolve: vi.fn(async ({ source: unresolved }) => unresolved),
|
|
},
|
|
sourceProviders: {
|
|
get: vi.fn(async () => ({
|
|
available: true,
|
|
capabilities: ["online-document"],
|
|
})),
|
|
},
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
expect(listPages).not.toHaveBeenCalled();
|
|
expect(fixture.publish).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("consumes every online-drive continuation page before completing", async () => {
|
|
const source = sourceRecord("online-drive", {
|
|
metadata: { providerKind: "online-drive" },
|
|
});
|
|
const browse = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
buckets: [
|
|
{
|
|
bucket: "bucket-a",
|
|
continuationToken: "next-a",
|
|
files: [{ id: "file-a", name: "A.txt", type: "file" }],
|
|
isTruncated: true,
|
|
},
|
|
],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
buckets: [
|
|
{
|
|
bucket: "bucket-a",
|
|
files: [{ id: "file-b", name: "B.txt", type: "file" }],
|
|
isTruncated: false,
|
|
},
|
|
],
|
|
});
|
|
const fixture = await createFixture({
|
|
inventory: [inventoryItem("file-a", "online-drive"), inventoryItem("file-b", "online-drive")],
|
|
onlineDrive: {
|
|
browse,
|
|
download: vi.fn(async ({ file }) => ({ body: new TextEncoder().encode(file.id) })),
|
|
},
|
|
source,
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
expect(browse).toHaveBeenCalledTimes(2);
|
|
expect(browse.mock.calls[1]?.[0]).toMatchObject({
|
|
bucket: "bucket-a",
|
|
continuationToken: "next-a",
|
|
});
|
|
expect(fixture.publish.mock.calls.map((call) => call[0].providerItemId).sort()).toEqual([
|
|
"file-a",
|
|
"file-b",
|
|
]);
|
|
});
|
|
|
|
it("keeps a deleted frozen selection empty without browsing or downloading provider roots", async () => {
|
|
const deletedProviderItemId = JSON.stringify(["bucket-a", "deleted/file.txt"]);
|
|
const browse = vi.fn(async () => ({
|
|
buckets: [
|
|
{
|
|
bucket: "bucket-a",
|
|
files: [{ id: "unselected.txt", name: "Unselected", type: "file" }],
|
|
isTruncated: false,
|
|
},
|
|
],
|
|
}));
|
|
const download = vi.fn(async () => ({ body: new TextEncoder().encode("unselected") }));
|
|
const fixture = await createFixture({
|
|
inventory: [],
|
|
onlineDrive: { browse, download },
|
|
source: sourceRecord("deleted-frozen-drive-selection", {
|
|
metadata: frozenSelectionMetadata("online-drive", [deletedProviderItemId]),
|
|
}),
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
expect(browse).not.toHaveBeenCalled();
|
|
expect(download).not.toHaveBeenCalled();
|
|
expect(fixture.publish).not.toHaveBeenCalled();
|
|
expect(fixture.markRemoteMissing).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails before provider work when logical inventory escapes the frozen identity allowlist", async () => {
|
|
const allowedProviderItemId = JSON.stringify(["workspace-a", "page-a"]);
|
|
const mismatchedProviderItemId = JSON.stringify(["workspace-a", "page-b"]);
|
|
const listPages = vi.fn(async () => ({ workspaces: [] }));
|
|
const fixture = await createFixture({
|
|
inventory: [
|
|
inventoryItem(mismatchedProviderItemId, "online-document", {
|
|
kind: "online-document",
|
|
pageId: "page-b",
|
|
type: "page",
|
|
workspaceId: "workspace-a",
|
|
}),
|
|
],
|
|
onlineDocuments: { listPages },
|
|
source: sourceRecord("mismatched-frozen-document-selection", {
|
|
metadata: frozenSelectionMetadata("online-document", [allowedProviderItemId]),
|
|
}),
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 0, failed: 1 });
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
lastErrorCode: "SOURCE_SYNC_SELECTION_MISMATCH",
|
|
state: "failed",
|
|
});
|
|
expect(listPages).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("syncs only selected online-document composite identities", async () => {
|
|
const providerItemId = JSON.stringify(["workspace-a", "page-selected"]);
|
|
const listPages = vi.fn(async () => ({
|
|
workspaces: [
|
|
{
|
|
pages: [
|
|
{
|
|
lastEditedTime: "v2",
|
|
pageId: "page-selected",
|
|
pageName: "Selected",
|
|
type: "page",
|
|
},
|
|
{
|
|
lastEditedTime: "v1",
|
|
pageId: "page-unselected",
|
|
pageName: "Unselected",
|
|
type: "page",
|
|
},
|
|
],
|
|
workspaceId: "workspace-a",
|
|
},
|
|
],
|
|
}));
|
|
const getPageContent = vi.fn(async ({ page }) => ({
|
|
content: `updated:${page.pageId}`,
|
|
pageId: page.pageId,
|
|
workspaceId: page.workspaceId,
|
|
}));
|
|
const fixture = await createFixture({
|
|
inventory: [
|
|
inventoryItem(providerItemId, "online-document", {
|
|
kind: "online-document",
|
|
pageId: "page-selected",
|
|
type: "page",
|
|
workspaceId: "workspace-a",
|
|
}),
|
|
],
|
|
onlineDocuments: { getPageContent, listPages },
|
|
source: sourceRecord("selected-online-document", {
|
|
metadata: frozenSelectionMetadata("online-document", [providerItemId]),
|
|
}),
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
expect(getPageContent).toHaveBeenCalledOnce();
|
|
expect(getPageContent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
page: { pageId: "page-selected", type: "page", workspaceId: "workspace-a" },
|
|
}),
|
|
);
|
|
expect(fixture.publish).toHaveBeenCalledOnce();
|
|
expect(fixture.publish).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
providerCoordinate: {
|
|
kind: "online-document",
|
|
pageId: "page-selected",
|
|
type: "page",
|
|
workspaceId: "workspace-a",
|
|
},
|
|
providerItemId,
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("looks up and downloads only the selected S3 bucket/key composite identity", async () => {
|
|
const providerItemId = JSON.stringify(["bucket-a", "folder/selected.txt"]);
|
|
const browse = vi.fn(async () => ({
|
|
buckets: [
|
|
{
|
|
bucket: "bucket-a",
|
|
files: [
|
|
{ id: "folder/selected.txt", name: "selected.txt", type: "file" },
|
|
{ id: "folder/unselected.txt", name: "unselected.txt", type: "file" },
|
|
],
|
|
isTruncated: false,
|
|
},
|
|
],
|
|
}));
|
|
const download = vi.fn(async ({ file }) => ({
|
|
body: new TextEncoder().encode(`${file.bucket}:${file.id}:updated`),
|
|
}));
|
|
const fixture = await createFixture({
|
|
inventory: [
|
|
inventoryItem(providerItemId, "online-drive", {
|
|
bucket: "bucket-a",
|
|
id: "folder/selected.txt",
|
|
kind: "online-drive",
|
|
mimeType: "text/plain",
|
|
name: "selected.txt",
|
|
}),
|
|
],
|
|
onlineDrive: { browse, download },
|
|
source: sourceRecord("selected-s3-drive", {
|
|
metadata: frozenSelectionMetadata("online-drive", [providerItemId]),
|
|
}),
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
expect(browse).toHaveBeenCalledOnce();
|
|
expect(browse).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
bucket: "bucket-a",
|
|
prefix: "folder/selected.txt",
|
|
}),
|
|
);
|
|
expect(download).toHaveBeenCalledOnce();
|
|
expect(download).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
file: { bucket: "bucket-a", id: "folder/selected.txt" },
|
|
}),
|
|
);
|
|
expect(fixture.publish).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
providerItemId,
|
|
providerKind: "online-drive",
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
expect(fixture.markRemoteMissing).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("tombstones only a selected S3 identity proven missing in its exact bucket", async () => {
|
|
const providerItemId = JSON.stringify(["bucket-a", "folder/missing.txt"]);
|
|
const download = vi.fn();
|
|
const fixture = await createFixture({
|
|
inventory: [
|
|
inventoryItem(providerItemId, "online-drive", {
|
|
bucket: "bucket-a",
|
|
id: "folder/missing.txt",
|
|
kind: "online-drive",
|
|
mimeType: "text/plain",
|
|
name: "missing.txt",
|
|
}),
|
|
],
|
|
onlineDrive: {
|
|
browse: vi.fn(async () => ({
|
|
buckets: [{ bucket: "bucket-a", files: [], isTruncated: false }],
|
|
})),
|
|
download,
|
|
},
|
|
source: sourceRecord("missing-selected-s3-drive", {
|
|
metadata: frozenSelectionMetadata("online-drive", [providerItemId]),
|
|
}),
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
expect(download).not.toHaveBeenCalled();
|
|
expect(fixture.publish).not.toHaveBeenCalled();
|
|
expect(fixture.markRemoteMissing).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
documentId: `document-${providerItemId}`,
|
|
providerItemId,
|
|
}),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it("shares one bounded browse-page budget across every selected drive coordinate", async () => {
|
|
const firstProviderItemId = JSON.stringify(["bucket-a", "first.txt"]);
|
|
const secondProviderItemId = JSON.stringify(["bucket-a", "second.txt"]);
|
|
const browse = vi.fn(async ({ continuationToken, prefix }) => {
|
|
if (prefix === "first.txt") {
|
|
return {
|
|
buckets: [
|
|
{
|
|
bucket: "bucket-a",
|
|
files: [{ id: "first.txt", name: "first.txt", type: "file" }],
|
|
isTruncated: false,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
return {
|
|
buckets: [
|
|
{
|
|
bucket: "bucket-a",
|
|
continuationToken: continuationToken ? undefined : "second-page",
|
|
files: continuationToken
|
|
? [{ id: "second.txt", name: "second.txt", type: "file" }]
|
|
: [],
|
|
isTruncated: continuationToken === undefined,
|
|
},
|
|
],
|
|
};
|
|
});
|
|
const fixture = await createFixture({
|
|
inventory: [
|
|
inventoryItem(firstProviderItemId, "online-drive", {
|
|
bucket: "bucket-a",
|
|
id: "first.txt",
|
|
kind: "online-drive",
|
|
name: "first.txt",
|
|
}),
|
|
inventoryItem(secondProviderItemId, "online-drive", {
|
|
bucket: "bucket-a",
|
|
id: "second.txt",
|
|
kind: "online-drive",
|
|
name: "second.txt",
|
|
}),
|
|
],
|
|
maxSyncItems: 2,
|
|
onlineDrive: { browse, download: vi.fn() },
|
|
source: sourceRecord("bounded-selected-drive-lookups", {
|
|
metadata: frozenSelectionMetadata("online-drive", [
|
|
firstProviderItemId,
|
|
secondProviderItemId,
|
|
]),
|
|
}),
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 0, failed: 1 });
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
lastErrorCode: "SOURCE_SYNC_RESULT_LIMIT_EXCEEDED",
|
|
state: "failed",
|
|
});
|
|
expect(browse).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("skips unchanged online-document revisions and retains remote-missing inventory", async () => {
|
|
const unchangedBody = new TextEncoder().encode("unchanged body");
|
|
const unchangedHash = createHash("sha256").update(unchangedBody).digest("hex");
|
|
const source = sourceRecord("online-document-unchanged", {
|
|
metadata: {
|
|
providerId: "document-provider",
|
|
providerKind: "online-document",
|
|
remoteDeletionPolicy: "retain",
|
|
},
|
|
});
|
|
const fixture = await createFixture({
|
|
inventory: [
|
|
{ ...inventoryItem("page-same-etag", "online-document"), etag: "v1" },
|
|
{
|
|
...inventoryItem("page-same-hash", "online-document"),
|
|
contentHash: unchangedHash,
|
|
etag: "v1",
|
|
},
|
|
{
|
|
...inventoryItem("page-without-etag", "online-document"),
|
|
contentHash: unchangedHash,
|
|
},
|
|
inventoryItem("page-remote-missing", "online-document"),
|
|
],
|
|
onlineDocuments: {
|
|
getPageContent: vi.fn(async () => ({ content: new TextDecoder().decode(unchangedBody) })),
|
|
listPages: vi.fn(async () => ({
|
|
workspaces: [
|
|
{
|
|
pages: [
|
|
{
|
|
lastEditedTime: "v1",
|
|
pageId: "page-same-etag",
|
|
pageName: "Same etag",
|
|
type: "page",
|
|
},
|
|
{
|
|
lastEditedTime: "v2",
|
|
pageId: "page-same-hash",
|
|
pageName: "Same hash",
|
|
type: "page",
|
|
},
|
|
{
|
|
pageId: "page-without-etag",
|
|
pageName: "Without etag",
|
|
type: "page",
|
|
},
|
|
],
|
|
workspaceId: "workspace-a",
|
|
},
|
|
],
|
|
})),
|
|
},
|
|
source,
|
|
sourceProviders: {
|
|
get: vi.fn(async () => ({
|
|
available: false,
|
|
capabilities: ["online-document", "online-drive"],
|
|
})),
|
|
},
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
expect(fixture.materializer.materialize).not.toHaveBeenCalled();
|
|
expect(fixture.publish).not.toHaveBeenCalled();
|
|
expect(fixture.markRemoteMissing).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores drive folders and avoids rematerializing unchanged unbucketed files", async () => {
|
|
const body = new TextEncoder().encode("same drive body");
|
|
const source = sourceRecord("online-drive-unchanged", {
|
|
metadata: { providerKind: "online-drive" },
|
|
});
|
|
const fixture = await createFixture({
|
|
inventory: [
|
|
{
|
|
...inventoryItem("same-file", "online-drive"),
|
|
contentHash: createHash("sha256").update(body).digest("hex"),
|
|
},
|
|
],
|
|
onlineDrive: {
|
|
browse: vi.fn(async () => ({
|
|
buckets: [
|
|
{
|
|
files: [
|
|
{ id: "folder-a", name: "Folder", type: "folder" },
|
|
{ id: "same-file", name: "Same.bin", type: "file" },
|
|
],
|
|
isTruncated: false,
|
|
},
|
|
],
|
|
})),
|
|
download: vi.fn(async () => ({ body })),
|
|
},
|
|
source,
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
expect(fixture.materializer.materialize).not.toHaveBeenCalled();
|
|
expect(fixture.publish).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails closed for unavailable sync providers, result overflow, and missing tombstoning", async () => {
|
|
const upload = await createFixture({
|
|
inventory: [],
|
|
source: sourceRecord("non-provider-upload", { type: "upload" }),
|
|
});
|
|
await expect(upload.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
|
|
const scenarios: readonly {
|
|
readonly expectedCode: string;
|
|
readonly fixture: Parameters<typeof createFixture>[0];
|
|
}[] = [
|
|
{
|
|
expectedCode: "SOURCE_CRAWL_PROVIDER_UNAVAILABLE",
|
|
fixture: { inventory: [], source: sourceRecord("web-unavailable", { type: "web" }) },
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_SYNC_RESULT_LIMIT_EXCEEDED",
|
|
fixture: {
|
|
inventory: [],
|
|
maxSyncItems: 1,
|
|
source: sourceRecord("web-overflow", { type: "web" }),
|
|
websiteCrawl: {
|
|
crawl: vi.fn(async () => ({
|
|
pages: [
|
|
{ content: "A", sourceUrl: "https://example.test/a" },
|
|
{ content: "B", sourceUrl: "https://example.test/b" },
|
|
],
|
|
})),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_ONLINE_DOCUMENT_UNAVAILABLE",
|
|
fixture: {
|
|
inventory: [inventoryItem("page-a", "online-document")],
|
|
source: sourceRecord("document-unavailable", {
|
|
metadata: { providerKind: "online-document" },
|
|
}),
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_ONLINE_DRIVE_UNAVAILABLE",
|
|
fixture: {
|
|
inventory: [inventoryItem("file-a", "online-drive")],
|
|
source: sourceRecord("drive-unavailable", {
|
|
metadata: { providerKind: "online-drive" },
|
|
}),
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_CRAWL_PROVIDER_UNAVAILABLE",
|
|
fixture: {
|
|
inventory: [],
|
|
source: sourceRecord("web-empty-response", { type: "web" }),
|
|
websiteCrawl: { crawl: vi.fn(() => undefined as never) },
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_ONLINE_DOCUMENT_UNAVAILABLE",
|
|
fixture: {
|
|
inventory: [inventoryItem("document-a", "online-document")],
|
|
onlineDocuments: { listPages: vi.fn(() => undefined as never) },
|
|
source: sourceRecord("document-list-empty-response", {
|
|
metadata: { providerKind: "online-document" },
|
|
}),
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_ONLINE_DRIVE_UNAVAILABLE",
|
|
fixture: {
|
|
inventory: [inventoryItem("drive-a", "online-drive")],
|
|
onlineDrive: { browse: vi.fn(() => undefined as never) },
|
|
source: sourceRecord("drive-list-empty-response", {
|
|
metadata: { providerKind: "online-drive" },
|
|
}),
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_ONLINE_DOCUMENT_UNAVAILABLE",
|
|
fixture: {
|
|
inventory: [inventoryItem("page-a", "online-document")],
|
|
onlineDocuments: {
|
|
getPageContent: vi.fn(() => undefined as never),
|
|
listPages: vi.fn(async () => ({
|
|
workspaces: [
|
|
{
|
|
pages: [{ pageId: "page-a", pageName: "Page A", type: "page" }],
|
|
workspaceId: "workspace-a",
|
|
},
|
|
],
|
|
})),
|
|
},
|
|
source: sourceRecord("document-content-empty-response", {
|
|
metadata: { providerKind: "online-document" },
|
|
}),
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_ONLINE_DRIVE_UNAVAILABLE",
|
|
fixture: {
|
|
inventory: [inventoryItem("file-a", "online-drive")],
|
|
onlineDrive: {
|
|
browse: vi.fn(async () => ({
|
|
buckets: [
|
|
{
|
|
files: [{ id: "file-a", name: "File A", type: "file" }],
|
|
isTruncated: false,
|
|
},
|
|
],
|
|
})),
|
|
download: vi.fn(() => undefined as never),
|
|
},
|
|
source: sourceRecord("drive-content-empty-response", {
|
|
metadata: { providerKind: "online-drive" },
|
|
}),
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_REMOTE_DELETION_UNAVAILABLE",
|
|
fixture: {
|
|
inventory: [inventoryItem("missing-website", "website")],
|
|
omitMarkRemoteMissing: true,
|
|
source: sourceRecord("tombstone-unavailable", { type: "web" }),
|
|
websiteCrawl: { crawl: vi.fn(async () => ({ pages: [] })) },
|
|
},
|
|
},
|
|
];
|
|
|
|
for (const scenario of scenarios) {
|
|
const fixture = await createFixture(scenario.fixture);
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 0, failed: 1 });
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
lastErrorCode: scenario.expectedCode,
|
|
state: "failed",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("tombstones capability-scoped remote-missing documents without legacy provenance", async () => {
|
|
const fixture = await createFixture({
|
|
capability: true,
|
|
inventory: [inventoryItem("capability-missing", "website")],
|
|
source: sourceRecord("capability-missing-source", {
|
|
permissionScope: ["team:camera"],
|
|
type: "web",
|
|
}),
|
|
websiteCrawl: { crawl: vi.fn(async () => ({ pages: [] })) },
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
expect(fixture.markRemoteMissing).toHaveBeenCalledWith(
|
|
expect.objectContaining({ capabilityGrantId, providerItemId: "capability-missing" }),
|
|
expect.any(Object),
|
|
);
|
|
const request = fixture.markRemoteMissing.mock.calls[0]?.[0];
|
|
expect(request).not.toHaveProperty("permissionSnapshot");
|
|
expect(request).not.toHaveProperty("requestedBySubjectId");
|
|
});
|
|
|
|
it("rejects ambiguous, duplicate, looping, and over-budget sync inventories", async () => {
|
|
const onlineDocumentSource = sourceRecord("invalid-online-document-sync", {
|
|
metadata: { providerKind: "online-document" },
|
|
});
|
|
const onlineDriveSource = sourceRecord("invalid-online-drive-sync", {
|
|
metadata: { providerKind: "online-drive" },
|
|
});
|
|
const invalidProvenance = {
|
|
...inventoryItem("invalid-provenance", "online-document"),
|
|
systemMetadata: {},
|
|
};
|
|
const scenarios: readonly {
|
|
readonly expectedCode: string;
|
|
readonly fixture: Parameters<typeof createFixture>[0];
|
|
}[] = [
|
|
{
|
|
expectedCode: "SOURCE_SYNC_PROVIDER_KIND_MISSING",
|
|
fixture: { inventory: [], source: sourceRecord("missing-provider-kind", {}) },
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_CONNECTION_UNAVAILABLE",
|
|
fixture: {
|
|
inventory: [],
|
|
source: sourceRecord("missing-connection-resolver", { connectionId: "connection-a" }),
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_SYNC_PROVIDER_KIND_MISSING",
|
|
fixture: { inventory: [invalidProvenance], source: onlineDocumentSource },
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_SYNC_PROVIDER_KIND_CONFLICT",
|
|
fixture: {
|
|
inventory: [
|
|
inventoryItem("mixed-document", "online-document"),
|
|
inventoryItem("mixed-drive", "online-drive"),
|
|
],
|
|
source: onlineDocumentSource,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_SYNC_PROVIDER_KIND_CONFLICT",
|
|
fixture: {
|
|
inventory: [inventoryItem("stored-drive", "online-drive")],
|
|
source: onlineDocumentSource,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_SYNC_PROVIDER_IDENTITY_INVALID",
|
|
fixture: {
|
|
inventory: [inventoryItem("page-a", "online-document")],
|
|
onlineDocuments: {
|
|
listPages: vi.fn(async () => ({
|
|
workspaces: [
|
|
{
|
|
pages: [{ pageId: "page-a", pageName: "A", type: "page" }],
|
|
workspaceId: "",
|
|
},
|
|
],
|
|
})),
|
|
},
|
|
source: onlineDocumentSource,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_SYNC_PROVIDER_IDENTITY_DUPLICATE",
|
|
fixture: {
|
|
inventory: [],
|
|
logicalInventory: {
|
|
listActiveBySource: vi.fn(async () => ({
|
|
items: [
|
|
inventoryItem("duplicate-logical", "online-document"),
|
|
inventoryItem("duplicate-logical", "online-document"),
|
|
],
|
|
})),
|
|
},
|
|
source: onlineDocumentSource,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_SYNC_RESULT_LIMIT_EXCEEDED",
|
|
fixture: {
|
|
inventory: [],
|
|
logicalInventory: {
|
|
listActiveBySource: vi.fn(async () => ({
|
|
items: [inventoryItem("logical-page-a", "online-document")],
|
|
nextCursor: { documentId: "document-a", providerItemId: "logical-page-a" },
|
|
})),
|
|
},
|
|
maxSyncItems: 1,
|
|
source: onlineDocumentSource,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_SYNC_PROVIDER_IDENTITY_DUPLICATE",
|
|
fixture: {
|
|
inventory: [inventoryItem("duplicate-page", "online-document")],
|
|
onlineDocuments: {
|
|
listPages: vi.fn(async () => ({
|
|
workspaces: [
|
|
{
|
|
pages: [
|
|
{ pageId: "duplicate-page", pageName: "A", type: "page" },
|
|
{ pageId: "duplicate-page", pageName: "B", type: "page" },
|
|
],
|
|
workspaceId: "workspace-a",
|
|
},
|
|
],
|
|
})),
|
|
},
|
|
source: onlineDocumentSource,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_SYNC_RESULT_LIMIT_EXCEEDED",
|
|
fixture: {
|
|
inventory: [inventoryItem("page-a", "online-document")],
|
|
maxSyncItems: 1,
|
|
onlineDocuments: {
|
|
listPages: vi.fn(async () => ({
|
|
workspaces: [
|
|
{
|
|
pages: [
|
|
{ pageId: "page-a", pageName: "A", type: "page" },
|
|
{ pageId: "page-b", pageName: "B", type: "page" },
|
|
],
|
|
workspaceId: "workspace-a",
|
|
},
|
|
],
|
|
})),
|
|
},
|
|
source: onlineDocumentSource,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_SYNC_RESULT_LIMIT_EXCEEDED",
|
|
fixture: {
|
|
inventory: [inventoryItem("page-a", "online-document")],
|
|
maxSyncItems: 1,
|
|
onlineDocuments: {
|
|
listPages: vi.fn(async () => ({
|
|
nextCursor: "page-2",
|
|
workspaces: [
|
|
{
|
|
pages: [{ pageId: "page-a", pageName: "A", type: "page" }],
|
|
workspaceId: "workspace-a",
|
|
},
|
|
],
|
|
})),
|
|
},
|
|
source: onlineDocumentSource,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_SYNC_CURSOR_LOOP",
|
|
fixture: {
|
|
inventory: [inventoryItem("selected-page", "online-document")],
|
|
onlineDocuments: {
|
|
listPages: vi.fn(async () => ({ nextCursor: "same-cursor", workspaces: [] })),
|
|
},
|
|
source: onlineDocumentSource,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_SYNC_CURSOR_LOOP",
|
|
fixture: {
|
|
inventory: [inventoryItem("selected-file", "online-drive")],
|
|
onlineDrive: {
|
|
browse: vi.fn(async () => ({
|
|
buckets: [
|
|
{
|
|
bucket: "bucket-a",
|
|
continuationToken: "same-token",
|
|
files: [],
|
|
isTruncated: true,
|
|
},
|
|
],
|
|
})),
|
|
},
|
|
source: onlineDriveSource,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_SYNC_PROVIDER_IDENTITY_DUPLICATE",
|
|
fixture: {
|
|
inventory: [inventoryItem("duplicate-file", "online-drive")],
|
|
onlineDrive: {
|
|
browse: vi.fn(async () => ({
|
|
buckets: [
|
|
{
|
|
bucket: "bucket-a",
|
|
files: [
|
|
{ id: "duplicate-file", name: "A", type: "file" },
|
|
{ id: "duplicate-file", name: "B", type: "file" },
|
|
],
|
|
isTruncated: false,
|
|
},
|
|
],
|
|
})),
|
|
},
|
|
source: onlineDriveSource,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_SYNC_RESULT_LIMIT_EXCEEDED",
|
|
fixture: {
|
|
inventory: [inventoryItem("file-a", "online-drive")],
|
|
maxSyncItems: 1,
|
|
onlineDrive: {
|
|
browse: vi.fn(async () => ({
|
|
buckets: [
|
|
{
|
|
files: [
|
|
{ id: "file-a", name: "A", type: "file" },
|
|
{ id: "file-b", name: "B", type: "file" },
|
|
],
|
|
isTruncated: false,
|
|
},
|
|
],
|
|
})),
|
|
},
|
|
source: onlineDriveSource,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_SYNC_RESULT_LIMIT_EXCEEDED",
|
|
fixture: {
|
|
inventory: [inventoryItem("file-a", "online-drive")],
|
|
maxSyncItems: 1,
|
|
onlineDrive: {
|
|
browse: vi.fn(async () => ({
|
|
buckets: [
|
|
{
|
|
continuationToken: "page-2",
|
|
files: [{ id: "file-a", name: "A", type: "file" }],
|
|
isTruncated: true,
|
|
},
|
|
],
|
|
})),
|
|
},
|
|
source: onlineDriveSource,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_SYNC_CURSOR_INVALID",
|
|
fixture: {
|
|
inventory: [inventoryItem("selected-file", "online-drive")],
|
|
onlineDrive: {
|
|
browse: vi.fn(async () => ({
|
|
buckets: [{ bucket: "bucket-a", files: [], isTruncated: true }],
|
|
})),
|
|
},
|
|
source: onlineDriveSource,
|
|
},
|
|
},
|
|
{
|
|
expectedCode: "SOURCE_SYNC_RESULT_LIMIT_EXCEEDED",
|
|
fixture: {
|
|
inventory: [],
|
|
logicalInventory: {
|
|
listActiveBySource: vi.fn(async () => ({
|
|
items: [
|
|
inventoryItem("over-budget-a", "online-document"),
|
|
inventoryItem("over-budget-b", "online-document"),
|
|
],
|
|
})),
|
|
},
|
|
maxSyncItems: 1,
|
|
source: onlineDocumentSource,
|
|
},
|
|
},
|
|
];
|
|
|
|
for (const scenario of scenarios) {
|
|
const fixture = await createFixture(scenario.fixture);
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 0, failed: 1 });
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
lastErrorCode: scenario.expectedCode,
|
|
state: "failed",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("fails a changed durable listing fingerprint, then retry clears the cursor and succeeds", async () => {
|
|
let clock = nowMs;
|
|
const source = sourceRecord("listing-change", {
|
|
metadata: { providerKind: "online-document" },
|
|
});
|
|
const listingB = [
|
|
{
|
|
lastEditedTime: "v2",
|
|
pageId: "page-b",
|
|
pageName: "Page B",
|
|
type: "page",
|
|
},
|
|
];
|
|
const fixture = await createFixture({
|
|
clock: () => clock,
|
|
inventory: [inventoryItem("page-b", "online-document")],
|
|
onlineDocuments: {
|
|
getPageContent: vi.fn(async ({ page }) => ({ content: "B", pageId: page.pageId })),
|
|
listPages: vi.fn(async () => ({
|
|
workspaces: [{ pages: listingB, workspaceId: "workspace-a" }],
|
|
})),
|
|
},
|
|
source,
|
|
startClaimed: false,
|
|
});
|
|
const claimed = (
|
|
await fixture.repository.claim({
|
|
leaseExpiresAt: "2026-07-14T12:00:01.000Z",
|
|
limit: 1,
|
|
now: nowIso,
|
|
workerId: "stale-worker",
|
|
})
|
|
)[0];
|
|
if (!claimed?.leaseToken) throw new Error("sync run was not claimed");
|
|
await fixture.repository.checkpoint({
|
|
checkpoint: "provider-read",
|
|
cursor: syncCursor("online-document", [["page-a", "workspace-a", "v1", "page"]], 1),
|
|
fence: workflowFence(claimed, "stale-worker"),
|
|
now: nowIso,
|
|
progressCompleted: 1,
|
|
progressTotal: 1,
|
|
state: "syncing",
|
|
});
|
|
|
|
clock = Date.parse("2026-07-14T12:00:02.000Z");
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ failed: 1 });
|
|
const failed = await fixture.getRun();
|
|
expect(failed).toMatchObject({
|
|
lastErrorCode: "SOURCE_SYNC_LISTING_CHANGED",
|
|
state: "failed",
|
|
});
|
|
await fixture.repository.retry({
|
|
accessChannel: "interactive",
|
|
now: "2026-07-14T12:00:03.000Z",
|
|
permissionSnapshotId: "permission-retry",
|
|
permissionSnapshotRevision: 1,
|
|
requestedBySubjectId: "editor-source",
|
|
runId: fixture.run.id,
|
|
});
|
|
clock = Date.parse("2026-07-14T12:00:04.000Z");
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
await expect(fixture.getRun()).resolves.toMatchObject({ state: "completed" });
|
|
expect(fixture.publish).toHaveBeenCalledWith(
|
|
expect.objectContaining({ providerItemId: "page-b" }),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "frozen run scope",
|
|
requiredPermissionScope: ["team:camera"],
|
|
sourcePermissionScope: [] as readonly string[],
|
|
},
|
|
{
|
|
name: "current source scope",
|
|
requiredPermissionScope: [] as readonly string[],
|
|
sourcePermissionScope: ["team:camera"],
|
|
},
|
|
])("fails before provider work when $name is no longer authorized", async (testCase) => {
|
|
const crawl = vi.fn(async () => ({ pages: [] }));
|
|
const fixture = await createFixture({
|
|
inventory: [],
|
|
requiredPermissionScope: testCase.requiredPermissionScope,
|
|
source: sourceRecord(`revoked-${testCase.name}`, {
|
|
permissionScope: [...testCase.sourcePermissionScope],
|
|
type: "web",
|
|
}),
|
|
websiteCrawl: { crawl },
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 0, failed: 1 });
|
|
expect(crawl).not.toHaveBeenCalled();
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
lastErrorCode: "SOURCE_WORKFLOW_PERMISSION_INVALID",
|
|
state: "failed",
|
|
});
|
|
});
|
|
|
|
it("waits for a late materializer settlement and compensates it after external timeout", async () => {
|
|
let release: (() => void) | undefined;
|
|
let markStarted: (() => void) | undefined;
|
|
const started = new Promise<void>((resolve) => {
|
|
markStarted = resolve;
|
|
});
|
|
const gate = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const compensate = vi.fn(async () => undefined);
|
|
const materializer: SourceDocumentMaterializer = {
|
|
compensate,
|
|
materialize: async (request: MaterializeSourceDocumentsInput) => {
|
|
markStarted?.();
|
|
await gate;
|
|
const document = request.documents[0];
|
|
const ownership = request.workflowExecution?.items[0];
|
|
if (!document || !ownership) throw new Error("missing durable materialization proof");
|
|
return {
|
|
documents: [
|
|
{
|
|
documentAssetId: "00000000-0000-4000-8000-000000000099",
|
|
documentAssetVersion: 1,
|
|
filename: document.filename,
|
|
mimeType: document.mimeType,
|
|
objectKey: "tenant/space/documents/asset/raw.md",
|
|
sizeBytes: document.body.byteLength,
|
|
workflowOwnership: ownership,
|
|
},
|
|
],
|
|
failed: [],
|
|
};
|
|
},
|
|
};
|
|
const fixture = await createFixture({
|
|
externalOperationTimeoutMs: 10,
|
|
inventory: [],
|
|
materializer,
|
|
source: sourceRecord("timeout-late", { type: "web" }),
|
|
websiteCrawl: {
|
|
crawl: vi.fn(async () => ({
|
|
pages: [
|
|
{
|
|
content: "late content",
|
|
sourceUrl: "https://example.test/late",
|
|
title: "Late",
|
|
},
|
|
],
|
|
})),
|
|
},
|
|
});
|
|
|
|
let settled = false;
|
|
const tick = fixture.runtime.tick().finally(() => {
|
|
settled = true;
|
|
});
|
|
await started;
|
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
expect(settled).toBe(false);
|
|
release?.();
|
|
|
|
await expect(tick).resolves.toMatchObject({ completed: 0, failed: 1 });
|
|
expect(compensate).toHaveBeenCalledTimes(1);
|
|
expect(fixture.publish).not.toHaveBeenCalled();
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
lastErrorCode: "SOURCE_WORKFLOW_EXTERNAL_TIMEOUT",
|
|
state: "failed",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("source-product workflow runtime bulk orchestration", () => {
|
|
it("aggregates terminal items and isolates disable failures per source", async () => {
|
|
const fixture = await createBulkFixture({
|
|
disableSource: async (sourceId) => {
|
|
if (sourceId === "disable-missing") return null;
|
|
if (sourceId === "disable-not-writable") {
|
|
throw new SourceWorkflowError(
|
|
"SOURCE_WORKFLOW_SOURCE_NOT_WRITABLE",
|
|
"Source is not writable",
|
|
);
|
|
}
|
|
if (sourceId === "disable-error") throw new Error("provider failed");
|
|
return sourceRecord(sourceId, { status: "disabled" });
|
|
},
|
|
getSource: (sourceId) => (sourceId === "source-missing" ? null : sourceRecord(sourceId, {})),
|
|
items: [
|
|
bulkItem("terminal-skipped", "disable", "skipped"),
|
|
bulkItem("terminal-completed", "disable", "completed"),
|
|
bulkItem("terminal-failed", "disable", "failed"),
|
|
bulkItem("disable-success", "disable"),
|
|
bulkItem("source-missing", "disable"),
|
|
bulkItem("disable-missing", "disable"),
|
|
bulkItem("disable-not-writable", "disable"),
|
|
bulkItem("disable-error", "disable"),
|
|
bulkItem("remove-unavailable", "remove"),
|
|
],
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toEqual({
|
|
claimed: 1,
|
|
completed: 1,
|
|
deferred: 0,
|
|
failed: 0,
|
|
stale: 0,
|
|
});
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
progressCompleted: 2,
|
|
progressFailed: 3,
|
|
progressSkipped: 4,
|
|
state: "completed",
|
|
});
|
|
expect(statusesBySource(await fixture.getItems())).toEqual({
|
|
"disable-error": "failed",
|
|
"disable-missing": "skipped",
|
|
"disable-not-writable": "skipped",
|
|
"disable-success": "completed",
|
|
"remove-unavailable": "failed",
|
|
"source-missing": "skipped",
|
|
"terminal-completed": "completed",
|
|
"terminal-failed": "failed",
|
|
"terminal-skipped": "skipped",
|
|
});
|
|
expect(fixture.disableWithPermissionFence).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
id: "disable-success",
|
|
permissionFence: expect.objectContaining({
|
|
permissionSnapshotId: "permission-bulk-runtime",
|
|
requestedBySubjectId: "editor-source",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("defers sync children until completed, zero-result, failed, and canceled states settle", async () => {
|
|
let clock = nowMs;
|
|
const fixture = await createBulkFixture({
|
|
clock: () => clock,
|
|
items: [
|
|
bulkItem("sync-completed", "sync"),
|
|
bulkItem("sync-zero", "sync"),
|
|
bulkItem("sync-failed", "sync"),
|
|
bulkItem("sync-canceled", "sync"),
|
|
],
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ deferred: 1 });
|
|
const children = await fixture.repository.claim({
|
|
leaseExpiresAt: new Date(nowMs + 60_000).toISOString(),
|
|
limit: 10,
|
|
now: nowIso,
|
|
workerId: "bulk-child-worker",
|
|
});
|
|
expect(children).toHaveLength(4);
|
|
const childBySource = new Map(children.map((child) => [child.sourceId, child]));
|
|
const completed = requiredMapValue(childBySource, "sync-completed");
|
|
const zero = requiredMapValue(childBySource, "sync-zero");
|
|
const failed = requiredMapValue(childBySource, "sync-failed");
|
|
const canceled = requiredMapValue(childBySource, "sync-canceled");
|
|
await fixture.repository.complete({
|
|
fence: workflowFence(completed, "bulk-child-worker"),
|
|
now: nowIso,
|
|
});
|
|
await fixture.repository.complete({
|
|
fence: workflowFence(zero, "bulk-child-worker"),
|
|
now: nowIso,
|
|
state: "zero_results",
|
|
});
|
|
await fixture.repository.fail({
|
|
errorCode: "PROVIDER_FAILED",
|
|
errorMessage: "Provider failed",
|
|
fence: workflowFence(failed, "bulk-child-worker"),
|
|
now: nowIso,
|
|
});
|
|
const repositoryGet = fixture.repository.get.bind(fixture.repository);
|
|
vi.spyOn(fixture.repository, "get").mockImplementation(async (request) => {
|
|
const run = await repositoryGet(request);
|
|
return run?.id === canceled.id
|
|
? { ...run, lastErrorCode: undefined, lastErrorMessage: undefined }
|
|
: run;
|
|
});
|
|
|
|
clock = nowMs + 2_000;
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ deferred: 1 });
|
|
await fixture.repository.cancel({
|
|
now: new Date(clock).toISOString(),
|
|
reason: "Canceled by operator",
|
|
runId: canceled.id,
|
|
});
|
|
|
|
clock = nowMs + 4_000;
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, deferred: 0 });
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
progressCompleted: 2,
|
|
progressFailed: 2,
|
|
state: "completed",
|
|
});
|
|
expect(statusesBySource(await fixture.getItems())).toEqual({
|
|
"sync-canceled": "failed",
|
|
"sync-completed": "completed",
|
|
"sync-failed": "failed",
|
|
"sync-zero": "completed",
|
|
});
|
|
expect(await fixture.getItems()).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ errorCode: "PROVIDER_FAILED", sourceId: "sync-failed" }),
|
|
expect.objectContaining({
|
|
errorCode: "SOURCE_BULK_CHILD_CANCELED",
|
|
sourceId: "sync-canceled",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("binds capability-scoped deletion jobs and aggregates every terminal removal outcome", async () => {
|
|
const pendingSources = new Set(["poll-success", "poll-failed", "poll-missing"]);
|
|
let pollSuccessChecks = 0;
|
|
const find = vi.fn(async ({ sourceId }: { readonly sourceId: string }) =>
|
|
pendingSources.has(sourceId)
|
|
? { deletionJobId: `deletion-${sourceId}`, state: "pending" as const }
|
|
: null,
|
|
);
|
|
const get = vi.fn(async ({ sourceId }: { readonly sourceId: string }) => {
|
|
if (sourceId === "poll-success") {
|
|
if (pollSuccessChecks++ === 0) {
|
|
return { deletionJobId: "deletion-poll-success", state: "pending" as const };
|
|
}
|
|
return { deletionJobId: "deletion-poll-success", state: "succeeded" as const };
|
|
}
|
|
if (sourceId === "poll-failed") {
|
|
return { deletionJobId: "deletion-poll-failed", state: "failed" as const };
|
|
}
|
|
return null;
|
|
});
|
|
const request = vi.fn(async ({ sourceId }: { readonly sourceId: string }) => {
|
|
if (sourceId === "immediate-success") {
|
|
return { deletionJobId: "deletion-immediate-success", state: "succeeded" as const };
|
|
}
|
|
if (sourceId === "immediate-failed") {
|
|
return { deletionJobId: "deletion-immediate-failed", state: "failed" as const };
|
|
}
|
|
return null as never;
|
|
});
|
|
const fixture = await createBulkFixture({
|
|
bulkRemoval: { find: find as never, get: get as never, request: request as never },
|
|
bulkChildPollMs: 0,
|
|
capability: true,
|
|
getSource: (sourceId) => (sourceId === "source-missing" ? null : sourceRecord(sourceId, {})),
|
|
items: [
|
|
bulkItem("poll-success", "remove"),
|
|
bulkItem("poll-failed", "remove"),
|
|
bulkItem("poll-missing", "remove"),
|
|
bulkItem("immediate-success", "remove"),
|
|
bulkItem("immediate-failed", "remove"),
|
|
bulkItem("request-missing", "remove"),
|
|
bulkItem("source-missing", "remove"),
|
|
bulkItem("capability-disable", "disable"),
|
|
],
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ deferred: 1 });
|
|
expect(request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
capabilityGrantId,
|
|
sourceId: "immediate-success",
|
|
}),
|
|
);
|
|
expect(request.mock.calls[0]?.[0]).not.toHaveProperty("permissionFence");
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ deferred: 1 });
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, deferred: 0 });
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
progressCompleted: 3,
|
|
progressFailed: 4,
|
|
progressSkipped: 1,
|
|
state: "completed",
|
|
});
|
|
expect(statusesBySource(await fixture.getItems())).toEqual({
|
|
"capability-disable": "completed",
|
|
"immediate-failed": "failed",
|
|
"immediate-success": "completed",
|
|
"poll-failed": "failed",
|
|
"poll-missing": "failed",
|
|
"poll-success": "completed",
|
|
"request-missing": "failed",
|
|
"source-missing": "skipped",
|
|
});
|
|
});
|
|
|
|
it("fails malformed running child references without failing the parent runtime", async () => {
|
|
const fixture = await createBulkFixture({
|
|
bulkRemoval: {
|
|
find: vi.fn(async () => null),
|
|
get: vi.fn(async () => null),
|
|
request: vi.fn(async () => ({ deletionJobId: "unused", state: "pending" as const })),
|
|
},
|
|
items: [
|
|
bulkItem("remove-without-job", "remove", "running"),
|
|
{ ...bulkItem("remove-missing-job", "remove", "running"), deletionJobId: "missing" },
|
|
bulkItem("sync-without-child", "sync", "running"),
|
|
{
|
|
...bulkItem("sync-mismatched-child", "sync", "running"),
|
|
childRunId: "mismatched-child",
|
|
},
|
|
],
|
|
});
|
|
await fixture.repository.start({
|
|
...runRecord("different-source"),
|
|
createdAt: new Date(nowMs + 60_000).toISOString(),
|
|
id: "mismatched-child",
|
|
idempotencyKey: "mismatched-child",
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
progressCompleted: 0,
|
|
progressFailed: 4,
|
|
state: "completed",
|
|
});
|
|
expect((await fixture.getItems()).map((item) => item.errorCode)).toEqual([
|
|
"SOURCE_BULK_REMOVAL_JOB_NOT_FOUND",
|
|
"SOURCE_BULK_REMOVAL_JOB_NOT_FOUND",
|
|
"SOURCE_BULK_CHILD_NOT_FOUND",
|
|
"SOURCE_BULK_CHILD_NOT_FOUND",
|
|
]);
|
|
});
|
|
|
|
it("paginates a large frozen item set and requests legacy removals with a permission fence", async () => {
|
|
const request = vi.fn(async (_input: { readonly sourceId: string }) => ({
|
|
deletionJobId: "deletion-legacy-removal",
|
|
state: "succeeded" as const,
|
|
}));
|
|
const fixture = await createBulkFixture({
|
|
bulkRemoval: {
|
|
find: vi.fn(async () => null),
|
|
get: vi.fn(async () => null),
|
|
request,
|
|
},
|
|
items: [
|
|
...Array.from({ length: 100 }, (_, index) =>
|
|
bulkItem(`terminal-${index.toString().padStart(3, "0")}`, "disable", "completed"),
|
|
),
|
|
bulkItem("zz-legacy-removal", "remove"),
|
|
],
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
progressCompleted: 101,
|
|
state: "completed",
|
|
});
|
|
expect(request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
permissionFence: expect.objectContaining({
|
|
permissionSnapshotId: "permission-bulk-runtime",
|
|
requestedBySubjectId: "editor-source",
|
|
}),
|
|
sourceId: "zz-legacy-removal",
|
|
}),
|
|
);
|
|
expect(request.mock.calls[0]?.[0]).not.toHaveProperty("capabilityGrantId");
|
|
});
|
|
});
|
|
|
|
describe("source-product workflow runtime authorization and recovery", () => {
|
|
it("fails closed for missing, revoked, and invalid permission provenance", async () => {
|
|
const source = sourceRecord("permission-validation", { type: "web" });
|
|
const scenarios: readonly Parameters<typeof createFixture>[0][] = [
|
|
{
|
|
inventory: [],
|
|
run: capabilityRunRecord(source.id),
|
|
source,
|
|
},
|
|
{
|
|
capability: true,
|
|
capabilityGrant: null,
|
|
inventory: [],
|
|
source,
|
|
},
|
|
{
|
|
capability: true,
|
|
capabilityGrant: {
|
|
contentScopeIds: [],
|
|
state: "revoked",
|
|
subjectId: "editor-source",
|
|
},
|
|
inventory: [],
|
|
source,
|
|
},
|
|
{
|
|
capability: true,
|
|
capabilityGrantError: new Error("grant was revoked"),
|
|
inventory: [],
|
|
source,
|
|
},
|
|
{
|
|
inventory: [],
|
|
run: { ...runRecord(source.id), accessChannel: undefined },
|
|
source,
|
|
},
|
|
{
|
|
inventory: [],
|
|
permissionRevision: 2,
|
|
source,
|
|
},
|
|
{
|
|
inventory: [],
|
|
permissionRole: "viewer",
|
|
source,
|
|
},
|
|
];
|
|
|
|
for (const scenario of scenarios) {
|
|
const crawl = vi.fn(async () => ({ pages: [] }));
|
|
const fixture = await createFixture({ ...scenario, websiteCrawl: { crawl } });
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 0, failed: 1 });
|
|
await expect(fixture.getRun()).resolves.toMatchObject({
|
|
lastErrorCode: "SOURCE_WORKFLOW_PERMISSION_INVALID",
|
|
state: "failed",
|
|
});
|
|
expect(crawl).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it("fails when a source disappears after validation and reports stale when failure fencing loses", async () => {
|
|
const source = sourceRecord("disappearing-source", { type: "web" });
|
|
const getSource = vi
|
|
.fn(async (): Promise<Source | null> => source)
|
|
.mockResolvedValueOnce(source)
|
|
.mockResolvedValueOnce(null);
|
|
const disappeared = await createFixture({
|
|
inventory: [],
|
|
source,
|
|
sources: { get: getSource } as never,
|
|
websiteCrawl: { crawl: vi.fn(async () => ({ pages: [] })) },
|
|
});
|
|
|
|
await expect(disappeared.runtime.tick()).resolves.toMatchObject({ failed: 1, stale: 0 });
|
|
await expect(disappeared.getRun()).resolves.toMatchObject({
|
|
lastErrorCode: "SOURCE_NOT_FOUND",
|
|
state: "failed",
|
|
});
|
|
|
|
const stale = await createFixture({ inventory: [], maxSyncItems: 0, source });
|
|
vi.spyOn(stale.repository, "fail").mockRejectedValueOnce(new Error("fence lost"));
|
|
await expect(stale.runtime.tick()).resolves.toMatchObject({ failed: 0, stale: 1 });
|
|
});
|
|
|
|
it("falls back to the claimed fence when deletion capture fails before execution starts", async () => {
|
|
const fixture = await createFixture({
|
|
deletionFence: {
|
|
assertDeletionFenceUnchanged: vi.fn(async () => undefined),
|
|
captureDeletionFence: vi.fn(async () => {
|
|
throw new Error("space deletion raced the claim");
|
|
}),
|
|
},
|
|
inventory: [],
|
|
source: sourceRecord("deletion-race", { type: "web" }),
|
|
});
|
|
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ failed: 1, stale: 0 });
|
|
await expect(fixture.getRun()).resolves.toMatchObject({ state: "failed" });
|
|
});
|
|
|
|
it("uses runtime defaults, resolves source credentials, and stops an active timer directly", async () => {
|
|
const resolve = vi.fn(async ({ source }: { readonly source: Source }) => ({
|
|
...source,
|
|
uri: "https://resolved.example.test",
|
|
}));
|
|
const fixture = await createFixture({
|
|
inventory: [],
|
|
source: sourceRecord("default-runtime", { type: "web" }),
|
|
sourceCredentials: { resolve },
|
|
useSystemClock: true,
|
|
websiteCrawl: { crawl: vi.fn(async () => ({ pages: [] })) },
|
|
});
|
|
|
|
const stopHandle = fixture.runtime.start();
|
|
await fixture.runtime.stop();
|
|
await stopHandle();
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
expect(resolve).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
async function createSelectedCrawlFixture(input: {
|
|
readonly cleanupHasMore?: boolean | undefined;
|
|
readonly contentAvailable?: boolean | undefined;
|
|
readonly materializer?: SourceDocumentMaterializer | undefined;
|
|
readonly maxCleanupBatchesPerRun?: number | undefined;
|
|
}) {
|
|
const body = new TextEncoder().encode("selected crawl body");
|
|
const source = sourceRecord("selected-crawl", { type: "web" });
|
|
const fixture = await createFixture({
|
|
contentStore: {
|
|
deleteRun: vi.fn(async () => ({
|
|
deleted: 0,
|
|
hasMore: input.cleanupHasMore ?? false,
|
|
})),
|
|
get: vi.fn(async () => (input.contentAvailable === false ? null : body)),
|
|
put: vi.fn(async () => "staged/selected-crawl"),
|
|
},
|
|
inventory: [],
|
|
materializer: input.materializer,
|
|
maxCleanupBatchesPerRun: input.maxCleanupBatchesPerRun,
|
|
run: providerRun(source.id, "crawl-preview", {}),
|
|
source,
|
|
websiteCrawl: {
|
|
crawl: vi.fn(async () => ({
|
|
pages: [
|
|
{
|
|
content: new TextDecoder().decode(body),
|
|
sourceUrl: "https://example.test/selected-crawl",
|
|
title: "Selected crawl",
|
|
},
|
|
],
|
|
})),
|
|
},
|
|
});
|
|
await expect(fixture.runtime.tick()).resolves.toMatchObject({ completed: 1, failed: 0 });
|
|
const staged = await fixture.repository.listCrawlPages({ limit: 10, runId: fixture.run.id });
|
|
const page = staged.items[0];
|
|
if (!page) throw new Error("crawl preview was not staged");
|
|
await fixture.repository.selectCrawlPages({
|
|
accessChannel: "interactive",
|
|
idempotencyKey: `select-${fixture.run.id}`,
|
|
now: nowIso,
|
|
pageIds: [page.pageId],
|
|
permissionSnapshotId: "permission-source",
|
|
permissionSnapshotRevision: 1,
|
|
requestedBySubjectId: "editor-source",
|
|
runId: fixture.run.id,
|
|
});
|
|
return fixture;
|
|
}
|
|
|
|
async function createFixture(input: {
|
|
readonly capability?: boolean | undefined;
|
|
readonly capabilityGrant?:
|
|
| {
|
|
readonly contentScopeIds: readonly string[];
|
|
readonly state: "active" | "revoked";
|
|
readonly subjectId: string;
|
|
}
|
|
| null
|
|
| undefined;
|
|
readonly capabilityGrantError?: Error | undefined;
|
|
readonly clock?: (() => number) | undefined;
|
|
readonly contentStore?: Parameters<typeof createSourceProductWorkflowRuntime>[0]["contentStore"];
|
|
readonly deletionFence?: Parameters<
|
|
typeof createSourceProductWorkflowRuntime
|
|
>[0]["deletionFence"];
|
|
readonly externalOperationTimeoutMs?: number | undefined;
|
|
readonly inventory: readonly SourceActiveDocumentInventoryItem[];
|
|
readonly intervalMs?: number | undefined;
|
|
readonly logicalInventory?: Parameters<
|
|
typeof createSourceProductWorkflowRuntime
|
|
>[0]["logicalInventory"];
|
|
readonly materializer?: SourceDocumentMaterializer | undefined;
|
|
readonly maxCleanupBatchesPerRun?: number | undefined;
|
|
readonly maxCrawlPages?: number | undefined;
|
|
readonly maxSyncItems?: number | undefined;
|
|
readonly onlineDocuments?: object | undefined;
|
|
readonly onlineDrive?: object | undefined;
|
|
readonly omitMarkRemoteMissing?: boolean | undefined;
|
|
readonly permissionRevision?: number | undefined;
|
|
readonly permissionRole?: "owner" | "editor" | "viewer" | undefined;
|
|
readonly permissionScopes?: readonly string[] | undefined;
|
|
readonly publishError?: Error | undefined;
|
|
readonly publishKind?: "activated" | "unchanged" | undefined;
|
|
readonly requiredPermissionScope?: readonly string[] | undefined;
|
|
readonly run?: NewSourceWorkflowRun | undefined;
|
|
readonly source: Source;
|
|
readonly sourceConnections?: object | undefined;
|
|
readonly sourceCredentials?: object | undefined;
|
|
readonly sourceProviders?: object | undefined;
|
|
readonly sources?: Parameters<typeof createSourceProductWorkflowRuntime>[0]["sources"];
|
|
readonly startClaimed?: boolean | undefined;
|
|
readonly useSystemClock?: boolean | undefined;
|
|
readonly websiteCrawl?: object | undefined;
|
|
}) {
|
|
let assetSequence = 0;
|
|
const repository = createInMemorySourceProductWorkflowRepository({
|
|
generateLeaseToken: () => `lease-${++assetSequence}`,
|
|
});
|
|
const run = await repository.start(
|
|
input.run ??
|
|
(input.capability
|
|
? capabilityRunRecord(input.source.id)
|
|
: {
|
|
...runRecord(input.source.id),
|
|
requiredPermissionScope: input.requiredPermissionScope ?? [],
|
|
}),
|
|
);
|
|
const publish = vi.fn(async (_request: PublishSourceLogicalRevisionInput) => {
|
|
if (input.publishError) throw input.publishError;
|
|
return {
|
|
documentId: `logical-${assetSequence}`,
|
|
kind: input.publishKind ?? "activated",
|
|
revision: 1,
|
|
};
|
|
});
|
|
const markRemoteMissing = vi.fn(async (_request: unknown) => undefined);
|
|
const revalidatePermissionSnapshot = vi.fn(
|
|
async () =>
|
|
({
|
|
permissionScopes: input.permissionScopes ?? [],
|
|
revision: input.permissionRevision ?? 1,
|
|
role: input.permissionRole ?? "editor",
|
|
}) as never,
|
|
);
|
|
const assertPublicationAllowed = vi.fn(async () => {
|
|
if (input.capabilityGrantError) throw input.capabilityGrantError;
|
|
});
|
|
const getCapabilityGrant = vi.fn(async () =>
|
|
input.capabilityGrant === undefined
|
|
? ({
|
|
contentScopeIds: ["team:camera"],
|
|
state: "active",
|
|
subjectId: "editor-source",
|
|
} as const)
|
|
: input.capabilityGrant,
|
|
);
|
|
const materializer = input.materializer ?? {
|
|
compensate: vi.fn(async () => undefined),
|
|
materialize: vi.fn(
|
|
async ({ documents, workflowExecution }: MaterializeSourceDocumentsInput) => ({
|
|
documents: documents.map((document, index) => ({
|
|
documentAssetId: `asset-${++assetSequence}`,
|
|
documentAssetVersion: 1,
|
|
filename: document.filename,
|
|
mimeType: document.mimeType,
|
|
objectKey: `objects/${assetSequence}`,
|
|
sizeBytes: document.body.byteLength,
|
|
workflowOwnership: workflowExecution?.items[index],
|
|
})),
|
|
failed: [],
|
|
}),
|
|
),
|
|
};
|
|
let currentSource = input.source;
|
|
const defaultSources = {
|
|
get: vi.fn(async () => currentSource),
|
|
update: vi.fn(
|
|
async (update: {
|
|
readonly expectedVersion?: number;
|
|
readonly metadata?: Source["metadata"];
|
|
readonly status?: Source["status"];
|
|
}) => {
|
|
if (
|
|
update.expectedVersion !== undefined &&
|
|
update.expectedVersion !== currentSource.version
|
|
) {
|
|
throw new Error("source version conflict");
|
|
}
|
|
currentSource = {
|
|
...currentSource,
|
|
...(update.metadata ? { metadata: update.metadata } : {}),
|
|
...(update.status ? { status: update.status } : {}),
|
|
version: currentSource.version + 1,
|
|
};
|
|
return currentSource;
|
|
},
|
|
),
|
|
};
|
|
const runtime = createSourceProductWorkflowRuntime({
|
|
access: {
|
|
revalidatePermissionSnapshot,
|
|
},
|
|
...(input.capability
|
|
? {
|
|
capabilityGrants: {
|
|
assertPublicationAllowed,
|
|
get: getCapabilityGrant as never,
|
|
},
|
|
}
|
|
: {}),
|
|
contentStore: input.contentStore ?? ({} as never),
|
|
deletionFence: input.deletionFence ?? {
|
|
assertDeletionFenceUnchanged: vi.fn(async () => undefined),
|
|
captureDeletionFence: vi.fn(async (scope) => ({ scope }) as never),
|
|
},
|
|
logicalInventory: input.logicalInventory ?? {
|
|
listActiveBySource: vi.fn(async () => ({ items: [...input.inventory] })),
|
|
},
|
|
...(input.externalOperationTimeoutMs
|
|
? { externalOperationTimeoutMs: input.externalOperationTimeoutMs }
|
|
: {}),
|
|
...(input.intervalMs !== undefined ? { intervalMs: input.intervalMs } : {}),
|
|
logicalRevisions: {
|
|
...(input.omitMarkRemoteMissing ? {} : { markRemoteMissing }),
|
|
publish,
|
|
},
|
|
materializer,
|
|
...(input.maxCleanupBatchesPerRun !== undefined
|
|
? { maxCleanupBatchesPerRun: input.maxCleanupBatchesPerRun }
|
|
: {}),
|
|
...(input.maxCrawlPages !== undefined ? { maxCrawlPages: input.maxCrawlPages } : {}),
|
|
...(input.maxSyncItems !== undefined ? { maxSyncItems: input.maxSyncItems } : {}),
|
|
...(input.useSystemClock ? {} : { now: input.clock ?? (() => nowMs) }),
|
|
...(input.onlineDocuments ? { onlineDocuments: input.onlineDocuments as never } : {}),
|
|
...(input.onlineDrive ? { onlineDrive: input.onlineDrive as never } : {}),
|
|
repository,
|
|
...(input.sourceConnections ? { sourceConnections: input.sourceConnections as never } : {}),
|
|
...(input.sourceCredentials ? { sourceCredentials: input.sourceCredentials as never } : {}),
|
|
...(input.sourceProviders ? { sourceProviders: input.sourceProviders as never } : {}),
|
|
sources: input.sources ?? (defaultSources as never),
|
|
...(input.websiteCrawl ? { websiteCrawl: input.websiteCrawl as never } : {}),
|
|
workerId: "source-runtime-worker",
|
|
});
|
|
return {
|
|
assertPublicationAllowed,
|
|
getRun: () => repository.get({ knowledgeSpaceId, runId: run.id, tenantId }),
|
|
getCapabilityGrant,
|
|
markRemoteMissing,
|
|
materializer,
|
|
publish,
|
|
revalidatePermissionSnapshot,
|
|
repository,
|
|
run,
|
|
runtime,
|
|
sourceUpdates: defaultSources.update,
|
|
sources: input.sources ?? defaultSources,
|
|
};
|
|
}
|
|
|
|
async function createBulkFixture(input: {
|
|
readonly bulkRemoval?: Parameters<typeof createSourceProductWorkflowRuntime>[0]["bulkRemoval"];
|
|
readonly bulkChildPollMs?: number | undefined;
|
|
readonly capability?: boolean | undefined;
|
|
readonly clock?: (() => number) | undefined;
|
|
readonly disableSource?: ((sourceId: string) => Promise<Source | null>) | undefined;
|
|
readonly getSource?: ((sourceId: string) => Source | null) | undefined;
|
|
readonly items: readonly Omit<SourceBulkWorkflowItem, "runId" | "updatedAt">[];
|
|
}) {
|
|
let leaseSequence = 0;
|
|
const repository = createInMemorySourceProductWorkflowRepository({
|
|
generateLeaseToken: () => `bulk-lease-${++leaseSequence}`,
|
|
});
|
|
const runId = "run-bulk-runtime";
|
|
const run: NewSourceWorkflowRun = input.capability
|
|
? {
|
|
capabilityGrantId,
|
|
createdAt: nowIso,
|
|
id: runId,
|
|
idempotencyKey: runId,
|
|
knowledgeSpaceId,
|
|
kind: "bulk",
|
|
maxExecutionAttempts: 5,
|
|
payload: {},
|
|
progressTotal: input.items.length,
|
|
tenantId,
|
|
}
|
|
: {
|
|
accessChannel: "interactive",
|
|
createdAt: nowIso,
|
|
id: runId,
|
|
idempotencyKey: runId,
|
|
knowledgeSpaceId,
|
|
kind: "bulk",
|
|
maxExecutionAttempts: 5,
|
|
payload: {},
|
|
permissionSnapshotId: "permission-bulk-runtime",
|
|
permissionSnapshotRevision: 1,
|
|
progressTotal: input.items.length,
|
|
requestedBySubjectId: "editor-source",
|
|
requiredPermissionScope: [],
|
|
tenantId,
|
|
};
|
|
await repository.startBulk({
|
|
items: input.items.map((item) => ({ ...item, runId, updatedAt: nowIso })),
|
|
run,
|
|
});
|
|
const getSource = vi.fn(async ({ id }: { readonly id: string }) =>
|
|
input.getSource ? input.getSource(id) : sourceRecord(id, {}),
|
|
);
|
|
const disableWithPermissionFence = vi.fn(async ({ id }: { readonly id: string }) =>
|
|
input.disableSource ? input.disableSource(id) : sourceRecord(id, { status: "disabled" }),
|
|
);
|
|
const runtime = createSourceProductWorkflowRuntime({
|
|
access: {
|
|
revalidatePermissionSnapshot: vi.fn(
|
|
async () => ({ permissionScopes: [], revision: 1, role: "editor" }) as never,
|
|
),
|
|
},
|
|
...(input.bulkRemoval ? { bulkRemoval: input.bulkRemoval } : {}),
|
|
bulkChildPollMs: input.bulkChildPollMs ?? 1_000,
|
|
claimBatchSize: 1,
|
|
...(input.capability
|
|
? {
|
|
capabilityGrants: {
|
|
assertPublicationAllowed: vi.fn(async () => undefined),
|
|
get: vi.fn(
|
|
async () =>
|
|
({
|
|
contentScopeIds: [],
|
|
state: "active",
|
|
subjectId: "editor-source",
|
|
}) as never,
|
|
),
|
|
},
|
|
}
|
|
: {}),
|
|
contentStore: {} as never,
|
|
deletionFence: {
|
|
assertDeletionFenceUnchanged: vi.fn(async () => undefined),
|
|
captureDeletionFence: vi.fn(async (scope) => ({ scope }) as never),
|
|
},
|
|
logicalInventory: {} as never,
|
|
logicalRevisions: {} as never,
|
|
materializer: {} as never,
|
|
...(input.clock ? { now: input.clock } : {}),
|
|
repository,
|
|
sources: { disableWithPermissionFence, get: getSource } as never,
|
|
workerId: "bulk-runtime-worker",
|
|
});
|
|
return {
|
|
disableWithPermissionFence,
|
|
getItems: async () => (await repository.listBulkItems({ limit: 100, runId })).items,
|
|
getRun: () => repository.get({ knowledgeSpaceId, runId, tenantId }),
|
|
repository,
|
|
runtime,
|
|
};
|
|
}
|
|
|
|
function fakeObjectStorage(input: {
|
|
readonly listResults?: Awaited<ReturnType<ObjectStorageAdapter["listObjects"]>>[];
|
|
readonly storedBody: Uint8Array | null;
|
|
}) {
|
|
const listResults = [...(input.listResults ?? [])];
|
|
return {
|
|
kind: "memory" as const,
|
|
deleteObject: vi.fn(async (_key: string) => undefined),
|
|
getObject: vi.fn(async (_key: string) => input.storedBody),
|
|
getObjectStream: vi.fn(async (_key: string) => null),
|
|
health: vi.fn(async () => true),
|
|
headObject: vi.fn(async (_key: string) => null),
|
|
listObjects: vi.fn(
|
|
async (_request: Parameters<ObjectStorageAdapter["listObjects"]>[0]) =>
|
|
listResults.shift() ?? { objects: [] },
|
|
),
|
|
putObject: vi.fn(
|
|
async ({ body, key, metadata }: Parameters<ObjectStorageAdapter["putObject"]>[0]) => ({
|
|
key,
|
|
metadata: metadata ?? {},
|
|
sizeBytes: body.byteLength,
|
|
}),
|
|
),
|
|
} satisfies ObjectStorageAdapter;
|
|
}
|
|
|
|
function sourceRecord(
|
|
id: string,
|
|
patch: Partial<Source> & { readonly metadata?: Readonly<Record<string, unknown>> },
|
|
): Source {
|
|
return {
|
|
createdAt: nowIso,
|
|
id,
|
|
knowledgeSpaceId,
|
|
metadata: {},
|
|
name: id,
|
|
permissionScope: [],
|
|
status: "active",
|
|
type: "connector",
|
|
updatedAt: nowIso,
|
|
uri: "https://example.test",
|
|
version: 1,
|
|
...patch,
|
|
};
|
|
}
|
|
|
|
function runRecord(sourceId: string): NewSourceWorkflowRun {
|
|
return {
|
|
accessChannel: "interactive",
|
|
createdAt: nowIso,
|
|
id: `run-${sourceId}`,
|
|
idempotencyKey: `sync-${sourceId}`,
|
|
knowledgeSpaceId,
|
|
kind: "sync",
|
|
maxExecutionAttempts: 5,
|
|
payload: {},
|
|
permissionSnapshotId: "permission-source",
|
|
permissionSnapshotRevision: 1,
|
|
requestedBySubjectId: "editor-source",
|
|
requiredPermissionScope: [],
|
|
sourceId,
|
|
tenantId,
|
|
};
|
|
}
|
|
|
|
function providerRun(
|
|
sourceId: string,
|
|
kind: NewSourceWorkflowRun["kind"],
|
|
payload: NewSourceWorkflowRun["payload"],
|
|
): NewSourceWorkflowRun {
|
|
return {
|
|
...runRecord(sourceId),
|
|
id: `run-${kind}-${sourceId}`,
|
|
idempotencyKey: `${kind}-${sourceId}`,
|
|
kind,
|
|
payload,
|
|
};
|
|
}
|
|
|
|
function bulkItem(
|
|
sourceId: string,
|
|
action: SourceBulkWorkflowItem["action"],
|
|
status: SourceBulkWorkflowItem["status"] = "eligible",
|
|
): Omit<SourceBulkWorkflowItem, "runId" | "updatedAt"> {
|
|
return {
|
|
action,
|
|
id: `item-${sourceId}`,
|
|
sourceId,
|
|
status,
|
|
};
|
|
}
|
|
|
|
function statusesBySource(
|
|
items: readonly SourceBulkWorkflowItem[],
|
|
): Record<string, SourceBulkWorkflowItem["status"]> {
|
|
return Object.fromEntries(items.map((item) => [item.sourceId, item.status]));
|
|
}
|
|
|
|
function requiredMapValue<K, V>(values: ReadonlyMap<K, V>, key: K): V {
|
|
const value = values.get(key);
|
|
if (!value) throw new Error(`Missing map value for ${String(key)}`);
|
|
return value;
|
|
}
|
|
|
|
function capabilityRunRecord(sourceId: string): NewSourceWorkflowRun {
|
|
return {
|
|
capabilityGrantId,
|
|
createdAt: nowIso,
|
|
id: `run-${sourceId}`,
|
|
idempotencyKey: `sync-${sourceId}`,
|
|
knowledgeSpaceId,
|
|
kind: "sync",
|
|
maxExecutionAttempts: 5,
|
|
payload: {},
|
|
sourceId,
|
|
tenantId,
|
|
};
|
|
}
|
|
|
|
function inventoryItem(
|
|
providerItemId: string,
|
|
providerKind: "website" | "online-document" | "online-drive",
|
|
providerCoordinate?:
|
|
| {
|
|
readonly kind: "online-document";
|
|
readonly pageId: string;
|
|
readonly type: string;
|
|
readonly workspaceId: string;
|
|
}
|
|
| {
|
|
readonly bucket?: string;
|
|
readonly id: string;
|
|
readonly kind: "online-drive";
|
|
readonly mimeType?: string;
|
|
readonly name: string;
|
|
},
|
|
): SourceActiveDocumentInventoryItem {
|
|
return {
|
|
contentHash: "a".repeat(64),
|
|
documentId: `document-${providerItemId}`,
|
|
providerItemId,
|
|
revision: 1,
|
|
rowVersion: 1,
|
|
systemMetadata: {
|
|
provenance: {
|
|
...(providerCoordinate ? { providerCoordinate } : {}),
|
|
providerKind,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function frozenSelectionMetadata(
|
|
kind: "online-document" | "online-drive",
|
|
providerItemIds: readonly string[],
|
|
): Readonly<Record<string, unknown>> {
|
|
const selections = providerItemIds.map((providerItemId) => {
|
|
const coordinate = JSON.parse(providerItemId) as unknown;
|
|
if (
|
|
!Array.isArray(coordinate) ||
|
|
coordinate.length !== 2 ||
|
|
typeof coordinate[0] !== "string" ||
|
|
typeof coordinate[1] !== "string"
|
|
) {
|
|
throw new Error("Frozen selection test identity must be a composite coordinate");
|
|
}
|
|
const coordinateKey = JSON.stringify(coordinate);
|
|
return {
|
|
coordinateHash: createHash("sha256")
|
|
.update(`${kind}\0${providerItemId}\0${coordinateKey}`, "utf8")
|
|
.digest("hex"),
|
|
identityHash: createHash("sha256").update(`${kind}\0${providerItemId}`, "utf8").digest("hex"),
|
|
};
|
|
});
|
|
return {
|
|
__knowledgeFsProviderSelection: {
|
|
coordinateHashes: selections.map(({ coordinateHash }) => coordinateHash).sort(),
|
|
identityHashes: selections.map(({ identityHash }) => identityHash).sort(),
|
|
kind,
|
|
version: 1,
|
|
},
|
|
providerKind: kind,
|
|
};
|
|
}
|
|
|
|
function workflowFence(run: SourceWorkflowRun, workerId: string) {
|
|
if (!run.leaseToken) throw new Error("workflow is not leased");
|
|
return {
|
|
leaseToken: run.leaseToken,
|
|
rowVersion: run.rowVersion,
|
|
runId: run.id,
|
|
workerId,
|
|
};
|
|
}
|
|
|
|
function syncCursor(
|
|
kind: "website" | "online-document" | "online-drive",
|
|
rows: readonly (readonly (string | number)[])[],
|
|
offset: number,
|
|
): string {
|
|
const hash = createHash("sha256");
|
|
hash.update(`${kind}\0${rows.length}\0`, "utf8");
|
|
for (const row of rows) {
|
|
const serialized = JSON.stringify(row);
|
|
hash.update(`${Buffer.byteLength(serialized, "utf8")}:`, "utf8");
|
|
hash.update(serialized, "utf8");
|
|
}
|
|
return `ss1:${Buffer.from(
|
|
JSON.stringify({
|
|
fingerprint: hash.digest("hex"),
|
|
kind,
|
|
offset,
|
|
phase: "items",
|
|
}),
|
|
"utf8",
|
|
).toString("base64url")}`;
|
|
}
|