@openscout/runtime 0.2.38 → 0.2.39

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.
@@ -7,11 +7,12 @@ import { FileBackedBrokerJournal } from "./broker-journal.js";
7
7
  import { buildDispatchEnvelope, resolveAgentLabel, } from "./scout-dispatcher.js";
8
8
  import { buildCollaborationInvocation } from "./collaboration-invocations.js";
9
9
  import { discoverMeshNodes } from "./mesh-discovery.js";
10
- import { buildMeshCollaborationEventBundle, buildMeshCollaborationRecordBundle, buildMeshInvocationBundle, buildMeshMessageBundle, forwardMeshCollaborationEvent, forwardMeshCollaborationRecord, forwardMeshInvocation, fetchPeerAgents, forwardMeshMessage, } from "./mesh-forwarding.js";
10
+ import { buildMeshCollaborationEventBundle, buildMeshCollaborationRecordBundle, buildMeshMessageBundle, forwardMeshCollaborationEvent, forwardMeshCollaborationRecord, fetchPeerAgents, forwardMeshMessage, } from "./mesh-forwarding.js";
11
+ import { createPeerDeliveryWorker } from "./peer-delivery.js";
11
12
  import { ensureLocalAgentBindingOnline, isLocalAgentEndpointAlive, isLocalAgentSessionAlive, invokeLocalAgentEndpoint, loadRegisteredLocalAgentBindings, shouldDisableGeneratedCodexEndpoint, } from "./local-agents.js";
12
13
  import { RecoverableSQLiteProjection } from "./sqlite-projection.js";
13
14
  import { ensureOpenScoutCleanSlateSync } from "./support-paths.js";
14
- import { buildDefaultBrokerUrl, DEFAULT_BROKER_HOST, DEFAULT_BROKER_PORT, } from "./broker-service.js";
15
+ import { buildDefaultBrokerUrl, DEFAULT_BROKER_PORT, isLoopbackHost, resolveAdvertiseScope, resolveBrokerHost, } from "./broker-service.js";
15
16
  function createRuntimeId(prefix) {
16
17
  return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
17
18
  }
@@ -52,7 +53,8 @@ const controlHome = resolveControlPlaneHome();
52
53
  const dbPath = join(controlHome, "control-plane.sqlite");
53
54
  const journalPath = join(controlHome, "broker-journal.jsonl");
54
55
  const port = Number.parseInt(process.env.OPENSCOUT_BROKER_PORT ?? String(DEFAULT_BROKER_PORT), 10);
55
- const host = process.env.OPENSCOUT_BROKER_HOST ?? DEFAULT_BROKER_HOST;
56
+ const advertiseScope = resolveAdvertiseScope();
57
+ const host = resolveBrokerHost(advertiseScope);
56
58
  const meshId = process.env.OPENSCOUT_MESH_ID ?? "openscout";
57
59
  const nodeName = process.env.OPENSCOUT_NODE_NAME ?? hostname();
58
60
  const tailnetName = process.env.TAILSCALE_TAILNET ?? undefined;
@@ -85,20 +87,59 @@ const activeInvocationTasks = new Map();
85
87
  const knownInvocations = new Map();
86
88
  const sseKeepAliveIntervalMs = Number.parseInt(process.env.OPENSCOUT_SSE_KEEPALIVE_MS ?? "15000", 10);
87
89
  const operatorActorId = "operator";
90
+ // Per-invocation SSE subscribers. A single caller can watch one invocation
91
+ // without draining the entire global event firehose.
92
+ const invocationStreamClients = new Map();
93
+ function invocationIdsForEvent(event) {
94
+ switch (event.kind) {
95
+ case "invocation.requested":
96
+ return [event.payload.invocation.id];
97
+ case "flight.updated":
98
+ return event.payload.flight.invocationId ? [event.payload.flight.invocationId] : [];
99
+ case "delivery.planned":
100
+ return event.payload.delivery.invocationId ? [event.payload.delivery.invocationId] : [];
101
+ case "delivery.attempted": {
102
+ const deliveryId = event.payload.attempt.deliveryId;
103
+ const delivery = journal.listDeliveries({ limit: 1000 }).find((d) => d.id === deliveryId);
104
+ return delivery?.invocationId ? [delivery.invocationId] : [];
105
+ }
106
+ case "delivery.state.changed":
107
+ return event.payload.delivery.invocationId ? [event.payload.delivery.invocationId] : [];
108
+ case "scout.dispatched":
109
+ return event.payload.dispatch.invocationId ? [event.payload.dispatch.invocationId] : [];
110
+ case "message.posted": {
111
+ const dispatch = event.payload.message.metadata
112
+ ?.scoutDispatch;
113
+ return dispatch?.invocationId ? [dispatch.invocationId] : [];
114
+ }
115
+ default:
116
+ return [];
117
+ }
118
+ }
88
119
  function streamEvent(event) {
89
120
  projection.enqueueEvent(event);
90
121
  const payload = `event: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`;
91
122
  for (const client of eventClients) {
92
123
  client.write(payload);
93
124
  }
125
+ for (const invocationId of invocationIdsForEvent(event)) {
126
+ const subscribers = invocationStreamClients.get(invocationId);
127
+ if (!subscribers)
128
+ continue;
129
+ for (const client of subscribers) {
130
+ client.write(payload);
131
+ }
132
+ }
94
133
  }
95
134
  function streamKeepAlive() {
96
- if (eventClients.size == 0) {
97
- return;
98
- }
99
135
  for (const client of eventClients) {
100
136
  client.write(": keepalive\n\n");
101
137
  }
138
+ for (const subscribers of invocationStreamClients.values()) {
139
+ for (const client of subscribers) {
140
+ client.write(": keepalive\n\n");
141
+ }
142
+ }
102
143
  }
103
144
  runtime.subscribe((event) => {
104
145
  streamEvent(event);
@@ -113,7 +154,7 @@ const localNode = {
113
154
  meshId,
114
155
  name: nodeName,
115
156
  hostName: hostname(),
116
- advertiseScope: host === DEFAULT_BROKER_HOST ? "local" : "mesh",
157
+ advertiseScope,
117
158
  brokerUrl,
118
159
  tailnetName,
119
160
  capabilities: ["broker", "mesh", "local_runtime"],
@@ -144,6 +185,9 @@ async function discoverPeers(seeds = []) {
144
185
  });
145
186
  for (const node of result.discovered) {
146
187
  await upsertNodeDurably(node);
188
+ // A previously-unreachable peer may have come back — flush any deferred
189
+ // outbox entries targeting it without waiting for the next backoff window.
190
+ peerDelivery.notifyPeerOnline(node.id);
147
191
  }
148
192
  // Sync agents from each discovered peer so local broker knows about remote agents.
149
193
  // This enables @mention resolution and message forwarding across the mesh.
@@ -351,6 +395,11 @@ async function recordFlightDurably(flight) {
351
395
  });
352
396
  });
353
397
  }
398
+ async function recordDeliveryDurably(delivery) {
399
+ await runDurableWrite(async () => {
400
+ await commitDurableEntries({ kind: "deliveries.record", deliveries: [delivery] }, async () => { });
401
+ });
402
+ }
354
403
  async function recordDeliveryAttemptDurably(attempt) {
355
404
  await runDurableWrite(async () => {
356
405
  await commitDurableEntries({
@@ -1247,27 +1296,6 @@ async function forwardPeerBrokerCollaborationEvent(event) {
1247
1296
  }
1248
1297
  return { forwarded, failed };
1249
1298
  }
1250
- async function maybeForwardInvocation(invocation) {
1251
- const targetAgent = runtime.agent(invocation.targetAgentId);
1252
- if (!targetAgent) {
1253
- throw new Error(`unknown agent ${invocation.targetAgentId}`);
1254
- }
1255
- if (targetAgent.authorityNodeId === nodeId) {
1256
- return { forwarded: false };
1257
- }
1258
- const authorityNode = runtime.node(targetAgent.authorityNodeId);
1259
- if (!authorityNode?.brokerUrl) {
1260
- throw new Error(`authority node ${targetAgent.authorityNodeId} is not reachable`);
1261
- }
1262
- const bundle = buildMeshInvocationBundle(runtime.peek(), currentLocalNode(), invocation);
1263
- const result = await forwardMeshInvocation(authorityNode.brokerUrl, bundle);
1264
- const { entries } = await recordInvocationDurably(invocation, {
1265
- flight: result.flight,
1266
- enqueueProjection: false,
1267
- });
1268
- projection.enqueueEntries(entries);
1269
- return { forwarded: true, flight: result.flight };
1270
- }
1271
1299
  function parseLimit(url) {
1272
1300
  const limit = Number.parseInt(url.searchParams.get("limit") ?? "100", 10);
1273
1301
  if (!Number.isFinite(limit) || limit <= 0)
@@ -1382,23 +1410,20 @@ async function handleCommand(command) {
1382
1410
  return { ok: true, message: command.message, deliveries, mesh };
1383
1411
  }
1384
1412
  case "agent.invoke": {
1385
- const forwarded = await maybeForwardInvocation(command.invocation);
1386
- if (forwarded.forwarded) {
1387
- console.log(`[openscout-runtime] invocation ${command.invocation.id} forwarded to ${command.invocation.targetAgentId}`);
1388
- return { ok: true, flight: forwarded.flight, forwarded: true };
1389
- }
1390
- const { flight, entries } = await recordInvocationDurably(command.invocation, {
1391
- enqueueProjection: false,
1413
+ const flight = await acceptInvocationDurably(command.invocation);
1414
+ console.log(`[openscout-runtime] invocation ${command.invocation.id} accepted for ${command.invocation.targetAgentId} (state=${flight.state})`);
1415
+ dispatchAcceptedInvocation(command.invocation).catch((error) => {
1416
+ console.error(`[openscout-runtime] background dispatch failed for invocation ${command.invocation.id}:`, error);
1392
1417
  });
1393
- console.log(`[openscout-runtime] invocation ${command.invocation.id} -> ${command.invocation.targetAgentId} is ${flight.state}${flight.summary ? ` (${flight.summary})` : ""}`);
1394
- if (flight.state === "failed") {
1395
- await postInvocationStatusMessage(command.invocation, flight);
1396
- }
1397
- else {
1398
- launchLocalInvocation(command.invocation, flight);
1399
- }
1400
- projection.enqueueEntries(entries);
1401
- return { ok: true, flight };
1418
+ return {
1419
+ ok: true,
1420
+ accepted: true,
1421
+ invocationId: command.invocation.id,
1422
+ flightId: flight.id,
1423
+ targetAgentId: command.invocation.targetAgentId,
1424
+ state: flight.state,
1425
+ flight,
1426
+ };
1402
1427
  }
1403
1428
  case "agent.ensure_awake":
1404
1429
  await runtime.dispatch(command);
@@ -1411,6 +1436,76 @@ async function handleCommand(command) {
1411
1436
  }
1412
1437
  }
1413
1438
  }
1439
+ async function acceptInvocationDurably(invocation) {
1440
+ const { flight, entries } = await recordInvocationDurably(invocation, {
1441
+ enqueueProjection: false,
1442
+ });
1443
+ projection.enqueueEntries(entries);
1444
+ return flight;
1445
+ }
1446
+ async function dispatchAcceptedInvocation(invocation) {
1447
+ const targetAgent = runtime.agent(invocation.targetAgentId);
1448
+ if (!targetAgent) {
1449
+ await failAcceptedInvocation(invocation, `unknown agent ${invocation.targetAgentId}`);
1450
+ return;
1451
+ }
1452
+ const flight = runtime.flightForInvocation(invocation.id);
1453
+ if (!flight) {
1454
+ console.warn(`[openscout-runtime] dispatch skipped — flight missing for invocation ${invocation.id}`);
1455
+ return;
1456
+ }
1457
+ if (targetAgent.authorityNodeId && targetAgent.authorityNodeId !== nodeId) {
1458
+ // Cross-node: hand off to the outbox worker. Peer reachability is now a
1459
+ // delivery concern (deferred ↔ accepted retries), not an HTTP error.
1460
+ const authorityNode = runtime.node(targetAgent.authorityNodeId);
1461
+ if (!authorityNode) {
1462
+ await failAcceptedInvocation(invocation, `authority node ${targetAgent.authorityNodeId} is not reachable`);
1463
+ return;
1464
+ }
1465
+ await peerDelivery.enqueue(invocation, authorityNode);
1466
+ return;
1467
+ }
1468
+ // Local authority — run the normal launch path.
1469
+ if (flight.state === "failed") {
1470
+ await postInvocationStatusMessage(invocation, flight);
1471
+ }
1472
+ else {
1473
+ launchLocalInvocation(invocation, flight);
1474
+ }
1475
+ }
1476
+ async function failAcceptedInvocation(invocation, detail) {
1477
+ const now = Date.now();
1478
+ const existing = runtime.flightForInvocation(invocation.id);
1479
+ const failed = {
1480
+ id: existing?.id ?? createRuntimeId("flt"),
1481
+ invocationId: invocation.id,
1482
+ requesterId: invocation.requesterId,
1483
+ targetAgentId: invocation.targetAgentId,
1484
+ state: "failed",
1485
+ startedAt: existing?.startedAt ?? now,
1486
+ completedAt: now,
1487
+ summary: detail,
1488
+ error: detail,
1489
+ metadata: invocation.metadata,
1490
+ };
1491
+ await recordFlightDurably(failed);
1492
+ await postInvocationStatusMessage(invocation, failed);
1493
+ }
1494
+ const peerDelivery = createPeerDeliveryWorker({
1495
+ journal,
1496
+ snapshot: () => runtime.peek(),
1497
+ localNode: currentLocalNode,
1498
+ localNodeId: nodeId,
1499
+ nodeFor: (id) => runtime.node(id),
1500
+ agentFor: (id) => runtime.agent(id),
1501
+ invocationFor: (id) => knownInvocations.get(id),
1502
+ recordDelivery: recordDeliveryDurably,
1503
+ updateDeliveryStatus: updateDeliveryStatusDurably,
1504
+ recordDeliveryAttempt: recordDeliveryAttemptDurably,
1505
+ recordFlight: recordFlightDurably,
1506
+ failInvocation: failAcceptedInvocation,
1507
+ emit: streamEvent,
1508
+ });
1414
1509
  async function routeRequest(request, response) {
1415
1510
  const method = request.method ?? "GET";
1416
1511
  const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `${host}:${port}`}`);
@@ -1524,6 +1619,75 @@ async function routeRequest(request, response) {
1524
1619
  json(response, 200, runtime.snapshot().nodes);
1525
1620
  return;
1526
1621
  }
1622
+ // Per-invocation snapshot — current state of the invocation, its flight,
1623
+ // its deliveries, and any scout dispatch that fired for it. Callers use
1624
+ // this to seed UI state before subscribing to the stream.
1625
+ const invocationSnapshotMatch = method === "GET"
1626
+ ? url.pathname.match(/^\/v1\/invocations\/([^/]+)$/)
1627
+ : null;
1628
+ if (invocationSnapshotMatch) {
1629
+ const invocationId = decodeURIComponent(invocationSnapshotMatch[1] ?? "");
1630
+ const flight = runtime.flightForInvocation(invocationId);
1631
+ const deliveries = journal
1632
+ .listDeliveries({ limit: 500 })
1633
+ .filter((delivery) => delivery.invocationId === invocationId);
1634
+ const dispatches = journal
1635
+ .listScoutDispatches({ limit: 50 })
1636
+ .filter((record) => record.invocationId === invocationId);
1637
+ const invocation = knownInvocations.get(invocationId);
1638
+ json(response, 200, {
1639
+ invocationId,
1640
+ invocation: invocation ?? null,
1641
+ flight: flight ?? null,
1642
+ deliveries,
1643
+ dispatches,
1644
+ });
1645
+ return;
1646
+ }
1647
+ // Per-invocation SSE — initial snapshot frame, then every event whose
1648
+ // payload references this invocation. Caller closes the stream when done.
1649
+ const invocationStreamMatch = method === "GET"
1650
+ ? url.pathname.match(/^\/v1\/invocations\/([^/]+)\/stream$/)
1651
+ : null;
1652
+ if (invocationStreamMatch) {
1653
+ const invocationId = decodeURIComponent(invocationStreamMatch[1] ?? "");
1654
+ response.writeHead(200, {
1655
+ "content-type": "text/event-stream",
1656
+ "cache-control": "no-cache, no-transform",
1657
+ connection: "keep-alive",
1658
+ });
1659
+ const flight = runtime.flightForInvocation(invocationId);
1660
+ const deliveries = journal
1661
+ .listDeliveries({ limit: 500 })
1662
+ .filter((delivery) => delivery.invocationId === invocationId);
1663
+ const dispatches = journal
1664
+ .listScoutDispatches({ limit: 50 })
1665
+ .filter((record) => record.invocationId === invocationId);
1666
+ const invocation = knownInvocations.get(invocationId);
1667
+ response.write(`event: snapshot\ndata: ${JSON.stringify({
1668
+ invocationId,
1669
+ invocation: invocation ?? null,
1670
+ flight: flight ?? null,
1671
+ deliveries,
1672
+ dispatches,
1673
+ })}\n\n`);
1674
+ let subscribers = invocationStreamClients.get(invocationId);
1675
+ if (!subscribers) {
1676
+ subscribers = new Set();
1677
+ invocationStreamClients.set(invocationId, subscribers);
1678
+ }
1679
+ subscribers.add(response);
1680
+ request.on("close", () => {
1681
+ const set = invocationStreamClients.get(invocationId);
1682
+ if (set) {
1683
+ set.delete(response);
1684
+ if (set.size === 0)
1685
+ invocationStreamClients.delete(invocationId);
1686
+ }
1687
+ response.end();
1688
+ });
1689
+ return;
1690
+ }
1527
1691
  if (method === "GET" && url.pathname === "/v1/events/stream") {
1528
1692
  response.writeHead(200, {
1529
1693
  "content-type": "text/event-stream",
@@ -1862,15 +2026,29 @@ async function routeRequest(request, response) {
1862
2026
  conversationId: payload.conversationId,
1863
2027
  requesterId: payload.requesterId,
1864
2028
  });
1865
- json(response, 200, buildScoutDispatchResponse(record, payload));
2029
+ json(response, 202, {
2030
+ accepted: true,
2031
+ invocationId: payload.id,
2032
+ dispatch: record,
2033
+ });
1866
2034
  return;
1867
2035
  }
1868
2036
  const invocation = {
1869
2037
  ...payload,
1870
2038
  targetAgentId: resolved.agent.id,
1871
2039
  };
1872
- const result = await handleCommand({ kind: "agent.invoke", invocation });
1873
- json(response, 200, result);
2040
+ const flight = await acceptInvocationDurably(invocation);
2041
+ json(response, 202, {
2042
+ accepted: true,
2043
+ invocationId: invocation.id,
2044
+ flightId: flight.id,
2045
+ targetAgentId: invocation.targetAgentId,
2046
+ state: flight.state,
2047
+ flight,
2048
+ });
2049
+ dispatchAcceptedInvocation(invocation).catch((error) => {
2050
+ console.error(`[openscout-runtime] background dispatch failed for invocation ${invocation.id}:`, error);
2051
+ });
1874
2052
  }
1875
2053
  catch (error) {
1876
2054
  badRequest(response, error);
@@ -1897,14 +2075,6 @@ function resolveInvocationTarget(payload) {
1897
2075
  helpers: { isStale: isStaleLocalAgent },
1898
2076
  });
1899
2077
  }
1900
- function buildScoutDispatchResponse(record, payload) {
1901
- return {
1902
- ok: false,
1903
- dispatchedTo: SCOUT_DISPATCHER_AGENT_ID,
1904
- invocationId: payload.id,
1905
- dispatch: record,
1906
- };
1907
- }
1908
2078
  const server = createServer((request, response) => {
1909
2079
  routeRequest(request, response).catch((error) => {
1910
2080
  json(response, 500, {
@@ -1915,7 +2085,11 @@ const server = createServer((request, response) => {
1915
2085
  });
1916
2086
  try {
1917
2087
  await listen(server);
1918
- console.log(`[openscout-runtime] broker listening on ${brokerUrl}`);
2088
+ peerDelivery.start();
2089
+ console.log(`[openscout-runtime] broker listening on ${host}:${port} (scope: ${advertiseScope}, url: ${brokerUrl})`);
2090
+ if (advertiseScope === "mesh" && isLoopbackHost(host)) {
2091
+ console.warn(`[openscout-runtime] WARNING: mesh scope bound to loopback ${host} — peers cannot reach this broker. Set OPENSCOUT_BROKER_HOST=0.0.0.0 or unset to use the mesh default.`);
2092
+ }
1919
2093
  console.log(`[openscout-runtime] node ${nodeId} in mesh ${meshId}`);
1920
2094
  console.log(`[openscout-runtime] journal ${journalPath}`);
1921
2095
  console.log(`[openscout-runtime] sqlite ${sqliteDisabled ? "disabled" : dbPath}`);
@@ -1955,9 +2129,16 @@ if (Number.isFinite(discoveryIntervalMs) && discoveryIntervalMs > 0) {
1955
2129
  }
1956
2130
  for (const signal of ["SIGINT", "SIGTERM"]) {
1957
2131
  process.on(signal, () => {
2132
+ peerDelivery.stop();
1958
2133
  for (const client of eventClients) {
1959
2134
  client.end();
1960
2135
  }
2136
+ for (const subscribers of invocationStreamClients.values()) {
2137
+ for (const client of subscribers) {
2138
+ client.end();
2139
+ }
2140
+ }
2141
+ invocationStreamClients.clear();
1961
2142
  projection.close();
1962
2143
  server.close(() => process.exit(0));
1963
2144
  });
@@ -1,4 +1,5 @@
1
1
  export type BrokerServiceMode = "dev" | "prod" | "custom";
2
+ export type BrokerAdvertiseScope = "local" | "mesh";
2
3
  export type BrokerServiceConfig = {
3
4
  label: string;
4
5
  mode: BrokerServiceMode;
@@ -16,6 +17,7 @@ export type BrokerServiceConfig = {
16
17
  brokerHost: string;
17
18
  brokerPort: number;
18
19
  brokerUrl: string;
20
+ advertiseScope: BrokerAdvertiseScope;
19
21
  };
20
22
  export type BrokerHealthSnapshot = {
21
23
  reachable: boolean;
@@ -59,7 +61,12 @@ type LaunchctlStatus = {
59
61
  raw: string;
60
62
  };
61
63
  export declare const DEFAULT_BROKER_HOST = "127.0.0.1";
64
+ export declare const DEFAULT_BROKER_HOST_MESH = "0.0.0.0";
62
65
  export declare const DEFAULT_BROKER_PORT = 65535;
66
+ export declare const DEFAULT_ADVERTISE_SCOPE: BrokerAdvertiseScope;
67
+ export declare function resolveAdvertiseScope(): BrokerAdvertiseScope;
68
+ export declare function resolveBrokerHost(scope?: BrokerAdvertiseScope): string;
69
+ export declare function isLoopbackHost(host: string): boolean;
63
70
  export declare function buildDefaultBrokerUrl(host?: string, port?: number): string;
64
71
  export declare const DEFAULT_BROKER_URL: string;
65
72
  export declare function resolveBrokerServiceConfig(): BrokerServiceConfig;
@@ -9,7 +9,28 @@ function isTmpPath(p) {
9
9
  return /^\/(?:private\/)?tmp\//.test(p);
10
10
  }
11
11
  export const DEFAULT_BROKER_HOST = "127.0.0.1";
12
+ export const DEFAULT_BROKER_HOST_MESH = "0.0.0.0";
12
13
  export const DEFAULT_BROKER_PORT = 65535;
14
+ export const DEFAULT_ADVERTISE_SCOPE = "local";
15
+ export function resolveAdvertiseScope() {
16
+ const raw = (process.env.OPENSCOUT_ADVERTISE_SCOPE ?? "").trim().toLowerCase();
17
+ if (raw === "mesh")
18
+ return "mesh";
19
+ if (raw === "local")
20
+ return "local";
21
+ return DEFAULT_ADVERTISE_SCOPE;
22
+ }
23
+ export function resolveBrokerHost(scope = resolveAdvertiseScope()) {
24
+ const explicit = process.env.OPENSCOUT_BROKER_HOST;
25
+ if (typeof explicit === "string" && explicit.trim().length > 0) {
26
+ return explicit;
27
+ }
28
+ return scope === "mesh" ? DEFAULT_BROKER_HOST_MESH : DEFAULT_BROKER_HOST;
29
+ }
30
+ export function isLoopbackHost(host) {
31
+ const trimmed = host.trim();
32
+ return trimmed === "127.0.0.1" || trimmed === "::1" || trimmed === "localhost";
33
+ }
13
34
  const BROKER_SERVICE_POLL_INTERVAL_MS = 100;
14
35
  const DEFAULT_BROKER_START_TIMEOUT_MS = 15_000;
15
36
  export function buildDefaultBrokerUrl(host = DEFAULT_BROKER_HOST, port = DEFAULT_BROKER_PORT) {
@@ -149,7 +170,8 @@ export function resolveBrokerServiceConfig() {
149
170
  const controlHome = isTmpPath(supportPaths.controlHome)
150
171
  ? join(homedir(), ".openscout", "control-plane")
151
172
  : supportPaths.controlHome;
152
- const brokerHost = process.env.OPENSCOUT_BROKER_HOST ?? DEFAULT_BROKER_HOST;
173
+ const advertiseScope = resolveAdvertiseScope();
174
+ const brokerHost = resolveBrokerHost(advertiseScope);
153
175
  const brokerPort = Number.parseInt(process.env.OPENSCOUT_BROKER_PORT ?? String(DEFAULT_BROKER_PORT), 10);
154
176
  const brokerUrl = process.env.OPENSCOUT_BROKER_URL ?? buildDefaultBrokerUrl(brokerHost, brokerPort);
155
177
  const launchAgentPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
@@ -170,6 +192,7 @@ export function resolveBrokerServiceConfig() {
170
192
  brokerHost,
171
193
  brokerPort,
172
194
  brokerUrl,
195
+ advertiseScope,
173
196
  };
174
197
  }
175
198
  export function renderLaunchAgentPlist(config) {
@@ -181,6 +204,7 @@ export function renderLaunchAgentPlist(config) {
181
204
  OPENSCOUT_CONTROL_HOME: config.controlHome,
182
205
  OPENSCOUT_BROKER_SERVICE_MODE: config.mode,
183
206
  OPENSCOUT_BROKER_SERVICE_LABEL: config.label,
207
+ OPENSCOUT_ADVERTISE_SCOPE: config.advertiseScope,
184
208
  HOME: homedir(),
185
209
  PATH: launchPath,
186
210
  ...collectOptionalEnvVars([
@@ -37,6 +37,29 @@ export declare function buildMeshMessageBundle(snapshot: Readonly<RuntimeRegistr
37
37
  export declare function buildMeshInvocationBundle(snapshot: Readonly<RuntimeRegistrySnapshot>, originNode: NodeDefinition, invocation: InvocationRequest): MeshInvocationBundle;
38
38
  export declare function buildMeshCollaborationRecordBundle(snapshot: Readonly<RuntimeRegistrySnapshot>, originNode: NodeDefinition, record: CollaborationRecord): MeshCollaborationRecordBundle;
39
39
  export declare function buildMeshCollaborationEventBundle(snapshot: Readonly<RuntimeRegistrySnapshot>, originNode: NodeDefinition, event: CollaborationEvent, record?: CollaborationRecord): MeshCollaborationEventBundle;
40
+ /**
41
+ * Network-level failure reaching the peer broker (DNS, TCP, TLS, abort).
42
+ * Originator outbox treats this as retry-able.
43
+ */
44
+ export declare class PeerUnreachableError extends Error {
45
+ readonly url: string;
46
+ readonly cause?: unknown | undefined;
47
+ readonly name = "PeerUnreachableError";
48
+ constructor(message: string, url: string, cause?: unknown | undefined);
49
+ }
50
+ /**
51
+ * Peer broker responded but rejected the request (HTTP non-2xx).
52
+ * Originator outbox treats 5xx as retry-able and 4xx as terminal.
53
+ */
54
+ export declare class PeerRejectedError extends Error {
55
+ readonly url: string;
56
+ readonly status: number;
57
+ readonly statusText: string;
58
+ readonly body?: string | undefined;
59
+ readonly name = "PeerRejectedError";
60
+ constructor(message: string, url: string, status: number, statusText: string, body?: string | undefined);
61
+ get retryable(): boolean;
62
+ }
40
63
  export declare function forwardMeshMessage(brokerUrl: string, bundle: MeshMessageBundle): Promise<{
41
64
  ok: true;
42
65
  deliveries?: DeliveryIntent[];
@@ -153,17 +153,65 @@ export function buildMeshCollaborationEventBundle(snapshot, originNode, event, r
153
153
  event,
154
154
  };
155
155
  }
156
+ /**
157
+ * Network-level failure reaching the peer broker (DNS, TCP, TLS, abort).
158
+ * Originator outbox treats this as retry-able.
159
+ */
160
+ export class PeerUnreachableError extends Error {
161
+ url;
162
+ cause;
163
+ name = "PeerUnreachableError";
164
+ constructor(message, url, cause) {
165
+ super(message);
166
+ this.url = url;
167
+ this.cause = cause;
168
+ }
169
+ }
170
+ /**
171
+ * Peer broker responded but rejected the request (HTTP non-2xx).
172
+ * Originator outbox treats 5xx as retry-able and 4xx as terminal.
173
+ */
174
+ export class PeerRejectedError extends Error {
175
+ url;
176
+ status;
177
+ statusText;
178
+ body;
179
+ name = "PeerRejectedError";
180
+ constructor(message, url, status, statusText, body) {
181
+ super(message);
182
+ this.url = url;
183
+ this.status = status;
184
+ this.statusText = statusText;
185
+ this.body = body;
186
+ }
187
+ get retryable() {
188
+ return this.status >= 500;
189
+ }
190
+ }
156
191
  async function postJson(url, payload) {
157
- const response = await fetch(url, {
158
- method: "POST",
159
- headers: {
160
- "content-type": "application/json",
161
- accept: "application/json",
162
- },
163
- body: JSON.stringify(payload),
164
- });
192
+ let response;
193
+ try {
194
+ response = await fetch(url, {
195
+ method: "POST",
196
+ headers: {
197
+ "content-type": "application/json",
198
+ accept: "application/json",
199
+ },
200
+ body: JSON.stringify(payload),
201
+ });
202
+ }
203
+ catch (error) {
204
+ throw new PeerUnreachableError(`peer broker unreachable: ${error instanceof Error ? error.message : String(error)}`, url, error);
205
+ }
165
206
  if (!response.ok) {
166
- throw new Error(`peer broker request failed: ${response.status} ${response.statusText}`);
207
+ let body;
208
+ try {
209
+ body = await response.text();
210
+ }
211
+ catch {
212
+ body = undefined;
213
+ }
214
+ throw new PeerRejectedError(`peer broker rejected request: ${response.status} ${response.statusText}`, url, response.status, response.statusText, body);
167
215
  }
168
216
  return await response.json();
169
217
  }