Skip to content

Commit 37f10b4

Browse files
georgiclaude
andcommitted
fix(documents): a PDF added to a workflow never loaded
Three separate defects sat between picking a PDF and reading its bytes. Every document node hand-rolled its own resolver: `resolvePdfBuffer` and its siblings understood inline `data`, one exact storage key, and a local path, then fell through to `fs.readFile(uri)`. An `asset://` ref, an https signed URL (S3/Supabase), a `data:` URI or a `package://` example asset died with a raw ENOENT. They now share `resolveDocumentBytes`, which delegates to the runtime's `loadMediaRefBytes` — the resolver every other media node already used. `FileStorageAdapter.list()` in the runtime returned nothing unless called with `delimiter: "/"`, which is not how `resolveAssetBytes` calls it. Its extension-tolerant lookup (`asset://<id>` -> `<id>.pdf` on disk) therefore always missed in CLI and DSL contexts. It now walks recursively, matching the storage package's adapter. `assets.createUpload` required a non-empty `parent_id`, but the property dropzones upload with no folder — so every pick 400'd and the value never reached the property. An empty parent now files under the caller's root, as `POST /api/assets/` has always done. Also: a graph opened before the node registry finished loading recorded every one of its types as unknown, and that append-only set is spread over the real node types — so known nodes kept rendering as "Missing Node" for the rest of the session. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent df1c031 commit 37f10b4

14 files changed

Lines changed: 355 additions & 123 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* One byte resolver for every document node.
3+
*
4+
* Document refs arrive with whatever URI the surface that produced them uses:
5+
* `asset://<id>` from the asset library and the chat composer, an
6+
* `/api/storage/<key>` URL from the editor's dropzone, an https signed URL on
7+
* S3/Supabase deployments, a `data:` URI, a `package://` example asset, or a
8+
* plain file path. `loadMediaRefBytes` already knows all of them — routing
9+
* every node through it keeps a PDF that opens in the editor from failing at
10+
* run time.
11+
*/
12+
import { loadMediaRefBytes, type MediaRefValue } from "@nodetool-ai/runtime";
13+
import type { ProcessingContext } from "@nodetool-ai/runtime";
14+
import { promises as fs } from "node:fs";
15+
import os from "node:os";
16+
import path from "node:path";
17+
18+
export type DocumentRefLike = MediaRefValue & {
19+
data?: Uint8Array | string | null;
20+
};
21+
22+
function expandUser(p: string): string {
23+
if (!p) return p;
24+
if (p === "~") return os.homedir();
25+
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
26+
return p;
27+
}
28+
29+
/**
30+
* Resolve a document ref to bytes, or null when nothing behind it can be read.
31+
* Falls back to the filesystem for relative and `~`-prefixed paths, which
32+
* `loadMediaRefBytes` deliberately does not touch.
33+
*/
34+
export async function resolveDocumentBytes(
35+
ref: DocumentRefLike,
36+
context?: ProcessingContext
37+
): Promise<Buffer | null> {
38+
const bytes = await loadMediaRefBytes(ref as MediaRefValue, context);
39+
if (bytes) {
40+
return Buffer.from(bytes);
41+
}
42+
43+
const uri = typeof ref.uri === "string" ? ref.uri : "";
44+
if (uri && !/^[a-z][a-z0-9+.-]*:\/\//i.test(uri) && !uri.startsWith("data:")) {
45+
try {
46+
return await fs.readFile(expandUser(uri));
47+
} catch {
48+
return null;
49+
}
50+
}
51+
52+
return null;
53+
}
54+
55+
/** Same as {@link resolveDocumentBytes}, but throws with the ref named. */
56+
export async function requireDocumentBytes(
57+
ref: DocumentRefLike,
58+
context: ProcessingContext | undefined,
59+
label: string
60+
): Promise<Buffer> {
61+
const bytes = await resolveDocumentBytes(ref, context);
62+
if (!bytes) {
63+
const where = ref?.uri || ref?.asset_id;
64+
throw new Error(
65+
where
66+
? `Could not read ${label} from "${where}"`
67+
: `No ${label} data or URI provided`
68+
);
69+
}
70+
return bytes;
71+
}

packages/document-nodes/src/nodes/document.ts

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import {
1010
loadNodeFsPromises,
1111
loadNodePath
1212
} from "@nodetool-ai/nodes-utils";
13+
import {
14+
resolveDocumentBytes,
15+
type DocumentRefLike as DocumentBytesRef
16+
} from "../document-bytes.js";
1317

1418
const NODE_ONLY: readonly Platform[] = ["node"];
1519

@@ -79,30 +83,16 @@ function splitByChunk(
7983

8084
async function readDocumentText(refOrPath: unknown, context?: ProcessingContext): Promise<string> {
8185
if (typeof refOrPath === "string" && refOrPath) {
82-
if (context?.storage) {
83-
const stored = await context.storage.retrieve(refOrPath);
84-
if (stored !== null) return Buffer.from(stored).toString("utf8");
85-
}
86+
const bytes = await resolveDocumentBytes({ uri: refOrPath }, context);
87+
if (bytes) return bytes.toString("utf8");
8688
const fs = await loadNodeFsPromises();
8789
return fs.readFile(toFilePath(refOrPath), "utf8");
8890
}
8991
if (refOrPath && typeof refOrPath === "object") {
9092
const ref = refOrPath as DocumentRefLike;
9193
if (typeof ref.text === "string") return ref.text;
92-
if (ref.data) {
93-
const bytes = asBytes(ref.data);
94-
return Buffer.from(bytes).toString("utf8");
95-
}
96-
if (typeof ref.uri === "string" && ref.uri) {
97-
if (context?.storage) {
98-
const stored = await context.storage.retrieve(ref.uri);
99-
if (stored !== null) return Buffer.from(stored).toString("utf8");
100-
}
101-
if (ref.uri.startsWith("file://") || !ref.uri.startsWith("http")) {
102-
const fs = await loadNodeFsPromises();
103-
return fs.readFile(toFilePath(ref.uri), "utf8");
104-
}
105-
}
94+
const bytes = await resolveDocumentBytes(ref as DocumentBytesRef, context);
95+
if (bytes) return bytes.toString("utf8");
10696
}
10797
return "";
10898
}

packages/document-nodes/src/nodes/lib-doc-convert.ts

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { promises as fs } from "node:fs";
66
import os from "node:os";
77
import path from "node:path";
88
import { promisify } from "node:util";
9+
import { resolveDocumentBytes } from "../document-bytes.js";
910

1011
const execFile = promisify(execFileCb);
1112

@@ -140,23 +141,6 @@ function refToBytes(ref: unknown): Buffer | null {
140141
return null;
141142
}
142143

143-
async function loadFromUri(uri: string, context?: ProcessingContext): Promise<Buffer | null> {
144-
if (!uri) return null;
145-
if (context?.storage) {
146-
const stored = await context.storage.retrieve(uri);
147-
if (stored !== null) return Buffer.from(stored);
148-
}
149-
let filePath = uri;
150-
if (filePath.startsWith("file://")) {
151-
filePath = decodeURIComponent(new URL(filePath).pathname);
152-
}
153-
try {
154-
return Buffer.from(await fs.readFile(filePath));
155-
} catch {
156-
return null;
157-
}
158-
}
159-
160144
async function pdfToText(inputBytes: Buffer): Promise<string> {
161145
const { LiteParse } = await import("@llamaindex/liteparse");
162146
const parser = new LiteParse({ ocrEnabled: false });
@@ -239,8 +223,8 @@ export class ConvertToMarkdownLibNode extends BaseNode {
239223
if (doc && typeof doc === "object") {
240224
const uri = typeof doc.uri === "string" ? doc.uri : "";
241225
let bytes = refToBytes(doc);
242-
if ((!bytes || bytes.length === 0) && uri) {
243-
bytes = await loadFromUri(uri, context);
226+
if ((!bytes || bytes.length === 0) && (uri || doc.asset_id)) {
227+
bytes = await resolveDocumentBytes({ uri, asset_id: doc.asset_id }, context);
244228
}
245229
if (bytes && bytes.length > 0) {
246230
return { output: await convertBytes(bytes, turndown) };

packages/document-nodes/src/nodes/lib-epub.ts

Lines changed: 21 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,11 @@ import type { ProcessingContext } from "@nodetool-ai/runtime";
99
import { promises as fs } from "node:fs";
1010
import os from "node:os";
1111
import path from "node:path";
12-
13-
type DocumentRefLike = {
14-
uri?: string;
15-
data?: Uint8Array | string;
16-
};
17-
18-
function asBytes(data: Uint8Array | string | undefined): Uint8Array {
19-
if (!data) return new Uint8Array();
20-
if (data instanceof Uint8Array) return data;
21-
return Uint8Array.from(Buffer.from(data, "base64"));
22-
}
12+
import { fileURLToPath } from "node:url";
13+
import {
14+
requireDocumentBytes,
15+
type DocumentRefLike
16+
} from "../document-bytes.js";
2317

2418
function expandUser(p: string): string {
2519
if (!p) return p;
@@ -28,34 +22,29 @@ function expandUser(p: string): string {
2822
return p;
2923
}
3024

25+
/**
26+
* epub2 reads from a path, so anything that isn't already a local file lands
27+
* in a temp file the caller deletes afterwards.
28+
*/
3129
async function resolveEpubPath(
3230
doc: DocumentRefLike,
3331
context?: ProcessingContext
3432
): Promise<{ filePath: string; cleanup?: () => Promise<void> }> {
35-
if (doc.uri && !doc.data) {
36-
const uri = doc.uri.startsWith("file://") ? doc.uri.slice(7) : doc.uri;
37-
if (context?.storage) {
38-
const stored = await context.storage.retrieve(doc.uri);
39-
if (stored !== null) {
40-
const tmp = path.join(
41-
os.tmpdir(),
42-
`nodetool-epub-${Date.now()}-${Math.random().toString(36).slice(2)}.epub`
43-
);
44-
await fs.writeFile(tmp, Buffer.from(stored));
45-
return { filePath: tmp, cleanup: async () => fs.unlink(tmp).catch(() => {}) };
46-
}
47-
}
33+
const uri = typeof doc.uri === "string" ? doc.uri : "";
34+
if (uri && !doc.data && !/^[a-z][a-z0-9+.-]*:/i.test(uri)) {
4835
return { filePath: expandUser(uri) };
4936
}
50-
if (doc.data) {
51-
const tmp = path.join(
52-
os.tmpdir(),
53-
`nodetool-epub-${Date.now()}-${Math.random().toString(36).slice(2)}.epub`
54-
);
55-
await fs.writeFile(tmp, Buffer.from(asBytes(doc.data)));
56-
return { filePath: tmp, cleanup: async () => fs.unlink(tmp).catch(() => {}) };
37+
if (uri.startsWith("file://")) {
38+
return { filePath: fileURLToPath(uri) };
5739
}
58-
throw new Error("No EPUB data or URI provided");
40+
41+
const bytes = await requireDocumentBytes(doc, context, "EPUB");
42+
const tmp = path.join(
43+
os.tmpdir(),
44+
`nodetool-epub-${Date.now()}-${Math.random().toString(36).slice(2)}.epub`
45+
);
46+
await fs.writeFile(tmp, bytes);
47+
return { filePath: tmp, cleanup: async () => fs.unlink(tmp).catch(() => {}) };
5948
}
6049

6150
async function loadEpub(

packages/document-nodes/src/nodes/lib-pdf.ts

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,33 +9,16 @@ import { BaseNode, prop } from "@nodetool-ai/node-sdk";
99
import type { ProcessingContext } from "@nodetool-ai/runtime";
1010
import type { ParseResult } from "@llamaindex/liteparse";
1111

12-
type DocumentRefLike = {
13-
uri?: string;
14-
data?: Uint8Array | string;
15-
};
16-
17-
function asBytes(data: Uint8Array | string | undefined): Uint8Array {
18-
if (!data) return new Uint8Array();
19-
if (data instanceof Uint8Array) return data;
20-
return Uint8Array.from(Buffer.from(data, "base64"));
21-
}
12+
import {
13+
requireDocumentBytes,
14+
type DocumentRefLike
15+
} from "../document-bytes.js";
2216

2317
async function resolvePdfBuffer(
2418
pdf: DocumentRefLike,
2519
context?: ProcessingContext
2620
): Promise<Buffer> {
27-
if (pdf.data) {
28-
return Buffer.from(asBytes(pdf.data));
29-
} else if (pdf.uri) {
30-
const uri = pdf.uri.startsWith("file://") ? pdf.uri.slice(7) : pdf.uri;
31-
if (context?.storage) {
32-
const stored = await context.storage.retrieve(pdf.uri);
33-
if (stored !== null) return Buffer.from(stored);
34-
}
35-
const { promises: fs } = await import("node:fs");
36-
return fs.readFile(uri);
37-
}
38-
throw new Error("No PDF data or URI provided");
21+
return requireDocumentBytes(pdf, context, "PDF");
3922
}
4023

4124
async function parsePdf(

packages/document-nodes/src/nodes/lib-pptx.ts

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,16 @@
33
*/
44
import { BaseNode, prop } from "@nodetool-ai/node-sdk";
55
import type { ProcessingContext } from "@nodetool-ai/runtime";
6-
import { promises as fs } from "node:fs";
7-
8-
type DocumentRefLike = {
9-
uri?: string;
10-
data?: Uint8Array | string;
11-
};
12-
13-
function asBytes(data: Uint8Array | string | undefined): Uint8Array {
14-
if (!data) return new Uint8Array();
15-
if (data instanceof Uint8Array) return data;
16-
return Uint8Array.from(Buffer.from(data, "base64"));
17-
}
6+
import {
7+
requireDocumentBytes,
8+
type DocumentRefLike
9+
} from "../document-bytes.js";
1810

1911
async function resolvePptxBuffer(
2012
doc: DocumentRefLike,
2113
context?: ProcessingContext
2214
): Promise<Buffer> {
23-
if (doc.data) {
24-
return Buffer.from(asBytes(doc.data));
25-
}
26-
if (doc.uri) {
27-
const uri = doc.uri.startsWith("file://") ? doc.uri.slice(7) : doc.uri;
28-
if (context?.storage) {
29-
const stored = await context.storage.retrieve(doc.uri);
30-
if (stored !== null) return Buffer.from(stored);
31-
}
32-
return fs.readFile(uri);
33-
}
34-
throw new Error("No PPTX data or URI provided");
15+
return requireDocumentBytes(doc, context, "PPTX");
3516
}
3617

3718
const PPTX_INPUT = {

packages/protocol/src/api-schemas/assets.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ export type UploadTarget = z.infer<typeof uploadTarget>;
8787
export const createUploadInput = z.object({
8888
name: z.string().min(1),
8989
content_type: z.string().min(1),
90-
parent_id: z.string().min(1),
90+
/** Empty or omitted means the user's root folder, as on `POST /api/assets/`. */
91+
parent_id: z.string().optional(),
9192
/** Declared byte size, checked against the upload cap before minting. */
9293
size: z.number().int().nonnegative(),
9394
workflow_id: z.string().nullable().optional(),

packages/runtime/src/context.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -872,7 +872,49 @@ export class FileStorageAdapter implements StorageAdapter {
872872
commonPrefixes: [...commonPrefixes].sort()
873873
};
874874
}
875-
return { entries: [], commonPrefixes: [] };
875+
876+
// No delimiter: flat listing of everything under the prefix, the shape S3
877+
// returns. `resolveAssetBytes` relies on it for the extension-tolerant
878+
// lookup that turns `asset://<id>` into `<id>.pdf` on disk.
879+
const walk = async (dirAbs: string, keyPrefix: string): Promise<void> => {
880+
let children: Array<{
881+
name: string;
882+
isDirectory: () => boolean;
883+
isFile: () => boolean;
884+
}>;
885+
try {
886+
children = (await rd(dirAbs, {
887+
withFileTypes: true
888+
})) as unknown as typeof children;
889+
} catch {
890+
return;
891+
}
892+
for (const child of children) {
893+
const childAbs = join(dirAbs, child.name);
894+
const childKey = keyPrefix ? `${keyPrefix}/${child.name}` : child.name;
895+
if (child.isDirectory()) {
896+
await walk(childAbs, childKey);
897+
continue;
898+
}
899+
if (!child.isFile()) continue;
900+
try {
901+
const s = await st(childAbs);
902+
entries.push({
903+
key: childKey,
904+
uri: pathToFileURL(childAbs).toString(),
905+
size: s.size,
906+
modifiedAt: s.mtimeMs
907+
});
908+
} catch {
909+
// skip
910+
}
911+
}
912+
};
913+
await walk(baseAbs, isRoot ? "" : normalizeStorageKey(prefix));
914+
return {
915+
entries: entries.sort((a, b) => a.key.localeCompare(b.key)),
916+
commonPrefixes: []
917+
};
876918
}
877919

878920
async delete(uri: string): Promise<boolean> {

packages/websocket/src/trpc/routers/assets.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ export const assetsRouter = router({
350350
user_id: ctx.userId,
351351
name: input.name,
352352
content_type: input.content_type,
353-
parent_id: input.parent_id,
353+
parent_id: input.parent_id || ctx.userId,
354354
workflow_id: input.workflow_id ?? null,
355355
node_id: input.node_id ?? null,
356356
job_id: input.job_id ?? null,

0 commit comments

Comments
 (0)