@camstack/system 1.1.0 → 1.1.2

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.
@@ -3351,926 +3351,945 @@ function createLocalTransport() {
3351
3351
  };
3352
3352
  }
3353
3353
  //#endregion
3354
- //#region src/kernel/transport/local-child-registry.ts
3355
- /**
3356
- * Sentinel prefix used in the no-route error thrown when `cap-call-out` has no
3357
- * local sibling and no `onUnownedCall` fallback. `ipcParentLink` detects this
3358
- * prefix to distinguish routing failures (safe to retry via broker) from real
3359
- * provider errors (must NOT retry to avoid double-executing side effects).
3360
- */
3361
- var UDS_NO_ROUTE_PREFIX = "UDS_NO_ROUTE";
3354
+ //#region src/kernel/moleculer/resilient-cap-call.ts
3355
+ /** Moleculer error `type` values meaning "the service is not (yet) routable". */
3356
+ var DISCOVERY_ERROR_TYPES = new Set(["SERVICE_NOT_FOUND", "SERVICE_NOT_AVAILABLE"]);
3357
+ /** Default ceiling for the discovery wait fail-fast past this. */
3358
+ var DEFAULT_DISCOVERY_TIMEOUT_MS$1 = 3e4;
3359
+ function isDiscoveryError(err) {
3360
+ if (typeof err !== "object" || err === null) return false;
3361
+ const type = err.type;
3362
+ return typeof type === "string" && DISCOVERY_ERROR_TYPES.has(type);
3363
+ }
3362
3364
  /**
3363
- * Parent-side authority for local addon-runners reachable over a
3364
- * `LocalTransportServer` (UDS). Children register their cap manifest on
3365
- * connect; the registry routes `(capName, deviceId?)` cap calls to the
3366
- * owning child over the channel and drops a child's caps on disconnect.
3365
+ * Call a Moleculer action; on a service-discovery error, wait for the
3366
+ * named service to be discovered and retry the call exactly once.
3367
3367
  */
3368
- var LocalChildRegistry = class {
3369
- children = /* @__PURE__ */ new Map();
3370
- registeredHandler = () => {};
3371
- goneHandler = () => {};
3372
- eventHandler = null;
3373
- logHandler = null;
3374
- readinessHandler = null;
3375
- server;
3376
- onUnownedCall;
3377
- logger;
3378
- getActiveSingletonAddonId;
3379
- /** Tracks capNames already logged as UDS-routed; one INFO line per capName per process. */
3380
- egressRoutedCaps = /* @__PURE__ */ new Set();
3381
- /**
3382
- * Accepts either a plain positional `server` argument (backward-compatible)
3383
- * or a full `LocalChildRegistryOptions` object.
3384
- *
3385
- * Positional overloads (existing call sites are unchanged):
3386
- * new LocalChildRegistry(server)
3387
- * new LocalChildRegistry(server, onUnownedCall)
3388
- *
3389
- * Options object (new call sites that pass a logger):
3390
- * new LocalChildRegistry({ server, onUnownedCall, logger })
3391
- */
3392
- constructor(serverOrOptions, onUnownedCallArg) {
3393
- if (serverOrOptions && !("listen" in serverOrOptions)) {
3394
- const opts = serverOrOptions;
3395
- this.server = opts.server;
3396
- this.onUnownedCall = opts.onUnownedCall;
3397
- this.logger = opts.logger;
3398
- this.getActiveSingletonAddonId = opts.getActiveSingletonAddonId;
3399
- } else {
3400
- this.server = serverOrOptions;
3401
- this.onUnownedCall = onUnownedCallArg;
3402
- }
3403
- }
3404
- async start() {
3405
- this.server.onConnection((channel) => this.onConnection(channel));
3406
- await this.server.listen();
3368
+ async function callWithServiceDiscovery(broker, serviceName, action, params, opts, discoveryTimeoutMs = DEFAULT_DISCOVERY_TIMEOUT_MS$1) {
3369
+ try {
3370
+ return await broker.call(action, params, opts);
3371
+ } catch (err) {
3372
+ if (!isDiscoveryError(err)) throw err;
3373
+ await broker.waitForServices([serviceName], discoveryTimeoutMs);
3374
+ return await broker.call(action, params, opts);
3407
3375
  }
3376
+ }
3377
+ //#endregion
3378
+ //#region src/kernel/transport/cap-route.ts
3379
+ function buildMessage(capName, method, detail) {
3380
+ const call = method !== void 0 ? `${capName}.${method}` : capName;
3381
+ const target = detail.nodeId !== void 0 ? ` to node ${detail.nodeId}` : "";
3382
+ const rejectedStr = detail.rejected.map((r) => `${r.kind}=${r.why}`).join("; ");
3383
+ const rejectedClause = rejectedStr.length > 0 ? ` (rejected: ${rejectedStr})` : "";
3384
+ return `${call} not routable${target}: ${detail.reason}${rejectedClause}`;
3385
+ }
3386
+ var CapRouteError = class extends Error {
3387
+ reason;
3388
+ nodeId;
3389
+ rejected;
3408
3390
  /**
3409
- * Child id that can service a call to `capName` (optionally addressing
3410
- * `deviceId`), or null.
3411
- *
3412
- * `deviceId` is a routing HINT, not a hard filter. A singleton cap
3413
- * (`pipeline-runner`, `stream-broker`, …) addresses devices through its
3414
- * METHOD ARGUMENTS — `attachCamera({ deviceId })` is one provider serving
3415
- * many cameras — so its descriptor carries no `deviceId`. Resolving on
3416
- * `deviceId` alone would never match it and would force the call onto the
3417
- * broker fallback (which then hangs in service discovery). Hence:
3418
- * 1. exact device-scoped owner (native per-device caps where each child
3419
- * owns a disjoint device subset) is preferred, then
3420
- * 2. a singleton owner (deviceId-less descriptor) is the fallback.
3421
- * A cap is globally singleton XOR device-scoped, so the two tiers never
3422
- * compete for the same capName.
3391
+ * @param cause Optional original error that triggered this routing failure.
3392
+ * Stored as `Error.cause` (TC39 standard option, Node 16.9+).
3393
+ * Dispatchers wrapping transport errors MUST pass the original.
3423
3394
  */
3424
- resolveChildId(capName, deviceId) {
3425
- if (deviceId !== void 0) {
3426
- const deviceOwner = this.findChildId((cap) => cap.capName === capName && cap.deviceId === deviceId);
3427
- if (deviceOwner !== null) return deviceOwner;
3428
- }
3429
- const candidates = this.findAllChildIds((cap) => cap.capName === capName && cap.deviceId === void 0);
3430
- if (candidates.length === 0) return null;
3431
- if (candidates.length === 1) return candidates[0];
3432
- const preferred = this.getActiveSingletonAddonId?.(capName) ?? null;
3433
- if (preferred !== null && candidates.includes(preferred)) return preferred;
3434
- return candidates[0];
3435
- }
3436
- /** First child whose cap manifest contains a descriptor matching `predicate`. */
3437
- findChildId(predicate) {
3438
- for (const entry of this.children.values()) if (entry.caps.some(predicate)) return entry.childId;
3439
- return null;
3395
+ constructor(capName, method, detail, cause) {
3396
+ super(buildMessage(capName, method, detail), cause !== void 0 ? { cause } : void 0);
3397
+ this.name = "CapRouteError";
3398
+ this.reason = detail.reason;
3399
+ this.nodeId = detail.nodeId;
3400
+ this.rejected = detail.rejected;
3440
3401
  }
3441
- /** Every child whose cap manifest contains a descriptor matching `predicate`. */
3442
- findAllChildIds(predicate) {
3443
- const out = [];
3444
- for (const entry of this.children.values()) if (entry.caps.some(predicate)) out.push(entry.childId);
3445
- return out;
3402
+ };
3403
+ /**
3404
+ * Classifies a (capName, opts) pair into a typed CapRoute dispatch descriptor.
3405
+ *
3406
+ * Precedence (explicit nodeId path):
3407
+ * 1. hub-in-process — hub node + hubInProcessProvides
3408
+ * 2. hub-local-uds — hub node + hubLocalChildProvides
3409
+ * 3. node-offline → CapRouteError{reason:'node-offline'}
3410
+ * 4. agent-child-forward — node is an agent AND nodeKnowsCap
3411
+ * 5. remote-moleculer — any other online non-agent node
3412
+ *
3413
+ * Singleton path (no nodeId):
3414
+ * 1. hub-in-process (hub provides in-process)
3415
+ * 2. hub-local-uds (a hub-local UDS child provides it)
3416
+ * 3. remote-moleculer (any online, non-hub node that knows it)
3417
+ * 4. → CapRouteError{reason:'no-provider', rejected: all considered routes}
3418
+ *
3419
+ * PURE: no side effects, no async, no broker/registry imports.
3420
+ */
3421
+ function classifyCapRoute(capName, opts, snapshot) {
3422
+ const rejected = [];
3423
+ if (opts.nodeId !== void 0) return classifyExplicitNode(capName, opts.nodeId, opts.deviceId, snapshot, rejected);
3424
+ return classifySingleton(capName, opts.deviceId, snapshot, rejected);
3425
+ }
3426
+ function classifyExplicitNode(capName, nodeId, deviceId, snap, rejected) {
3427
+ if (nodeId === snap.hubNodeId) {
3428
+ if (snap.hubInProcessProvides(capName)) {
3429
+ const ref = snap.getInProcessProviderRef?.(capName) ?? null;
3430
+ if (ref !== null) return {
3431
+ kind: "hub-in-process",
3432
+ capName,
3433
+ ref
3434
+ };
3435
+ rejected.push({
3436
+ kind: "hub-in-process",
3437
+ why: "provider ref not available in snapshot"
3438
+ });
3439
+ throw new CapRouteError(capName, void 0, {
3440
+ reason: "no-provider",
3441
+ nodeId,
3442
+ rejected
3443
+ });
3444
+ }
3445
+ if (snap.hubLocalChildProvides(capName, deviceId)) {
3446
+ const childId = snap.getHubLocalChildId?.(capName, deviceId) ?? null;
3447
+ if (childId !== null) return {
3448
+ kind: "hub-local-uds",
3449
+ capName,
3450
+ childId
3451
+ };
3452
+ rejected.push({
3453
+ kind: "hub-local-uds",
3454
+ why: "child id not resolvable from snapshot"
3455
+ });
3456
+ throw new CapRouteError(capName, void 0, {
3457
+ reason: "no-provider",
3458
+ nodeId,
3459
+ rejected
3460
+ });
3461
+ }
3462
+ rejected.push({
3463
+ kind: "hub-in-process",
3464
+ why: "hub does not provide this cap in-process"
3465
+ });
3466
+ rejected.push({
3467
+ kind: "hub-local-uds",
3468
+ why: "no hub-local child provides this cap"
3469
+ });
3470
+ throw new CapRouteError(capName, void 0, {
3471
+ reason: "no-provider",
3472
+ nodeId,
3473
+ rejected
3474
+ });
3446
3475
  }
3447
- /**
3448
- * Does the named child currently provide `(capName, deviceId?)`?
3449
- *
3450
- * Used by the hub proxy seam, which knows the EXACT addon (→ runner →
3451
- * childId) a provider belongs to. Unlike `resolveChildId` (which picks the
3452
- * first child owning `capName`), this targets one child — required for
3453
- * COLLECTION caps (`addon-widgets-source`, …) where many children register
3454
- * the same capName: routing by capName alone collapses every provider to
3455
- * the first child. Same deviceId-as-hint semantics as `resolveChildId`:
3456
- * a device-scoped descriptor matching `deviceId` OR a deviceId-less
3457
- * (singleton/collection) descriptor counts.
3458
- */
3459
- /**
3460
- * Is `childId` currently connected (has it completed its UDS handshake)?
3461
- * Coarser than {@link childProvides}: it answers "is the child reachable
3462
- * over UDS at all", regardless of which caps it has announced yet. Used by
3463
- * the route-mount fallback to decide between the handler-stripped
3464
- * `callAddonOnChild(target:'routes')` path (child reachable) and awaiting a
3465
- * cap proxy's `getRoutes()` (child not yet UDS-registered).
3466
- */
3467
- isChildKnown(childId) {
3468
- return this.children.has(childId);
3476
+ if (!snap.nodeOnline(nodeId)) {
3477
+ rejected.push({
3478
+ kind: "remote-moleculer",
3479
+ why: `node ${nodeId} is offline`
3480
+ });
3481
+ throw new CapRouteError(capName, void 0, {
3482
+ reason: "node-offline",
3483
+ nodeId,
3484
+ rejected
3485
+ });
3469
3486
  }
3470
- childProvides(childId, capName, deviceId) {
3471
- const entry = this.children.get(childId);
3472
- if (!entry) return false;
3473
- if (deviceId !== void 0 && entry.caps.some((cap) => cap.capName === capName && cap.deviceId === deviceId)) return true;
3474
- return entry.caps.some((cap) => cap.capName === capName && cap.deviceId === void 0);
3487
+ if (snap.nodeIsAgent(nodeId)) {
3488
+ if (snap.nodeKnowsCap(nodeId, capName)) return {
3489
+ kind: "agent-child-forward",
3490
+ capName,
3491
+ agentNodeId: nodeId,
3492
+ childId: snap.getAgentChildId?.(nodeId, capName) ?? void 0
3493
+ };
3494
+ rejected.push({
3495
+ kind: "agent-child-forward",
3496
+ why: `agent ${nodeId} does not know cap ${capName}`
3497
+ });
3498
+ throw new CapRouteError(capName, void 0, {
3499
+ reason: "no-provider",
3500
+ nodeId,
3501
+ rejected
3502
+ });
3475
3503
  }
3476
- /** Forward a cap method call to a SPECIFIC child by id over UDS; rejects if that child is absent. */
3477
- async callCapOnChild(childId, input) {
3478
- const entry = this.children.get(childId);
3479
- if (!entry) throw new Error(`no local child "${childId}" for cap "${input.capName}"`);
3480
- return entry.channel.request(this.toCapCall(input));
3481
- }
3482
- /** Forward a cap method call to the owning child over UDS; rejects if none. */
3483
- async callCap(input) {
3484
- const childId = this.resolveChildId(input.capName, input.deviceId);
3485
- const entry = childId === null ? void 0 : this.children.get(childId);
3486
- if (!entry) {
3487
- const where = input.deviceId === void 0 ? "" : ` for device ${input.deviceId}`;
3488
- throw new Error(`no local child provides cap "${input.capName}"${where}`);
3489
- }
3490
- return entry.channel.request(this.toCapCall(input));
3491
- }
3492
- /**
3493
- * Forward an ADDON-LEVEL call (routes / custom-action) to a SPECIFIC child
3494
- * by id over UDS; rejects if that child is absent.
3495
- *
3496
- * The childId for a hub-local single-addon runner equals the addonId
3497
- * (`resolveRunnerId` returns the addonId when no `execution.group` is
3498
- * declared — no shipped addon declares one). Mirrors `callCapOnChild` for
3499
- * the cap plane; carries the two surfaces the removed per-addon Moleculer
3500
- * broker used to serve (`getRoutes` + `custom.<action>`).
3501
- */
3502
- async callAddonOnChild(childId, input) {
3503
- const entry = this.children.get(childId);
3504
- if (!entry) throw new Error(`no local child "${childId}" for addon-call (${input.target})`);
3505
- return entry.channel.request(this.toAddonCall(input));
3506
- }
3507
- /** Build the parent→child `addon-call` wire message from an addon-call input. */
3508
- toAddonCall(input) {
3509
- return {
3510
- kind: "addon-call",
3511
- addonId: input.addonId,
3512
- target: input.target,
3513
- ...input.action !== void 0 ? { action: input.action } : {},
3514
- ...input.method !== void 0 ? { method: input.method } : {},
3515
- ...input.args !== void 0 ? { args: input.args } : {}
3504
+ return {
3505
+ kind: "remote-moleculer",
3506
+ capName,
3507
+ nodeId
3508
+ };
3509
+ }
3510
+ function classifySingleton(capName, deviceId, snap, rejected) {
3511
+ if (snap.hubInProcessProvides(capName)) {
3512
+ const ref = snap.getInProcessProviderRef?.(capName) ?? null;
3513
+ if (ref !== null) return {
3514
+ kind: "hub-in-process",
3515
+ capName,
3516
+ ref
3516
3517
  };
3517
- }
3518
- /** Build the parent→child `cap-call` wire message from a routing input. */
3519
- toCapCall(input) {
3520
- return {
3521
- kind: "cap-call",
3522
- capName: input.capName,
3523
- method: input.method,
3524
- args: input.args,
3525
- ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {}
3518
+ rejected.push({
3519
+ kind: "hub-in-process",
3520
+ why: "provider ref not available in snapshot"
3521
+ });
3522
+ } else rejected.push({
3523
+ kind: "hub-in-process",
3524
+ why: "hub does not provide this cap in-process"
3525
+ });
3526
+ if (snap.hubLocalChildProvides(capName, deviceId)) {
3527
+ const childId = snap.getHubLocalChildId?.(capName, deviceId) ?? null;
3528
+ if (childId !== null) return {
3529
+ kind: "hub-local-uds",
3530
+ capName,
3531
+ childId
3526
3532
  };
3533
+ rejected.push({
3534
+ kind: "hub-local-uds",
3535
+ why: "child id not resolvable from snapshot"
3536
+ });
3537
+ } else rejected.push({
3538
+ kind: "hub-local-uds",
3539
+ why: "no hub-local child provides this cap"
3540
+ });
3541
+ const knownNodes = snap.listKnownNodeIds?.() ?? [];
3542
+ for (const nodeId of knownNodes) if (nodeId !== snap.hubNodeId && snap.nodeOnline(nodeId) && snap.nodeKnowsCap(nodeId, capName)) return {
3543
+ kind: "remote-moleculer",
3544
+ capName,
3545
+ nodeId
3546
+ };
3547
+ rejected.push({
3548
+ kind: "remote-moleculer",
3549
+ why: "no online remote node knows this cap"
3550
+ });
3551
+ throw new CapRouteError(capName, void 0, {
3552
+ reason: "no-provider",
3553
+ rejected
3554
+ });
3555
+ }
3556
+ //#endregion
3557
+ //#region src/kernel/transport/cap-route-resolver.ts
3558
+ /** The Moleculer service name that Task 6's agent registers. */
3559
+ var AGENT_CAP_FWD_SERVICE = "$agent-cap-fwd";
3560
+ /** The Moleculer action (service.action) for agent cap forwarding. */
3561
+ var AGENT_CAP_FWD_ACTION = `${AGENT_CAP_FWD_SERVICE}.forward`;
3562
+ /** Default timeout for remote Moleculer cap calls (ms). */
3563
+ var REMOTE_CALL_TIMEOUT_MS = 6e4;
3564
+ function extractDeviceId(args) {
3565
+ if (args === null || typeof args !== "object") return void 0;
3566
+ const raw = Reflect.get(args, "deviceId");
3567
+ return typeof raw === "number" ? raw : void 0;
3568
+ }
3569
+ /**
3570
+ * Extract an inline `nodeId` string from a cap call's args — the per-call node
3571
+ * pin a forked addon expresses by carrying `nodeId` in the input (the SAME way
3572
+ * device-scoped caps carry `deviceId`, and the way the generated cap-router on
3573
+ * the hub already honours an inline pin). Mirrors {@link extractDeviceId}.
3574
+ *
3575
+ * This is the routing source for hub→remote-node EXECUTION pinning (e.g. the
3576
+ * benchmark addon running a synthetic/decoder workload ON a chosen agent). The
3577
+ * out-of-band `nodePin(op.context)` path does NOT survive the forked addon →
3578
+ * hub UDS link chain, so `onUnownedCall` reads the inline pin instead. Provider
3579
+ * methods that don't declare `nodeId` simply ignore the extra field (they
3580
+ * destructure the fields they need); methods that DO declare it (e.g.
3581
+ * `getEngineProvisioning({nodeId})`) still receive it unchanged.
3582
+ */
3583
+ function extractNodeId(args) {
3584
+ if (args === null || typeof args !== "object") return void 0;
3585
+ const raw = Reflect.get(args, "nodeId");
3586
+ return typeof raw === "string" && raw.length > 0 ? raw : void 0;
3587
+ }
3588
+ var CapRouteResolver = class {
3589
+ hubNodeId;
3590
+ broker;
3591
+ hubLocalRegistry;
3592
+ nodeAuthority;
3593
+ inProcessProviders;
3594
+ snapshot;
3595
+ constructor(deps) {
3596
+ this.hubNodeId = deps.hubNodeId;
3597
+ this.broker = deps.broker;
3598
+ this.hubLocalRegistry = deps.hubLocalRegistry;
3599
+ this.nodeAuthority = deps.nodeAuthority;
3600
+ this.inProcessProviders = deps.inProcessProviders;
3601
+ this.snapshot = this.buildSnapshot();
3527
3602
  }
3528
- listChildren() {
3529
- return [...this.children.values()].map((e) => ({
3530
- childId: e.childId,
3531
- caps: e.caps
3532
- }));
3533
- }
3534
- /** Register the (single) child-registered handler. Only one handler is active at a time. */
3535
- onChildRegistered(handler) {
3536
- this.registeredHandler = handler;
3537
- }
3538
- /** Register the (single) child-gone handler. Only one handler is active at a time. */
3539
- onChildGone(handler) {
3540
- this.goneHandler = handler;
3541
- }
3542
- /**
3543
- * Register the (single) child-event handler. Invoked when a child sends an
3544
- * event via `LocalChildClient.emitEvent`. Only one handler is active at a
3545
- * time; a new handler replaces the prior one. Pass `null` to clear the
3546
- * handler entirely (used by the UDS event bridge disposer on shutdown).
3547
- */
3548
- onChildEvent(handler) {
3549
- this.eventHandler = handler;
3550
- }
3551
- /**
3552
- * Register the (single) child-log handler. Invoked when a child sends a log
3553
- * entry via `LocalChildClient.sendLog`. Only one handler is active.
3554
- */
3555
- onChildLog(handler) {
3556
- this.logHandler = handler;
3557
- }
3558
- /**
3559
- * Register the handler that supplies the authoritative readiness snapshot
3560
- * when a child sends a `readiness-request`. Only one handler is active.
3561
- */
3562
- onReadinessSnapshotRequest(handler) {
3563
- this.readinessHandler = handler;
3603
+ resolveCapRoute(capName, opts) {
3604
+ return classifyCapRoute(capName, opts, this.snapshot);
3564
3605
  }
3565
3606
  /**
3566
- * Push a parent→child event to a specific child. Fire-and-forget (uses the
3567
- * one-way `emit` path on the channel). No-op if the child is not connected.
3607
+ * Resolve the hub-local-uds route for a cap owned by a forked hub-local
3608
+ * child, IGNORING any in-hub provider registered for the same cap name.
3609
+ *
3610
+ * `resolveCapRoute` gives Priority 1 to `hub-in-process`: when an in-hub
3611
+ * provider (e.g. a wrapper) is registered for the cap, the route always
3612
+ * classifies as `hub-in-process` and the hub-local-uds NATIVE child is never
3613
+ * reached. That is correct for the generic dispatch path (the wrapper is the
3614
+ * active provider), but WRONG for the native-cap fallback
3615
+ * (`setNativeFallback`), whose contract is to reach the NATIVE provider in
3616
+ * the forked vendor child so a wrapper can delegate to it. A wrapper cap
3617
+ * with a forked native (today: `snapshot`) otherwise resolves to the wrapper
3618
+ * itself, the native is never invoked, and the wrapper silently falls
3619
+ * through to its secondary strategy.
3620
+ *
3621
+ * This method consults ONLY the hub-local-child authority
3622
+ * (`hubLocalChildProvides` + `getHubLocalChildId`, both deviceId-aware), so
3623
+ * it returns the native child route regardless of any in-process shadow.
3624
+ * Returns null when no hub-local child owns `(capName, deviceId)` — the
3625
+ * caller then falls through to its remote-resolution branch.
3568
3626
  */
3569
- sendEventToChild(childId, event, sourceNodeId) {
3570
- const entry = this.children.get(childId);
3571
- if (entry === void 0) return;
3572
- const msg = {
3573
- kind: "event",
3574
- event,
3575
- sourceNodeId
3627
+ resolveHubLocalUdsRoute(capName, deviceId) {
3628
+ if (!this.snapshot.hubLocalChildProvides(capName, deviceId)) return null;
3629
+ const childId = this.snapshot.getHubLocalChildId?.(capName, deviceId) ?? null;
3630
+ if (childId === null) return null;
3631
+ return {
3632
+ kind: "hub-local-uds",
3633
+ capName,
3634
+ childId
3576
3635
  };
3577
- entry.channel.emit(msg);
3578
3636
  }
3579
- /**
3580
- * Push a parent→child event to every registered child, optionally skipping
3581
- * one (the originating child, to avoid echo). Fire-and-forget.
3582
- */
3583
- broadcastEventToChildren(event, sourceNodeId, exceptChildId) {
3584
- for (const entry of this.children.values()) {
3585
- if (entry.childId === exceptChildId) continue;
3586
- const msg = {
3587
- kind: "event",
3588
- event,
3589
- sourceNodeId
3590
- };
3591
- entry.channel.emit(msg);
3637
+ async dispatch(route, method, args) {
3638
+ try {
3639
+ return await this.dispatchInner(route, method, args);
3640
+ } catch (err) {
3641
+ if (err instanceof CapRouteError) throw err;
3642
+ const nodeId = this.routeNodeId(route);
3643
+ const cause = err instanceof Error ? err : new Error(String(err));
3644
+ throw new CapRouteError(route.capName, method, {
3645
+ reason: "transport-failed",
3646
+ nodeId,
3647
+ rejected: [{
3648
+ kind: route.kind,
3649
+ why: cause.message
3650
+ }]
3651
+ }, cause);
3592
3652
  }
3593
3653
  }
3594
3654
  /**
3595
- * E2: Send a `set-log-level` control message to a specific child.
3596
- * Returns `true` if the child is currently connected and the message was
3597
- * emitted; `false` if the child is not connected (no-op). The `false`
3598
- * return lets the caller (MoleculerService.setChildLogLevelByNodeId) fall
3599
- * back to the Moleculer `$node-mgmt.setLogLevel` action for the node.
3600
- * Mirrors the `$node-mgmt.setLogLevel` Moleculer action for UDS children.
3655
+ * Inner dispatch: may throw CapRouteError (validation failures) or arbitrary
3656
+ * transport errors. The outer `dispatch` wraps non-CapRouteErrors.
3601
3657
  */
3602
- setChildLogLevel(childId, level) {
3603
- const entry = this.children.get(childId);
3604
- if (entry === void 0) return false;
3605
- const msg = {
3606
- kind: "set-log-level",
3607
- level
3608
- };
3609
- entry.channel.emit(msg);
3610
- return true;
3611
- }
3612
- async close() {
3613
- await this.server.close();
3614
- }
3615
- onConnection(channel) {
3616
- let childId = null;
3617
- channel.onEvent((body) => {
3618
- const msg = body;
3619
- if (msg.kind === "event") {
3620
- if (childId !== null) this.eventHandler?.(childId, msg.event);
3621
- return;
3622
- }
3623
- if (msg.kind === "log") {
3624
- if (childId !== null) this.logHandler?.(childId, msg);
3625
- return;
3658
+ async dispatchInner(route, method, args) {
3659
+ switch (route.kind) {
3660
+ case "hub-in-process": return route.ref.invoke(method, args);
3661
+ case "hub-local-uds": {
3662
+ const registry = this.hubLocalRegistry;
3663
+ if (registry === null) throw new CapRouteError(route.capName, method, {
3664
+ reason: "no-provider",
3665
+ rejected: [{
3666
+ kind: "hub-local-uds",
3667
+ why: "UDS registry not available"
3668
+ }]
3669
+ });
3670
+ const deviceId = extractDeviceId(args);
3671
+ const input = {
3672
+ capName: route.capName,
3673
+ method,
3674
+ args,
3675
+ ...deviceId !== void 0 ? { deviceId } : {}
3676
+ };
3677
+ return registry.callCapOnChild(route.childId, input);
3626
3678
  }
3627
- });
3628
- channel.onRequest(async (body) => {
3629
- const msg = body;
3630
- if (msg.kind === "register") {
3631
- if (childId !== null && childId !== msg.childId) throw new Error(`child attempted to change identity from "${childId}" to "${msg.childId}"`);
3632
- childId = msg.childId;
3633
- this.children.set(msg.childId, {
3634
- childId: msg.childId,
3635
- channel,
3636
- caps: msg.caps
3679
+ case "remote-moleculer": {
3680
+ const addonId = this.nodeAuthority.getAddonId(route.nodeId, route.capName);
3681
+ if (addonId === null) throw new CapRouteError(route.capName, method, {
3682
+ reason: "no-provider",
3683
+ nodeId: route.nodeId,
3684
+ rejected: [{
3685
+ kind: "remote-moleculer",
3686
+ why: `no addonId known for ${route.nodeId}/${route.capName}`
3687
+ }]
3637
3688
  });
3638
- this.registeredHandler({
3639
- childId: msg.childId,
3640
- caps: msg.caps
3689
+ const deviceId = extractDeviceId(args);
3690
+ const isNative = this.nodeAuthority.isNativeCap(route.nodeId, route.capName, deviceId);
3691
+ const action = capActionName(addonId, route.capName, method, isNative);
3692
+ return callWithServiceDiscovery(this.broker, addonId, action, args, {
3693
+ nodeID: route.nodeId,
3694
+ timeout: REMOTE_CALL_TIMEOUT_MS
3641
3695
  });
3642
- return { ok: true };
3643
3696
  }
3644
- if (msg.kind === "cap-call-out") {
3645
- const out = msg;
3646
- const input = {
3647
- capName: out.capName,
3648
- method: out.method,
3649
- args: out.args,
3650
- ...out.deviceId !== void 0 ? { deviceId: out.deviceId } : {},
3651
- ...out.nodeId !== void 0 ? { nodeId: out.nodeId } : {}
3697
+ case "agent-child-forward": {
3698
+ const deviceId = extractDeviceId(args);
3699
+ const params = {
3700
+ capName: route.capName,
3701
+ method,
3702
+ args,
3703
+ ...route.childId !== void 0 ? { childId: route.childId } : {},
3704
+ ...deviceId !== void 0 ? { deviceId } : {}
3652
3705
  };
3653
- if (this.resolveChildId(out.capName, out.deviceId) !== null) {
3654
- if (!this.egressRoutedCaps.has(out.capName)) {
3655
- this.egressRoutedCaps.add(out.capName);
3656
- this.logger?.info("routed child egress over UDS", { capName: out.capName });
3657
- }
3658
- return this.callCap(input);
3659
- }
3660
- if (this.onUnownedCall !== void 0) return this.onUnownedCall(input);
3661
- throw new Error(`${UDS_NO_ROUTE_PREFIX}: cap-call-out has no local provider for "${out.capName}" and no fallback`);
3706
+ return callWithServiceDiscovery(this.broker, AGENT_CAP_FWD_SERVICE, AGENT_CAP_FWD_ACTION, params, {
3707
+ nodeID: route.agentNodeId,
3708
+ timeout: REMOTE_CALL_TIMEOUT_MS
3709
+ });
3662
3710
  }
3663
- if (msg.kind === "readiness-request") return {
3664
- kind: "readiness-snapshot",
3665
- records: this.readinessHandler?.() ?? []
3666
- };
3667
- throw new Error(`unknown child request kind: ${msg.kind}`);
3668
- });
3669
- channel.onClose(() => {
3670
- if (childId !== null && this.children.delete(childId)) this.goneHandler(childId);
3671
- });
3711
+ }
3712
+ }
3713
+ buildSnapshot() {
3714
+ const hubLocalRegistry = this.hubLocalRegistry;
3715
+ const nodeAuthority = this.nodeAuthority;
3716
+ const inProcessProviders = this.inProcessProviders;
3717
+ return {
3718
+ hubNodeId: this.hubNodeId,
3719
+ hubInProcessProvides: (cap) => inProcessProviders(cap) !== null,
3720
+ hubLocalChildProvides: (cap, deviceId) => hubLocalRegistry !== null && hubLocalRegistry.resolveChildId(cap, deviceId) !== null,
3721
+ nodeKnowsCap: (nodeId, cap) => nodeAuthority.nodeKnowsCap(nodeId, cap),
3722
+ nodeIsAgent: (nodeId) => nodeAuthority.nodeIsAgent(nodeId),
3723
+ nodeOnline: (nodeId) => nodeAuthority.nodeOnline(nodeId),
3724
+ listKnownNodeIds: () => nodeAuthority.listNodeIds(),
3725
+ getInProcessProviderRef: (cap) => inProcessProviders(cap),
3726
+ getHubLocalChildId: (cap, deviceId) => hubLocalRegistry !== null ? hubLocalRegistry.resolveChildId(cap, deviceId) : null,
3727
+ getAgentChildId: (agentNodeId, cap) => nodeAuthority.getAgentChildId(agentNodeId, cap)
3728
+ };
3729
+ }
3730
+ /** Extract a nodeId string from a route for error reporting, or undefined. */
3731
+ routeNodeId(route) {
3732
+ switch (route.kind) {
3733
+ case "hub-in-process": return this.hubNodeId;
3734
+ case "hub-local-uds": return `${this.hubNodeId}/${route.childId}`;
3735
+ case "remote-moleculer": return route.nodeId;
3736
+ case "agent-child-forward": return route.agentNodeId;
3737
+ }
3672
3738
  }
3673
3739
  };
3674
3740
  //#endregion
3675
- //#region src/kernel/transport/local-child-client.ts
3741
+ //#region src/kernel/transport/local-child-registry.ts
3676
3742
  /**
3677
- * Child side of the local UDS transport. Connects to its parent (hub or
3678
- * agent), registers its cap manifest, and serves parent→child cap calls by
3679
- * delegating to `dispatch`. The provider implementation lives in the child;
3680
- * only routing keys + call arguments cross the wire.
3681
- *
3682
- * Additional channels beyond cap-call:
3683
- * - `emitEvent` fire-and-forget event toward the parent
3684
- * - `sendLog` fire-and-forget log entry toward the parent
3685
- * - `requestReadinessSnapshot` request/response snapshot of readiness records
3686
- * - `onEvent` register a handler for parent→child events
3687
- *
3688
- * Events and logs emitted before `start()` resolves are buffered and flushed
3689
- * on connect (mirrors the `updateCaps`/`latestCaps` pattern).
3743
+ * Sentinel prefix used in the no-route error thrown when `cap-call-out` has no
3744
+ * local sibling and no `onUnownedCall` fallback. `ipcParentLink` detects this
3745
+ * prefix to distinguish routing failures (safe to retry via broker) from real
3746
+ * provider errors (must NOT retry to avoid double-executing side effects).
3690
3747
  */
3691
- var LocalChildClient = class {
3692
- options;
3693
- client = null;
3694
- channel = null;
3695
- /**
3696
- * The cap set `start()` will register, kept current by `updateCaps`. Native
3697
- * device caps register on device-restore, which can race AHEAD of the UDS
3698
- * connect buffering here lets a pre-start `updateCaps` survive (start sends
3699
- * the latest set) instead of being lost or throwing.
3700
- */
3701
- latestCaps;
3702
- /** Events and logs queued while the channel is not yet open. */
3703
- pendingEmits = [];
3704
- /** Handler for parent→child events. Registered via `onEvent`. */
3748
+ var UDS_NO_ROUTE_PREFIX = "UDS_NO_ROUTE";
3749
+ /**
3750
+ * Parent-side authority for local addon-runners reachable over a
3751
+ * `LocalTransportServer` (UDS). Children register their cap manifest on
3752
+ * connect; the registry routes `(capName, deviceId?)` cap calls to the
3753
+ * owning child over the channel and drops a child's caps on disconnect.
3754
+ */
3755
+ var LocalChildRegistry = class {
3756
+ children = /* @__PURE__ */ new Map();
3757
+ registeredHandler = () => {};
3758
+ goneHandler = () => {};
3705
3759
  eventHandler = null;
3760
+ logHandler = null;
3761
+ readinessHandler = null;
3762
+ server;
3763
+ onUnownedCall;
3764
+ logger;
3765
+ getActiveSingletonAddonId;
3766
+ /** Tracks capNames already logged as UDS-routed; one INFO line per capName per process. */
3767
+ egressRoutedCaps = /* @__PURE__ */ new Set();
3706
3768
  /**
3707
- * Handler for parent→child addon-level calls (`routes` / `custom`).
3708
- * Registered via `onAddonCall`. Resolves the loaded addon instance and
3709
- * invokes its `addon-routes.getRoutes()` or its custom-action handler.
3710
- * Replaces the per-addon Moleculer `getRoutes` / `custom.<action>` actions
3711
- * removed in F1/F2. Only one handler is active at a time.
3712
- */
3713
- addonCallHandler = null;
3714
- /**
3715
- * Handler for `set-log-level` messages pushed by the parent.
3716
- * Registered via `onSetLogLevel`. The handler applies the new level to the
3717
- * child's local Moleculer logger (mirrors `$node-mgmt.setLogLevel`).
3718
- * E2: wired in `addon-runner.ts` to forward to the broker logger.
3769
+ * Accepts either a plain positional `server` argument (backward-compatible)
3770
+ * or a full `LocalChildRegistryOptions` object.
3771
+ *
3772
+ * Positional overloads (existing call sites are unchanged):
3773
+ * new LocalChildRegistry(server)
3774
+ * new LocalChildRegistry(server, onUnownedCall)
3775
+ *
3776
+ * Options object (new call sites that pass a logger):
3777
+ * new LocalChildRegistry({ server, onUnownedCall, logger })
3719
3778
  */
3720
- setLogLevelHandler = null;
3721
- /** Callbacks registered via `onConnected`. Fired on every (re)connect. */
3722
- connectedHandlers = [];
3723
- constructor(options) {
3724
- this.options = options;
3725
- this.latestCaps = options.caps;
3779
+ constructor(serverOrOptions, onUnownedCallArg) {
3780
+ if (serverOrOptions && !("listen" in serverOrOptions)) {
3781
+ const opts = serverOrOptions;
3782
+ this.server = opts.server;
3783
+ this.onUnownedCall = opts.onUnownedCall;
3784
+ this.logger = opts.logger;
3785
+ this.getActiveSingletonAddonId = opts.getActiveSingletonAddonId;
3786
+ } else {
3787
+ this.server = serverOrOptions;
3788
+ this.onUnownedCall = onUnownedCallArg;
3789
+ }
3726
3790
  }
3727
- /**
3728
- * Register a callback that fires each time the client successfully connects
3729
- * (or reconnects) to its parent. Multiple handlers may be registered; all
3730
- * are called in registration order. Used by readiness-context in UDS mode
3731
- * to trigger a snapshot hydrate on connect/reconnect.
3732
- */
3733
- onConnected(handler) {
3734
- this.connectedHandlers.push(handler);
3791
+ async start() {
3792
+ this.server.onConnection((channel) => this.onConnection(channel));
3793
+ await this.server.listen();
3735
3794
  }
3736
3795
  /**
3737
- * Register a handler for events pushed from the parent to this child.
3738
- * Must be called before `start()` to avoid missing early events (though
3739
- * registration after start is also safe for events not yet delivered).
3740
- * Replaces any previously registered handler.
3796
+ * Child id that can service a call to `capName` (optionally addressing
3797
+ * `deviceId`), or null.
3798
+ *
3799
+ * `deviceId` is a routing HINT, not a hard filter. A singleton cap
3800
+ * (`pipeline-runner`, `stream-broker`, …) addresses devices through its
3801
+ * METHOD ARGUMENTS — `attachCamera({ deviceId })` is one provider serving
3802
+ * many cameras — so its descriptor carries no `deviceId`. Resolving on
3803
+ * `deviceId` alone would never match it and would force the call onto the
3804
+ * broker fallback (which then hangs in service discovery). Hence:
3805
+ * 1. exact device-scoped owner (native per-device caps where each child
3806
+ * owns a disjoint device subset) is preferred, then
3807
+ * 2. a singleton owner (deviceId-less descriptor) is the fallback.
3808
+ * A cap is globally singleton XOR device-scoped, so the two tiers never
3809
+ * compete for the same capName.
3741
3810
  */
3742
- onEvent(handler) {
3743
- this.eventHandler = handler;
3811
+ resolveChildId(capName, deviceId) {
3812
+ if (deviceId !== void 0) {
3813
+ const deviceOwner = this.findChildId((cap) => cap.capName === capName && cap.deviceId === deviceId);
3814
+ if (deviceOwner !== null) return deviceOwner;
3815
+ }
3816
+ const candidates = this.findAllChildIds((cap) => cap.capName === capName && cap.deviceId === void 0);
3817
+ if (candidates.length === 0) return null;
3818
+ if (candidates.length === 1) return candidates[0];
3819
+ const preferred = this.getActiveSingletonAddonId?.(capName) ?? null;
3820
+ if (preferred !== null && candidates.includes(preferred)) return preferred;
3821
+ return candidates[0];
3744
3822
  }
3745
- /**
3746
- * Register the handler invoked when the parent sends an `addon-call` (the
3747
- * addon-level routes / custom-action plane). The addon-runner wires this to
3748
- * resolve the loaded addon by id and dispatch to its `getRoutes()` or its
3749
- * custom-action handler. Safe to register before or after `start()`;
3750
- * replaces any prior handler.
3751
- */
3752
- onAddonCall(handler) {
3753
- this.addonCallHandler = handler;
3823
+ /** First child whose cap manifest contains a descriptor matching `predicate`. */
3824
+ findChildId(predicate) {
3825
+ for (const entry of this.children.values()) if (entry.caps.some(predicate)) return entry.childId;
3826
+ return null;
3827
+ }
3828
+ /** Every child whose cap manifest contains a descriptor matching `predicate`. */
3829
+ findAllChildIds(predicate) {
3830
+ const out = [];
3831
+ for (const entry of this.children.values()) if (entry.caps.some(predicate)) out.push(entry.childId);
3832
+ return out;
3754
3833
  }
3755
3834
  /**
3756
- * E2: Register a handler invoked when the parent sends a `set-log-level`
3757
- * message to this child. The handler should forward the new level to the
3758
- * child's local Moleculer broker logger (mirrors `$node-mgmt.setLogLevel`).
3759
- * Safe to register before or after `start()`. Replaces any prior handler.
3835
+ * Does the named child currently provide `(capName, deviceId?)`?
3836
+ *
3837
+ * Used by the hub proxy seam, which knows the EXACT addon ( runner →
3838
+ * childId) a provider belongs to. Unlike `resolveChildId` (which picks the
3839
+ * first child owning `capName`), this targets one child — required for
3840
+ * COLLECTION caps (`addon-widgets-source`, …) where many children register
3841
+ * the same capName: routing by capName alone collapses every provider to
3842
+ * the first child. Same deviceId-as-hint semantics as `resolveChildId`:
3843
+ * a device-scoped descriptor matching `deviceId` OR a deviceId-less
3844
+ * (singleton/collection) descriptor counts.
3760
3845
  */
3761
- onSetLogLevel(handler) {
3762
- this.setLogLevelHandler = handler;
3763
- }
3764
3846
  /**
3765
- * True once `start()` has successfully connected and registered. Used by
3766
- * callers (event-bus/logger bridges) to know the channel is live.
3847
+ * Is `childId` currently connected (has it completed its UDS handshake)?
3848
+ * Coarser than {@link childProvides}: it answers "is the child reachable
3849
+ * over UDS at all", regardless of which caps it has announced yet. Used by
3850
+ * the route-mount fallback to decide between the handler-stripped
3851
+ * `callAddonOnChild(target:'routes')` path (child reachable) and awaiting a
3852
+ * cap proxy's `getRoutes()` (child not yet UDS-registered).
3767
3853
  */
3768
- get isConnected() {
3769
- return this.channel !== null;
3854
+ isChildKnown(childId) {
3855
+ return this.children.has(childId);
3770
3856
  }
3771
- async start() {
3772
- if (this.client !== null) throw new Error("LocalChildClient: already started — call close() first");
3773
- const client = createLocalTransport().createClient(this.options.nodeId);
3774
- const channel = await client.connect();
3775
- channel.onRequest(async (body) => {
3776
- const msg = body;
3777
- if (msg.kind === "cap-call") return this.options.dispatch({
3778
- capName: msg.capName,
3779
- method: msg.method,
3780
- args: msg.args,
3781
- ...msg.deviceId !== void 0 ? { deviceId: msg.deviceId } : {}
3782
- });
3783
- if (msg.kind === "addon-call") {
3784
- if (this.addonCallHandler === null) throw new Error(`LocalChildClient: addon-call for "${msg.addonId}" arrived but no onAddonCall handler is registered`);
3785
- return this.addonCallHandler({
3786
- addonId: msg.addonId,
3787
- target: msg.target,
3788
- ...msg.action !== void 0 ? { action: msg.action } : {},
3789
- ...msg.method !== void 0 ? { method: msg.method } : {},
3790
- ...msg.args !== void 0 ? { args: msg.args } : {}
3791
- });
3792
- }
3793
- throw new Error(`unknown parent request kind: ${msg.kind}`);
3794
- });
3795
- channel.onEvent((body) => {
3796
- const msg = body;
3797
- if (msg.kind === "event") {
3798
- this.eventHandler?.(msg.event);
3799
- return;
3800
- }
3801
- if (msg.kind === "set-log-level") this.setLogLevelHandler?.(msg.level);
3802
- });
3803
- const register = {
3804
- kind: "register",
3805
- childId: this.options.childId,
3806
- caps: this.latestCaps
3807
- };
3808
- try {
3809
- await channel.request(register);
3810
- } catch (err) {
3811
- await client.close();
3812
- throw err;
3813
- }
3814
- this.client = client;
3815
- this.channel = channel;
3816
- this.flushPending(channel);
3817
- for (const cb of this.connectedHandlers) cb();
3857
+ childProvides(childId, capName, deviceId) {
3858
+ const entry = this.children.get(childId);
3859
+ if (!entry) return false;
3860
+ if (deviceId !== void 0 && entry.caps.some((cap) => cap.capName === capName && cap.deviceId === deviceId)) return true;
3861
+ return entry.caps.some((cap) => cap.capName === capName && cap.deviceId === void 0);
3818
3862
  }
3819
- /** Flush buffered pre-start emits over the now-open channel. */
3820
- flushPending(channel) {
3821
- for (const item of this.pendingEmits) channel.emit(item.msg);
3822
- this.pendingEmits.length = 0;
3863
+ /** Forward a cap method call to a SPECIFIC child by id over UDS; rejects if that child is absent. */
3864
+ async callCapOnChild(childId, input) {
3865
+ const entry = this.children.get(childId);
3866
+ if (!entry) throw new Error(`no local child "${childId}" for cap "${input.capName}"`);
3867
+ return entry.channel.request(this.toCapCall(input));
3868
+ }
3869
+ /** Forward a cap method call to the owning child over UDS; rejects if none. */
3870
+ async callCap(input) {
3871
+ const childId = this.resolveChildId(input.capName, input.deviceId);
3872
+ const entry = childId === null ? void 0 : this.children.get(childId);
3873
+ if (!entry) {
3874
+ const where = input.deviceId === void 0 ? "" : ` for device ${input.deviceId}`;
3875
+ throw new Error(`no local child provides cap "${input.capName}"${where}`);
3876
+ }
3877
+ return entry.channel.request(this.toCapCall(input));
3823
3878
  }
3824
3879
  /**
3825
- * Re-send the cap manifest to the parent, atomically replacing the child's
3826
- * registered descriptor set. Call this after device-restore so newly
3827
- * registered native (device-scoped) caps become routable over UDS.
3880
+ * Forward an ADDON-LEVEL call (routes / custom-action) to a SPECIFIC child
3881
+ * by id over UDS; rejects if that child is absent.
3828
3882
  *
3829
- * Safe to call before `start()`: the new set is buffered and `start()` sends
3830
- * it (device-restore can race ahead of the UDS connect). After `start()`,
3831
- * the set is sent immediately.
3883
+ * The childId for a hub-local single-addon runner equals the addonId
3884
+ * (`resolveRunnerId` returns the addonId when no `execution.group` is
3885
+ * declared no shipped addon declares one). Mirrors `callCapOnChild` for
3886
+ * the cap plane; carries the two surfaces the removed per-addon Moleculer
3887
+ * broker used to serve (`getRoutes` + `custom.<action>`).
3832
3888
  */
3833
- async updateCaps(caps) {
3834
- this.latestCaps = caps;
3835
- if (this.channel === null) return;
3836
- const register = {
3837
- kind: "register",
3838
- childId: this.options.childId,
3839
- caps
3889
+ async callAddonOnChild(childId, input) {
3890
+ const entry = this.children.get(childId);
3891
+ if (!entry) throw new Error(`no local child "${childId}" for addon-call (${input.target})`);
3892
+ return entry.channel.request(this.toAddonCall(input));
3893
+ }
3894
+ /** Build the parent→child `addon-call` wire message from an addon-call input. */
3895
+ toAddonCall(input) {
3896
+ return {
3897
+ kind: "addon-call",
3898
+ addonId: input.addonId,
3899
+ target: input.target,
3900
+ ...input.action !== void 0 ? { action: input.action } : {},
3901
+ ...input.method !== void 0 ? { method: input.method } : {},
3902
+ ...input.args !== void 0 ? { args: input.args } : {}
3840
3903
  };
3841
- await this.channel.request(register);
3904
+ }
3905
+ /** Build the parent→child `cap-call` wire message from a routing input. */
3906
+ toCapCall(input) {
3907
+ return {
3908
+ kind: "cap-call",
3909
+ capName: input.capName,
3910
+ method: input.method,
3911
+ args: input.args,
3912
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {}
3913
+ };
3914
+ }
3915
+ listChildren() {
3916
+ return [...this.children.values()].map((e) => ({
3917
+ childId: e.childId,
3918
+ caps: e.caps
3919
+ }));
3920
+ }
3921
+ /** Register the (single) child-registered handler. Only one handler is active at a time. */
3922
+ onChildRegistered(handler) {
3923
+ this.registeredHandler = handler;
3924
+ }
3925
+ /** Register the (single) child-gone handler. Only one handler is active at a time. */
3926
+ onChildGone(handler) {
3927
+ this.goneHandler = handler;
3842
3928
  }
3843
3929
  /**
3844
- * Fire-and-forget: send a system event to the parent for forwarding to the
3845
- * hub event bus. Safe to call before `start()` events are buffered and
3846
- * flushed on connect.
3930
+ * Register the (single) child-event handler. Invoked when a child sends an
3931
+ * event via `LocalChildClient.emitEvent`. Only one handler is active at a
3932
+ * time; a new handler replaces the prior one. Pass `null` to clear the
3933
+ * handler entirely (used by the UDS event bridge disposer on shutdown).
3847
3934
  */
3848
- emitEvent(event) {
3849
- const msg = {
3850
- kind: "event",
3851
- event
3852
- };
3853
- if (this.channel !== null) this.channel.emit(msg);
3854
- else this.pendingEmits.push({
3855
- kind: "event",
3856
- msg
3857
- });
3935
+ onChildEvent(handler) {
3936
+ this.eventHandler = handler;
3858
3937
  }
3859
3938
  /**
3860
- * Fire-and-forget: send a structured log entry to the parent. Safe to call
3861
- * before `start()` log entries are buffered and flushed on connect.
3939
+ * Register the (single) child-log handler. Invoked when a child sends a log
3940
+ * entry via `LocalChildClient.sendLog`. Only one handler is active.
3862
3941
  */
3863
- sendLog(entry) {
3864
- const msg = {
3865
- kind: "log",
3866
- ...entry
3867
- };
3868
- if (this.channel !== null) this.channel.emit(msg);
3869
- else this.pendingEmits.push({
3870
- kind: "log",
3871
- msg
3872
- });
3942
+ onChildLog(handler) {
3943
+ this.logHandler = handler;
3873
3944
  }
3874
3945
  /**
3875
- * Request the current readiness snapshot from the parent. Returns the
3876
- * authoritative set of `IReadinessRegistryRecord` entries the hub holds.
3877
- * Requires `start()` to have been called; throws with a clear message if not.
3946
+ * Register the handler that supplies the authoritative readiness snapshot
3947
+ * when a child sends a `readiness-request`. Only one handler is active.
3878
3948
  */
3879
- async requestReadinessSnapshot() {
3880
- if (this.channel === null) throw new Error("LocalChildClient: requestReadinessSnapshot called before start()");
3881
- return (await this.channel.request({ kind: "readiness-request" })).records;
3949
+ onReadinessSnapshotRequest(handler) {
3950
+ this.readinessHandler = handler;
3882
3951
  }
3883
3952
  /**
3884
- * Ask the parent to execute a cap call this child does NOT own. The parent
3885
- * routes to the owning local sibling over UDS, or — if no sibling owns it
3886
- * to its `onUnownedCall` fallback (the cluster CapabilityRegistry in
3887
- * production). Throws if called before `start()`.
3953
+ * Push a parent→child event to a specific child. Fire-and-forget (uses the
3954
+ * one-way `emit` path on the channel). No-op if the child is not connected.
3888
3955
  */
3889
- async callOut(input) {
3890
- if (this.channel === null) throw new Error("LocalChildClient: callOut before start");
3956
+ sendEventToChild(childId, event, sourceNodeId) {
3957
+ const entry = this.children.get(childId);
3958
+ if (entry === void 0) return;
3891
3959
  const msg = {
3892
- kind: "cap-call-out",
3893
- capName: input.capName,
3894
- method: input.method,
3895
- args: input.args,
3896
- ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
3897
- ...input.nodeId !== void 0 ? { nodeId: input.nodeId } : {}
3960
+ kind: "event",
3961
+ event,
3962
+ sourceNodeId
3898
3963
  };
3899
- return this.channel.request(msg);
3900
- }
3901
- /** Disconnect from the parent. Safe to call before `start()` (no-op) and idempotent. */
3902
- async close() {
3903
- await this.client?.close();
3904
- this.client = null;
3905
- this.channel = null;
3964
+ entry.channel.emit(msg);
3906
3965
  }
3907
- };
3908
- //#endregion
3909
- //#region src/kernel/transport/cap-route.ts
3910
- function buildMessage(capName, method, detail) {
3911
- const call = method !== void 0 ? `${capName}.${method}` : capName;
3912
- const target = detail.nodeId !== void 0 ? ` to node ${detail.nodeId}` : "";
3913
- const rejectedStr = detail.rejected.map((r) => `${r.kind}=${r.why}`).join("; ");
3914
- const rejectedClause = rejectedStr.length > 0 ? ` (rejected: ${rejectedStr})` : "";
3915
- return `${call} not routable${target}: ${detail.reason}${rejectedClause}`;
3916
- }
3917
- var CapRouteError = class extends Error {
3918
- reason;
3919
- nodeId;
3920
- rejected;
3921
3966
  /**
3922
- * @param cause Optional original error that triggered this routing failure.
3923
- * Stored as `Error.cause` (TC39 standard option, Node 16.9+).
3924
- * Dispatchers wrapping transport errors MUST pass the original.
3967
+ * Push a parent→child event to every registered child, optionally skipping
3968
+ * one (the originating child, to avoid echo). Fire-and-forget.
3925
3969
  */
3926
- constructor(capName, method, detail, cause) {
3927
- super(buildMessage(capName, method, detail), cause !== void 0 ? { cause } : void 0);
3928
- this.name = "CapRouteError";
3929
- this.reason = detail.reason;
3930
- this.nodeId = detail.nodeId;
3931
- this.rejected = detail.rejected;
3932
- }
3933
- };
3934
- /**
3935
- * Classifies a (capName, opts) pair into a typed CapRoute dispatch descriptor.
3936
- *
3937
- * Precedence (explicit nodeId path):
3938
- * 1. hub-in-process — hub node + hubInProcessProvides
3939
- * 2. hub-local-uds — hub node + hubLocalChildProvides
3940
- * 3. node-offline → CapRouteError{reason:'node-offline'}
3941
- * 4. agent-child-forward — node is an agent AND nodeKnowsCap
3942
- * 5. remote-moleculer — any other online non-agent node
3943
- *
3944
- * Singleton path (no nodeId):
3945
- * 1. hub-in-process (hub provides in-process)
3946
- * 2. hub-local-uds (a hub-local UDS child provides it)
3947
- * 3. remote-moleculer (any online, non-hub node that knows it)
3948
- * 4. → CapRouteError{reason:'no-provider', rejected: all considered routes}
3949
- *
3950
- * PURE: no side effects, no async, no broker/registry imports.
3951
- */
3952
- function classifyCapRoute(capName, opts, snapshot) {
3953
- const rejected = [];
3954
- if (opts.nodeId !== void 0) return classifyExplicitNode(capName, opts.nodeId, opts.deviceId, snapshot, rejected);
3955
- return classifySingleton(capName, opts.deviceId, snapshot, rejected);
3956
- }
3957
- function classifyExplicitNode(capName, nodeId, deviceId, snap, rejected) {
3958
- if (nodeId === snap.hubNodeId) {
3959
- if (snap.hubInProcessProvides(capName)) {
3960
- const ref = snap.getInProcessProviderRef?.(capName) ?? null;
3961
- if (ref !== null) return {
3962
- kind: "hub-in-process",
3963
- capName,
3964
- ref
3965
- };
3966
- rejected.push({
3967
- kind: "hub-in-process",
3968
- why: "provider ref not available in snapshot"
3969
- });
3970
- throw new CapRouteError(capName, void 0, {
3971
- reason: "no-provider",
3972
- nodeId,
3973
- rejected
3974
- });
3975
- }
3976
- if (snap.hubLocalChildProvides(capName, deviceId)) {
3977
- const childId = snap.getHubLocalChildId?.(capName, deviceId) ?? null;
3978
- if (childId !== null) return {
3979
- kind: "hub-local-uds",
3980
- capName,
3981
- childId
3970
+ broadcastEventToChildren(event, sourceNodeId, exceptChildId) {
3971
+ for (const entry of this.children.values()) {
3972
+ if (entry.childId === exceptChildId) continue;
3973
+ const msg = {
3974
+ kind: "event",
3975
+ event,
3976
+ sourceNodeId
3982
3977
  };
3983
- rejected.push({
3984
- kind: "hub-local-uds",
3985
- why: "child id not resolvable from snapshot"
3986
- });
3987
- throw new CapRouteError(capName, void 0, {
3988
- reason: "no-provider",
3989
- nodeId,
3990
- rejected
3991
- });
3978
+ entry.channel.emit(msg);
3992
3979
  }
3993
- rejected.push({
3994
- kind: "hub-in-process",
3995
- why: "hub does not provide this cap in-process"
3996
- });
3997
- rejected.push({
3998
- kind: "hub-local-uds",
3999
- why: "no hub-local child provides this cap"
4000
- });
4001
- throw new CapRouteError(capName, void 0, {
4002
- reason: "no-provider",
4003
- nodeId,
4004
- rejected
4005
- });
4006
3980
  }
4007
- if (!snap.nodeOnline(nodeId)) {
4008
- rejected.push({
4009
- kind: "remote-moleculer",
4010
- why: `node ${nodeId} is offline`
4011
- });
4012
- throw new CapRouteError(capName, void 0, {
4013
- reason: "node-offline",
4014
- nodeId,
4015
- rejected
4016
- });
4017
- }
4018
- if (snap.nodeIsAgent(nodeId)) {
4019
- if (snap.nodeKnowsCap(nodeId, capName)) return {
4020
- kind: "agent-child-forward",
4021
- capName,
4022
- agentNodeId: nodeId,
4023
- childId: snap.getAgentChildId?.(nodeId, capName) ?? void 0
3981
+ /**
3982
+ * E2: Send a `set-log-level` control message to a specific child.
3983
+ * Returns `true` if the child is currently connected and the message was
3984
+ * emitted; `false` if the child is not connected (no-op). The `false`
3985
+ * return lets the caller (MoleculerService.setChildLogLevelByNodeId) fall
3986
+ * back to the Moleculer `$node-mgmt.setLogLevel` action for the node.
3987
+ * Mirrors the `$node-mgmt.setLogLevel` Moleculer action for UDS children.
3988
+ */
3989
+ setChildLogLevel(childId, level) {
3990
+ const entry = this.children.get(childId);
3991
+ if (entry === void 0) return false;
3992
+ const msg = {
3993
+ kind: "set-log-level",
3994
+ level
4024
3995
  };
4025
- rejected.push({
4026
- kind: "agent-child-forward",
4027
- why: `agent ${nodeId} does not know cap ${capName}`
4028
- });
4029
- throw new CapRouteError(capName, void 0, {
4030
- reason: "no-provider",
4031
- nodeId,
4032
- rejected
4033
- });
3996
+ entry.channel.emit(msg);
3997
+ return true;
4034
3998
  }
4035
- return {
4036
- kind: "remote-moleculer",
4037
- capName,
4038
- nodeId
4039
- };
4040
- }
4041
- function classifySingleton(capName, deviceId, snap, rejected) {
4042
- if (snap.hubInProcessProvides(capName)) {
4043
- const ref = snap.getInProcessProviderRef?.(capName) ?? null;
4044
- if (ref !== null) return {
4045
- kind: "hub-in-process",
4046
- capName,
4047
- ref
4048
- };
4049
- rejected.push({
4050
- kind: "hub-in-process",
4051
- why: "provider ref not available in snapshot"
3999
+ async close() {
4000
+ await this.server.close();
4001
+ }
4002
+ onConnection(channel) {
4003
+ let childId = null;
4004
+ channel.onEvent((body) => {
4005
+ const msg = body;
4006
+ if (msg.kind === "event") {
4007
+ if (childId !== null) this.eventHandler?.(childId, msg.event);
4008
+ return;
4009
+ }
4010
+ if (msg.kind === "log") {
4011
+ if (childId !== null) this.logHandler?.(childId, msg);
4012
+ return;
4013
+ }
4052
4014
  });
4053
- } else rejected.push({
4054
- kind: "hub-in-process",
4055
- why: "hub does not provide this cap in-process"
4056
- });
4057
- if (snap.hubLocalChildProvides(capName, deviceId)) {
4058
- const childId = snap.getHubLocalChildId?.(capName, deviceId) ?? null;
4059
- if (childId !== null) return {
4060
- kind: "hub-local-uds",
4061
- capName,
4062
- childId
4063
- };
4064
- rejected.push({
4065
- kind: "hub-local-uds",
4066
- why: "child id not resolvable from snapshot"
4015
+ channel.onRequest(async (body) => {
4016
+ const msg = body;
4017
+ if (msg.kind === "register") {
4018
+ if (childId !== null && childId !== msg.childId) throw new Error(`child attempted to change identity from "${childId}" to "${msg.childId}"`);
4019
+ childId = msg.childId;
4020
+ this.children.set(msg.childId, {
4021
+ childId: msg.childId,
4022
+ channel,
4023
+ caps: msg.caps
4024
+ });
4025
+ this.registeredHandler({
4026
+ childId: msg.childId,
4027
+ caps: msg.caps
4028
+ });
4029
+ return { ok: true };
4030
+ }
4031
+ if (msg.kind === "cap-call-out") {
4032
+ const out = msg;
4033
+ const input = {
4034
+ capName: out.capName,
4035
+ method: out.method,
4036
+ args: out.args,
4037
+ ...out.deviceId !== void 0 ? { deviceId: out.deviceId } : {},
4038
+ ...out.nodeId !== void 0 ? { nodeId: out.nodeId } : {}
4039
+ };
4040
+ if (((out.nodeId ?? extractNodeId(out.args)) === void 0 ? this.resolveChildId(out.capName, out.deviceId) : null) !== null) {
4041
+ if (!this.egressRoutedCaps.has(out.capName)) {
4042
+ this.egressRoutedCaps.add(out.capName);
4043
+ this.logger?.info("routed child egress over UDS", { capName: out.capName });
4044
+ }
4045
+ return this.callCap(input);
4046
+ }
4047
+ if (this.onUnownedCall !== void 0) return this.onUnownedCall(input);
4048
+ throw new Error(`${UDS_NO_ROUTE_PREFIX}: cap-call-out has no local provider for "${out.capName}" and no fallback`);
4049
+ }
4050
+ if (msg.kind === "readiness-request") return {
4051
+ kind: "readiness-snapshot",
4052
+ records: this.readinessHandler?.() ?? []
4053
+ };
4054
+ throw new Error(`unknown child request kind: ${msg.kind}`);
4067
4055
  });
4068
- } else rejected.push({
4069
- kind: "hub-local-uds",
4070
- why: "no hub-local child provides this cap"
4071
- });
4072
- const knownNodes = snap.listKnownNodeIds?.() ?? [];
4073
- for (const nodeId of knownNodes) if (nodeId !== snap.hubNodeId && snap.nodeOnline(nodeId) && snap.nodeKnowsCap(nodeId, capName)) return {
4074
- kind: "remote-moleculer",
4075
- capName,
4076
- nodeId
4077
- };
4078
- rejected.push({
4079
- kind: "remote-moleculer",
4080
- why: "no online remote node knows this cap"
4081
- });
4082
- throw new CapRouteError(capName, void 0, {
4083
- reason: "no-provider",
4084
- rejected
4085
- });
4086
- }
4056
+ channel.onClose(() => {
4057
+ if (childId !== null && this.children.delete(childId)) this.goneHandler(childId);
4058
+ });
4059
+ }
4060
+ };
4087
4061
  //#endregion
4088
- //#region src/kernel/moleculer/resilient-cap-call.ts
4089
- /** Moleculer error `type` values meaning "the service is not (yet) routable". */
4090
- var DISCOVERY_ERROR_TYPES = new Set(["SERVICE_NOT_FOUND", "SERVICE_NOT_AVAILABLE"]);
4091
- /** Default ceiling for the discovery wait — fail-fast past this. */
4092
- var DEFAULT_DISCOVERY_TIMEOUT_MS$1 = 3e4;
4093
- function isDiscoveryError(err) {
4094
- if (typeof err !== "object" || err === null) return false;
4095
- const type = err.type;
4096
- return typeof type === "string" && DISCOVERY_ERROR_TYPES.has(type);
4097
- }
4062
+ //#region src/kernel/transport/local-child-client.ts
4098
4063
  /**
4099
- * Call a Moleculer action; on a service-discovery error, wait for the
4100
- * named service to be discovered and retry the call exactly once.
4064
+ * Child side of the local UDS transport. Connects to its parent (hub or
4065
+ * agent), registers its cap manifest, and serves parent→child cap calls by
4066
+ * delegating to `dispatch`. The provider implementation lives in the child;
4067
+ * only routing keys + call arguments cross the wire.
4068
+ *
4069
+ * Additional channels beyond cap-call:
4070
+ * - `emitEvent` fire-and-forget event toward the parent
4071
+ * - `sendLog` fire-and-forget log entry toward the parent
4072
+ * - `requestReadinessSnapshot` request/response snapshot of readiness records
4073
+ * - `onEvent` register a handler for parent→child events
4074
+ *
4075
+ * Events and logs emitted before `start()` resolves are buffered and flushed
4076
+ * on connect (mirrors the `updateCaps`/`latestCaps` pattern).
4101
4077
  */
4102
- async function callWithServiceDiscovery(broker, serviceName, action, params, opts, discoveryTimeoutMs = DEFAULT_DISCOVERY_TIMEOUT_MS$1) {
4103
- try {
4104
- return await broker.call(action, params, opts);
4105
- } catch (err) {
4106
- if (!isDiscoveryError(err)) throw err;
4107
- await broker.waitForServices([serviceName], discoveryTimeoutMs);
4108
- return await broker.call(action, params, opts);
4078
+ var LocalChildClient = class {
4079
+ options;
4080
+ client = null;
4081
+ channel = null;
4082
+ /**
4083
+ * The cap set `start()` will register, kept current by `updateCaps`. Native
4084
+ * device caps register on device-restore, which can race AHEAD of the UDS
4085
+ * connect — buffering here lets a pre-start `updateCaps` survive (start sends
4086
+ * the latest set) instead of being lost or throwing.
4087
+ */
4088
+ latestCaps;
4089
+ /** Events and logs queued while the channel is not yet open. */
4090
+ pendingEmits = [];
4091
+ /** Handler for parent→child events. Registered via `onEvent`. */
4092
+ eventHandler = null;
4093
+ /**
4094
+ * Handler for parent→child addon-level calls (`routes` / `custom`).
4095
+ * Registered via `onAddonCall`. Resolves the loaded addon instance and
4096
+ * invokes its `addon-routes.getRoutes()` or its custom-action handler.
4097
+ * Replaces the per-addon Moleculer `getRoutes` / `custom.<action>` actions
4098
+ * removed in F1/F2. Only one handler is active at a time.
4099
+ */
4100
+ addonCallHandler = null;
4101
+ /**
4102
+ * Handler for `set-log-level` messages pushed by the parent.
4103
+ * Registered via `onSetLogLevel`. The handler applies the new level to the
4104
+ * child's local Moleculer logger (mirrors `$node-mgmt.setLogLevel`).
4105
+ * E2: wired in `addon-runner.ts` to forward to the broker logger.
4106
+ */
4107
+ setLogLevelHandler = null;
4108
+ /** Callbacks registered via `onConnected`. Fired on every (re)connect. */
4109
+ connectedHandlers = [];
4110
+ constructor(options) {
4111
+ this.options = options;
4112
+ this.latestCaps = options.caps;
4109
4113
  }
4110
- }
4111
- //#endregion
4112
- //#region src/kernel/transport/cap-route-resolver.ts
4113
- /** The Moleculer service name that Task 6's agent registers. */
4114
- var AGENT_CAP_FWD_SERVICE = "$agent-cap-fwd";
4115
- /** The Moleculer action (service.action) for agent cap forwarding. */
4116
- var AGENT_CAP_FWD_ACTION = `${AGENT_CAP_FWD_SERVICE}.forward`;
4117
- /** Default timeout for remote Moleculer cap calls (ms). */
4118
- var REMOTE_CALL_TIMEOUT_MS = 6e4;
4119
- function extractDeviceId(args) {
4120
- if (args === null || typeof args !== "object") return void 0;
4121
- const raw = Reflect.get(args, "deviceId");
4122
- return typeof raw === "number" ? raw : void 0;
4123
- }
4124
- var CapRouteResolver = class {
4125
- hubNodeId;
4126
- broker;
4127
- hubLocalRegistry;
4128
- nodeAuthority;
4129
- inProcessProviders;
4130
- snapshot;
4131
- constructor(deps) {
4132
- this.hubNodeId = deps.hubNodeId;
4133
- this.broker = deps.broker;
4134
- this.hubLocalRegistry = deps.hubLocalRegistry;
4135
- this.nodeAuthority = deps.nodeAuthority;
4136
- this.inProcessProviders = deps.inProcessProviders;
4137
- this.snapshot = this.buildSnapshot();
4114
+ /**
4115
+ * Register a callback that fires each time the client successfully connects
4116
+ * (or reconnects) to its parent. Multiple handlers may be registered; all
4117
+ * are called in registration order. Used by readiness-context in UDS mode
4118
+ * to trigger a snapshot hydrate on connect/reconnect.
4119
+ */
4120
+ onConnected(handler) {
4121
+ this.connectedHandlers.push(handler);
4122
+ }
4123
+ /**
4124
+ * Register a handler for events pushed from the parent to this child.
4125
+ * Must be called before `start()` to avoid missing early events (though
4126
+ * registration after start is also safe for events not yet delivered).
4127
+ * Replaces any previously registered handler.
4128
+ */
4129
+ onEvent(handler) {
4130
+ this.eventHandler = handler;
4131
+ }
4132
+ /**
4133
+ * Register the handler invoked when the parent sends an `addon-call` (the
4134
+ * addon-level routes / custom-action plane). The addon-runner wires this to
4135
+ * resolve the loaded addon by id and dispatch to its `getRoutes()` or its
4136
+ * custom-action handler. Safe to register before or after `start()`;
4137
+ * replaces any prior handler.
4138
+ */
4139
+ onAddonCall(handler) {
4140
+ this.addonCallHandler = handler;
4141
+ }
4142
+ /**
4143
+ * E2: Register a handler invoked when the parent sends a `set-log-level`
4144
+ * message to this child. The handler should forward the new level to the
4145
+ * child's local Moleculer broker logger (mirrors `$node-mgmt.setLogLevel`).
4146
+ * Safe to register before or after `start()`. Replaces any prior handler.
4147
+ */
4148
+ onSetLogLevel(handler) {
4149
+ this.setLogLevelHandler = handler;
4150
+ }
4151
+ /**
4152
+ * True once `start()` has successfully connected and registered. Used by
4153
+ * callers (event-bus/logger bridges) to know the channel is live.
4154
+ */
4155
+ get isConnected() {
4156
+ return this.channel !== null;
4138
4157
  }
4139
- resolveCapRoute(capName, opts) {
4140
- return classifyCapRoute(capName, opts, this.snapshot);
4158
+ async start() {
4159
+ if (this.client !== null) throw new Error("LocalChildClient: already started — call close() first");
4160
+ const client = createLocalTransport().createClient(this.options.nodeId);
4161
+ const channel = await client.connect();
4162
+ channel.onRequest(async (body) => {
4163
+ const msg = body;
4164
+ if (msg.kind === "cap-call") return this.options.dispatch({
4165
+ capName: msg.capName,
4166
+ method: msg.method,
4167
+ args: msg.args,
4168
+ ...msg.deviceId !== void 0 ? { deviceId: msg.deviceId } : {}
4169
+ });
4170
+ if (msg.kind === "addon-call") {
4171
+ if (this.addonCallHandler === null) throw new Error(`LocalChildClient: addon-call for "${msg.addonId}" arrived but no onAddonCall handler is registered`);
4172
+ return this.addonCallHandler({
4173
+ addonId: msg.addonId,
4174
+ target: msg.target,
4175
+ ...msg.action !== void 0 ? { action: msg.action } : {},
4176
+ ...msg.method !== void 0 ? { method: msg.method } : {},
4177
+ ...msg.args !== void 0 ? { args: msg.args } : {}
4178
+ });
4179
+ }
4180
+ throw new Error(`unknown parent request kind: ${msg.kind}`);
4181
+ });
4182
+ channel.onEvent((body) => {
4183
+ const msg = body;
4184
+ if (msg.kind === "event") {
4185
+ this.eventHandler?.(msg.event);
4186
+ return;
4187
+ }
4188
+ if (msg.kind === "set-log-level") this.setLogLevelHandler?.(msg.level);
4189
+ });
4190
+ const register = {
4191
+ kind: "register",
4192
+ childId: this.options.childId,
4193
+ caps: this.latestCaps
4194
+ };
4195
+ try {
4196
+ await channel.request(register);
4197
+ } catch (err) {
4198
+ await client.close();
4199
+ throw err;
4200
+ }
4201
+ this.client = client;
4202
+ this.channel = channel;
4203
+ this.flushPending(channel);
4204
+ for (const cb of this.connectedHandlers) cb();
4205
+ }
4206
+ /** Flush buffered pre-start emits over the now-open channel. */
4207
+ flushPending(channel) {
4208
+ for (const item of this.pendingEmits) channel.emit(item.msg);
4209
+ this.pendingEmits.length = 0;
4141
4210
  }
4142
4211
  /**
4143
- * Resolve the hub-local-uds route for a cap owned by a forked hub-local
4144
- * child, IGNORING any in-hub provider registered for the same cap name.
4145
- *
4146
- * `resolveCapRoute` gives Priority 1 to `hub-in-process`: when an in-hub
4147
- * provider (e.g. a wrapper) is registered for the cap, the route always
4148
- * classifies as `hub-in-process` and the hub-local-uds NATIVE child is never
4149
- * reached. That is correct for the generic dispatch path (the wrapper is the
4150
- * active provider), but WRONG for the native-cap fallback
4151
- * (`setNativeFallback`), whose contract is to reach the NATIVE provider in
4152
- * the forked vendor child so a wrapper can delegate to it. A wrapper cap
4153
- * with a forked native (today: `snapshot`) otherwise resolves to the wrapper
4154
- * itself, the native is never invoked, and the wrapper silently falls
4155
- * through to its secondary strategy.
4212
+ * Re-send the cap manifest to the parent, atomically replacing the child's
4213
+ * registered descriptor set. Call this after device-restore so newly
4214
+ * registered native (device-scoped) caps become routable over UDS.
4156
4215
  *
4157
- * This method consults ONLY the hub-local-child authority
4158
- * (`hubLocalChildProvides` + `getHubLocalChildId`, both deviceId-aware), so
4159
- * it returns the native child route regardless of any in-process shadow.
4160
- * Returns null when no hub-local child owns `(capName, deviceId)` — the
4161
- * caller then falls through to its remote-resolution branch.
4216
+ * Safe to call before `start()`: the new set is buffered and `start()` sends
4217
+ * it (device-restore can race ahead of the UDS connect). After `start()`,
4218
+ * the set is sent immediately.
4162
4219
  */
4163
- resolveHubLocalUdsRoute(capName, deviceId) {
4164
- if (!this.snapshot.hubLocalChildProvides(capName, deviceId)) return null;
4165
- const childId = this.snapshot.getHubLocalChildId?.(capName, deviceId) ?? null;
4166
- if (childId === null) return null;
4167
- return {
4168
- kind: "hub-local-uds",
4169
- capName,
4170
- childId
4220
+ async updateCaps(caps) {
4221
+ this.latestCaps = caps;
4222
+ if (this.channel === null) return;
4223
+ const register = {
4224
+ kind: "register",
4225
+ childId: this.options.childId,
4226
+ caps
4171
4227
  };
4228
+ await this.channel.request(register);
4172
4229
  }
4173
- async dispatch(route, method, args) {
4174
- try {
4175
- return await this.dispatchInner(route, method, args);
4176
- } catch (err) {
4177
- if (err instanceof CapRouteError) throw err;
4178
- const nodeId = this.routeNodeId(route);
4179
- const cause = err instanceof Error ? err : new Error(String(err));
4180
- throw new CapRouteError(route.capName, method, {
4181
- reason: "transport-failed",
4182
- nodeId,
4183
- rejected: [{
4184
- kind: route.kind,
4185
- why: cause.message
4186
- }]
4187
- }, cause);
4188
- }
4230
+ /**
4231
+ * Fire-and-forget: send a system event to the parent for forwarding to the
4232
+ * hub event bus. Safe to call before `start()` events are buffered and
4233
+ * flushed on connect.
4234
+ */
4235
+ emitEvent(event) {
4236
+ const msg = {
4237
+ kind: "event",
4238
+ event
4239
+ };
4240
+ if (this.channel !== null) this.channel.emit(msg);
4241
+ else this.pendingEmits.push({
4242
+ kind: "event",
4243
+ msg
4244
+ });
4189
4245
  }
4190
4246
  /**
4191
- * Inner dispatch: may throw CapRouteError (validation failures) or arbitrary
4192
- * transport errors. The outer `dispatch` wraps non-CapRouteErrors.
4247
+ * Fire-and-forget: send a structured log entry to the parent. Safe to call
4248
+ * before `start()` log entries are buffered and flushed on connect.
4193
4249
  */
4194
- async dispatchInner(route, method, args) {
4195
- switch (route.kind) {
4196
- case "hub-in-process": return route.ref.invoke(method, args);
4197
- case "hub-local-uds": {
4198
- const registry = this.hubLocalRegistry;
4199
- if (registry === null) throw new CapRouteError(route.capName, method, {
4200
- reason: "no-provider",
4201
- rejected: [{
4202
- kind: "hub-local-uds",
4203
- why: "UDS registry not available"
4204
- }]
4205
- });
4206
- const deviceId = extractDeviceId(args);
4207
- const input = {
4208
- capName: route.capName,
4209
- method,
4210
- args,
4211
- ...deviceId !== void 0 ? { deviceId } : {}
4212
- };
4213
- return registry.callCapOnChild(route.childId, input);
4214
- }
4215
- case "remote-moleculer": {
4216
- const addonId = this.nodeAuthority.getAddonId(route.nodeId, route.capName);
4217
- if (addonId === null) throw new CapRouteError(route.capName, method, {
4218
- reason: "no-provider",
4219
- nodeId: route.nodeId,
4220
- rejected: [{
4221
- kind: "remote-moleculer",
4222
- why: `no addonId known for ${route.nodeId}/${route.capName}`
4223
- }]
4224
- });
4225
- const deviceId = extractDeviceId(args);
4226
- const isNative = this.nodeAuthority.isNativeCap(route.nodeId, route.capName, deviceId);
4227
- const action = capActionName(addonId, route.capName, method, isNative);
4228
- return callWithServiceDiscovery(this.broker, addonId, action, args, {
4229
- nodeID: route.nodeId,
4230
- timeout: REMOTE_CALL_TIMEOUT_MS
4231
- });
4232
- }
4233
- case "agent-child-forward": {
4234
- const deviceId = extractDeviceId(args);
4235
- const params = {
4236
- capName: route.capName,
4237
- method,
4238
- args,
4239
- ...route.childId !== void 0 ? { childId: route.childId } : {},
4240
- ...deviceId !== void 0 ? { deviceId } : {}
4241
- };
4242
- return callWithServiceDiscovery(this.broker, AGENT_CAP_FWD_SERVICE, AGENT_CAP_FWD_ACTION, params, {
4243
- nodeID: route.agentNodeId,
4244
- timeout: REMOTE_CALL_TIMEOUT_MS
4245
- });
4246
- }
4247
- }
4250
+ sendLog(entry) {
4251
+ const msg = {
4252
+ kind: "log",
4253
+ ...entry
4254
+ };
4255
+ if (this.channel !== null) this.channel.emit(msg);
4256
+ else this.pendingEmits.push({
4257
+ kind: "log",
4258
+ msg
4259
+ });
4248
4260
  }
4249
- buildSnapshot() {
4250
- const hubLocalRegistry = this.hubLocalRegistry;
4251
- const nodeAuthority = this.nodeAuthority;
4252
- const inProcessProviders = this.inProcessProviders;
4253
- return {
4254
- hubNodeId: this.hubNodeId,
4255
- hubInProcessProvides: (cap) => inProcessProviders(cap) !== null,
4256
- hubLocalChildProvides: (cap, deviceId) => hubLocalRegistry !== null && hubLocalRegistry.resolveChildId(cap, deviceId) !== null,
4257
- nodeKnowsCap: (nodeId, cap) => nodeAuthority.nodeKnowsCap(nodeId, cap),
4258
- nodeIsAgent: (nodeId) => nodeAuthority.nodeIsAgent(nodeId),
4259
- nodeOnline: (nodeId) => nodeAuthority.nodeOnline(nodeId),
4260
- listKnownNodeIds: () => nodeAuthority.listNodeIds(),
4261
- getInProcessProviderRef: (cap) => inProcessProviders(cap),
4262
- getHubLocalChildId: (cap, deviceId) => hubLocalRegistry !== null ? hubLocalRegistry.resolveChildId(cap, deviceId) : null,
4263
- getAgentChildId: (agentNodeId, cap) => nodeAuthority.getAgentChildId(agentNodeId, cap)
4261
+ /**
4262
+ * Request the current readiness snapshot from the parent. Returns the
4263
+ * authoritative set of `IReadinessRegistryRecord` entries the hub holds.
4264
+ * Requires `start()` to have been called; throws with a clear message if not.
4265
+ */
4266
+ async requestReadinessSnapshot() {
4267
+ if (this.channel === null) throw new Error("LocalChildClient: requestReadinessSnapshot called before start()");
4268
+ return (await this.channel.request({ kind: "readiness-request" })).records;
4269
+ }
4270
+ /**
4271
+ * Ask the parent to execute a cap call this child does NOT own. The parent
4272
+ * routes to the owning local sibling over UDS, or — if no sibling owns it —
4273
+ * to its `onUnownedCall` fallback (the cluster CapabilityRegistry in
4274
+ * production). Throws if called before `start()`.
4275
+ */
4276
+ async callOut(input) {
4277
+ if (this.channel === null) throw new Error("LocalChildClient: callOut before start");
4278
+ const msg = {
4279
+ kind: "cap-call-out",
4280
+ capName: input.capName,
4281
+ method: input.method,
4282
+ args: input.args,
4283
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
4284
+ ...input.nodeId !== void 0 ? { nodeId: input.nodeId } : {}
4264
4285
  };
4286
+ return this.channel.request(msg);
4265
4287
  }
4266
- /** Extract a nodeId string from a route for error reporting, or undefined. */
4267
- routeNodeId(route) {
4268
- switch (route.kind) {
4269
- case "hub-in-process": return this.hubNodeId;
4270
- case "hub-local-uds": return `${this.hubNodeId}/${route.childId}`;
4271
- case "remote-moleculer": return route.nodeId;
4272
- case "agent-child-forward": return route.agentNodeId;
4273
- }
4288
+ /** Disconnect from the parent. Safe to call before `start()` (no-op) and idempotent. */
4289
+ async close() {
4290
+ await this.client?.close();
4291
+ this.client = null;
4292
+ this.channel = null;
4274
4293
  }
4275
4294
  };
4276
4295
  //#endregion
@@ -4586,10 +4605,11 @@ var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4586
4605
  function createParentUnownedCallHandler(deps) {
4587
4606
  return async (input) => {
4588
4607
  const deviceId = input.deviceId ?? extractDeviceId(input.args);
4608
+ const nodeId = input.nodeId ?? extractNodeId(input.args);
4589
4609
  const resolver = deps.getResolver();
4590
4610
  if (resolver !== null) try {
4591
4611
  const route = resolver.resolveCapRoute(input.capName, {
4592
- ...input.nodeId !== void 0 ? { nodeId: input.nodeId } : {},
4612
+ ...nodeId !== void 0 ? { nodeId } : {},
4593
4613
  ...deviceId !== void 0 ? { deviceId } : {}
4594
4614
  });
4595
4615
  return await resolver.dispatch(route, input.method, input.args);
@@ -4666,10 +4686,15 @@ function parsePath(path) {
4666
4686
  * on a forked worker that has a co-located pipeline-executor.
4667
4687
  */
4668
4688
  function localProviderLink(resolver, observerOpts) {
4689
+ const localBase = resolver.localNodeId !== void 0 ? resolver.localNodeId.split("/")[0] ?? resolver.localNodeId : void 0;
4669
4690
  return () => {
4670
4691
  return ({ op, next }) => {
4671
4692
  const parsed = parsePath(op.path);
4672
4693
  if (!parsed) return next(op);
4694
+ if (localBase !== void 0) {
4695
+ const pinnedNodeId = readNodePin(op.context);
4696
+ if (pinnedNodeId !== void 0 && pinnedNodeId !== localBase) return next(op);
4697
+ }
4673
4698
  const provider = resolver.getByName(parsed.capName);
4674
4699
  if (!provider || typeof provider !== "object") return next(op);
4675
4700
  const fn = Reflect.get(provider, parsed.method);
@@ -6218,15 +6243,18 @@ async function buildAddonContext(runtime, declaration, dataDir, options) {
6218
6243
  fs.mkdirSync(addonDataDir, { recursive: true });
6219
6244
  if (runtime.mode === "broker") ensureBindingSubscription(runtime.broker);
6220
6245
  const regs = getRegistries(nodeId);
6221
- const workerResolver = { getByName(capabilityName) {
6222
- const preferred = regs.preferred.get(capabilityName);
6223
- if (preferred) {
6224
- const provider = regs.byAddonId.get(capabilityName)?.get(preferred);
6225
- if (provider) return provider;
6246
+ const workerResolver = {
6247
+ localNodeId: nodeId,
6248
+ getByName(capabilityName) {
6249
+ const preferred = regs.preferred.get(capabilityName);
6250
+ if (preferred) {
6251
+ const provider = regs.byAddonId.get(capabilityName)?.get(preferred);
6252
+ if (provider) return provider;
6253
+ }
6254
+ const providers = regs.providers.get(capabilityName);
6255
+ return (providers && providers.length > 0 ? providers[0] : void 0) ?? null;
6226
6256
  }
6227
- const providers = regs.providers.get(capabilityName);
6228
- return (providers && providers.length > 0 ? providers[0] : void 0) ?? null;
6229
- } };
6257
+ };
6230
6258
  const usageRegistry = getCapUsageRegistry();
6231
6259
  const observerOpts = {
6232
6260
  callerAddonId: addonId,
@@ -6546,4 +6574,4 @@ async function installManifestPythonDeps(declaration, addonDir, deps, logger) {
6546
6574
  await deps.installPythonRequirements(reqAbs);
6547
6575
  }
6548
6576
  //#endregion
6549
- export { setWorkerNativeCapsChangeListener as $, createUdsLoggerWithControl as A, createLocalTransport as B, ipcParentLink as C, createUdsEventBus as D, createUdsEventBridge as E, CapRouteError as F, FrameDecoder as G, UdsLocalTransportServer as H, classifyCapRoute as I, buildUdsNativeCapProxy as J, encodeFrame as K, LocalChildClient as L, AGENT_CAP_FWD_SERVICE as M, CapRouteResolver as N, udsChildLogToWorkerEntry as O, callWithServiceDiscovery as P, mountNativeCapService as Q, LocalChildRegistry as R, ipcChildLink as S, createParentUnownedCallHandler as T, SocketChannel as U, UdsLocalTransportClient as V, localEndpointPath as W, getWorkerNativeCapProvider as X, createBrokerDeviceManagerApi as Y, getWorkerNativeCapSnapshot as Z, __resetCapUsageRegistryForTests as _, getWorkerDeviceRegistry as a, capBareAction as at, brokerTransportLink as b, setHubConnected as c, deserializeTypedArrays as ct, registerEventBusService as d, CapabilityHandle as dt, createAddonService as et, AddonDepsManager as f, CapabilityUnavailableError as ft, CapUsageRegistry as g, createHwAccelService as h, createUdsAddonContext as i, capActionSuffix as it, AGENT_CAP_FWD_ACTION as j, createUdsLogger as k, EVENT_TOPIC_PREFIX as l, serializeTypedArrays as lt, resolveHwAccel as m, resolveAddonClass as mt, adaptBrokerToCluster as n, NATIVE_PROVIDER_SERVICE_INFIX as nt, getOrInitReadinessRegistry as o, capServiceName as ot, createKernelHwAccel as p, installManifestNativeDeps as pt, buildNativeCapProxy as q, createAddonContext as r, capActionName as rt, getOrInitReadinessRegistryForClient as s, parseCapAction as st, installManifestPythonDeps as t, validateProviderRegistrations as tt, getBrokerEventBus as u, DeviceRegistry as ut, getCapUsageRegistry as v, localProviderLink as w, buildLinkChain as x, brokerCallForCap as y, UDS_NO_ROUTE_PREFIX as z };
6577
+ export { setWorkerNativeCapsChangeListener as $, createUdsLoggerWithControl as A, createLocalTransport as B, ipcParentLink as C, createUdsEventBus as D, createUdsEventBridge as E, AGENT_CAP_FWD_SERVICE as F, FrameDecoder as G, UdsLocalTransportServer as H, CapRouteResolver as I, buildUdsNativeCapProxy as J, encodeFrame as K, CapRouteError as L, LocalChildRegistry as M, UDS_NO_ROUTE_PREFIX as N, udsChildLogToWorkerEntry as O, AGENT_CAP_FWD_ACTION as P, mountNativeCapService as Q, classifyCapRoute as R, ipcChildLink as S, createParentUnownedCallHandler as T, SocketChannel as U, UdsLocalTransportClient as V, localEndpointPath as W, getWorkerNativeCapProvider as X, createBrokerDeviceManagerApi as Y, getWorkerNativeCapSnapshot as Z, __resetCapUsageRegistryForTests as _, getWorkerDeviceRegistry as a, capBareAction as at, brokerTransportLink as b, setHubConnected as c, deserializeTypedArrays as ct, registerEventBusService as d, CapabilityHandle as dt, createAddonService as et, AddonDepsManager as f, CapabilityUnavailableError as ft, CapUsageRegistry as g, createHwAccelService as h, createUdsAddonContext as i, capActionSuffix as it, LocalChildClient as j, createUdsLogger as k, EVENT_TOPIC_PREFIX as l, serializeTypedArrays as lt, resolveHwAccel as m, resolveAddonClass as mt, adaptBrokerToCluster as n, NATIVE_PROVIDER_SERVICE_INFIX as nt, getOrInitReadinessRegistry as o, capServiceName as ot, createKernelHwAccel as p, installManifestNativeDeps as pt, buildNativeCapProxy as q, createAddonContext as r, capActionName as rt, getOrInitReadinessRegistryForClient as s, parseCapAction as st, installManifestPythonDeps as t, validateProviderRegistrations as tt, getBrokerEventBus as u, DeviceRegistry as ut, getCapUsageRegistry as v, localProviderLink as w, buildLinkChain as x, brokerCallForCap as y, callWithServiceDiscovery as z };