@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.
- package/dist/broker-daemon.js +237 -56
- package/dist/broker-service.d.ts +7 -0
- package/dist/broker-service.js +25 -1
- package/dist/mesh-forwarding.d.ts +23 -0
- package/dist/mesh-forwarding.js +57 -9
- package/dist/peer-delivery.d.ts +110 -0
- package/dist/peer-delivery.js +427 -0
- package/package.json +2 -2
- package/src/broker-daemon.test.ts +14 -12
- package/src/broker-daemon.ts +257 -70
- package/src/broker-service.test.ts +1 -0
- package/src/broker-service.ts +28 -1
- package/src/mesh-forwarding.ts +66 -9
- package/src/peer-delivery.test.ts +394 -0
- package/src/peer-delivery.ts +597 -0
- package/src/scout-broker.ts +5 -1
package/dist/broker-daemon.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
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
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
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,
|
|
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
|
|
1873
|
-
json(response,
|
|
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
|
-
|
|
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
|
});
|
package/dist/broker-service.d.ts
CHANGED
|
@@ -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;
|
package/dist/broker-service.js
CHANGED
|
@@ -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
|
|
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[];
|
package/dist/mesh-forwarding.js
CHANGED
|
@@ -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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
}
|