@query-farm/vgi 0.27.0 → 0.29.0

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 (83) hide show
  1. package/README.md +26 -7
  2. package/dist/arguments/argument-spec.d.ts +53 -0
  3. package/dist/arrow/impl-arrowjs/index.d.ts +1 -1
  4. package/dist/arrow/impl-arrowjs/iterate.d.ts +10 -1
  5. package/dist/arrow/impl-arrowjs/schema.d.ts +2 -0
  6. package/dist/arrow/impl-flechette/index.d.ts +1 -1
  7. package/dist/arrow/impl-flechette/iterate.d.ts +10 -1
  8. package/dist/arrow/impl-flechette/schema.d.ts +2 -0
  9. package/dist/arrow/index.d.ts +1 -1
  10. package/dist/arrow/schema-types.d.ts +3 -1
  11. package/dist/arrow/types.d.ts +39 -3
  12. package/dist/catalog/interface.d.ts +43 -0
  13. package/dist/client/client.d.ts +1 -1
  14. package/dist/client-entry.js +360 -178
  15. package/dist/client-entry.js.map +14 -14
  16. package/dist/functions/table.d.ts +63 -0
  17. package/dist/functions/types.d.ts +38 -0
  18. package/dist/generated/vgi-client.d.ts +59 -0
  19. package/dist/generated/vgi-protocol-schemas.d.ts +19 -0
  20. package/dist/http/fetch.d.ts +10 -0
  21. package/dist/index.core.d.ts +2 -2
  22. package/dist/index.js +1203 -834
  23. package/dist/index.js.map +40 -37
  24. package/dist/protocol/handlers/catalog/shared.d.ts +0 -8
  25. package/dist/protocol/handlers/function.d.ts +11 -0
  26. package/dist/protocol/serializers/bind.d.ts +1 -2
  27. package/dist/protocol/serializers/cardinality.d.ts +1 -2
  28. package/dist/protocol/serializers/splits.d.ts +126 -0
  29. package/dist/protocol/types.d.ts +27 -0
  30. package/dist/request-auth.d.ts +28 -0
  31. package/dist/serve-entry.d.ts +4 -0
  32. package/dist/serve-entry.js +745 -359
  33. package/dist/serve-entry.js.map +32 -29
  34. package/dist/split-token.d.ts +88 -0
  35. package/dist/worker-cf-entry.js +1029 -619
  36. package/dist/worker-cf-entry.js.map +37 -34
  37. package/package.json +3 -3
  38. package/src/arguments/argument-spec.ts +81 -0
  39. package/src/arguments/arguments.ts +0 -1
  40. package/src/arrow/impl-arrowjs/index.ts +1 -1
  41. package/src/arrow/impl-arrowjs/iterate.ts +21 -0
  42. package/src/arrow/impl-arrowjs/schema.ts +4 -0
  43. package/src/arrow/impl-flechette/index.ts +1 -1
  44. package/src/arrow/impl-flechette/iterate.ts +24 -1
  45. package/src/arrow/impl-flechette/schema.ts +4 -0
  46. package/src/arrow/index.ts +1 -1
  47. package/src/arrow/schema-types.ts +4 -0
  48. package/src/arrow/types.ts +39 -3
  49. package/src/catalog/interface.ts +124 -29
  50. package/src/catalog/read-only.ts +18 -18
  51. package/src/client/client.ts +6 -2
  52. package/src/functions/aggregate.ts +3 -0
  53. package/src/functions/copy-from.ts +2 -1
  54. package/src/functions/copy-to.ts +2 -1
  55. package/src/functions/scalar.ts +11 -0
  56. package/src/functions/table-buffering.ts +3 -0
  57. package/src/functions/table-in-out.ts +8 -4
  58. package/src/functions/table.ts +129 -2
  59. package/src/functions/types.ts +40 -0
  60. package/src/generated/vgi-client.ts +63 -1
  61. package/src/generated/vgi-protocol-schemas.ts +240 -14
  62. package/src/http/fetch.ts +117 -2
  63. package/src/index.core.ts +2 -2
  64. package/src/protocol/handlers/aggregate.ts +10 -5
  65. package/src/protocol/handlers/catalog/admin.ts +41 -48
  66. package/src/protocol/handlers/catalog/index_methods.ts +4 -11
  67. package/src/protocol/handlers/catalog/macro.ts +8 -16
  68. package/src/protocol/handlers/catalog/shared.ts +10 -57
  69. package/src/protocol/handlers/catalog/table.ts +33 -96
  70. package/src/protocol/handlers/catalog/view.ts +13 -17
  71. package/src/protocol/handlers/function.ts +311 -18
  72. package/src/protocol/handlers/table-buffering.ts +7 -4
  73. package/src/protocol/serializers/bind.ts +56 -63
  74. package/src/protocol/serializers/cardinality.ts +7 -7
  75. package/src/protocol/serializers/init.ts +52 -10
  76. package/src/protocol/serializers/splits.ts +219 -0
  77. package/src/protocol/state-serializer.ts +55 -1
  78. package/src/protocol/types.ts +27 -0
  79. package/src/request-auth.ts +73 -0
  80. package/src/serve-entry.ts +5 -0
  81. package/src/split-token.ts +297 -0
  82. package/src/worker-cf-entry.ts +2 -1
  83. package/src/worker.ts +30 -10
@@ -0,0 +1,73 @@
1
+ // Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
+ // Request-scoped caller identity, for the code paths the RPC framework cannot
3
+ // hand a CallContext to.
4
+ //
5
+ // Every unary VGI method receives the caller's `AuthContext` directly — the
6
+ // dispatcher passes the OutputCollector, which *is* a CallContext. The stream
7
+ // `init` method does not: `@query-farm/vgi-rpc` types stream initialization as
8
+ // `(params) => state`, with no context parameter, on both the pipe and HTTP
9
+ // dispatchers. That is a framework signature this package cannot change.
10
+ //
11
+ // It matters because `init` opens two identity-bound AEAD envelopes:
12
+ // `attach_opaque_data` (sealed under `attachAad(auth)` by `catalog_attach`) and
13
+ // every split token (sealed under `splitTokenAad(body, auth)` by
14
+ // `table_function_plan`). Both of those minting sites ARE unary and therefore
15
+ // seal under the REAL principal. Opening them under a hard-coded `undefined`
16
+ // yields the anonymous identity tail, so the AAD only ever matches for an
17
+ // anonymous caller: an authenticated one gets `OpaqueDataRejectedError:
18
+ // attach_opaque_data not recognized` on the first scan. Anonymous workers never
19
+ // saw it, which is why it survived — the example HTTP workers authenticate
20
+ // nobody.
21
+ //
22
+ // The reference implementations solve this with an ambient, request-scoped
23
+ // identity rather than a parameter: vgi-python reads a `ContextVar` via
24
+ // `current_auth()`; vgi-go's RPC framework threads a `*CallContext` into
25
+ // `handleInit`. This is the TypeScript equivalent — an `AsyncLocalStorage`
26
+ // scope opened per HTTP request by `createVgiFetch` (see `http/fetch.ts`),
27
+ // read here.
28
+ //
29
+ // Deliberately a registry rather than a direct `node:async_hooks` import: this
30
+ // module is reachable from the protocol handlers, which also run under stdio,
31
+ // AF_UNIX and browser builds. Only the HTTP entry points install a scope, and
32
+ // they load `node:async_hooks` defensively. With no scope installed
33
+ // `currentRequestAuth()` returns `undefined`, which is exactly the previous
34
+ // behaviour — and correct, because a worker with no signing key seals nothing.
35
+
36
+ import type { AuthContext } from "@query-farm/vgi-rpc";
37
+
38
+ /** Mutable per-request cell. The scope is opened before the identity is known
39
+ * (authentication happens inside the handler), so the holder is published
40
+ * first and filled in when the framework reports the principal. */
41
+ export interface RequestAuthHolder {
42
+ auth?: AuthContext;
43
+ }
44
+
45
+ /** The slice of `AsyncLocalStorage` this module needs. Structural on purpose:
46
+ * it keeps `node:async_hooks` out of every non-HTTP build, and lets a test
47
+ * install a trivial stub. */
48
+ export interface RequestAuthScope {
49
+ getStore(): RequestAuthHolder | undefined;
50
+ }
51
+
52
+ let scope: RequestAuthScope | undefined;
53
+
54
+ /**
55
+ * Publish the scope `currentRequestAuth()` reads. Called once per process by
56
+ * the HTTP entry point; idempotent for the same scope.
57
+ */
58
+ export function setRequestAuthScope(next: RequestAuthScope | undefined): void {
59
+ scope = next;
60
+ }
61
+
62
+ /**
63
+ * The authenticated principal of the request being served on this async
64
+ * context, or `undefined` when there is none — no scope installed (stdio /
65
+ * AF_UNIX / a host that has no `AsyncLocalStorage`), or an unauthenticated
66
+ * request.
67
+ *
68
+ * `undefined` and an anonymous `AuthContext` produce the same identity tail, so
69
+ * the two cases need not be distinguished by callers.
70
+ */
71
+ export function currentRequestAuth(): AuthContext | undefined {
72
+ return scope?.getStore()?.auth;
73
+ }
@@ -23,6 +23,7 @@
23
23
 
24
24
  import type { CatalogInterface } from "./catalog/interface.js";
25
25
  import type { FunctionRegistry } from "./functions/registry.js";
26
+ import type { AuthenticateFn } from "@query-farm/vgi-rpc";
26
27
  import { createVgiFetch } from "./http/fetch.js";
27
28
 
28
29
  /** Environment variables `serveVgiWorker` reads. Injectable for testing. */
@@ -68,6 +69,9 @@ export interface ServeVgiWorkerOptions {
68
69
  quiet?: boolean;
69
70
  /** Environment source (default `process.env`). */
70
71
  env?: ServeEnv;
72
+ /** Authenticates each request, returning the caller's `AuthContext`.
73
+ * Omit for an anonymous worker (the default). */
74
+ authenticate?: AuthenticateFn;
71
75
  }
72
76
 
73
77
  /** The slice of `Bun.serve`'s return value this module uses. */
@@ -194,6 +198,7 @@ export function createVgiWorkerFetch(
194
198
  corsOrigins,
195
199
  repositoryUrl: opts.repositoryUrl,
196
200
  landingInfo: { name: opts.name, doc: opts.doc, version: opts.version },
201
+ authenticate: opts.authenticate,
197
202
  });
198
203
  }
199
204
 
@@ -0,0 +1,297 @@
1
+ // Copyright 2025, 2026 Query Farm LLC - https://query.farm
2
+
3
+ /**
4
+ * Split-token envelope: the framework's wrapper around a worker's split payload.
5
+ *
6
+ * A split token *names* a unit of scan work so a distributed engine can
7
+ * re-request exactly the work it was handed. The worker supplies only the
8
+ * payload; everything around it is stamped here, so an author cannot forget the
9
+ * consistency anchor or mis-bind the fingerprint, and never writes crypto.
10
+ *
11
+ * Layout (little-endian, fixed prefix) — byte-identical across every SDK:
12
+ *
13
+ * ```
14
+ * offset size field
15
+ * 0 1 format_version currently 1
16
+ * 1 1 flags bit0 = payload_sealed; bits 1-7 reserved, MUST be 0
17
+ * 2 2 anchor_len u16 LE
18
+ * 4 16 bind_fingerprint truncated SHA-256 of the bind identity
19
+ * 20 var consistency_anchor anchor_len bytes
20
+ * 20+n var payload the worker's own bytes
21
+ * ```
22
+ *
23
+ * **The header is plaintext on every transport; only the payload is sealed.**
24
+ * That is not a preference: a worker has no signing key on subprocess and unix,
25
+ * which is DuckDB's primary path, so a header readable only through AEAD would be
26
+ * unreadable exactly where DuckDB runs. It also matters for streaming — a
27
+ * checkpointed position must survive key rotation.
28
+ *
29
+ * @module
30
+ */
31
+
32
+ import type { AuthContext } from "@query-farm/vgi-rpc";
33
+
34
+ import { identityTail, openBytes, sealBytes } from "./crypto.js";
35
+
36
+ /** Envelope format version. Checked unconditionally, before anything else. */
37
+ export const SPLIT_TOKEN_FORMAT_VERSION = 1;
38
+
39
+ /** bit0 of `flags`: the payload is AEAD-sealed rather than plaintext. */
40
+ const FLAG_PAYLOAD_SEALED = 0x01;
41
+
42
+ /** bits 1-7 are reserved and MUST be zero; a set bit is a forward-compat violation. */
43
+ const RESERVED_FLAGS_MASK = 0xfe;
44
+
45
+ const FINGERPRINT_LEN = 16;
46
+ const HEADER_LEN = 4 + FINGERPRINT_LEN;
47
+
48
+ /**
49
+ * Matches the reference default (`crypto.seal_bytes` version=1), so a token
50
+ * sealed by one SDK opens in another.
51
+ */
52
+ const SEAL_VERSION = 1;
53
+
54
+ const AAD_PREFIX = new TextEncoder().encode("vgi.split_token.v1\u0000");
55
+
56
+ /** Stable error-kind strings, identical across SDKs. */
57
+ export const SPLIT_TOKEN_INVALID = "SPLIT_TOKEN_INVALID";
58
+ export const SPLIT_SNAPSHOT_EXPIRED = "SPLIT_SNAPSHOT_EXPIRED";
59
+ export const SPLIT_TRANSACTION_ENDED = "SPLIT_TRANSACTION_ENDED";
60
+
61
+ /**
62
+ * Why a split token was refused.
63
+ *
64
+ * The kind matters to a connector: only `SPLIT_SNAPSHOT_EXPIRED` means "re-run
65
+ * the query", and neither kind is retriable in place. Keeping the anchor in the
66
+ * PLAINTEXT header rather than in the AAD is what makes that distinction
67
+ * expressible — inside the AAD both collapse into one tag-check failure.
68
+ */
69
+ export class SplitTokenError extends Error {
70
+ readonly kind: string;
71
+
72
+ constructor(kind: string, message: string) {
73
+ // The message carries the stable KIND, because the kind is the part a caller
74
+ // acts on: only SPLIT_SNAPSHOT_EXPIRED means "re-run the query", and a
75
+ // connector several layers up sees the message string rather than this
76
+ // class. Without it the three failures are indistinguishable to everyone
77
+ // downstream.
78
+ super(`[${kind}] ${message}`);
79
+ this.name = "SplitTokenError";
80
+ this.kind = kind;
81
+ }
82
+ }
83
+
84
+ function concat(...parts: Uint8Array[]): Uint8Array {
85
+ const total = parts.reduce((n, p) => n + p.length, 0);
86
+ const out = new Uint8Array(total);
87
+ let at = 0;
88
+ for (const p of parts) {
89
+ out.set(p, at);
90
+ at += p.length;
91
+ }
92
+ return out;
93
+ }
94
+
95
+ function equalBytes(a: Uint8Array, b: Uint8Array): boolean {
96
+ if (a.length !== b.length) return false;
97
+ let diff = 0;
98
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
99
+ return diff === 0;
100
+ }
101
+
102
+ /**
103
+ * Derive the 16-byte binding check for a bind call.
104
+ *
105
+ * Minted **and** verified by the same worker, so it needs self-consistency only
106
+ * — it does not have to agree with any client, which is why the cross-SDK byte
107
+ * fixtures do not cover it. 16 bytes is a binding check, not a MAC: forgery
108
+ * resistance comes from the seal where a key exists, and from the uid trust
109
+ * boundary where one does not.
110
+ *
111
+ * Uses Web Crypto rather than a hash dependency so this works unchanged in the
112
+ * browser and Cloudflare Workers builds.
113
+ */
114
+ export async function bindFingerprint(
115
+ schemaName: string,
116
+ functionName: string,
117
+ args: Uint8Array,
118
+ settings: Uint8Array,
119
+ projection: Uint8Array,
120
+ ): Promise<Uint8Array> {
121
+ const enc = new TextEncoder();
122
+ const zero = Uint8Array.of(0);
123
+ const field = (label: string, value: Uint8Array) => concat(enc.encode(label), zero, value, zero);
124
+ const message = concat(
125
+ AAD_PREFIX,
126
+ field("schema_name", enc.encode(schemaName)),
127
+ field("function_name", enc.encode(functionName)),
128
+ field("arguments", args),
129
+ field("settings", settings),
130
+ field("projection_ids", projection),
131
+ );
132
+ const digest = await crypto.subtle.digest("SHA-256", message as BufferSource);
133
+ return new Uint8Array(digest).slice(0, FINGERPRINT_LEN);
134
+ }
135
+
136
+ /**
137
+ * AAD for a sealed split payload: the plaintext header plus the caller identity.
138
+ *
139
+ * The identity half is load-bearing, not incidental — it stops a token minted for
140
+ * one principal being replayed by another, exactly as the attach envelope does. A
141
+ * split token names data (files, offsets, tenant partitions), so dropping it here
142
+ * while keeping it on attach would be a regression.
143
+ */
144
+ function splitTokenAad(header: Uint8Array, auth: AuthContext | undefined): Uint8Array {
145
+ return concat(header, identityTail(auth));
146
+ }
147
+
148
+ /**
149
+ * Encode the consistency anchor.
150
+ *
151
+ * `catalog_version` is the counter that MOVES within an attach, so it is what a
152
+ * plan is pinned to; `resolved_data_version` is fixed at attach and would say
153
+ * nothing about staleness.
154
+ */
155
+ export function splitAnchor(catalogVersion: bigint | number): Uint8Array {
156
+ const out = new Uint8Array(8);
157
+ new DataView(out.buffer).setBigInt64(0, BigInt(catalogVersion), true);
158
+ return out;
159
+ }
160
+
161
+ /** Stamp (and, when a key exists, seal) a worker payload into a split token. */
162
+ export async function buildSplitToken(opts: {
163
+ payload: Uint8Array;
164
+ fingerprint: Uint8Array;
165
+ anchor: Uint8Array;
166
+ signingKey?: Uint8Array;
167
+ auth?: AuthContext;
168
+ }): Promise<Uint8Array> {
169
+ const { payload, fingerprint, anchor, signingKey, auth } = opts;
170
+ if (fingerprint.length !== FINGERPRINT_LEN) {
171
+ throw new SplitTokenError(
172
+ SPLIT_TOKEN_INVALID,
173
+ `bind_fingerprint must be ${FINGERPRINT_LEN} bytes, got ${fingerprint.length}`,
174
+ );
175
+ }
176
+ if (anchor.length > 0xffff) {
177
+ throw new SplitTokenError(
178
+ SPLIT_TOKEN_INVALID,
179
+ `consistency_anchor too long: ${anchor.length} bytes exceeds u16`,
180
+ );
181
+ }
182
+
183
+ const head = new Uint8Array(4);
184
+ head[0] = SPLIT_TOKEN_FORMAT_VERSION;
185
+ head[1] = signingKey ? FLAG_PAYLOAD_SEALED : 0;
186
+ new DataView(head.buffer).setUint16(2, anchor.length, true);
187
+ const body = concat(head, fingerprint, anchor);
188
+
189
+ if (!signingKey) return concat(body, payload);
190
+ const sealed = await sealBytes(payload, signingKey, splitTokenAad(body, auth), SEAL_VERSION);
191
+ return concat(body, sealed);
192
+ }
193
+
194
+ /**
195
+ * Verify a split token and return the worker's payload.
196
+ *
197
+ * `expectedFingerprint` and `currentAnchor` are optional; omitting one skips that
198
+ * check.
199
+ */
200
+ export async function openSplitToken(
201
+ token: Uint8Array,
202
+ opts: {
203
+ signingKey?: Uint8Array;
204
+ auth?: AuthContext;
205
+ expectedFingerprint?: Uint8Array;
206
+ currentAnchor?: Uint8Array;
207
+ } = {},
208
+ ): Promise<Uint8Array> {
209
+ const { signingKey, auth, expectedFingerprint, currentAnchor } = opts;
210
+
211
+ if (token.length < HEADER_LEN) {
212
+ throw new SplitTokenError(
213
+ SPLIT_TOKEN_INVALID,
214
+ `split token too short: ${token.length} bytes, need at least ${HEADER_LEN}`,
215
+ );
216
+ }
217
+ const version = token[0];
218
+ const flags = token[1];
219
+ const view = new DataView(token.buffer, token.byteOffset, token.byteLength);
220
+ const anchorLen = view.getUint16(2, true);
221
+
222
+ if (version !== SPLIT_TOKEN_FORMAT_VERSION) {
223
+ throw new SplitTokenError(
224
+ SPLIT_TOKEN_INVALID,
225
+ `unsupported split-token format_version ${version}; this worker speaks ` +
226
+ `${SPLIT_TOKEN_FORMAT_VERSION}`,
227
+ );
228
+ }
229
+ if (flags & RESERVED_FLAGS_MASK) {
230
+ throw new SplitTokenError(
231
+ SPLIT_TOKEN_INVALID,
232
+ `split token sets reserved flag bits (flags=0x${flags.toString(16).padStart(2, "0")})`,
233
+ );
234
+ }
235
+ const sealed = (flags & FLAG_PAYLOAD_SEALED) !== 0;
236
+
237
+ // ---- The alg:none refusal. Load-bearing; do not relax. ----
238
+ // `flags` is attacker-controlled plaintext, so it may say "not sealed" on a
239
+ // token an attacker wrote by hand. A keyed worker that honoured that would
240
+ // redeem forged work without ever opening an envelope. The WORKER'S OWN KEY
241
+ // STATE decides, never the token.
242
+ if (signingKey && !sealed) {
243
+ throw new SplitTokenError(
244
+ SPLIT_TOKEN_INVALID,
245
+ "split token is unsealed but this worker holds a signing key; refusing. An unsealed " +
246
+ "token cannot be authenticated, so accepting one here would let any caller forge a " +
247
+ "split (alg:none).",
248
+ );
249
+ }
250
+ if (!signingKey && sealed) {
251
+ throw new SplitTokenError(
252
+ SPLIT_TOKEN_INVALID,
253
+ "split token is sealed but this worker holds no signing key; cannot open it",
254
+ );
255
+ }
256
+
257
+ const endOfAnchor = HEADER_LEN + anchorLen;
258
+ if (token.length < endOfAnchor) {
259
+ throw new SplitTokenError(
260
+ SPLIT_TOKEN_INVALID,
261
+ `split token truncated: anchor_len=${anchorLen} exceeds token length ${token.length}`,
262
+ );
263
+ }
264
+
265
+ const fingerprint = token.subarray(4, HEADER_LEN);
266
+ const anchor = token.subarray(HEADER_LEN, endOfAnchor);
267
+ const body = token.subarray(0, endOfAnchor);
268
+ const rest = token.subarray(endOfAnchor);
269
+
270
+ if (expectedFingerprint && !equalBytes(fingerprint, expectedFingerprint)) {
271
+ throw new SplitTokenError(
272
+ SPLIT_TOKEN_INVALID,
273
+ "split token was minted for a different bind (fingerprint mismatch)",
274
+ );
275
+ }
276
+ // Anchor check AFTER the bind check, and as its own kind: "read version N" is
277
+ // a different situation from "this token is not yours".
278
+ if (currentAnchor && !equalBytes(anchor, currentAnchor)) {
279
+ throw new SplitTokenError(SPLIT_SNAPSHOT_EXPIRED, "split snapshot expired; re-run the query");
280
+ }
281
+
282
+ if (!sealed) return new Uint8Array(rest);
283
+ try {
284
+ return await openBytes(
285
+ rest,
286
+ signingKey as Uint8Array,
287
+ splitTokenAad(body, auth),
288
+ SEAL_VERSION,
289
+ "split token",
290
+ );
291
+ } catch (err) {
292
+ throw new SplitTokenError(
293
+ SPLIT_TOKEN_INVALID,
294
+ `split token failed authentication: ${err instanceof Error ? err.message : String(err)}`,
295
+ );
296
+ }
297
+ }
@@ -17,7 +17,8 @@ export { createVgiFetch } from "./http/fetch.js";
17
17
  export type { VgiFetchOptions } from "./http/fetch.js";
18
18
 
19
19
  // Re-export the public types CF Worker authors need to wire a registry +
20
- // catalog. They import from "vgi/worker-cf" rather than from "vgi" directly
20
+ // catalog. They import from "@query-farm/vgi/worker-cf" rather than from
21
+ // "@query-farm/vgi" directly
21
22
  // so the build-condition resolution picks the flechette Arrow backend.
22
23
  export {
23
24
  defineScalarFunction,
package/src/worker.ts CHANGED
@@ -10,6 +10,26 @@ import type { CatalogDescriptor } from "./catalog/descriptors.js";
10
10
  import type { CatalogInterface } from "./catalog/interface.js";
11
11
  import { ReadOnlyCatalogInterface } from "./catalog/read-only.js";
12
12
 
13
+ /**
14
+ * Startup/lifecycle tracing, off unless `VGI_DEBUG` is set to something other
15
+ * than "" or "0".
16
+ *
17
+ * These lines used to be unconditional, which put three of them in the log of
18
+ * every worker anyone ever deployed. They are genuinely useful when an ATTACH
19
+ * hangs and you need to know how far startup got, so they are kept rather than
20
+ * deleted — just behind a flag. Errors are NOT gated: `Worker error:` and
21
+ * `Worker init error:` always print.
22
+ *
23
+ * The message is a thunk so an unset flag costs no interpolation.
24
+ *
25
+ * stderr, never stdout: on the stdio transport stdout is the protocol.
26
+ */
27
+ function debug(message: () => string): void {
28
+ const flag = process.env.VGI_DEBUG;
29
+ if (flag === undefined || flag === "" || flag === "0") return;
30
+ process.stderr.write(message() + "\n");
31
+ }
32
+
13
33
  // argv parser for the launcher's `--unix PATH` / `--tcp [HOST:]PORT` /
14
34
  // `--idle-timeout SEC` contract. The C++ launcher (vgi extension) appends
15
35
  // these to the worker's argv whenever LOCATION uses the `launch:` scheme.
@@ -123,10 +143,10 @@ export class Worker {
123
143
  }
124
144
 
125
145
  run(argv: readonly string[] = process.argv.slice(2)): void {
126
- process.stderr.write(`[worker] starting, pid=${process.pid}\n`);
146
+ debug(() => `[worker] starting, pid=${process.pid}`);
127
147
  try {
128
148
  const protocol = this.buildProtocol();
129
- process.stderr.write(`[worker] protocol built\n`);
149
+ debug(() => `[worker] protocol built`);
130
150
 
131
151
  const launcher = parseLauncherArgs(argv);
132
152
  if (launcher.unixPath !== undefined) {
@@ -139,14 +159,14 @@ export class Worker {
139
159
  const idleTimeout = envOverride !== undefined && envOverride !== ""
140
160
  ? Number(envOverride)
141
161
  : launcher.idleTimeout;
142
- process.stderr.write(
143
- `[worker] AF_UNIX mode: ${launcher.unixPath} idle=${idleTimeout ?? 300}s${envOverride !== undefined && envOverride !== "" ? " (env override)" : ""}\n`,
162
+ debug(() =>
163
+ `[worker] AF_UNIX mode: ${launcher.unixPath} idle=${idleTimeout ?? 300}s${envOverride !== undefined && envOverride !== "" ? " (env override)" : ""}`,
144
164
  );
145
165
  serveUnix(protocol, {
146
166
  unixPath: launcher.unixPath,
147
167
  idleTimeout,
148
168
  }).then((handle) => handle.done).then(() => {
149
- process.stderr.write(`[worker] serveUnix done (idle shutdown)\n`);
169
+ debug(() => `[worker] serveUnix done (idle shutdown)`);
150
170
  }).catch((err: any) => {
151
171
  process.stderr.write(`Worker error: ${err.message}\n${err.stack}\n`);
152
172
  process.exit(1);
@@ -164,15 +184,15 @@ export class Worker {
164
184
  const idleTimeout = envOverride !== undefined && envOverride !== ""
165
185
  ? Number(envOverride)
166
186
  : launcher.idleTimeout;
167
- process.stderr.write(
168
- `[worker] TCP mode: ${launcher.tcpHost}:${launcher.tcpPort} idle=${idleTimeout ?? 300}s${envOverride !== undefined && envOverride !== "" ? " (env override)" : ""}\n`,
187
+ debug(() =>
188
+ `[worker] TCP mode: ${launcher.tcpHost}:${launcher.tcpPort} idle=${idleTimeout ?? 300}s${envOverride !== undefined && envOverride !== "" ? " (env override)" : ""}`,
169
189
  );
170
190
  serveTcp(protocol, {
171
191
  host: launcher.tcpHost,
172
192
  port: launcher.tcpPort,
173
193
  idleTimeout,
174
194
  }).then((handle) => handle.done).then(() => {
175
- process.stderr.write(`[worker] serveTcp done (idle shutdown)\n`);
195
+ debug(() => `[worker] serveTcp done (idle shutdown)`);
176
196
  }).catch((err: any) => {
177
197
  process.stderr.write(`Worker error: ${err.message}\n${err.stack}\n`);
178
198
  process.exit(1);
@@ -181,9 +201,9 @@ export class Worker {
181
201
  }
182
202
 
183
203
  const server = new VgiRpcServer(protocol);
184
- process.stderr.write(`[worker] server created, calling run()\n`);
204
+ debug(() => `[worker] server created, calling run()`);
185
205
  server.run().then(() => {
186
- process.stderr.write(`[worker] server.run() resolved cleanly\n`);
206
+ debug(() => `[worker] server.run() resolved cleanly`);
187
207
  }).catch((err) => {
188
208
  process.stderr.write(`Worker error: ${err.message}\n${err.stack}\n`);
189
209
  process.exit(1);