@stackables/bridge-core 0.0.1

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.
@@ -0,0 +1,1261 @@
1
+ import { SpanStatusCode, metrics, trace } from "@opentelemetry/api";
2
+ import { parsePath } from "./utils.js";
3
+ import { internal } from "./tools/index.js";
4
+ import { SELF_MODULE } from "./types.js";
5
+ /** Fatal panic error — bypasses all error boundaries (`?.` and `catch`). */
6
+ export class BridgePanicError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = "BridgePanicError";
10
+ }
11
+ }
12
+ /** Abort error — raised when an external AbortSignal cancels execution. */
13
+ export class BridgeAbortError extends Error {
14
+ constructor(message = "Execution aborted by external signal") {
15
+ super(message);
16
+ this.name = "BridgeAbortError";
17
+ }
18
+ }
19
+ /** Sentinel for `continue` — skip the current array element */
20
+ const CONTINUE_SYM = Symbol.for("BRIDGE_CONTINUE");
21
+ /** Sentinel for `break` — halt array iteration */
22
+ const BREAK_SYM = Symbol.for("BRIDGE_BREAK");
23
+ const otelTracer = trace.getTracer("@stackables/bridge");
24
+ const otelMeter = metrics.getMeter("@stackables/bridge");
25
+ const toolCallCounter = otelMeter.createCounter("bridge.tool.calls", {
26
+ description: "Total number of tool invocations",
27
+ });
28
+ const toolDurationHistogram = otelMeter.createHistogram("bridge.tool.duration", {
29
+ description: "Tool call duration in milliseconds",
30
+ unit: "ms",
31
+ });
32
+ const toolErrorCounter = otelMeter.createCounter("bridge.tool.errors", {
33
+ description: "Total number of tool invocation errors",
34
+ });
35
+ /** Round milliseconds to 2 decimal places */
36
+ function roundMs(ms) {
37
+ return Math.round(ms * 100) / 100;
38
+ }
39
+ /** Stable string key for the state map */
40
+ function trunkKey(ref) {
41
+ if (ref.element)
42
+ return `${ref.module}:${ref.type}:${ref.field}:*`;
43
+ return `${ref.module}:${ref.type}:${ref.field}${ref.instance != null ? `:${ref.instance}` : ""}`;
44
+ }
45
+ /** Match two trunks (ignoring path and element) */
46
+ function sameTrunk(a, b) {
47
+ return (a.module === b.module &&
48
+ a.type === b.type &&
49
+ a.field === b.field &&
50
+ (a.instance ?? undefined) === (b.instance ?? undefined));
51
+ }
52
+ /** Strict path equality */
53
+ function pathEquals(a, b) {
54
+ return a.length === b.length && a.every((v, i) => v === b[i]);
55
+ }
56
+ /** Check whether an error is a fatal halt (abort or panic) that must bypass all error boundaries. */
57
+ function isFatalError(err) {
58
+ return (err instanceof BridgePanicError ||
59
+ err instanceof BridgeAbortError ||
60
+ err?.name === "BridgeAbortError" ||
61
+ err?.name === "BridgePanicError");
62
+ }
63
+ /** Execute a control flow instruction, returning a sentinel or throwing. */
64
+ function applyControlFlow(ctrl) {
65
+ if (ctrl.kind === "throw")
66
+ throw new Error(ctrl.message);
67
+ if (ctrl.kind === "panic")
68
+ throw new BridgePanicError(ctrl.message);
69
+ if (ctrl.kind === "continue")
70
+ return CONTINUE_SYM;
71
+ /* ctrl.kind === "break" */
72
+ return BREAK_SYM;
73
+ }
74
+ /** Shared trace collector — one per request, passed through the tree. */
75
+ export class TraceCollector {
76
+ traces = [];
77
+ level;
78
+ epoch = performance.now();
79
+ constructor(level = "full") {
80
+ this.level = level;
81
+ }
82
+ /** Returns ms since the collector was created */
83
+ now() {
84
+ return roundMs(performance.now() - this.epoch);
85
+ }
86
+ record(trace) {
87
+ this.traces.push(trace);
88
+ }
89
+ /** Build a trace entry, omitting input/output for basic level. */
90
+ entry(base) {
91
+ if (this.level === "basic") {
92
+ const t = {
93
+ tool: base.tool,
94
+ fn: base.fn,
95
+ durationMs: base.durationMs,
96
+ startedAt: base.startedAt,
97
+ };
98
+ if (base.error)
99
+ t.error = base.error;
100
+ return t;
101
+ }
102
+ // full
103
+ const t = {
104
+ tool: base.tool,
105
+ fn: base.fn,
106
+ durationMs: base.durationMs,
107
+ startedAt: base.startedAt,
108
+ };
109
+ if (base.input)
110
+ t.input = structuredClone(base.input);
111
+ if (base.error)
112
+ t.error = base.error;
113
+ else if (base.output !== undefined)
114
+ t.output = base.output;
115
+ return t;
116
+ }
117
+ }
118
+ /** Set a value at a nested path, creating intermediate objects/arrays as needed */
119
+ /**
120
+ * Coerce a constant wire value string to its proper JS type.
121
+ *
122
+ * The parser stores all bare constants as strings (because the Wire type
123
+ * uses `value: string`). JSON.parse recovers the original type:
124
+ * "true" → true, "false" → false, "null" → null, "42" → 42
125
+ * Plain strings that aren't valid JSON (like "hello", "/search") fall
126
+ * through and are returned as-is.
127
+ */
128
+ function coerceConstant(raw) {
129
+ try {
130
+ return JSON.parse(raw);
131
+ }
132
+ catch {
133
+ return raw;
134
+ }
135
+ }
136
+ const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
137
+ function setNested(obj, path, value) {
138
+ for (let i = 0; i < path.length - 1; i++) {
139
+ const key = path[i];
140
+ if (UNSAFE_KEYS.has(key))
141
+ throw new Error(`Unsafe assignment key: ${key}`);
142
+ const nextKey = path[i + 1];
143
+ if (obj[key] == null) {
144
+ obj[key] = /^\d+$/.test(nextKey) ? [] : {};
145
+ }
146
+ obj = obj[key];
147
+ }
148
+ if (path.length > 0) {
149
+ const finalKey = path[path.length - 1];
150
+ if (UNSAFE_KEYS.has(finalKey))
151
+ throw new Error(`Unsafe assignment key: ${finalKey}`);
152
+ obj[finalKey] = value;
153
+ }
154
+ }
155
+ export class ExecutionTree {
156
+ trunk;
157
+ instructions;
158
+ context;
159
+ parent;
160
+ state = {};
161
+ bridge;
162
+ toolDepCache = new Map();
163
+ toolDefCache = new Map();
164
+ pipeHandleMap;
165
+ /** Promise that resolves when all critical `force` handles have settled. */
166
+ forcedExecution;
167
+ /** Shared trace collector — present only when tracing is enabled. */
168
+ tracer;
169
+ /** Structured logger passed from BridgeOptions. Defaults to no-ops. */
170
+ logger;
171
+ /** External abort signal — cancels execution when triggered. */
172
+ signal;
173
+ toolFns;
174
+ constructor(trunk, instructions, toolFns, context, parent) {
175
+ this.trunk = trunk;
176
+ this.instructions = instructions;
177
+ this.context = context;
178
+ this.parent = parent;
179
+ this.toolFns = { internal, ...(toolFns ?? {}) };
180
+ this.bridge = instructions.find((i) => i.kind === "bridge" && i.type === trunk.type && i.field === trunk.field);
181
+ if (this.bridge?.pipeHandles) {
182
+ this.pipeHandleMap = new Map(this.bridge.pipeHandles.map((ph) => [ph.key, ph]));
183
+ }
184
+ if (context) {
185
+ this.state[trunkKey({ module: SELF_MODULE, type: "Context", field: "context" })] = context;
186
+ }
187
+ // Collect const definitions into a single namespace object
188
+ const constObj = {};
189
+ for (const inst of instructions) {
190
+ if (inst.kind === "const") {
191
+ constObj[inst.name] = JSON.parse(inst.value);
192
+ }
193
+ }
194
+ if (Object.keys(constObj).length > 0) {
195
+ this.state[trunkKey({ module: SELF_MODULE, type: "Const", field: "const" })] = constObj;
196
+ }
197
+ }
198
+ /** Derive tool name from a trunk */
199
+ getToolName(target) {
200
+ if (target.module === SELF_MODULE)
201
+ return target.field;
202
+ return `${target.module}.${target.field}`;
203
+ }
204
+ /** Deep-lookup a tool function by dotted name (e.g. "std.str.toUpperCase").
205
+ * Falls back to a flat key lookup for backward compat (e.g. "hereapi.geocode" as literal key). */
206
+ lookupToolFn(name) {
207
+ if (name.includes(".")) {
208
+ // Try namespace traversal first
209
+ const parts = name.split(".");
210
+ let current = this.toolFns;
211
+ for (const part of parts) {
212
+ if (UNSAFE_KEYS.has(part))
213
+ return undefined;
214
+ if (current == null || typeof current !== "object") {
215
+ current = undefined;
216
+ break;
217
+ }
218
+ current = current[part];
219
+ }
220
+ if (typeof current === "function")
221
+ return current;
222
+ // Fall back to flat key (e.g. "hereapi.geocode" as a literal property name)
223
+ const flat = this.toolFns?.[name];
224
+ return typeof flat === "function" ? flat : undefined;
225
+ }
226
+ // Try root level first
227
+ const fn = this.toolFns?.[name];
228
+ if (typeof fn === "function")
229
+ return fn;
230
+ // Fall back to std namespace (builtins are callable without std. prefix)
231
+ const stdFn = this.toolFns?.std?.[name];
232
+ if (typeof stdFn === "function")
233
+ return stdFn;
234
+ // Fall back to internal namespace (engine-internal tools: math ops, concat, etc.)
235
+ const internalFn = this.toolFns?.internal?.[name];
236
+ return typeof internalFn === "function" ? internalFn : undefined;
237
+ }
238
+ /** Resolve a ToolDef by name, merging the extends chain (cached) */
239
+ resolveToolDefByName(name) {
240
+ if (this.toolDefCache.has(name))
241
+ return this.toolDefCache.get(name) ?? undefined;
242
+ const toolDefs = this.instructions.filter((i) => i.kind === "tool");
243
+ const base = toolDefs.find((t) => t.name === name);
244
+ if (!base) {
245
+ this.toolDefCache.set(name, null);
246
+ return undefined;
247
+ }
248
+ // Build extends chain: root → ... → leaf
249
+ const chain = [base];
250
+ let current = base;
251
+ while (current.extends) {
252
+ const parent = toolDefs.find((t) => t.name === current.extends);
253
+ if (!parent)
254
+ throw new Error(`Tool "${current.name}" extends unknown tool "${current.extends}"`);
255
+ chain.unshift(parent);
256
+ current = parent;
257
+ }
258
+ // Merge: root provides base, each child overrides
259
+ const merged = {
260
+ kind: "tool",
261
+ name,
262
+ fn: chain[0].fn, // fn from root ancestor
263
+ deps: [],
264
+ wires: [],
265
+ };
266
+ for (const def of chain) {
267
+ // Merge deps (dedupe by handle)
268
+ for (const dep of def.deps) {
269
+ if (!merged.deps.some((d) => d.handle === dep.handle)) {
270
+ merged.deps.push(dep);
271
+ }
272
+ }
273
+ // Merge wires (child overrides parent by target; onError replaces onError)
274
+ for (const wire of def.wires) {
275
+ if (wire.kind === "onError") {
276
+ const idx = merged.wires.findIndex((w) => w.kind === "onError");
277
+ if (idx >= 0)
278
+ merged.wires[idx] = wire;
279
+ else
280
+ merged.wires.push(wire);
281
+ }
282
+ else {
283
+ const idx = merged.wires.findIndex((w) => "target" in w && w.target === wire.target);
284
+ if (idx >= 0)
285
+ merged.wires[idx] = wire;
286
+ else
287
+ merged.wires.push(wire);
288
+ }
289
+ }
290
+ }
291
+ this.toolDefCache.set(name, merged);
292
+ return merged;
293
+ }
294
+ /** Resolve a tool definition's wires into a nested input object */
295
+ async resolveToolWires(toolDef, input) {
296
+ // Constants applied synchronously
297
+ for (const wire of toolDef.wires) {
298
+ if (wire.kind === "constant") {
299
+ setNested(input, parsePath(wire.target), coerceConstant(wire.value));
300
+ }
301
+ }
302
+ // Pull wires resolved in parallel (independent deps shouldn't wait on each other)
303
+ const pullWires = toolDef.wires.filter((w) => w.kind === "pull");
304
+ if (pullWires.length > 0) {
305
+ const resolved = await Promise.all(pullWires.map(async (wire) => ({
306
+ target: wire.target,
307
+ value: await this.resolveToolSource(wire.source, toolDef),
308
+ })));
309
+ for (const { target, value } of resolved) {
310
+ setNested(input, parsePath(target), value);
311
+ }
312
+ }
313
+ }
314
+ /** Resolve a source reference from a tool wire against its dependencies */
315
+ async resolveToolSource(source, toolDef) {
316
+ const dotIdx = source.indexOf(".");
317
+ const handle = dotIdx === -1 ? source : source.substring(0, dotIdx);
318
+ const restPath = dotIdx === -1 ? [] : source.substring(dotIdx + 1).split(".");
319
+ const dep = toolDef.deps.find((d) => d.handle === handle);
320
+ if (!dep)
321
+ throw new Error(`Unknown source "${handle}" in tool "${toolDef.name}"`);
322
+ let value;
323
+ if (dep.kind === "context") {
324
+ // Walk the full parent chain for context
325
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
326
+ let cursor = this;
327
+ while (cursor && value === undefined) {
328
+ value = cursor.context;
329
+ cursor = cursor.parent;
330
+ }
331
+ }
332
+ else if (dep.kind === "const") {
333
+ // Walk the full parent chain for const state
334
+ const constKey = trunkKey({
335
+ module: SELF_MODULE,
336
+ type: "Const",
337
+ field: "const",
338
+ });
339
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
340
+ let cursor = this;
341
+ while (cursor && value === undefined) {
342
+ value = cursor.state[constKey];
343
+ cursor = cursor.parent;
344
+ }
345
+ }
346
+ else if (dep.kind === "tool") {
347
+ value = await this.resolveToolDep(dep.tool);
348
+ }
349
+ for (const segment of restPath) {
350
+ value = value?.[segment];
351
+ }
352
+ return value;
353
+ }
354
+ /** Call a tool dependency (cached per request) */
355
+ resolveToolDep(toolName) {
356
+ // Check parent first (shadow trees delegate)
357
+ if (this.parent)
358
+ return this.parent.resolveToolDep(toolName);
359
+ if (this.toolDepCache.has(toolName))
360
+ return this.toolDepCache.get(toolName);
361
+ const promise = (async () => {
362
+ const toolDef = this.resolveToolDefByName(toolName);
363
+ if (!toolDef)
364
+ throw new Error(`Tool dependency "${toolName}" not found`);
365
+ const input = {};
366
+ await this.resolveToolWires(toolDef, input);
367
+ const fn = this.lookupToolFn(toolDef.fn);
368
+ if (!fn)
369
+ throw new Error(`Tool function "${toolDef.fn}" not registered`);
370
+ // on error: wrap the tool call with fallback from onError wire
371
+ const onErrorWire = toolDef.wires.find((w) => w.kind === "onError");
372
+ try {
373
+ return await this.callTool(toolName, toolDef.fn, fn, input);
374
+ }
375
+ catch (err) {
376
+ if (!onErrorWire)
377
+ throw err;
378
+ if ("value" in onErrorWire)
379
+ return JSON.parse(onErrorWire.value);
380
+ return this.resolveToolSource(onErrorWire.source, toolDef);
381
+ }
382
+ })();
383
+ this.toolDepCache.set(toolName, promise);
384
+ return promise;
385
+ }
386
+ schedule(target) {
387
+ // Delegate to parent (shadow trees don't schedule directly) unless
388
+ // the target fork has bridge wires sourced from element data,
389
+ // or a __local binding whose source chain touches element data.
390
+ if (this.parent) {
391
+ const forkWires = this.bridge?.wires.filter((w) => sameTrunk(w.to, target)) ?? [];
392
+ const hasElementSource = forkWires.some((w) => ("from" in w && !!w.from.element) ||
393
+ ("condAnd" in w &&
394
+ (!!w.condAnd.leftRef.element || !!w.condAnd.rightRef?.element)) ||
395
+ ("condOr" in w &&
396
+ (!!w.condOr.leftRef.element || !!w.condOr.rightRef?.element)));
397
+ // For __local trunks, also check transitively: if the source is a
398
+ // pipe fork whose own wires reference element data, keep it local.
399
+ const hasTransitiveElementSource = target.module === "__local" &&
400
+ forkWires.some((w) => {
401
+ if (!("from" in w))
402
+ return false;
403
+ const srcTrunk = {
404
+ module: w.from.module,
405
+ type: w.from.type,
406
+ field: w.from.field,
407
+ instance: w.from.instance,
408
+ };
409
+ return (this.bridge?.wires.some((iw) => sameTrunk(iw.to, srcTrunk) && "from" in iw && !!iw.from.element) ?? false);
410
+ });
411
+ if (!hasElementSource && !hasTransitiveElementSource) {
412
+ return this.parent.schedule(target);
413
+ }
414
+ }
415
+ return (async () => {
416
+ // If this target is a pipe fork, also apply bridge wires from its base
417
+ // handle (non-pipe wires, e.g. `c.currency <- i.currency`) as defaults
418
+ // before the fork-specific pipe wires.
419
+ const targetKey = trunkKey(target);
420
+ const pipeFork = this.pipeHandleMap?.get(targetKey);
421
+ const baseTrunk = pipeFork?.baseTrunk;
422
+ const baseWires = baseTrunk
423
+ ? (this.bridge?.wires.filter((w) => !("pipe" in w) && sameTrunk(w.to, baseTrunk)) ?? [])
424
+ : [];
425
+ // Fork-specific wires (pipe wires targeting the fork's own instance)
426
+ const forkWires = this.bridge?.wires.filter((w) => sameTrunk(w.to, target)) ?? [];
427
+ // Merge: base provides defaults, fork overrides
428
+ const bridgeWires = [...baseWires, ...forkWires];
429
+ // Look up ToolDef for this target
430
+ const toolName = this.getToolName(target);
431
+ const toolDef = this.resolveToolDefByName(toolName);
432
+ // Build input object: tool wires first (base), then bridge wires (override)
433
+ const input = {};
434
+ if (toolDef) {
435
+ await this.resolveToolWires(toolDef, input);
436
+ }
437
+ // Resolve bridge wires and apply on top.
438
+ // Group wires by target path so that || (null-fallback) and ??
439
+ // (error-fallback) semantics are honoured via resolveWires().
440
+ const wireGroups = new Map();
441
+ for (const w of bridgeWires) {
442
+ const key = w.to.path.join(".");
443
+ let group = wireGroups.get(key);
444
+ if (!group) {
445
+ group = [];
446
+ wireGroups.set(key, group);
447
+ }
448
+ group.push(w);
449
+ }
450
+ const groupEntries = Array.from(wireGroups.entries());
451
+ const resolved = await Promise.all(groupEntries.map(async ([, group]) => {
452
+ const value = await this.resolveWires(group);
453
+ return [group[0].to.path, value];
454
+ }));
455
+ for (const [path, value] of resolved) {
456
+ if (path.length === 0 && value != null && typeof value === "object") {
457
+ Object.assign(input, value);
458
+ }
459
+ else {
460
+ setNested(input, path, value);
461
+ }
462
+ }
463
+ // Call ToolDef-backed tool function
464
+ if (toolDef) {
465
+ const fn = this.lookupToolFn(toolDef.fn);
466
+ if (!fn)
467
+ throw new Error(`Tool function "${toolDef.fn}" not registered`);
468
+ // on error: wrap the tool call with fallback from onError wire
469
+ const onErrorWire = toolDef.wires.find((w) => w.kind === "onError");
470
+ try {
471
+ return await this.callTool(toolName, toolDef.fn, fn, input);
472
+ }
473
+ catch (err) {
474
+ if (!onErrorWire)
475
+ throw err;
476
+ if ("value" in onErrorWire)
477
+ return JSON.parse(onErrorWire.value);
478
+ return this.resolveToolSource(onErrorWire.source, toolDef);
479
+ }
480
+ }
481
+ // Direct tool function lookup by name (simple or dotted)
482
+ const directFn = this.lookupToolFn(toolName);
483
+ if (directFn) {
484
+ return this.callTool(toolName, toolName, directFn, input);
485
+ }
486
+ // Define pass-through: synthetic trunks created by define inlining
487
+ // act as data containers — bridge wires set their values, no tool needed.
488
+ if (target.module.startsWith("__define_")) {
489
+ return input;
490
+ }
491
+ // Local binding or logic node: the wire resolves the source and stores
492
+ // the result — no tool call needed. For path=[] wires the resolved
493
+ // value may be a primitive (boolean from condAnd/condOr, string from
494
+ // a pipe tool like upperCase), so return the resolved value directly.
495
+ if (target.module === "__local" ||
496
+ target.field === "__and" ||
497
+ target.field === "__or") {
498
+ for (const [path, value] of resolved) {
499
+ if (path.length === 0)
500
+ return value;
501
+ }
502
+ return input;
503
+ }
504
+ throw new Error(`No tool found for "${toolName}"`);
505
+ })();
506
+ }
507
+ /**
508
+ * Invoke a tool function, recording both an OpenTelemetry span and (when
509
+ * tracing is enabled) a ToolTrace entry. All three tool-call sites in the
510
+ * engine delegate here so instrumentation lives in exactly one place.
511
+ */
512
+ async callTool(toolName, fnName, fnImpl, input) {
513
+ // Short-circuit before starting if externally aborted
514
+ if (this.signal?.aborted) {
515
+ throw new BridgeAbortError();
516
+ }
517
+ const tracer = this.tracer;
518
+ const logger = this.logger;
519
+ const toolContext = {
520
+ logger: logger ?? {},
521
+ signal: this.signal,
522
+ };
523
+ const traceStart = tracer?.now();
524
+ const metricAttrs = {
525
+ "bridge.tool.name": toolName,
526
+ "bridge.tool.fn": fnName,
527
+ };
528
+ return otelTracer.startActiveSpan(`bridge.tool.${toolName}.${fnName}`, { attributes: metricAttrs }, async (span) => {
529
+ const wallStart = performance.now();
530
+ try {
531
+ const result = await fnImpl(input, toolContext);
532
+ const durationMs = roundMs(performance.now() - wallStart);
533
+ toolCallCounter.add(1, metricAttrs);
534
+ toolDurationHistogram.record(durationMs, metricAttrs);
535
+ if (tracer && traceStart != null) {
536
+ tracer.record(tracer.entry({
537
+ tool: toolName,
538
+ fn: fnName,
539
+ input,
540
+ output: result,
541
+ durationMs: roundMs(tracer.now() - traceStart),
542
+ startedAt: traceStart,
543
+ }));
544
+ }
545
+ logger?.debug?.("[bridge] tool %s (%s) completed in %dms", toolName, fnName, durationMs);
546
+ return result;
547
+ }
548
+ catch (err) {
549
+ const durationMs = roundMs(performance.now() - wallStart);
550
+ toolCallCounter.add(1, metricAttrs);
551
+ toolDurationHistogram.record(durationMs, metricAttrs);
552
+ toolErrorCounter.add(1, metricAttrs);
553
+ if (tracer && traceStart != null) {
554
+ tracer.record(tracer.entry({
555
+ tool: toolName,
556
+ fn: fnName,
557
+ input,
558
+ error: err.message,
559
+ durationMs: roundMs(tracer.now() - traceStart),
560
+ startedAt: traceStart,
561
+ }));
562
+ }
563
+ span.recordException(err);
564
+ span.setStatus({
565
+ code: SpanStatusCode.ERROR,
566
+ message: err.message,
567
+ });
568
+ logger?.error?.("[bridge] tool %s (%s) failed: %s", toolName, fnName, err.message);
569
+ throw err;
570
+ }
571
+ finally {
572
+ span.end();
573
+ }
574
+ });
575
+ }
576
+ shadow() {
577
+ const child = new ExecutionTree(this.trunk, this.instructions, this.toolFns, undefined, this);
578
+ child.tracer = this.tracer;
579
+ child.logger = this.logger;
580
+ child.signal = this.signal;
581
+ return child;
582
+ }
583
+ /** Returns collected traces (empty array when tracing is disabled). */
584
+ getTraces() {
585
+ return this.tracer?.traces ?? [];
586
+ }
587
+ async pullSingle(ref) {
588
+ const key = trunkKey(ref);
589
+ // Walk the full parent chain — shadow trees may be nested multiple levels
590
+ let value = undefined;
591
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
592
+ let cursor = this;
593
+ while (cursor && value === undefined) {
594
+ value = cursor.state[key];
595
+ cursor = cursor.parent;
596
+ }
597
+ if (value === undefined) {
598
+ // ── Lazy define field resolution ────────────────────────────────
599
+ // For define trunks (__define_in_* / __define_out_*) with a specific
600
+ // field path, resolve ONLY the wire(s) targeting that field instead
601
+ // of scheduling the entire trunk. This avoids triggering unrelated
602
+ // dependency chains (e.g. requesting "city" should not fire the
603
+ // lat/lon coalesce chains that call the geo tool).
604
+ if (ref.path.length > 0 && ref.module.startsWith("__define_")) {
605
+ const fieldWires = this.bridge?.wires.filter((w) => sameTrunk(w.to, ref) && pathEquals(w.to.path, ref.path)) ?? [];
606
+ if (fieldWires.length > 0) {
607
+ return this.resolveWires(fieldWires);
608
+ }
609
+ }
610
+ this.state[key] = this.schedule(ref);
611
+ value = this.state[key];
612
+ }
613
+ // Always await in case the stored value is a Promise (e.g. from schedule()).
614
+ const resolved = await Promise.resolve(value);
615
+ if (!ref.path.length) {
616
+ return resolved;
617
+ }
618
+ let result = resolved;
619
+ // Root-level null check: if root data is null/undefined
620
+ if (result == null && ref.path.length > 0) {
621
+ if (ref.rootSafe)
622
+ return undefined;
623
+ throw new TypeError(`Cannot read properties of ${result} (reading '${ref.path[0]}')`);
624
+ }
625
+ for (let i = 0; i < ref.path.length; i++) {
626
+ const segment = ref.path[i];
627
+ if (UNSAFE_KEYS.has(segment))
628
+ throw new Error(`Unsafe property traversal: ${segment}`);
629
+ if (Array.isArray(result) && !/^\d+$/.test(segment)) {
630
+ this.logger?.warn?.(`[bridge] Accessing ".${segment}" on an array (${result.length} items) — did you mean to use pickFirst or array mapping? Source: ${trunkKey(ref)}.${ref.path.join(".")}`);
631
+ }
632
+ result = result[segment];
633
+ // Check for null/undefined AFTER access, before next segment
634
+ if (result == null && i < ref.path.length - 1) {
635
+ const nextSafe = ref.pathSafe?.[i + 1] ?? false;
636
+ if (nextSafe)
637
+ return undefined;
638
+ throw new TypeError(`Cannot read properties of ${result} (reading '${ref.path[i + 1]}')`);
639
+ }
640
+ }
641
+ return result;
642
+ }
643
+ async pull(refs) {
644
+ if (refs.length === 1)
645
+ return this.pullSingle(refs[0]);
646
+ // Strict left-to-right sequential evaluation with short-circuit.
647
+ // We respect the exact fallback priority authored by the developer.
648
+ const errors = [];
649
+ for (const ref of refs) {
650
+ try {
651
+ const value = await this.pullSingle(ref);
652
+ if (value != null)
653
+ return value; // Short-circuit: found data
654
+ }
655
+ catch (err) {
656
+ errors.push(err);
657
+ }
658
+ }
659
+ // All resolved to null/undefined, or all threw
660
+ if (errors.length === refs.length) {
661
+ throw new AggregateError(errors, "All sources failed");
662
+ }
663
+ return undefined;
664
+ }
665
+ /**
666
+ * Safe execution pull: wraps individual safe-flagged pulls in try/catch.
667
+ * Wires with `safe: true` swallow errors and return undefined.
668
+ * Non-safe wires propagate errors normally.
669
+ */
670
+ async pullSafe(pulls) {
671
+ if (pulls.length === 1) {
672
+ const w = pulls[0];
673
+ if (w.safe) {
674
+ try {
675
+ return await this.pullSingle(w.from);
676
+ }
677
+ catch {
678
+ return undefined;
679
+ }
680
+ }
681
+ return this.pullSingle(w.from);
682
+ }
683
+ const errors = [];
684
+ for (const w of pulls) {
685
+ try {
686
+ const value = w.safe
687
+ ? await this.pullSingle(w.from).catch(() => undefined)
688
+ : await this.pullSingle(w.from);
689
+ if (value != null)
690
+ return value;
691
+ }
692
+ catch (err) {
693
+ errors.push(err);
694
+ }
695
+ }
696
+ if (errors.length === pulls.length) {
697
+ throw new AggregateError(errors, "All sources failed");
698
+ }
699
+ return undefined;
700
+ }
701
+ push(args) {
702
+ this.state[trunkKey(this.trunk)] = args;
703
+ }
704
+ /** Store the aggregated promise for critical forced handles so
705
+ * `response()` can await it exactly once per bridge execution. */
706
+ setForcedExecution(p) {
707
+ this.forcedExecution = p;
708
+ }
709
+ /** Return the critical forced-execution promise (if any). */
710
+ getForcedExecution() {
711
+ return this.forcedExecution;
712
+ }
713
+ /**
714
+ * Eagerly schedule tools targeted by `force <handle>` statements.
715
+ *
716
+ * Returns an array of promises for **critical** forced handles (those
717
+ * without `?? null`). Fire-and-forget handles (`catchError: true`) are
718
+ * scheduled but their errors are silently suppressed.
719
+ *
720
+ * Callers must `await Promise.all(...)` the returned promises so that a
721
+ * critical force failure propagates as a standard error.
722
+ */
723
+ executeForced() {
724
+ const forces = this.bridge?.forces;
725
+ if (!forces || forces.length === 0)
726
+ return [];
727
+ const critical = [];
728
+ const scheduled = new Set();
729
+ for (const f of forces) {
730
+ const trunk = {
731
+ module: f.module,
732
+ type: f.type,
733
+ field: f.field,
734
+ instance: f.instance,
735
+ };
736
+ const key = trunkKey(trunk);
737
+ if (scheduled.has(key) || this.state[key] !== undefined)
738
+ continue;
739
+ scheduled.add(key);
740
+ this.state[key] = this.schedule(trunk);
741
+ if (f.catchError) {
742
+ // Fire-and-forget: suppress unhandled rejection.
743
+ Promise.resolve(this.state[key]).catch(() => { });
744
+ }
745
+ else {
746
+ // Critical: caller must await and let failure propagate.
747
+ critical.push(Promise.resolve(this.state[key]));
748
+ }
749
+ }
750
+ return critical;
751
+ }
752
+ /**
753
+ * Resolve a set of matched wires.
754
+ *
755
+ * Architecture: two distinct resolution axes —
756
+ *
757
+ * **Falsy Gate** (`||`, within a wire): `falsyFallbackRefs` + `falsyFallback`
758
+ * → truthy check — falsy values (0, "", false) trigger fallback chain.
759
+ *
760
+ * **Overdefinition** (across wires): multiple wires target the same path
761
+ * → nullish check — only null/undefined falls through to the next wire.
762
+ *
763
+ * Per-wire layers:
764
+ * Layer 1 — Execution (pullSingle + safe modifier)
765
+ * Layer 2a — Falsy Gate (falsyFallbackRefs → falsyFallback / falsyControl)
766
+ * Layer 2b — Nullish Gate (nullishFallbackRef / nullishFallback / nullishControl)
767
+ * Layer 3 — Catch (catchFallbackRef / catchFallback / catchControl)
768
+ *
769
+ * After layers 1–2b, the overdefinition boundary (`!= null`) decides whether
770
+ * to return or continue to the next wire.
771
+ */
772
+ async resolveWires(wires) {
773
+ let lastError;
774
+ for (const w of wires) {
775
+ // Constant wire — always wins, no modifiers
776
+ if ("value" in w)
777
+ return coerceConstant(w.value);
778
+ try {
779
+ // --- Layer 1: Execution ---
780
+ let resolvedValue;
781
+ if ("cond" in w) {
782
+ const condValue = await this.pullSingle(w.cond);
783
+ if (condValue) {
784
+ if (w.thenRef !== undefined)
785
+ resolvedValue = await this.pullSingle(w.thenRef);
786
+ else if (w.thenValue !== undefined)
787
+ resolvedValue = coerceConstant(w.thenValue);
788
+ }
789
+ else {
790
+ if (w.elseRef !== undefined)
791
+ resolvedValue = await this.pullSingle(w.elseRef);
792
+ else if (w.elseValue !== undefined)
793
+ resolvedValue = coerceConstant(w.elseValue);
794
+ }
795
+ }
796
+ else if ("condAnd" in w) {
797
+ const { leftRef, rightRef, rightValue, safe: isSafe, rightSafe, } = w.condAnd;
798
+ const leftVal = isSafe
799
+ ? await this.pullSingle(leftRef).catch((e) => {
800
+ if (isFatalError(e))
801
+ throw e;
802
+ return undefined;
803
+ })
804
+ : await this.pullSingle(leftRef);
805
+ if (!leftVal) {
806
+ resolvedValue = false;
807
+ }
808
+ else if (rightRef !== undefined) {
809
+ const rightVal = rightSafe
810
+ ? await this.pullSingle(rightRef).catch((e) => {
811
+ if (isFatalError(e))
812
+ throw e;
813
+ return undefined;
814
+ })
815
+ : await this.pullSingle(rightRef);
816
+ resolvedValue = Boolean(rightVal);
817
+ }
818
+ else if (rightValue !== undefined) {
819
+ resolvedValue = Boolean(coerceConstant(rightValue));
820
+ }
821
+ else {
822
+ resolvedValue = Boolean(leftVal);
823
+ }
824
+ }
825
+ else if ("condOr" in w) {
826
+ const { leftRef, rightRef, rightValue, safe: isSafe, rightSafe, } = w.condOr;
827
+ const leftVal = isSafe
828
+ ? await this.pullSingle(leftRef).catch((e) => {
829
+ if (isFatalError(e))
830
+ throw e;
831
+ return undefined;
832
+ })
833
+ : await this.pullSingle(leftRef);
834
+ if (leftVal) {
835
+ resolvedValue = true;
836
+ }
837
+ else if (rightRef !== undefined) {
838
+ const rightVal = rightSafe
839
+ ? await this.pullSingle(rightRef).catch((e) => {
840
+ if (isFatalError(e))
841
+ throw e;
842
+ return undefined;
843
+ })
844
+ : await this.pullSingle(rightRef);
845
+ resolvedValue = Boolean(rightVal);
846
+ }
847
+ else if (rightValue !== undefined) {
848
+ resolvedValue = Boolean(coerceConstant(rightValue));
849
+ }
850
+ else {
851
+ resolvedValue = Boolean(leftVal);
852
+ }
853
+ }
854
+ else if ("from" in w) {
855
+ if (w.safe) {
856
+ try {
857
+ resolvedValue = await this.pullSingle(w.from);
858
+ }
859
+ catch (err) {
860
+ if (isFatalError(err))
861
+ throw err;
862
+ resolvedValue = undefined;
863
+ }
864
+ }
865
+ else {
866
+ resolvedValue = await this.pullSingle(w.from);
867
+ }
868
+ }
869
+ else {
870
+ continue;
871
+ }
872
+ // --- Layer 2a: Falsy Gate (||) ---
873
+ if (!resolvedValue && w.falsyFallbackRefs?.length) {
874
+ for (const ref of w.falsyFallbackRefs) {
875
+ // Assign the fallback value regardless of whether it is truthy or falsy.
876
+ // e.g. `false || 0` will correctly update resolvedValue to `0`.
877
+ resolvedValue = await this.pullSingle(ref);
878
+ // If it is truthy, we are done! Short-circuit the || chain.
879
+ if (resolvedValue)
880
+ break;
881
+ }
882
+ }
883
+ if (!resolvedValue) {
884
+ if (w.falsyControl) {
885
+ resolvedValue = applyControlFlow(w.falsyControl);
886
+ }
887
+ else if (w.falsyFallback != null) {
888
+ resolvedValue = coerceConstant(w.falsyFallback);
889
+ }
890
+ }
891
+ // --- Layer 2b: Nullish Gate (??) ---
892
+ if (resolvedValue == null) {
893
+ if (w.nullishControl) {
894
+ resolvedValue = applyControlFlow(w.nullishControl);
895
+ }
896
+ else if (w.nullishFallbackRef) {
897
+ resolvedValue = await this.pullSingle(w.nullishFallbackRef);
898
+ }
899
+ else if (w.nullishFallback != null) {
900
+ resolvedValue = coerceConstant(w.nullishFallback);
901
+ }
902
+ }
903
+ // --- Overdefinition Boundary ---
904
+ if (resolvedValue != null)
905
+ return resolvedValue;
906
+ }
907
+ catch (err) {
908
+ // --- Layer 3: Catch ---
909
+ if (isFatalError(err))
910
+ throw err;
911
+ if (w.catchControl)
912
+ return applyControlFlow(w.catchControl);
913
+ if (w.catchFallbackRef)
914
+ return this.pullSingle(w.catchFallbackRef);
915
+ if (w.catchFallback != null)
916
+ return coerceConstant(w.catchFallback);
917
+ lastError = err;
918
+ }
919
+ }
920
+ if (lastError)
921
+ throw lastError;
922
+ return undefined;
923
+ }
924
+ /**
925
+ * Resolve an output field by path for use outside of a GraphQL resolver.
926
+ *
927
+ * This is the non-GraphQL equivalent of what `response()` does per field:
928
+ * it finds all wires targeting `this.trunk` at `path` and resolves them.
929
+ *
930
+ * Used by `executeBridge()` so standalone bridge execution does not need to
931
+ * fabricate GraphQL Path objects to pull output data.
932
+ *
933
+ * @param path - Output field path, e.g. `["lat"]`. Pass `[]` for whole-output
934
+ * array bridges (`o <- items[] as x { ... }`).
935
+ * @param array - When `true` and the result is an array, wraps each element
936
+ * in a shadow tree (mirrors `response()` array handling).
937
+ */
938
+ async pullOutputField(path, array = false) {
939
+ const matches = this.bridge?.wires.filter((w) => sameTrunk(w.to, this.trunk) && pathEquals(w.to.path, path)) ?? [];
940
+ if (matches.length === 0)
941
+ return undefined;
942
+ const result = this.resolveWires(matches);
943
+ if (!array)
944
+ return result;
945
+ const resolved = await result;
946
+ if (resolved === BREAK_SYM || resolved === CONTINUE_SYM)
947
+ return [];
948
+ const items = resolved;
949
+ const finalShadowTrees = [];
950
+ for (const item of items) {
951
+ if (item === BREAK_SYM)
952
+ break;
953
+ if (item === CONTINUE_SYM)
954
+ continue;
955
+ const s = this.shadow();
956
+ s.state[trunkKey({ ...this.trunk, element: true })] = item;
957
+ finalShadowTrees.push(s);
958
+ }
959
+ return finalShadowTrees;
960
+ }
961
+ /**
962
+ * Execute the bridge end-to-end without GraphQL.
963
+ *
964
+ * Injects `input` as the trunk arguments, runs forced wires, then pulls
965
+ * and materialises every output field into a plain JS object (or array of
966
+ * objects for array-mapped bridges).
967
+ *
968
+ * This is the single entry-point used by `executeBridge()`.
969
+ */
970
+ async run(input) {
971
+ const bridge = this.bridge;
972
+ if (!bridge) {
973
+ throw new Error(`No bridge definition found for ${this.trunk.type}.${this.trunk.field}`);
974
+ }
975
+ this.push(input);
976
+ const forcePromises = this.executeForced();
977
+ const { type, field } = this.trunk;
978
+ // Is there a root-level wire targeting the output with path []?
979
+ const hasRootWire = bridge.wires.some((w) => "from" in w &&
980
+ w.to.module === SELF_MODULE &&
981
+ w.to.type === type &&
982
+ w.to.field === field &&
983
+ w.to.path.length === 0);
984
+ // Array-mapped output (`o <- items[] as x { ... }`) has BOTH a root wire
985
+ // AND element-level wires (from.element === true). A plain passthrough
986
+ // (`o <- api.user`) only has the root wire.
987
+ // Local bindings (from.__local) are also element-scoped.
988
+ // Pipe fork output wires in element context (e.g. concat template strings)
989
+ // may have to.element === true instead.
990
+ const hasElementWires = bridge.wires.some((w) => "from" in w &&
991
+ (w.from.element === true ||
992
+ w.from.module === "__local" ||
993
+ w.to.element === true) &&
994
+ w.to.module === SELF_MODULE &&
995
+ w.to.type === type &&
996
+ w.to.field === field);
997
+ if (hasRootWire && hasElementWires) {
998
+ const [shadows] = await Promise.all([
999
+ this.pullOutputField([], true),
1000
+ ...forcePromises,
1001
+ ]);
1002
+ return this.materializeShadows(shadows, []);
1003
+ }
1004
+ // Whole-object passthrough: `o <- api.user`
1005
+ if (hasRootWire) {
1006
+ const [result] = await Promise.all([
1007
+ this.pullOutputField([]),
1008
+ ...forcePromises,
1009
+ ]);
1010
+ return result;
1011
+ }
1012
+ // Object output — collect unique top-level field names
1013
+ const outputFields = new Set();
1014
+ for (const wire of bridge.wires) {
1015
+ if (wire.to.module === SELF_MODULE &&
1016
+ wire.to.type === type &&
1017
+ wire.to.field === field &&
1018
+ wire.to.path.length > 0) {
1019
+ outputFields.add(wire.to.path[0]);
1020
+ }
1021
+ }
1022
+ if (outputFields.size === 0) {
1023
+ throw new Error(`Bridge "${type}.${field}" has no output wires. ` +
1024
+ `Ensure at least one wire targets the output (e.g. \`o.field <- ...\`).`);
1025
+ }
1026
+ const result = {};
1027
+ await Promise.all([
1028
+ ...[...outputFields].map(async (name) => {
1029
+ result[name] = await this.pullOutputField([name]);
1030
+ }),
1031
+ ...forcePromises,
1032
+ ]);
1033
+ return result;
1034
+ }
1035
+ /**
1036
+ * Recursively convert shadow trees into plain JS objects.
1037
+ *
1038
+ * Wire categories at each level (prefix = P):
1039
+ * Leaf — `to.path = [...P, name]`, no deeper paths → scalar
1040
+ * Array — direct wire AND deeper paths → pull as array, recurse
1041
+ * Nested object — only deeper paths, no direct wire → pull each
1042
+ * full path and assemble via setNested
1043
+ */
1044
+ async materializeShadows(items, pathPrefix) {
1045
+ const wires = this.bridge.wires;
1046
+ const { type, field } = this.trunk;
1047
+ const directFields = new Set();
1048
+ const deepPaths = new Map();
1049
+ for (const wire of wires) {
1050
+ const p = wire.to.path;
1051
+ if (wire.to.module !== SELF_MODULE ||
1052
+ wire.to.type !== type ||
1053
+ wire.to.field !== field)
1054
+ continue;
1055
+ if (p.length <= pathPrefix.length)
1056
+ continue;
1057
+ if (!pathPrefix.every((seg, i) => p[i] === seg))
1058
+ continue;
1059
+ const name = p[pathPrefix.length];
1060
+ if (p.length === pathPrefix.length + 1) {
1061
+ directFields.add(name);
1062
+ }
1063
+ else {
1064
+ let arr = deepPaths.get(name);
1065
+ if (!arr) {
1066
+ arr = [];
1067
+ deepPaths.set(name, arr);
1068
+ }
1069
+ arr.push(p);
1070
+ }
1071
+ }
1072
+ const rawResults = await Promise.all(items.map(async (shadow) => {
1073
+ const obj = {};
1074
+ const tasks = [];
1075
+ for (const name of directFields) {
1076
+ const fullPath = [...pathPrefix, name];
1077
+ const hasDeeper = deepPaths.has(name);
1078
+ tasks.push((async () => {
1079
+ if (hasDeeper) {
1080
+ const children = await shadow.pullOutputField(fullPath, true);
1081
+ obj[name] = Array.isArray(children)
1082
+ ? await this.materializeShadows(children, fullPath)
1083
+ : children;
1084
+ }
1085
+ else {
1086
+ obj[name] = await shadow.pullOutputField(fullPath);
1087
+ }
1088
+ })());
1089
+ }
1090
+ for (const [name, paths] of deepPaths) {
1091
+ if (directFields.has(name))
1092
+ continue;
1093
+ tasks.push((async () => {
1094
+ const nested = {};
1095
+ await Promise.all(paths.map(async (fullPath) => {
1096
+ const value = await shadow.pullOutputField(fullPath);
1097
+ setNested(nested, fullPath.slice(pathPrefix.length + 1), value);
1098
+ }));
1099
+ obj[name] = nested;
1100
+ })());
1101
+ }
1102
+ await Promise.all(tasks);
1103
+ // Check if any field resolved to a sentinel — propagate it
1104
+ for (const v of Object.values(obj)) {
1105
+ if (v === CONTINUE_SYM)
1106
+ return CONTINUE_SYM;
1107
+ if (v === BREAK_SYM)
1108
+ return BREAK_SYM;
1109
+ }
1110
+ return obj;
1111
+ }));
1112
+ // Filter sentinels from the final result
1113
+ const finalResults = [];
1114
+ for (const item of rawResults) {
1115
+ if (item === BREAK_SYM)
1116
+ break;
1117
+ if (item === CONTINUE_SYM)
1118
+ continue;
1119
+ finalResults.push(item);
1120
+ }
1121
+ return finalResults;
1122
+ }
1123
+ async response(ipath, array) {
1124
+ // Build path segments from GraphQL resolver info
1125
+ const pathSegments = [];
1126
+ let index = ipath;
1127
+ while (index.prev) {
1128
+ pathSegments.unshift(`${index.key}`);
1129
+ index = index.prev;
1130
+ }
1131
+ if (pathSegments.length === 0) {
1132
+ // Direct output for scalar/list return types (e.g. [String!])
1133
+ const directOutput = this.bridge?.wires.filter((w) => sameTrunk(w.to, this.trunk) &&
1134
+ w.to.path.length === 1 &&
1135
+ w.to.path[0] === this.trunk.field) ?? [];
1136
+ if (directOutput.length > 0) {
1137
+ return this.resolveWires(directOutput);
1138
+ }
1139
+ }
1140
+ // Strip numeric indices (array positions) from path for wire matching
1141
+ const cleanPath = pathSegments.filter((p) => !/^\d+$/.test(p));
1142
+ // Find wires whose target matches this trunk + path
1143
+ const matches = this.bridge?.wires.filter((w) => (w.to.element ? !!this.parent : true) &&
1144
+ sameTrunk(w.to, this.trunk) &&
1145
+ pathEquals(w.to.path, cleanPath)) ?? [];
1146
+ if (matches.length > 0) {
1147
+ // ── Lazy define resolution ──────────────────────────────────────
1148
+ // When ALL matches at the root object level (path=[]) are
1149
+ // whole-object wires sourced from define output modules, defer
1150
+ // resolution to field-by-field GraphQL traversal. This avoids
1151
+ // eagerly scheduling every tool inside the define block — only
1152
+ // fields actually requested by the query will trigger their
1153
+ // dependency chains.
1154
+ if (cleanPath.length === 0 &&
1155
+ !array &&
1156
+ matches.every((w) => "from" in w &&
1157
+ w.from.module.startsWith("__define_out_") &&
1158
+ w.from.path.length === 0)) {
1159
+ return this;
1160
+ }
1161
+ const response = this.resolveWires(matches);
1162
+ if (!array) {
1163
+ return response;
1164
+ }
1165
+ // Array: create shadow trees for per-element resolution
1166
+ const resolved = await response;
1167
+ if (resolved === BREAK_SYM || resolved === CONTINUE_SYM)
1168
+ return [];
1169
+ const items = resolved;
1170
+ const shadowTrees = [];
1171
+ for (const item of items) {
1172
+ if (item === BREAK_SYM)
1173
+ break;
1174
+ if (item === CONTINUE_SYM)
1175
+ continue;
1176
+ const s = this.shadow();
1177
+ s.state[trunkKey({ ...this.trunk, element: true })] = item;
1178
+ shadowTrees.push(s);
1179
+ }
1180
+ return shadowTrees;
1181
+ }
1182
+ // ── Resolve field from deferred define ────────────────────────────
1183
+ // No direct wires for this field path — check whether a define
1184
+ // forward wire exists at the root level (`o <- defineHandle`) and
1185
+ // resolve only the matching field wire from the define's output.
1186
+ if (cleanPath.length > 0) {
1187
+ const defineFieldWires = this.findDefineFieldWires(cleanPath);
1188
+ if (defineFieldWires.length > 0) {
1189
+ const response = this.resolveWires(defineFieldWires);
1190
+ if (!array)
1191
+ return response;
1192
+ const resolved = await response;
1193
+ if (resolved === BREAK_SYM || resolved === CONTINUE_SYM)
1194
+ return [];
1195
+ const items = resolved;
1196
+ const shadowTrees = [];
1197
+ for (const item of items) {
1198
+ if (item === BREAK_SYM)
1199
+ break;
1200
+ if (item === CONTINUE_SYM)
1201
+ continue;
1202
+ const s = this.shadow();
1203
+ s.state[trunkKey({ ...this.trunk, element: true })] = item;
1204
+ shadowTrees.push(s);
1205
+ }
1206
+ return shadowTrees;
1207
+ }
1208
+ }
1209
+ // Fallback: if this shadow tree has stored element data, resolve the
1210
+ // requested field directly from it. This handles passthrough arrays
1211
+ // where the bridge maps an inner array (e.g. `.stops <- j.stops`) but
1212
+ // doesn't explicitly wire each scalar field on the element type.
1213
+ if (this.parent) {
1214
+ const elementKey = trunkKey({ ...this.trunk, element: true });
1215
+ const elementData = this.state[elementKey];
1216
+ if (elementData != null &&
1217
+ typeof elementData === "object" &&
1218
+ !Array.isArray(elementData)) {
1219
+ const fieldName = cleanPath[cleanPath.length - 1];
1220
+ if (fieldName !== undefined && fieldName in elementData) {
1221
+ const value = elementData[fieldName];
1222
+ if (array && Array.isArray(value)) {
1223
+ // Nested array: wrap items in shadow trees so they can
1224
+ // resolve their own fields via this same fallback path.
1225
+ return value.map((item) => {
1226
+ const s = this.shadow();
1227
+ s.state[elementKey] = item;
1228
+ return s;
1229
+ });
1230
+ }
1231
+ return value;
1232
+ }
1233
+ }
1234
+ }
1235
+ // Return self to trigger downstream resolvers
1236
+ return this;
1237
+ }
1238
+ /**
1239
+ * Find define output wires for a specific field path.
1240
+ *
1241
+ * Looks for whole-object define forward wires (`o <- defineHandle`)
1242
+ * at path=[] for this trunk, then searches the define's output wires
1243
+ * for ones matching the requested field path.
1244
+ */
1245
+ findDefineFieldWires(cleanPath) {
1246
+ const forwards = this.bridge?.wires.filter((w) => "from" in w &&
1247
+ sameTrunk(w.to, this.trunk) &&
1248
+ w.to.path.length === 0 &&
1249
+ w.from.module.startsWith("__define_out_") &&
1250
+ w.from.path.length === 0) ?? [];
1251
+ if (forwards.length === 0)
1252
+ return [];
1253
+ const result = [];
1254
+ for (const fw of forwards) {
1255
+ const defOutTrunk = fw.from;
1256
+ const fieldWires = this.bridge?.wires.filter((w) => sameTrunk(w.to, defOutTrunk) && pathEquals(w.to.path, cleanPath)) ?? [];
1257
+ result.push(...fieldWires);
1258
+ }
1259
+ return result;
1260
+ }
1261
+ }