@gmickel/gno 2.8.2 → 2.8.3
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.
- package/README.md +1 -1
- package/browser-extension/artifacts/{gno-browser-clipper-v2.8.2.zip → gno-browser-clipper-v2.8.3.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.3.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +9 -1
- package/spec/cli.md +66 -4
- package/spec/mcp.md +5 -6
- package/src/cli/commands/daemon.ts +7 -2
- package/src/cli/commands/doctor.ts +42 -24
- package/src/cli/commands/index-cmd.ts +10 -2
- package/src/cli/commands/links.ts +5 -22
- package/src/cli/commands/shared.ts +26 -1
- package/src/cli/commands/status.ts +60 -0
- package/src/cli/commands/update.ts +6 -1
- package/src/cli/commands/vec.ts +42 -2
- package/src/cli/program.ts +2 -2
- package/src/config/types.ts +19 -0
- package/src/converters/adapters/markitdownTs/adapter.ts +1 -52
- package/src/converters/adapters/shared/ooxml-protection.ts +58 -0
- package/src/converters/adapters/xlsx/adapter.ts +132 -0
- package/src/converters/adapters/xlsx/sheet-markdown.ts +312 -0
- package/src/converters/registry.ts +62 -6
- package/src/converters/versions.ts +2 -0
- package/src/core/audit-freshness.ts +1 -1
- package/src/core/audit-outside-index.ts +48 -3
- package/src/core/change-diff.ts +16 -4
- package/src/core/link-destination-parse.ts +21 -4
- package/src/core/link-inventory-markdown.ts +23 -3
- package/src/core/links.ts +74 -16
- package/src/core/network-boundary-inventory.ts +18 -0
- package/src/index.ts +10 -0
- package/src/ingestion/document-metadata.ts +256 -0
- package/src/ingestion/file-child-env.ts +9 -0
- package/src/ingestion/file-child.ts +106 -0
- package/src/ingestion/file-processor.ts +546 -0
- package/src/ingestion/frontmatter.ts +6 -2
- package/src/ingestion/index.ts +1 -0
- package/src/ingestion/prepare-convert.ts +32 -0
- package/src/ingestion/prepare-file.ts +272 -0
- package/src/ingestion/prepare-worker.ts +43 -0
- package/src/ingestion/record-container.ts +7 -2
- package/src/ingestion/strip.ts +71 -8
- package/src/ingestion/sync-options.ts +7 -1
- package/src/ingestion/sync.ts +207 -400
- package/src/ingestion/types.ts +18 -0
- package/src/llm/native-worker/embedding-identity.ts +15 -3
- package/src/mcp/http-transport.ts +11 -0
- package/src/mcp/tools/links.ts +10 -38
- package/src/serve/resident-request.ts +19 -0
- package/src/serve/resident-runtime.ts +84 -8
- package/src/serve/routes/links.ts +6 -24
- package/src/serve/server.ts +3 -2
- package/src/sessions/config-refresh.ts +3 -2
- package/src/store/sqlite/graph-similarity.ts +5 -13
- package/src/store/vector/partition-index.ts +100 -0
- package/src/store/vector/stored-vectors.ts +20 -15
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.2.zip.sha256 +0 -1
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-file indexing budget: the terminable file processor (fn-198).
|
|
3
|
+
*
|
|
4
|
+
* Everything CPU-bound between a file's bytes and its database write
|
|
5
|
+
* (`prepareFile`: conversion, metadata, code regions, change-journal
|
|
6
|
+
* structure, chunking, link parsing) runs outside the main thread, one file
|
|
7
|
+
* at a time. The main thread stops it the moment a file runs past its
|
|
8
|
+
* wall-clock budget or resident memory passes the memory budget, mid-step
|
|
9
|
+
* if need be; the file fails with TIMEOUT or MEMORY_LIMIT, the processor is
|
|
10
|
+
* replaced, and the run continues with the next file.
|
|
11
|
+
*
|
|
12
|
+
* Two backends share that contract:
|
|
13
|
+
* - worker: a Bun Worker (source runtimes: npm and desktop installs);
|
|
14
|
+
* - child: a child process of the same executable (standalone compiled
|
|
15
|
+
* executables, which cannot start a TypeScript worker entry), talking
|
|
16
|
+
* over IPC. Its memory is the parent's plus the child's resident set,
|
|
17
|
+
* sampled from /proc on Linux and `ps` elsewhere; on Windows the child
|
|
18
|
+
* backend enforces the time budget only.
|
|
19
|
+
* If no backend can start, the file fails closed with
|
|
20
|
+
* ISOLATION_UNAVAILABLE; it is never prepared unbounded.
|
|
21
|
+
*
|
|
22
|
+
* One processor is reused across files (starting one costs about a third of
|
|
23
|
+
* a second of module loading). It is replaced after an overrun, after a
|
|
24
|
+
* file that leaves memory above half the budget, and after a short idle
|
|
25
|
+
* period, and never keeps a process alive on its own.
|
|
26
|
+
*
|
|
27
|
+
* @module src/ingestion/file-processor
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
// node:os totalmem: Bun has no API for physical memory size.
|
|
31
|
+
import { totalmem } from "node:os";
|
|
32
|
+
|
|
33
|
+
import type { DocumentStructureSnapshot } from "../core/change-diff";
|
|
34
|
+
import type {
|
|
35
|
+
PrepareFailure,
|
|
36
|
+
PrepareFileRequest,
|
|
37
|
+
PrepareOutcome,
|
|
38
|
+
} from "./prepare-file";
|
|
39
|
+
|
|
40
|
+
import { isBunfsPath } from "../serve/spa-production-build";
|
|
41
|
+
import { FILE_PROCESSOR_CHILD_ENV } from "./file-child-env";
|
|
42
|
+
import { PREPARE_PHASES } from "./prepare-file";
|
|
43
|
+
|
|
44
|
+
/** Resource budget for indexing one file. */
|
|
45
|
+
export interface FileBudgetLimits {
|
|
46
|
+
/** Wall-clock budget in milliseconds (`conversion.timeoutMs`). */
|
|
47
|
+
timeoutMs: number;
|
|
48
|
+
/** Resident-memory ceiling in bytes (`conversion.maxMemoryMb`). */
|
|
49
|
+
maxMemoryBytes: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One file for the worker, with the shared slot it records its step in. */
|
|
53
|
+
export interface FileWorkerRequest {
|
|
54
|
+
request: PrepareFileRequest;
|
|
55
|
+
/** Index into PREPARE_PHASES of the running step; -1 before the first. */
|
|
56
|
+
phaseSlot: Int32Array;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Messages the processor posts while it prepares one file. */
|
|
60
|
+
export type FileWorkerMessage =
|
|
61
|
+
| { type: "previous"; structure: DocumentStructureSnapshot }
|
|
62
|
+
| { type: "done"; outcome: PrepareOutcome };
|
|
63
|
+
|
|
64
|
+
/** Child messages add the running step (no shared memory across processes). */
|
|
65
|
+
export type FileChildMessage =
|
|
66
|
+
| FileWorkerMessage
|
|
67
|
+
| { type: "phase"; phase: string };
|
|
68
|
+
|
|
69
|
+
export interface FileProcessorHooks {
|
|
70
|
+
onPrevious: (structure: DocumentStructureSnapshot) => void;
|
|
71
|
+
/** Called when the processor starts on the file (its clock starts). */
|
|
72
|
+
onStarted?: () => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Posted once by a processor when it has loaded. */
|
|
76
|
+
export interface ReadyMessage {
|
|
77
|
+
type: "ready";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type FileProcessorBackendKind = "worker" | "child";
|
|
81
|
+
|
|
82
|
+
/** Error codes of a file stopped at its budget. */
|
|
83
|
+
export const BUDGET_ERROR_CODES: ReadonlySet<string> = new Set([
|
|
84
|
+
"TIMEOUT",
|
|
85
|
+
"MEMORY_LIMIT",
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
/** How often resident memory is sampled while a file is processed. */
|
|
89
|
+
const MEMORY_SAMPLE_INTERVAL_MS = 200;
|
|
90
|
+
/** An idle processor is stopped so a long-running process does not hold it. */
|
|
91
|
+
const PROCESSOR_IDLE_MS = 30_000;
|
|
92
|
+
const BYTES_PER_MB = 1_048_576;
|
|
93
|
+
const BYTES_PER_KB = 1024;
|
|
94
|
+
/** Floor for the default memory budget on small machines. */
|
|
95
|
+
const MIN_DEFAULT_MEMORY_MB = 2048;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Default resident-memory budget in MB: half of physical memory, at least
|
|
99
|
+
* 2 GB. `conversion.maxMemoryMb` overrides it.
|
|
100
|
+
*/
|
|
101
|
+
export function defaultConversionMemoryMb(): number {
|
|
102
|
+
return Math.max(
|
|
103
|
+
MIN_DEFAULT_MEMORY_MB,
|
|
104
|
+
Math.floor(totalmem() / 2 / BYTES_PER_MB)
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Budget failure for elapsed time, reported against the running step. */
|
|
109
|
+
export function timeoutFailure(
|
|
110
|
+
phase: string,
|
|
111
|
+
elapsedMs: number,
|
|
112
|
+
timeoutMs: number
|
|
113
|
+
): PrepareFailure {
|
|
114
|
+
return {
|
|
115
|
+
code: "TIMEOUT",
|
|
116
|
+
message: `Indexing stopped during ${phase}: ${Math.round(elapsedMs)}ms exceeded the ${timeoutMs}ms budget`,
|
|
117
|
+
details: { phase, elapsedMs: Math.round(elapsedMs), timeoutMs },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Budget failure for resident memory. */
|
|
122
|
+
export function memoryFailure(
|
|
123
|
+
phase: string,
|
|
124
|
+
rssBytes: number,
|
|
125
|
+
maxMemoryBytes: number
|
|
126
|
+
): PrepareFailure {
|
|
127
|
+
const rssMb = Math.round(rssBytes / BYTES_PER_MB);
|
|
128
|
+
const maxMemoryMb = Math.round(maxMemoryBytes / BYTES_PER_MB);
|
|
129
|
+
return {
|
|
130
|
+
code: "MEMORY_LIMIT",
|
|
131
|
+
message: `Indexing stopped during ${phase}: resident memory ${rssMb} MB exceeded the ${maxMemoryMb} MB budget`,
|
|
132
|
+
details: { phase, rssMb, maxMemoryMb },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const unavailableFailure = (reason: string): PrepareFailure => ({
|
|
137
|
+
code: "ISOLATION_UNAVAILABLE",
|
|
138
|
+
message: `Could not start the file processor, so the file was not indexed without its budget: ${reason}`,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
142
|
+
// Backends
|
|
143
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
/** A started processor that prepares one file at a time. */
|
|
146
|
+
interface Processor {
|
|
147
|
+
readonly kind: FileProcessorBackendKind;
|
|
148
|
+
/** Settles once the processor has loaded and can take a file. */
|
|
149
|
+
readonly ready: Promise<void>;
|
|
150
|
+
/** Settles, with a reason, if the processor dies or errors. */
|
|
151
|
+
readonly failed: Promise<string>;
|
|
152
|
+
/** Send one file; its messages go to `onMessage`. */
|
|
153
|
+
run(
|
|
154
|
+
request: PrepareFileRequest,
|
|
155
|
+
onMessage: (message: FileWorkerMessage) => void
|
|
156
|
+
): void;
|
|
157
|
+
/** Name of the running step, for overrun messages. */
|
|
158
|
+
phase(): string;
|
|
159
|
+
/** Resident bytes of this processor outside the main process. */
|
|
160
|
+
externalRss(): Promise<number>;
|
|
161
|
+
/** False once the processor died on its own; it is then replaced. */
|
|
162
|
+
alive(): boolean;
|
|
163
|
+
/** Child process id (child backend), for shutdown and tests. */
|
|
164
|
+
readonly pid: number | null;
|
|
165
|
+
stop(): void;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const phaseName = (index: number): string => PREPARE_PHASES[index] ?? "startup";
|
|
169
|
+
|
|
170
|
+
function startWorker(): Processor {
|
|
171
|
+
const worker = new Worker(new URL("./prepare-worker.ts", import.meta.url));
|
|
172
|
+
// An idle worker must never keep a CLI process alive. Bun's Worker has
|
|
173
|
+
// unref(); the DOM lib type in this tsconfig does not declare it.
|
|
174
|
+
(worker as Worker & { unref(): void }).unref();
|
|
175
|
+
const phaseSlot = new Int32Array(new SharedArrayBuffer(4));
|
|
176
|
+
let onMessage: ((message: FileWorkerMessage) => void) | null = null;
|
|
177
|
+
let markReady: () => void = () => undefined;
|
|
178
|
+
let markFailed: (reason: string) => void = () => undefined;
|
|
179
|
+
let dead = false;
|
|
180
|
+
const ready = new Promise<void>((resolve) => {
|
|
181
|
+
markReady = resolve;
|
|
182
|
+
});
|
|
183
|
+
const failed = new Promise<string>((resolve) => {
|
|
184
|
+
markFailed = (reason) => {
|
|
185
|
+
dead = true;
|
|
186
|
+
resolve(reason);
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
worker.onmessage = (
|
|
190
|
+
event: MessageEvent<FileWorkerMessage | ReadyMessage>
|
|
191
|
+
) => {
|
|
192
|
+
if (event.data.type === "ready") markReady();
|
|
193
|
+
else onMessage?.(event.data);
|
|
194
|
+
};
|
|
195
|
+
worker.onerror = (event: ErrorEvent) => {
|
|
196
|
+
markFailed(event.message || "file worker failed");
|
|
197
|
+
};
|
|
198
|
+
return {
|
|
199
|
+
kind: "worker",
|
|
200
|
+
ready,
|
|
201
|
+
failed,
|
|
202
|
+
run(request, messageHandler) {
|
|
203
|
+
Atomics.store(phaseSlot, 0, -1);
|
|
204
|
+
onMessage = messageHandler;
|
|
205
|
+
const job: FileWorkerRequest = { request, phaseSlot };
|
|
206
|
+
worker.postMessage(job);
|
|
207
|
+
},
|
|
208
|
+
phase: () => phaseName(Atomics.load(phaseSlot, 0)),
|
|
209
|
+
externalRss: () => Promise.resolve(0),
|
|
210
|
+
alive: () => !dead,
|
|
211
|
+
pid: null,
|
|
212
|
+
stop() {
|
|
213
|
+
worker.terminate();
|
|
214
|
+
markFailed("file processor was stopped");
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Resident set of another process, in bytes (0 when unknown). */
|
|
220
|
+
async function processRss(pid: number): Promise<number> {
|
|
221
|
+
try {
|
|
222
|
+
if (process.platform === "linux") {
|
|
223
|
+
const status = await Bun.file(`/proc/${pid}/status`).text();
|
|
224
|
+
const kb = /^VmRSS:\s+(\d+)/m.exec(status)?.[1];
|
|
225
|
+
return kb ? Number(kb) * BYTES_PER_KB : 0;
|
|
226
|
+
}
|
|
227
|
+
if (process.platform === "win32") return 0;
|
|
228
|
+
const ps = Bun.spawn(["ps", "-o", "rss=", "-p", String(pid)], {
|
|
229
|
+
stdout: "pipe",
|
|
230
|
+
stderr: "ignore",
|
|
231
|
+
});
|
|
232
|
+
const kb = Number((await new Response(ps.stdout).text()).trim());
|
|
233
|
+
return Number.isFinite(kb) ? kb * BYTES_PER_KB : 0;
|
|
234
|
+
} catch {
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function startChild(): Processor {
|
|
240
|
+
const compiled = isBunfsPath(import.meta.path);
|
|
241
|
+
let phase = "startup";
|
|
242
|
+
let onMessage: ((message: FileWorkerMessage) => void) | null = null;
|
|
243
|
+
let markReady: () => void = () => undefined;
|
|
244
|
+
let stopping = false;
|
|
245
|
+
let exited = false;
|
|
246
|
+
const ready = new Promise<void>((resolve) => {
|
|
247
|
+
markReady = resolve;
|
|
248
|
+
});
|
|
249
|
+
const child = Bun.spawn({
|
|
250
|
+
// A compiled executable re-runs itself in child mode (src/index.ts); a
|
|
251
|
+
// source runtime runs the child module directly.
|
|
252
|
+
cmd: compiled
|
|
253
|
+
? [process.execPath]
|
|
254
|
+
: [process.execPath, `${import.meta.dir}/file-child.ts`],
|
|
255
|
+
env: { ...process.env, [FILE_PROCESSOR_CHILD_ENV]: "1" },
|
|
256
|
+
stdin: "ignore",
|
|
257
|
+
stdout: "ignore",
|
|
258
|
+
stderr: "inherit",
|
|
259
|
+
serialization: "advanced",
|
|
260
|
+
ipc(message: FileChildMessage | ReadyMessage) {
|
|
261
|
+
if (message.type === "ready") markReady();
|
|
262
|
+
else if (message.type === "phase") phase = message.phase;
|
|
263
|
+
else onMessage?.(message);
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
child.unref();
|
|
267
|
+
liveChildren.add(child);
|
|
268
|
+
ensureShutdownHooks();
|
|
269
|
+
const failed = child.exited.then((code) => {
|
|
270
|
+
exited = true;
|
|
271
|
+
liveChildren.delete(child);
|
|
272
|
+
return stopping
|
|
273
|
+
? "file processor was stopped"
|
|
274
|
+
: `file processor child exited (${code})`;
|
|
275
|
+
});
|
|
276
|
+
return {
|
|
277
|
+
kind: "child",
|
|
278
|
+
ready,
|
|
279
|
+
failed,
|
|
280
|
+
run(request, messageHandler) {
|
|
281
|
+
phase = "startup";
|
|
282
|
+
onMessage = messageHandler;
|
|
283
|
+
child.send({ request });
|
|
284
|
+
},
|
|
285
|
+
phase: () => phase,
|
|
286
|
+
externalRss: () => processRss(child.pid),
|
|
287
|
+
alive: () => !exited,
|
|
288
|
+
pid: child.pid,
|
|
289
|
+
stop() {
|
|
290
|
+
stopping = true;
|
|
291
|
+
child.kill(9);
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
297
|
+
// Parent shutdown
|
|
298
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
/** Child processors still running; killed on every parent shutdown path. */
|
|
301
|
+
const liveChildren = new Set<ReturnType<typeof Bun.spawn>>();
|
|
302
|
+
let shutdownHooksInstalled = false;
|
|
303
|
+
|
|
304
|
+
/** SIGKILL every child processor (they are disposable). Synchronous. */
|
|
305
|
+
function killChildren(): void {
|
|
306
|
+
for (const child of liveChildren) {
|
|
307
|
+
try {
|
|
308
|
+
child.kill(9);
|
|
309
|
+
} catch {
|
|
310
|
+
// Already gone.
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
liveChildren.clear();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* A signal the process would otherwise die from by default: kill the
|
|
318
|
+
* children, and when no one else handles the signal, stop listening and
|
|
319
|
+
* re-raise it so the process still ends the default way (same exit status).
|
|
320
|
+
* The listener is prepended, so it runs before any other handler and counts
|
|
321
|
+
* the real owners: a `process.once` handler (serve, daemon) removes itself
|
|
322
|
+
* before it runs, so a listener running after it would see none.
|
|
323
|
+
*/
|
|
324
|
+
function onTerminatingSignal(signal: "SIGINT" | "SIGTERM"): () => void {
|
|
325
|
+
const listener = (): void => {
|
|
326
|
+
killChildren();
|
|
327
|
+
if (process.listenerCount(signal) === 1) {
|
|
328
|
+
process.off(signal, listener);
|
|
329
|
+
process.kill(process.pid, signal);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
return listener;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Kill child processors whenever this process ends: on exit (the CLI's
|
|
337
|
+
* SIGINT handler and every normal exit end in process.exit), and on SIGTERM.
|
|
338
|
+
* SIGINT gets its own listener only when nothing else owns it: the CLI
|
|
339
|
+
* treats an extra SIGINT listener as a command that finishes its own
|
|
340
|
+
* teardown, so adding one there would stop Ctrl-C from exiting. An
|
|
341
|
+
* uncaught exception is not intercepted (a listener would keep the process
|
|
342
|
+
* alive); the child's parent-death signal (Linux) or its between-step
|
|
343
|
+
* parent check covers it.
|
|
344
|
+
*/
|
|
345
|
+
function ensureShutdownHooks(): void {
|
|
346
|
+
if (shutdownHooksInstalled) return;
|
|
347
|
+
shutdownHooksInstalled = true;
|
|
348
|
+
process.on("exit", killChildren);
|
|
349
|
+
process.prependListener("SIGTERM", onTerminatingSignal("SIGTERM"));
|
|
350
|
+
if (process.listenerCount("SIGINT") === 0) {
|
|
351
|
+
process.on("SIGINT", onTerminatingSignal("SIGINT"));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Stop the file processor and any child process now (resident shutdown).
|
|
357
|
+
* A file being prepared fails; the next file starts a new processor.
|
|
358
|
+
*/
|
|
359
|
+
export function disposeFileProcessor(): void {
|
|
360
|
+
stopProcessor();
|
|
361
|
+
killChildren();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Tests only: the running child processor's pid, if any. */
|
|
365
|
+
export function activeFileProcessorPid(): number | null {
|
|
366
|
+
return processor?.pid ?? null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
370
|
+
// Processor lifecycle
|
|
371
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
let forcedBackend: FileProcessorBackendKind | null = null;
|
|
374
|
+
let processor: Processor | null = null;
|
|
375
|
+
let processorsStarted = 0;
|
|
376
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
377
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
378
|
+
|
|
379
|
+
/** Which backend this runtime uses: the worker unless it cannot start. */
|
|
380
|
+
export function fileProcessorBackend(): FileProcessorBackendKind {
|
|
381
|
+
return forcedBackend ?? (isBunfsPath(import.meta.path) ? "child" : "worker");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Tests only: force a backend (null restores the default). Stops the current
|
|
386
|
+
* processor so the next file starts the chosen one.
|
|
387
|
+
*/
|
|
388
|
+
export function useFileProcessorBackend(
|
|
389
|
+
kind: FileProcessorBackendKind | null
|
|
390
|
+
): void {
|
|
391
|
+
forcedBackend = kind;
|
|
392
|
+
stopProcessor();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Number of file processors started in this process (for tests). */
|
|
396
|
+
export function fileProcessorsStarted(): number {
|
|
397
|
+
return processorsStarted;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function stopProcessor(): void {
|
|
401
|
+
clearTimeout(idleTimer);
|
|
402
|
+
processor?.stop();
|
|
403
|
+
processor = null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function acquireProcessor(): Processor {
|
|
407
|
+
clearTimeout(idleTimer);
|
|
408
|
+
const kind = fileProcessorBackend();
|
|
409
|
+
if (processor?.kind !== kind || !processor.alive()) {
|
|
410
|
+
processor?.stop();
|
|
411
|
+
processor = kind === "worker" ? startWorker() : startChild();
|
|
412
|
+
processorsStarted += 1;
|
|
413
|
+
}
|
|
414
|
+
return processor;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** How long a new processor may take to load before the file fails closed. */
|
|
418
|
+
const PROCESSOR_START_TIMEOUT_MS = 30_000;
|
|
419
|
+
|
|
420
|
+
/** Wait for the processor to load: null once ready, else why it did not. */
|
|
421
|
+
const awaitReady = (active: Processor): Promise<string | null> => {
|
|
422
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
423
|
+
const timedOut = new Promise<string>((resolve) => {
|
|
424
|
+
timer = setTimeout(
|
|
425
|
+
() => resolve(`no response within ${PROCESSOR_START_TIMEOUT_MS} ms`),
|
|
426
|
+
PROCESSOR_START_TIMEOUT_MS
|
|
427
|
+
);
|
|
428
|
+
});
|
|
429
|
+
return Promise.race([
|
|
430
|
+
active.ready.then(() => null),
|
|
431
|
+
active.failed,
|
|
432
|
+
timedOut,
|
|
433
|
+
]).finally(() => clearTimeout(timer));
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
const runOne = async (
|
|
437
|
+
request: PrepareFileRequest,
|
|
438
|
+
budget: FileBudgetLimits,
|
|
439
|
+
hooks: FileProcessorHooks
|
|
440
|
+
): Promise<PrepareOutcome> => {
|
|
441
|
+
let active: Processor;
|
|
442
|
+
try {
|
|
443
|
+
active = acquireProcessor();
|
|
444
|
+
} catch (cause) {
|
|
445
|
+
processor = null;
|
|
446
|
+
return {
|
|
447
|
+
ok: false,
|
|
448
|
+
error: unavailableFailure(
|
|
449
|
+
cause instanceof Error ? cause.message : String(cause)
|
|
450
|
+
),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
// Loading the processor is not the file's time: its clock starts once the
|
|
454
|
+
// processor can take it.
|
|
455
|
+
const notReady = await awaitReady(active);
|
|
456
|
+
if (notReady) {
|
|
457
|
+
if (processor === active) stopProcessor();
|
|
458
|
+
return { ok: false, error: unavailableFailure(notReady) };
|
|
459
|
+
}
|
|
460
|
+
hooks.onStarted?.();
|
|
461
|
+
const startedAt = performance.now();
|
|
462
|
+
return new Promise<PrepareOutcome>((resolve) => {
|
|
463
|
+
let settled = false;
|
|
464
|
+
let sampling = false;
|
|
465
|
+
const finish = (outcome: PrepareOutcome, keep: boolean): void => {
|
|
466
|
+
if (settled) return;
|
|
467
|
+
settled = true;
|
|
468
|
+
clearTimeout(deadline);
|
|
469
|
+
clearInterval(sampler);
|
|
470
|
+
if (keep && process.memoryUsage.rss() <= budget.maxMemoryBytes / 2) {
|
|
471
|
+
idleTimer = setTimeout(stopProcessor, PROCESSOR_IDLE_MS);
|
|
472
|
+
idleTimer.unref?.();
|
|
473
|
+
} else if (processor === active) {
|
|
474
|
+
stopProcessor();
|
|
475
|
+
} else {
|
|
476
|
+
active.stop();
|
|
477
|
+
}
|
|
478
|
+
resolve(outcome);
|
|
479
|
+
};
|
|
480
|
+
const deadline = setTimeout(() => {
|
|
481
|
+
finish(
|
|
482
|
+
{
|
|
483
|
+
ok: false,
|
|
484
|
+
error: timeoutFailure(
|
|
485
|
+
active.phase(),
|
|
486
|
+
performance.now() - startedAt,
|
|
487
|
+
budget.timeoutMs
|
|
488
|
+
),
|
|
489
|
+
},
|
|
490
|
+
false
|
|
491
|
+
);
|
|
492
|
+
}, budget.timeoutMs);
|
|
493
|
+
const checkMemory = async (): Promise<void> => {
|
|
494
|
+
if (sampling || settled) return;
|
|
495
|
+
sampling = true;
|
|
496
|
+
const rss = process.memoryUsage.rss() + (await active.externalRss());
|
|
497
|
+
sampling = false;
|
|
498
|
+
if (rss > budget.maxMemoryBytes) {
|
|
499
|
+
finish(
|
|
500
|
+
{
|
|
501
|
+
ok: false,
|
|
502
|
+
error: memoryFailure(active.phase(), rss, budget.maxMemoryBytes),
|
|
503
|
+
},
|
|
504
|
+
false
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
const sampler = setInterval(() => {
|
|
509
|
+
void checkMemory();
|
|
510
|
+
}, MEMORY_SAMPLE_INTERVAL_MS);
|
|
511
|
+
void active.failed.then((reason) =>
|
|
512
|
+
finish({ ok: false, error: { code: "INTERNAL", message: reason } }, false)
|
|
513
|
+
);
|
|
514
|
+
// A process already over the budget does not start another file.
|
|
515
|
+
const rss = process.memoryUsage.rss();
|
|
516
|
+
if (rss > budget.maxMemoryBytes) {
|
|
517
|
+
finish(
|
|
518
|
+
{
|
|
519
|
+
ok: false,
|
|
520
|
+
error: memoryFailure("startup", rss, budget.maxMemoryBytes),
|
|
521
|
+
},
|
|
522
|
+
false
|
|
523
|
+
);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
active.run(request, (message) => {
|
|
527
|
+
if (message.type === "previous") hooks.onPrevious(message.structure);
|
|
528
|
+
else finish(message.outcome, true);
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Prepare one file in the file processor under its time and memory budget.
|
|
535
|
+
* Files run one at a time; the budget clock starts when a file reaches the
|
|
536
|
+
* processor, not while it queues.
|
|
537
|
+
*/
|
|
538
|
+
export function prepareInProcessor(
|
|
539
|
+
request: PrepareFileRequest,
|
|
540
|
+
budget: FileBudgetLimits,
|
|
541
|
+
hooks: FileProcessorHooks
|
|
542
|
+
): Promise<PrepareOutcome> {
|
|
543
|
+
const result = queue.then(() => runOne(request, budget, hooks));
|
|
544
|
+
queue = result.catch(() => undefined);
|
|
545
|
+
return result;
|
|
546
|
+
}
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
|
|
12
12
|
import { normalizeTag } from "../core/tags";
|
|
13
13
|
|
|
14
|
+
/** Frontmatter keys that would write to an object's prototype. */
|
|
15
|
+
const PROTOTYPE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
16
|
+
const isPrototypeKey = (key: string): boolean => PROTOTYPE_KEYS.has(key);
|
|
17
|
+
|
|
14
18
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
19
|
// Types
|
|
16
20
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -233,7 +237,7 @@ function parseNestedMapValue(
|
|
|
233
237
|
}
|
|
234
238
|
}
|
|
235
239
|
|
|
236
|
-
if (key.length > 0 && values.length > 0) {
|
|
240
|
+
if (key.length > 0 && values.length > 0 && !isPrototypeKey(key)) {
|
|
237
241
|
result[key] = values;
|
|
238
242
|
}
|
|
239
243
|
}
|
|
@@ -337,7 +341,7 @@ export function parseFrontmatter(source: string): FrontmatterResult {
|
|
|
337
341
|
const value = line.slice(colonIdx + 1).trim();
|
|
338
342
|
if (key !== "tags") {
|
|
339
343
|
const metadataValue = parseMetadataValue(value, lines, i);
|
|
340
|
-
if (metadataValue !== undefined) {
|
|
344
|
+
if (metadataValue !== undefined && !isPrototypeKey(key)) {
|
|
341
345
|
result.metadata[key] = metadataValue;
|
|
342
346
|
}
|
|
343
347
|
}
|
package/src/ingestion/index.ts
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversion used by the file processor backends (fn-198).
|
|
3
|
+
*
|
|
4
|
+
* Native files (Markdown, plain text) skip the default registry, whose
|
|
5
|
+
* PDF/Office adapters take about half a second to load. The default
|
|
6
|
+
* registry tries these two converters first, so the output is the same.
|
|
7
|
+
*
|
|
8
|
+
* @module src/ingestion/prepare-convert
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ConvertInput, PipelineResult } from "../converters/types";
|
|
12
|
+
|
|
13
|
+
import { markdownConverter } from "../converters/native/markdown";
|
|
14
|
+
import { plaintextConverter } from "../converters/native/plaintext";
|
|
15
|
+
import { ConversionPipeline, getDefaultPipeline } from "../converters/pipeline";
|
|
16
|
+
import { ConverterRegistry } from "../converters/registry";
|
|
17
|
+
|
|
18
|
+
const nativeRegistry = new ConverterRegistry();
|
|
19
|
+
nativeRegistry.register(markdownConverter);
|
|
20
|
+
nativeRegistry.register(plaintextConverter);
|
|
21
|
+
const nativePipeline = new ConversionPipeline(nativeRegistry);
|
|
22
|
+
|
|
23
|
+
/** Convert one file, loading the heavy adapters only when needed. */
|
|
24
|
+
export function convertForPreparation(
|
|
25
|
+
input: ConvertInput
|
|
26
|
+
): Promise<PipelineResult> {
|
|
27
|
+
return (
|
|
28
|
+
nativeRegistry.select(input.mime, input.ext)
|
|
29
|
+
? nativePipeline
|
|
30
|
+
: getDefaultPipeline()
|
|
31
|
+
).convert(input);
|
|
32
|
+
}
|