@firedrill-tools/unstructured 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +275 -0
  3. package/firedrill/agent.target.json +16 -0
  4. package/firedrill/baseline.scenario.json +5 -0
  5. package/firedrill/conformance.suite.json +21 -0
  6. package/firedrill/overloaded.scenario.json +11 -0
  7. package/firedrill/rate-limited.scenario.json +11 -0
  8. package/firedrill/run-response-lost.scenario.json +11 -0
  9. package/firedrill/small-responses.scenario.json +21 -0
  10. package/firedrill/tight-limits.scenario.json +21 -0
  11. package/firedrill/tools/unstructured/behavior.mjs +69 -0
  12. package/firedrill/tools/unstructured/lib/connectors.mjs +68 -0
  13. package/firedrill/tools/unstructured/lib/errors.mjs +58 -0
  14. package/firedrill/tools/unstructured/lib/gzip.mjs +38 -0
  15. package/firedrill/tools/unstructured/lib/identity.mjs +19 -0
  16. package/firedrill/tools/unstructured/lib/ids.mjs +36 -0
  17. package/firedrill/tools/unstructured/lib/jobs-derive.mjs +92 -0
  18. package/firedrill/tools/unstructured/lib/multipart.mjs +171 -0
  19. package/firedrill/tools/unstructured/lib/pages.mjs +40 -0
  20. package/firedrill/tools/unstructured/lib/partition/chunk.mjs +243 -0
  21. package/firedrill/tools/unstructured/lib/partition/csv.mjs +85 -0
  22. package/firedrill/tools/unstructured/lib/partition/csvout.mjs +25 -0
  23. package/firedrill/tools/unstructured/lib/partition/elements.mjs +171 -0
  24. package/firedrill/tools/unstructured/lib/partition/email.mjs +269 -0
  25. package/firedrill/tools/unstructured/lib/partition/html-tokens.mjs +134 -0
  26. package/firedrill/tools/unstructured/lib/partition/html-util.mjs +99 -0
  27. package/firedrill/tools/unstructured/lib/partition/html.mjs +211 -0
  28. package/firedrill/tools/unstructured/lib/partition/index.mjs +122 -0
  29. package/firedrill/tools/unstructured/lib/partition/markdown.mjs +220 -0
  30. package/firedrill/tools/unstructured/lib/partition/other.mjs +118 -0
  31. package/firedrill/tools/unstructured/lib/partition/text.mjs +53 -0
  32. package/firedrill/tools/unstructured/lib/sha256.mjs +161 -0
  33. package/firedrill/tools/unstructured/lib/store.mjs +33 -0
  34. package/firedrill/tools/unstructured/lib/util.mjs +149 -0
  35. package/firedrill/tools/unstructured/lib/validate.mjs +115 -0
  36. package/firedrill/tools/unstructured/lib/wire-multipart.mjs +78 -0
  37. package/firedrill/tools/unstructured/lib/wire.mjs +154 -0
  38. package/firedrill/tools/unstructured/ops/connectors.mjs +129 -0
  39. package/firedrill/tools/unstructured/ops/jobs.mjs +83 -0
  40. package/firedrill/tools/unstructured/ops/nodes.mjs +107 -0
  41. package/firedrill/tools/unstructured/ops/partition.mjs +112 -0
  42. package/firedrill/tools/unstructured/ops/workflows.mjs +180 -0
  43. package/firedrill/tools/unstructured/unstructured.tool.json +4892 -0
  44. package/firedrill/unstructured-archivist.drill.json +68 -0
  45. package/firedrill/unstructured-chunking.drill.json +67 -0
  46. package/firedrill/unstructured-connectors.drill.json +121 -0
  47. package/firedrill/unstructured-denied.drill.json +58 -0
  48. package/firedrill/unstructured-fresh-actor.drill.json +68 -0
  49. package/firedrill/unstructured-overloaded.drill.json +51 -0
  50. package/firedrill/unstructured-partition-errors.drill.json +66 -0
  51. package/firedrill/unstructured-partition.drill.json +95 -0
  52. package/firedrill/unstructured-rate-limited.drill.json +66 -0
  53. package/firedrill/unstructured-revoked-key.drill.json +773 -0
  54. package/firedrill/unstructured-run-lost.drill.json +51 -0
  55. package/firedrill/unstructured-small-responses.drill.json +173 -0
  56. package/firedrill/unstructured-tight-limits.drill.json +203 -0
  57. package/firedrill/unstructured-workflows-jobs.drill.json +167 -0
  58. package/firedrill/world.json +1556 -0
  59. package/firedrill.json +5 -0
  60. package/package.json +52 -0
  61. package/starter.json +1114 -0
  62. package/test/conformance.mjs +37 -0
  63. package/test/flows/access.mjs +54 -0
  64. package/test/flows/chunking.mjs +95 -0
  65. package/test/flows/connectors.mjs +76 -0
  66. package/test/flows/errors.mjs +115 -0
  67. package/test/flows/faults.mjs +73 -0
  68. package/test/flows/partition.mjs +225 -0
  69. package/test/flows/workflows.mjs +123 -0
  70. package/test/hostile-gen.mjs +0 -0
  71. package/test/hostile.mjs +155 -0
  72. package/test/lib.mjs +113 -0
@@ -0,0 +1,37 @@
1
+ // Unstructured Tool conformance target: a scripted Tool test, not a model-driven agent. Node built-ins only.
2
+ // Reads the drill task from stdin, picks the flow named in the instruction and runs it against the provider-shaped
3
+ // routes (FIREDRILL_HTTP_URL with the unstructured-api-key header set to FIREDRILL_HTTP_TOKEN).
4
+ import { archivistFlow, deniedFlow, freshFlow, revokedFlow } from "./flows/access.mjs";
5
+ import { chunkingFlow } from "./flows/chunking.mjs";
6
+ import { connectorsFlow } from "./flows/connectors.mjs";
7
+ import { errorsFlow } from "./flows/errors.mjs";
8
+ import { overloadedFlow, rateLimitedFlow, runLostFlow, smallResponsesFlow, tightLimitsFlow } from "./flows/faults.mjs";
9
+ import { partitionFlow } from "./flows/partition.mjs";
10
+ import { workflowsFlow } from "./flows/workflows.mjs";
11
+
12
+ let task = "";
13
+ for await (const chunk of process.stdin) task += chunk;
14
+ const invocation = JSON.parse(task);
15
+ const instruction = String(invocation.instruction ?? invocation.task?.instruction ?? "");
16
+
17
+ const flows = {
18
+ partition: partitionFlow,
19
+ chunking: chunkingFlow,
20
+ errors: errorsFlow,
21
+ connectors: connectorsFlow,
22
+ workflows: workflowsFlow,
23
+ fresh: freshFlow,
24
+ archivist: archivistFlow,
25
+ revoked: revokedFlow,
26
+ denied: deniedFlow,
27
+ "rate-limited": rateLimitedFlow,
28
+ overloaded: overloadedFlow,
29
+ "run-lost": runLostFlow,
30
+ "tight-limits": tightLimitsFlow,
31
+ "small-responses": smallResponsesFlow,
32
+ };
33
+
34
+ const selected = /Run the ([a-z-]+) flow/.exec(instruction)?.[1];
35
+ if (selected === undefined || !Object.hasOwn(flows, selected)) throw new Error(`Unknown drill instruction: ${instruction}`);
36
+ await flows[selected]();
37
+ process.stdout.write(JSON.stringify({ completed: true, flow: selected }));
@@ -0,0 +1,54 @@
1
+ // Access flow: fresh-install fallback, workspace scoping, an invalid key on every operation, framework denial.
2
+ import assert from "node:assert/strict";
3
+ import { ALL_OPERATIONS, ID, api, detail, get, op, partition } from "../lib.mjs";
4
+
5
+ /** Arguments that would succeed for a valid key, used to prove UNAUTHORIZED fires before anything else. */
6
+ const ARGS = {
7
+ "general.partition": { files: [{ filename: "a.txt", content: "Hello" }] },
8
+ "sources.list": {}, "sources.create": { name: "x", type: "s3", config: { remote_url: "s3://x/" } }, "sources.get": { source_id: ID.S1 }, "sources.update": { source_id: ID.S1, name: "x" },
9
+ "sources.delete": { source_id: ID.S1 }, "sources.check_connection": { source_id: ID.S1 }, "sources.get_connection_check": { source_id: ID.S1 },
10
+ "destinations.list": {}, "destinations.create": { name: "x", type: "s3", config: { remote_url: "s3://x/" } }, "destinations.get": { destination_id: ID.D1 }, "destinations.update": { destination_id: ID.D1, name: "x" }, "destinations.delete": { destination_id: ID.D1 },
11
+ "workflows.list": {}, "workflows.create": { name: "x", workflow_type: "auto" }, "workflows.get": { workflow_id: ID.W1 }, "workflows.update": { workflow_id: ID.W1, name: "x" }, "workflows.delete": { workflow_id: ID.W1 }, "workflows.run": { workflow_id: ID.W1 },
12
+ "jobs.list": {}, "jobs.get": { job_id: ID.J1 }, "jobs.cancel": { job_id: ID.J5 }, "jobs.get_details": { job_id: ID.J1 }, "jobs.get_failed_files": { job_id: ID.J1 }, "jobs.download_output": { job_id: ID.J2, file_id: `${ID.S1}:handbook-md`, node_id: ID.W1_EMBED_NODE },
13
+ };
14
+
15
+ /** A fresh `firedrill tool add` actor (no attributes) sees the seeded Northgate account. */
16
+ export async function freshFlow() {
17
+ assert.equal((await get("/api/v1/sources")).json.length, 3);
18
+ assert.equal((await get("/api/v1/destinations")).json.length, 2);
19
+ assert.equal((await get("/api/v1/workflows")).json.length, 3);
20
+ assert.equal((await get("/api/v1/jobs")).json.length, 6);
21
+ const elements = (await partition([{ filename: "hello.txt", content: "Fresh Install\n\nThe fresh actor can partition documents without any identity attribute." }])).json;
22
+ assert.deepEqual(elements.map((e) => e.type), ["Title", "NarrativeText"]);
23
+ assert.equal((await get(`/api/v1/jobs/${ID.J1}`)).json.status, "COMPLETED");
24
+ }
25
+
26
+ /** The archive workspace key sees only S4 / D3 / W4; Northgate ids do not exist for it. */
27
+ export async function archivistFlow() {
28
+ assert.deepEqual((await get("/api/v1/sources")).json.map((s) => s.id), [ID.S4]);
29
+ assert.deepEqual((await get("/api/v1/destinations")).json.map((d) => d.id), [ID.D3]);
30
+ assert.deepEqual((await get("/api/v1/workflows")).json.map((w) => w.id), [ID.W4]);
31
+ assert.deepEqual((await get("/api/v1/jobs")).json, []);
32
+ await detail("GET", `/api/v1/sources/${ID.S1}`, 404, "Source connector not found");
33
+ await detail("GET", `/api/v1/workflows/${ID.W1}`, 404, "Workflow not found");
34
+ await detail("GET", `/api/v1/jobs/${ID.J1}`, 404, "Job not found");
35
+ await detail("POST", "/api/v1/workflows", 422, "Source connector not found", { body: { name: "cross", workflow_type: "auto", source_id: ID.S1 } });
36
+ await detail("POST", `/api/v1/workflows/${ID.W4}/run`, 422, "holds no files");
37
+ assert.equal((await get("/api/v1/jobs")).json.length, 0);
38
+ }
39
+
40
+ /** A key whose workspace does not exist: 401 API key is invalid on every operation, wire and canonical. */
41
+ export async function revokedFlow() {
42
+ await detail("POST", "/general/v0/general", 401, "API key is invalid", { raw: "--b\r\nContent-Disposition: form-data; name=\"files\"; filename=\"a.txt\"\r\n\r\nHi\r\n--b--\r\n", contentType: "multipart/form-data; boundary=b" });
43
+ await detail("GET", "/api/v1/sources", 401, "API key is invalid");
44
+ await detail("GET", `/api/v1/jobs/${ID.J1}`, 401, "API key is invalid");
45
+ for (const operationId of ALL_OPERATIONS) await op(operationId, ARGS[operationId], "UNAUTHORIZED");
46
+ }
47
+
48
+ /** An actor without grants: the framework denies before the Tool runs; the codec renders it in the detail envelope. */
49
+ export async function deniedFlow() {
50
+ await detail("GET", "/api/v1/sources", 403, "not granted");
51
+ await detail("POST", "/general/v0/general", 403, "not granted", { raw: "--b\r\nContent-Disposition: form-data; name=\"files\"; filename=\"a.txt\"\r\n\r\nHi\r\n--b--\r\n", contentType: "multipart/form-data; boundary=b" });
52
+ await op("sources.list", {}, "denied");
53
+ await api("GET", "/api/v1/sources", { status: 401, token: "wrong-key" });
54
+ }
@@ -0,0 +1,95 @@
1
+ // Chunking flow: basic, by_title, by_page, overlap, orig_elements (real gzip), TableChunk.
2
+ import assert from "node:assert/strict";
3
+ import { gunzipSync } from "node:zlib";
4
+ import { fixtures, partition } from "../lib.mjs";
5
+
6
+ const decodeOrig = (element) => {
7
+ const bytes = Buffer.from(element.metadata.orig_elements, "base64");
8
+ assert.equal(bytes[0], 0x1f);
9
+ assert.equal(bytes[1], 0x8b);
10
+ return JSON.parse(gunzipSync(bytes).toString("utf8"));
11
+ };
12
+
13
+ export async function chunkingFlow() {
14
+ const fx = fixtures();
15
+ const file = (name) => ({ filename: name, content: fx.get(name).content });
16
+
17
+ // 1. basic, max 500: every chunk fits, the long paragraph is split into continuation chunks, orig_elements decode.
18
+ const basic = (await partition([file("glossary.md")], { chunking_strategy: "basic", max_characters: "500" })).json;
19
+ assert.ok(basic.length >= 6, `chunks ${basic.length}`);
20
+ assert.ok(basic.every((c) => c.type === "CompositeElement" && c.text.length <= 500));
21
+ assert.ok(basic.some((c) => c.metadata.is_continuation === true), "continuation chunks");
22
+ for (const chunk of basic) {
23
+ const originals = decodeOrig(chunk);
24
+ assert.ok(Array.isArray(originals) && originals.length > 0);
25
+ assert.ok(originals.every((e) => typeof e.type === "string" && typeof e.text === "string" && e.metadata.filename === "glossary.md"));
26
+ if (chunk.metadata.is_continuation !== true && originals.length > 1) assert.equal(chunk.text, originals.map((e) => e.text).join("\n\n"));
27
+ }
28
+ assert.match(basic[1].text, /^A glossary entry explains/);
29
+
30
+ // 2. overlap=50: each continuation chunk starts with the last 50 characters of the previous chunk.
31
+ const overlapped = (await partition([file("glossary.md")], { chunking_strategy: "basic", max_characters: "500", overlap: "50" })).json;
32
+ let continuations = 0;
33
+ for (let i = 1; i < overlapped.length; i += 1) {
34
+ if (overlapped[i].metadata.is_continuation !== true) continue;
35
+ continuations += 1;
36
+ assert.ok(overlapped[i].text.startsWith(overlapped[i - 1].text.slice(-50)), `overlap prefix at ${i}`);
37
+ assert.ok(overlapped[i].text.length <= 500);
38
+ }
39
+ assert.ok(continuations >= 4, `continuations ${continuations}`);
40
+
41
+ // 3. overlap_all: every chunk after the first carries the prefix.
42
+ const all = (await partition([file("glossary.md")], { chunking_strategy: "basic", max_characters: "500", overlap: "40", overlap_all: "true" })).json;
43
+ for (let i = 1; i < all.length; i += 1) assert.ok(all[i].text.startsWith(all[i - 1].text.slice(-40)), `overlap_all prefix at ${i}`);
44
+
45
+ // 4. by_title with combine_under_n_chars: chunks start at titles; small sections are merged.
46
+ const byTitle = (await partition([file("handbook.md")], { chunking_strategy: "by_title", max_characters: "800", combine_under_n_chars: "200", include_orig_elements: "false" })).json;
47
+ assert.ok(byTitle[0].text.startsWith("Northgate Research Employee Handbook\n\nWelcome to Northgate Research."));
48
+ assert.ok(byTitle.some((c) => c.text.startsWith("Working Hours\n\n")));
49
+ assert.ok(byTitle.some((c) => c.type === "Table"), "table kept whole");
50
+ assert.ok(byTitle.every((c) => c.metadata.orig_elements === undefined), "orig_elements omitted");
51
+ assert.ok(byTitle.every((c) => c.text.length <= 800));
52
+
53
+ // 5. by_page: one chunk per page of the refund policy.
54
+ const byPage = (await partition([file("refund-policy.txt")], { chunking_strategy: "by_page", max_characters: "1500", new_after_n_chars: "1500" })).json;
55
+ assert.equal(byPage.length, 2);
56
+ assert.deepEqual(byPage.map((c) => c.metadata.page_number), [1, 2]);
57
+ assert.ok(byPage[1].text.startsWith("Exceptions"));
58
+ assert.ok(byPage.every((c) => c.type === "CompositeElement"));
59
+
60
+ // 6. include_orig_elements=false on basic; 7. new_after_n_chars larger than max_characters is lowered.
61
+ const noOrig = (await partition([file("2026-09-08-site-visit.txt")], { chunking_strategy: "basic", include_orig_elements: "false" })).json;
62
+ assert.ok(noOrig.every((c) => c.metadata.orig_elements === undefined));
63
+ const lowered = (await partition([file("2026-09-08-site-visit.txt")], { chunking_strategy: "basic", max_characters: "300", new_after_n_chars: "5000" })).json;
64
+ assert.ok(lowered.length >= 3 && lowered.every((c) => c.text.length <= 300));
65
+
66
+ // 8. A table longer than max_characters becomes TableChunk pieces split by rows.
67
+ const chunks = (await partition([file("vendors.csv")], { chunking_strategy: "basic", max_characters: "300" })).json;
68
+ assert.ok(chunks.length >= 2);
69
+ assert.ok(chunks.every((c) => c.type === "TableChunk" && c.text.length <= 300 && c.metadata.text_as_html.startsWith("<table><tr>")));
70
+ assert.equal(chunks[0].metadata.is_continuation, undefined);
71
+ assert.ok(chunks.slice(1).every((c) => c.metadata.is_continuation === true));
72
+ assert.equal(chunks.map((c) => c.text.split("\n").length).reduce((a, b) => a + b, 0), 13);
73
+
74
+ // 9. The largest chunk size (100,000, the element text bound) over two 90,000-character paragraphs: one chunk each,
75
+ // never a schema violation; 10. many tiny blocks accumulate into few chunks in linear time.
76
+ const wide = { filename: "wide.txt", content: `${"b".repeat(90000)}\n\n${"c".repeat(90000)}` };
77
+ const widest = (await partition([wide], { chunking_strategy: "basic", max_characters: "100000", new_after_n_chars: "100000", include_orig_elements: "false" })).json;
78
+ assert.equal(widest.length, 2);
79
+ assert.ok(widest.every((c) => c.type === "CompositeElement" && c.text.length === 90000));
80
+ const many = { filename: "many.txt", content: "a\n\n".repeat(20000) };
81
+ const started = Date.now();
82
+ const few = (await partition([many], { chunking_strategy: "basic", max_characters: "100000", new_after_n_chars: "100000", include_orig_elements: "false" })).json;
83
+ assert.ok(Date.now() - started < 5000, `chunking 20,000 blocks took ${Date.now() - started}ms`);
84
+ assert.equal(few.length, 1);
85
+ assert.equal(few[0].text.length, 20000 * 3 - 2);
86
+
87
+ // 11. Continuation pieces of one chunk group share the group's metadata: every piece of a 40-link paragraph split at
88
+ // max_characters=100 carries all 40 merged link_urls/link_texts (built once per group, never rebuilt per piece).
89
+ const linked = { filename: "linked.html", content: `<p>${'<a href="https://example.test/u">link text</a> '.repeat(40)}</p>` };
90
+ const shared = (await partition([linked], { chunking_strategy: "basic", max_characters: "100", include_orig_elements: "false" })).json;
91
+ assert.equal(shared.length, 4);
92
+ assert.ok(shared.every((c) => c.metadata.link_urls.length === 40 && c.metadata.link_texts.length === 40 && c.metadata.link_urls[39] === "https://example.test/u"));
93
+ assert.equal(shared[0].metadata.is_continuation, undefined);
94
+ assert.ok(shared.slice(1).every((c) => c.metadata.is_continuation === true));
95
+ }
@@ -0,0 +1,76 @@
1
+ // Connector flow: sources and destinations CRUD, masking, connection checks, in-use protection.
2
+ import assert from "node:assert/strict";
3
+ import { ID, api, del, detail, get, post, put } from "../lib.mjs";
4
+
5
+ export async function connectorsFlow() {
6
+ const sources = (await get("/api/v1/sources")).json;
7
+ assert.deepEqual(sources.map((s) => s.name), ["Policy archive", "Field notes", "Legacy scans"]);
8
+ assert.equal((await get("/api/v1/sources?source_type=s3")).json.length, 1);
9
+ await detail("GET", "/api/v1/sources?source_type=ftp", 422, "query.source_type");
10
+ await api("GET", "/api/v1/sources/", { status: 404 }); // trailing slash: framework 404 (route templates cannot end with /)
11
+ const s1 = (await get(`/api/v1/sources/${ID.S1}`)).json;
12
+ assert.equal(s1.config.secret_access_key, "********");
13
+ assert.equal(s1.config.remote_url, "s3://northgate-policies/");
14
+ assert.equal(s1.key, "policy-archive");
15
+ await detail("GET", `/api/v1/sources/${ID.S4}`, 404, "Source connector not found");
16
+ await detail("GET", `/api/v1/sources/${"x".repeat(600)}`, 400, "source_id"); // schema-invalid argument: framework status 400, detail array from the codec
17
+
18
+ const missing = await detail("POST", "/api/v1/sources", 422, "drive_id", { body: { name: "Drive", type: "google_drive", config: {} } });
19
+ assert.deepEqual(missing.json.detail[0].loc, ["body", "config", "drive_id"]);
20
+ await detail("POST", "/api/v1/sources", 422, "already exists", { body: { name: "Dup", type: "s3", config: { remote_url: "s3://x/" }, key: "policy-archive" } });
21
+ await detail("POST", "/api/v1/sources", 422, "Extra inputs are not permitted", { body: { name: "Dup", type: "s3", config: { remote_url: "s3://x/" }, colour: "red" } });
22
+ await detail("POST", "/api/v1/sources", 422, "at most 200 characters", { body: { name: "n".repeat(5000), type: "s3", config: { remote_url: "s3://x/" } } });
23
+ await detail("POST", "/api/v1/sources", 422, "Invalid key", { body: { name: "Bad", type: "s3", config: { remote_url: "s3://x/", constructor: 1 } } });
24
+ await detail("POST", "/api/v1/sources", 422, "Input should be a valid dictionary or object", { raw: "[1,2]" });
25
+ await api("POST", "/api/v1/sources", { raw: "", status: 400 }); // empty JSON body: framework HTTP_BODY_INVALID before any codec runs
26
+ await api("POST", "/api/v1/sources", { raw: "{not json", status: 400 });
27
+
28
+ const created = (await post("/api/v1/sources", { name: "Survey bucket", type: "s3", config: { remote_url: "s3://northgate-surveys/", access_key_id: "AKIAEXAMPLE0000009", secret_access_key: "not-a-real-secret" }, key: "survey-bucket" })).json;
29
+ assert.equal(created.config.secret_access_key, "********");
30
+ assert.equal(created.updated_at, null);
31
+ assert.equal((await get(`/api/v1/sources/${created.id}`)).json.config.secret_access_key, "********");
32
+ const renamed = (await put(`/api/v1/sources/${created.id}`, { name: "Survey bucket (EU)" })).json;
33
+ assert.equal(renamed.name, "Survey bucket (EU)");
34
+ assert.equal(renamed.updated_at, "2026-09-16T09:00:00.000Z");
35
+ await detail("PUT", `/api/v1/sources/${created.id}`, 422, "cannot be changed", { body: { type: "gcs" } });
36
+ await detail("PUT", `/api/v1/sources/${created.id}`, 422, "remote_url", { body: { config: { access_key_id: "x" } } });
37
+ await detail("PUT", `/api/v1/sources/${ID.NONE}`, 404, "Source connector not found", { body: { name: "x" } });
38
+ const masked = (await put(`/api/v1/sources/${created.id}`, { config: { remote_url: "s3://northgate-surveys-eu/", secret_access_key: "********" } })).json;
39
+ assert.equal(masked.config.remote_url, "s3://northgate-surveys-eu/");
40
+ assert.equal(masked.config.secret_access_key, "********");
41
+ assert.equal(masked.config.access_key_id, undefined, "config is replaced whole");
42
+
43
+ // Connection checks: computed from the required keys of the type.
44
+ const check = (await post(`/api/v1/sources/${ID.S1}/connection-check`, undefined)).json;
45
+ assert.deepEqual(check, { status: "SUCCESS", reason: null, created_at: "2026-09-16T09:00:00.000Z" });
46
+ assert.equal((await get(`/api/v1/sources/${ID.S3}/connection-check`)).json.status, "FAILURE");
47
+ await detail("GET", `/api/v1/sources/${created.id}/connection-check`, 404, "No connection check found");
48
+ const broken = (await post("/api/v1/sources", { name: "No drive", type: "google_drive", config: { drive_id: "1", service_account_key: "x" } })).json;
49
+ await put(`/api/v1/sources/${broken.id}`, { config: { drive_id: "", service_account_key: "x" } }, { status: 422 });
50
+ await detail("POST", `/api/v1/sources/${ID.NONE}/connection-check`, 404, "Source connector not found");
51
+ await detail("GET", `/api/v1/sources/${ID.NONE}/connection-check`, 404, "Source connector not found");
52
+
53
+ // Destinations mirror the same rules.
54
+ const destinations = (await get("/api/v1/destinations")).json;
55
+ assert.deepEqual(destinations.map((d) => d.name), ["Parsed output bucket", "Policy index"]);
56
+ assert.equal((await get("/api/v1/destinations?destination_type=pinecone")).json[0].config.api_key, "********");
57
+ await detail("GET", "/api/v1/destinations?destination_type=s3x", 422, "destination_type");
58
+ await detail("POST", "/api/v1/destinations", 422, "cluster_url", { body: { name: "Vectors", type: "weaviate_cloud", config: { collection: "docs" } } });
59
+ const weaviate = (await post("/api/v1/destinations", { name: "Vectors", type: "weaviate_cloud", config: { cluster_url: "https://vectors.example.test", collection: "docs", api_key: "x" } })).json;
60
+ assert.equal((await get(`/api/v1/destinations/${weaviate.id}`)).json.config.api_key, "********");
61
+ assert.equal((await put(`/api/v1/destinations/${weaviate.id}`, { name: "Vectors (prod)" })).json.name, "Vectors (prod)");
62
+ await detail("PUT", `/api/v1/destinations/${weaviate.id}`, 422, "cannot be changed", { body: { type: "s3" } });
63
+ await detail("PUT", `/api/v1/destinations/${ID.NONE}`, 404, "Destination connector not found", { body: { name: "x" } });
64
+ assert.deepEqual((await del(`/api/v1/destinations/${weaviate.id}`)).json, {});
65
+ await detail("GET", `/api/v1/destinations/${weaviate.id}`, 404, "Destination connector not found");
66
+ await detail("DELETE", `/api/v1/destinations/${weaviate.id}`, 404, "Destination connector not found");
67
+ await detail("GET", `/api/v1/destinations/${ID.D3}`, 404, "Destination connector not found");
68
+
69
+ // Connectors used by a running job cannot be deleted (J5 runs W2 = S2 -> D1; J6 runs W1 = S1 -> D2).
70
+ await detail("DELETE", `/api/v1/sources/${ID.S1}`, 422, "in use by a running job");
71
+ await detail("DELETE", `/api/v1/destinations/${ID.D1}`, 422, "in use by a running job");
72
+ await detail("DELETE", `/api/v1/sources/${ID.NONE}`, 404, "Source connector not found");
73
+ assert.deepEqual((await del(`/api/v1/sources/${broken.id}`)).json, {});
74
+ await detail("GET", `/api/v1/sources/${broken.id}`, 404);
75
+ assert.equal((await get("/api/v1/sources")).json.length, 4);
76
+ }
@@ -0,0 +1,115 @@
1
+ // Partition error flow: provider error envelopes for every rejected request; nothing is logged for failures.
2
+ import assert from "node:assert/strict";
3
+ import { api, detail, multipart, partition } from "../lib.mjs";
4
+
5
+ export async function errorsFlow() {
6
+ const one = [{ filename: "note.txt", content: "Just a short note." }];
7
+ const bad = (fields, status, fragment, files = one) => partition(files, fields, { status }).then((r) => check(r, status, fragment));
8
+ const check = (r, status, fragment) => {
9
+ const d = r.json?.detail;
10
+ assert.ok(d !== undefined, `expected detail envelope, got ${r.text.slice(0, 200)}`);
11
+ const text = typeof d === "string" ? d : JSON.stringify(d);
12
+ assert.ok(text.includes(fragment), `expected ${JSON.stringify(fragment)} in ${text.slice(0, 300)}`);
13
+ return d;
14
+ };
15
+
16
+ const missing = await bad({ strategy: "fast" }, 422, "Field required", []);
17
+ assert.deepEqual(missing[0].loc, ["body", "files"]);
18
+ assert.equal(missing[0].type, "missing");
19
+ await bad({ strategy: "layout" }, 400, "Invalid strategy: layout. Must be one of ['fast', 'hi_res', 'auto', 'ocr_only', 'vlm']");
20
+ await bad({ chunking_strategy: "by_similarity" }, 400, "by_similarity is not supported");
21
+ await bad({ chunking_strategy: "by_word" }, 400, "Invalid chunking strategy: by_word");
22
+ await bad({ output_format: "text/xml" }, 400, "Invalid output format: text/xml");
23
+ await bad({ hi_res_model_name: "chipper" }, 400, "Unknown model type: chipper");
24
+ const vlm = await bad({ strategy: "vlm" }, 422, "vlm_model_provider");
25
+ assert.ok(vlm.some((i) => i.loc.join(".") === "body.vlm_model_provider" && i.type === "missing"));
26
+ const int = await bad({ max_characters: "abc", chunking_strategy: "basic" }, 422, "valid integer");
27
+ assert.equal(int[0].type, "int_parsing");
28
+ assert.deepEqual(int[0].loc, ["body", "max_characters"]);
29
+ await bad({ chunking_strategy: "basic", max_characters: "500", overlap: "600" }, 422, "less than max_characters");
30
+ const tooBig = await bad({ chunking_strategy: "basic", max_characters: "100001" }, 422, "less than or equal to 100000");
31
+ assert.equal(tooBig[0].type, "less_than_equal");
32
+ assert.deepEqual(tooBig[0].loc, ["body", "max_characters"]);
33
+ await bad({ include_page_breaks: "maybe" }, 422, "valid boolean");
34
+ await bad({ encoding: "latin-1" }, 400, "Unsupported encoding: latin-1");
35
+ await bad({}, 400, "application/pdf not currently supported", [{ filename: "scan.pdf", content: "%PDF-1.7 pretend" }]);
36
+ await bad({ content_type: "image/png" }, 400, "image/png not currently supported");
37
+ await bad({}, 400, "None not currently supported", [{ filename: "noext", content: "x" }]);
38
+ await bad({}, 422, "File is not a valid csv", [{ filename: "broken.csv", content: 'a,b\n1,"unterminated' }]);
39
+ await bad({}, 422, "File is not a valid xml", [{ filename: "broken.xml", content: "<a><b>x</a>" }]);
40
+ await bad({}, 400, "Json schema does not match the Unstructured schema", [{ filename: "notes.json", content: '{"a":1}' }]);
41
+ await bad({}, 422, "too many text blocks", [{ filename: "blocks.txt", content: "x\n\n".repeat(100001) }]);
42
+ await bad({ chunking_strategy: "basic", max_characters: "100" }, 413, "exceeds the 921600 byte response limit", [{ filename: "big.txt", content: Array.from({ length: 3000 }, (_, i) => `Entry ${i} posted.`).join("\n\n") }]);
43
+
44
+ // Transport-level problems the codec reports itself (never a framework runtime message).
45
+ await detail("POST", "/general/v0/general", 400, "boundary", { raw: "not multipart", contentType: "multipart/form-data" });
46
+ await detail("POST", "/general/v0/general", 400, "Content-Disposition", { raw: "--b\r\nContent-Type: text/plain\r\n\r\nx\r\n--b--\r\n", contentType: "multipart/form-data; boundary=b" });
47
+ await detail("POST", "/general/v0/general", 422, "Expected multipart/form-data", { raw: "files=x", contentType: "application/x-www-form-urlencoded" });
48
+ await detail("POST", "/general/v0/general", 422, "Expected multipart/form-data", { body: { files: [] } });
49
+ const b64bad = multipart([{ name: "files", filename: "a.txt", value: "!!!notbase64", headers: { "Content-Transfer-Encoding": "base64" } }]);
50
+ await detail("POST", "/general/v0/general", 422, "base64", { raw: b64bad.raw, contentType: b64bad.contentType });
51
+
52
+ // A valid base64 part is decoded and partitioned (the one success of this flow).
53
+ const b64 = multipart([{ name: "files", filename: "a.txt", value: Buffer.from("Encoded Title\n\nThis sentence arrived as base64 text and was decoded.").toString("base64"), headers: { "Content-Transfer-Encoding": "base64" } }]);
54
+ const ok = await api("POST", "/general/v0/general", { raw: b64.raw, contentType: b64.contentType });
55
+ assert.deepEqual(ok.json.map((e) => e.type), ["Title", "NarrativeText"]);
56
+
57
+ // Wrong key: the framework's own 401 (before any codec runs).
58
+ await api("POST", "/general/v0/general", { raw: b64.raw, contentType: b64.contentType, status: 401, token: "not-the-key" });
59
+
60
+ // Partitioner bounds fail with the declared 422, never truncate, and answer in linear time: 300 nested elements,
61
+ // a 12,000-row table, a 201-cell row and 300 nested XML elements.
62
+ const bounds = [
63
+ ["<div>a".repeat(300), "deep.html", "elements nested deeper than 256 levels"],
64
+ ["<table>" + "<tr><td>a</td><td>b</td></tr>".repeat(12000) + "</table>", "rows.html", "File has a table with more than 10000 rows"],
65
+ ["<table><tr>" + "<td>a</td>".repeat(201) + "</tr></table>", "cells.html", "File has a table row with more than 200 cells"],
66
+ ["<a>".repeat(300) + "t" + "</a>".repeat(300), "deep.xml", "File is not a valid xml: elements nested deeper than 256 levels"],
67
+ // Markdown pipe tables share the bound at any width: a 5,000-column table (separator row over 25,000 characters)
68
+ // and a one-column header over a 300-cell separator both fail instead of being read as text.
69
+ [`${"| a ".repeat(5000)}|\n${"| --- ".repeat(5000)}|\n${"| 1 ".repeat(5000)}|\n`, "wide.md", "File has a table row with more than 200 cells"],
70
+ [`| a |\n${"| --- ".repeat(300)}|\n| 1 |\n`, "narrow-header.md", "File has a table row with more than 200 cells"],
71
+ ];
72
+ for (const [content, filename, fragment] of bounds) {
73
+ const started = Date.now();
74
+ await bad({}, 422, fragment, [{ filename, content }]);
75
+ assert.ok(Date.now() - started < 3000, `bound ${filename} took ${Date.now() - started}ms`);
76
+ }
77
+
78
+ // Chunk output is sized arithmetically before any piece is built: overlap_all with overlap = max_characters - 1 would
79
+ // multiply two 20,000-character paragraphs into 400 MB of chunk text; the request answers 413 in milliseconds instead.
80
+ const paragraphs = `${"a".repeat(20000)}\n\n${"b".repeat(20000)}`;
81
+ for (const chunking_strategy of ["basic", "by_title"]) {
82
+ const started = Date.now();
83
+ await bad({ chunking_strategy, max_characters: "20000", overlap: "19999", overlap_all: "true" }, 413, "exceeds the 921600 byte response limit", [{ filename: "wide.txt", content: paragraphs }]);
84
+ assert.ok(Date.now() - started < 3000, `overlap_all ${chunking_strategy} took ${Date.now() - started}ms`);
85
+ }
86
+ await bad({ chunking_strategy: "basic", max_characters: "1" }, 413, "exceeds the 921600 byte response limit", [{ filename: "one.txt", content: "a".repeat(100000) }]);
87
+
88
+ // Every piece of a chunk group carries a copy of the group's metadata, so large merged link/emphasis arrays, e-mail recipient
89
+ // lists or orig_elements multiply by the piece count: 5,000 links at the default max_characters (8.9 MB of pieces), 20,000
90
+ // `<b>` marks at max_characters=19 (169 MB), a 100,000-character paragraph at 19 with orig_elements (700 MB), an e-mail with
91
+ // 50,000 recipients (2.4 GB) and a 5,000-row table at 19 with orig_elements (2.1 GB) each answer 413 before any piece exists.
92
+ const multiplied = [
93
+ [{ chunking_strategy: "basic" }, { filename: "links.html", content: `<p>${'<a href="https://example.test/u">x</a> '.repeat(5000)}</p>` }],
94
+ [{ chunking_strategy: "basic", max_characters: "19", include_orig_elements: "false" }, { filename: "marks.html", content: `<p>${"<b>x</b>".repeat(20000)}</p>` }],
95
+ [{ chunking_strategy: "basic", max_characters: "19" }, { filename: "orig.html", content: `<p>${"a".repeat(100000)}</p>` }],
96
+ [{ chunking_strategy: "basic", max_characters: "19", include_orig_elements: "false" }, { filename: "wide.eml", content: `From: a@example.test\nTo: ${"a@example.test,".repeat(50000)}\nSubject: s\n\n${"x".repeat(100000)}` }],
97
+ [{ chunking_strategy: "by_title", max_characters: "19" }, { filename: "table.html", content: `<table>${"<tr><td>abcdefghijklmnopqrst</td></tr>".repeat(5000)}</table>` }],
98
+ ];
99
+ for (const [fields, file] of multiplied) {
100
+ const started = Date.now();
101
+ await bad(fields, 413, "exceeds the 921600 byte response limit", [file]);
102
+ assert.ok(Date.now() - started < 3000, `${file.filename} took ${Date.now() - started}ms`);
103
+ }
104
+
105
+ // JSON metadata that cannot be returned as given (more than 100 keys, a string over 10,000 characters, an array over 200
106
+ // items, a nested object, a mistyped known key) fails the upload with the schema message; nothing is dropped on a 200.
107
+ const many = {};
108
+ for (let i = 0; i < 101; i += 1) many[`k${i}`] = i;
109
+ const metadataShapes = [many, { big: "x".repeat(10001) }, { arr: Array(201).fill(1) }, { nested: { a: 1 } }, { page_number: -1 }, { languages: Array(51).fill("x") }];
110
+ // The languages parameter is bounded (20 entries of at most 20 characters): over the bound is a 422, never a shortened list.
111
+ await bad({ languages: Array.from({ length: 25 }, (_, i) => `l${i}`) }, 422, "at most 20 items");
112
+ await bad({ languages: ["abcdefghijklmnopqrstuvwxyz0123"] }, 422, "at most 20 characters");
113
+ await bad({ languages: "eng,deu,fra,spa,ita,por,nld,swe,nor,dan,fin,pol,ces,slk,hun,ron,bul,ell,tur,rus,ukr" }, 422, "at most 20 characters");
114
+ for (const metadata of metadataShapes) await bad({}, 400, "Json schema does not match the Unstructured schema", [{ filename: "meta.json", content: JSON.stringify([{ type: "Title", text: "t", metadata }]) }]);
115
+ }
@@ -0,0 +1,73 @@
1
+ // Fault and bound flows: rate limit, overload, committed-but-lost run, lowered state bounds and a lowered response budget.
2
+ import assert from "node:assert/strict";
3
+ import { ID, api, detail, get, partition, post } from "../lib.mjs";
4
+
5
+ const NOTE = [{ filename: "note.txt", content: "Fault Drill\n\nThis note is uploaded while a fault is active." }];
6
+
7
+ export async function rateLimitedFlow() {
8
+ const first = await detail("POST", "/general/v0/general", 429, "Rate limit exceeded", { ...multipartNote() });
9
+ assert.equal(first.headers.get("retry-after"), "1");
10
+ await partition(NOTE, {}, { status: 429 });
11
+ assert.equal((await get("/api/v1/sources")).json.length, 3, "platform routes are unaffected");
12
+ }
13
+
14
+ export async function overloadedFlow() {
15
+ for (let i = 0; i < 2; i += 1) await detail("POST", "/general/v0/general", 503, "Server is under heavy load. Please try again later.", { ...multipartNote() });
16
+ assert.equal((await get("/api/v1/workflows")).json.length, 3);
17
+ }
18
+
19
+ export async function runLostFlow() {
20
+ const before = (await get(`/api/v1/jobs?workflow_id=${ID.W2}`)).json.length;
21
+ await detail("POST", `/api/v1/workflows/${ID.W2}/run`, 500, "Internal Server Error");
22
+ const after = (await get(`/api/v1/jobs?workflow_id=${ID.W2}`)).json;
23
+ assert.equal(after.length, before + 1, "the job was committed although the response was lost");
24
+ assert.equal(after[0].status, "SCHEDULED");
25
+ await detail("POST", `/api/v1/workflows/${ID.W2}/run`, 500, "Internal Server Error");
26
+ assert.equal((await get(`/api/v1/jobs?workflow_id=${ID.W2}`)).json.length, before + 2, "a naive retry creates a second job");
27
+ }
28
+
29
+ export async function tightLimitsFlow() {
30
+ await detail("GET", "/api/v1/sources", 500, "state exceeds the supported bound of 2 sources rows");
31
+ await detail("GET", "/api/v1/destinations", 500, "state exceeds the supported bound of 2 destinations rows");
32
+ await detail("GET", "/api/v1/workflows", 500, "state exceeds the supported bound of 2 workflows rows");
33
+ await detail("GET", "/api/v1/jobs", 500, "state exceeds the supported bound of 2 jobs rows");
34
+ await detail("POST", "/api/v1/sources", 500, "supported bound", { body: { name: "n", type: "s3", config: { remote_url: "s3://x/" }, key: "fresh-key" } });
35
+ await detail("POST", "/api/v1/destinations", 500, "supported bound", { body: { name: "n", type: "s3", config: { remote_url: "s3://x/" }, key: "fresh-key" } });
36
+ await detail("POST", "/api/v1/workflows", 500, "supported bound", { body: { name: "n", workflow_type: "auto", key: "fresh-key" } });
37
+ await detail("DELETE", `/api/v1/sources/${ID.S1}`, 500, "supported bound of 2 jobs rows");
38
+ await detail("DELETE", `/api/v1/destinations/${ID.D1}`, 500, "supported bound of 2 jobs rows");
39
+ await detail("DELETE", `/api/v1/workflows/${ID.W1}`, 500, "supported bound of 2 jobs rows");
40
+ await detail("POST", `/api/v1/workflows/${ID.W1}/run`, 500, "supported bound of 2 source-files rows");
41
+ assert.equal((await get(`/api/v1/sources/${ID.S1}`)).json.name, "Policy archive", "point reads still work");
42
+ assert.equal((await post("/api/v1/sources", { name: "No key", type: "s3", config: { remote_url: "s3://x/" } })).json.type, "s3", "a create without a key needs no scan");
43
+ await api("GET", `/api/v1/jobs/${ID.J1}`);
44
+ }
45
+
46
+ /** response_bytes = 520: every list or page over the budget answers 413 and is never shortened; small lists and point reads work. */
47
+ export async function smallResponsesFlow() {
48
+ const tooLarge = (path, fragment) => detail("GET", path, 413, fragment);
49
+ await tooLarge("/api/v1/sources", "The list of 3 source connectors of ");
50
+ await tooLarge("/api/v1/sources", "exceeds the 520 byte response limit; filter by source_type or delete unused source connectors");
51
+ await tooLarge("/api/v1/destinations", "The list of 2 destination connectors of ");
52
+ await tooLarge("/api/v1/workflows", "A page of 3 rows of ");
53
+ await tooLarge("/api/v1/workflows", "exceeds the 520 byte response limit; lower page_size");
54
+ await tooLarge("/api/v1/workflows?page_size=2", "A page of 2 rows of ");
55
+ await tooLarge("/api/v1/workflows?page_size=2&page=2", "A page of 1 rows of ");
56
+ await tooLarge("/api/v1/workflows?page_size=1", "the row itself exceeds the budget");
57
+ await tooLarge("/api/v1/jobs", "A page of 6 rows of ");
58
+ await tooLarge("/api/v1/jobs?page_size=1", "A page of 1 rows of ");
59
+ assert.equal((await get("/api/v1/sources?source_type=s3")).json.length, 1, "a filtered list under the budget is returned whole");
60
+ assert.equal((await get("/api/v1/destinations?destination_type=s3")).json.length, 1);
61
+ assert.equal((await get("/api/v1/jobs?status=FAILED")).json.length, 1, "a page under the budget is returned whole");
62
+ assert.equal((await get("/api/v1/jobs?status=FAILED&page=2")).json.length, 0, "an empty page is fine");
63
+ assert.equal((await get("/api/v1/workflows?name=no-such-workflow")).json.length, 0, "an empty workflow page is fine");
64
+ assert.equal((await get(`/api/v1/workflows/${ID.W1}`)).json.name, "policy-ingest", "point reads are not budgeted");
65
+ const created = (await post("/api/v1/sources", { name: "Fourth", type: "s3", config: { remote_url: "s3://x/" } })).json;
66
+ assert.equal(created.type, "s3", "writes still work");
67
+ await tooLarge("/api/v1/sources", "The list of 4 source connectors of ");
68
+ assert.equal((await get(`/api/v1/sources/${created.id}`)).json.name, "Fourth", "the created row is reachable by id although the list is over budget");
69
+ }
70
+
71
+ function multipartNote() {
72
+ return { raw: `--b\r\nContent-Disposition: form-data; name="files"; filename="note.txt"\r\n\r\n${NOTE[0].content}\r\n--b--\r\n`, contentType: "multipart/form-data; boundary=b" };
73
+ }