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