@openscout/runtime 0.2.37 → 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.
@@ -1,16 +1,18 @@
1
1
  import { createServer } from "node:http";
2
2
  import { hostname } from "node:os";
3
3
  import { join } from "node:path";
4
- import { assertValidCollaborationEvent, assertValidCollaborationRecord, buildRelayReturnAddress, } from "@openscout/protocol";
4
+ import { assertValidCollaborationEvent, assertValidCollaborationRecord, buildRelayReturnAddress, SCOUT_DISPATCHER_AGENT_ID, } from "@openscout/protocol";
5
5
  import { createInMemoryControlRuntime } from "./broker.js";
6
6
  import { FileBackedBrokerJournal } from "./broker-journal.js";
7
+ import { buildDispatchEnvelope, resolveAgentLabel, } from "./scout-dispatcher.js";
7
8
  import { buildCollaborationInvocation } from "./collaboration-invocations.js";
8
9
  import { discoverMeshNodes } from "./mesh-discovery.js";
9
- 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";
10
12
  import { ensureLocalAgentBindingOnline, isLocalAgentEndpointAlive, isLocalAgentSessionAlive, invokeLocalAgentEndpoint, loadRegisteredLocalAgentBindings, shouldDisableGeneratedCodexEndpoint, } from "./local-agents.js";
11
13
  import { RecoverableSQLiteProjection } from "./sqlite-projection.js";
12
14
  import { ensureOpenScoutCleanSlateSync } from "./support-paths.js";
13
- 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";
14
16
  function createRuntimeId(prefix) {
15
17
  return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
16
18
  }
@@ -51,7 +53,8 @@ const controlHome = resolveControlPlaneHome();
51
53
  const dbPath = join(controlHome, "control-plane.sqlite");
52
54
  const journalPath = join(controlHome, "broker-journal.jsonl");
53
55
  const port = Number.parseInt(process.env.OPENSCOUT_BROKER_PORT ?? String(DEFAULT_BROKER_PORT), 10);
54
- const host = process.env.OPENSCOUT_BROKER_HOST ?? DEFAULT_BROKER_HOST;
56
+ const advertiseScope = resolveAdvertiseScope();
57
+ const host = resolveBrokerHost(advertiseScope);
55
58
  const meshId = process.env.OPENSCOUT_MESH_ID ?? "openscout";
56
59
  const nodeName = process.env.OPENSCOUT_NODE_NAME ?? hostname();
57
60
  const tailnetName = process.env.TAILSCALE_TAILNET ?? undefined;
@@ -84,20 +87,59 @@ const activeInvocationTasks = new Map();
84
87
  const knownInvocations = new Map();
85
88
  const sseKeepAliveIntervalMs = Number.parseInt(process.env.OPENSCOUT_SSE_KEEPALIVE_MS ?? "15000", 10);
86
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
+ }
87
119
  function streamEvent(event) {
88
120
  projection.enqueueEvent(event);
89
121
  const payload = `event: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`;
90
122
  for (const client of eventClients) {
91
123
  client.write(payload);
92
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
+ }
93
133
  }
94
134
  function streamKeepAlive() {
95
- if (eventClients.size == 0) {
96
- return;
97
- }
98
135
  for (const client of eventClients) {
99
136
  client.write(": keepalive\n\n");
100
137
  }
138
+ for (const subscribers of invocationStreamClients.values()) {
139
+ for (const client of subscribers) {
140
+ client.write(": keepalive\n\n");
141
+ }
142
+ }
101
143
  }
102
144
  runtime.subscribe((event) => {
103
145
  streamEvent(event);
@@ -112,7 +154,7 @@ const localNode = {
112
154
  meshId,
113
155
  name: nodeName,
114
156
  hostName: hostname(),
115
- advertiseScope: host === DEFAULT_BROKER_HOST ? "local" : "mesh",
157
+ advertiseScope,
116
158
  brokerUrl,
117
159
  tailnetName,
118
160
  capabilities: ["broker", "mesh", "local_runtime"],
@@ -143,6 +185,9 @@ async function discoverPeers(seeds = []) {
143
185
  });
144
186
  for (const node of result.discovered) {
145
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);
146
191
  }
147
192
  // Sync agents from each discovered peer so local broker knows about remote agents.
148
193
  // This enables @mention resolution and message forwarding across the mesh.
@@ -192,13 +237,12 @@ function runDurableWrite(work) {
192
237
  durableWriteQueue = next.then(() => undefined, () => undefined);
193
238
  return next;
194
239
  }
195
- async function appendJournalEntries(entries) {
196
- await journal.appendEntries(entries);
197
- }
198
240
  async function commitDurableEntries(entriesInput, applyRuntime, options = {}) {
199
- const entries = normalizeJournalEntries(entriesInput);
200
- await appendJournalEntries(entries);
201
- await applyRuntime();
241
+ const entries = await journal.appendEntries(normalizeJournalEntries(entriesInput));
242
+ if (entries.length === 0) {
243
+ return [];
244
+ }
245
+ await applyRuntime(entries);
202
246
  if (options.enqueueProjection !== false) {
203
247
  projection.enqueueEntries(entries);
204
248
  }
@@ -223,9 +267,13 @@ async function upsertAgentDurably(agent) {
223
267
  await commitDurableEntries([
224
268
  { kind: "actor.upsert", actor: agent },
225
269
  { kind: "agent.upsert", agent },
226
- ], async () => {
227
- await runtime.upsertActor(agent);
228
- await runtime.upsertAgent(agent);
270
+ ], async (entries) => {
271
+ if (entries.some((entry) => entry.kind === "actor.upsert")) {
272
+ await runtime.upsertActor(agent);
273
+ }
274
+ if (entries.some((entry) => entry.kind === "agent.upsert")) {
275
+ await runtime.upsertAgent(agent);
276
+ }
229
277
  });
230
278
  });
231
279
  }
@@ -284,6 +332,49 @@ async function recordMessageDurably(message, options = {}) {
284
332
  return { deliveries, entries };
285
333
  });
286
334
  }
335
+ async function recordScoutDispatchDurably(envelope, options = {}) {
336
+ const record = {
337
+ id: createRuntimeId("scout-dispatch"),
338
+ invocationId: options.invocationId,
339
+ conversationId: options.conversationId,
340
+ requesterId: options.requesterId,
341
+ ...envelope,
342
+ };
343
+ const dispatchEntries = [
344
+ { kind: "scout.dispatch.record", dispatch: record },
345
+ ];
346
+ let syntheticMessage = null;
347
+ if (options.conversationId) {
348
+ syntheticMessage = {
349
+ id: createRuntimeId("msg-scout"),
350
+ conversationId: options.conversationId,
351
+ actorId: SCOUT_DISPATCHER_AGENT_ID,
352
+ originNodeId: nodeId,
353
+ class: "system",
354
+ body: record.detail,
355
+ visibility: "workspace",
356
+ policy: "best_effort",
357
+ createdAt: record.dispatchedAt,
358
+ metadata: {
359
+ scoutDispatch: record,
360
+ },
361
+ };
362
+ }
363
+ return runDurableWrite(async () => {
364
+ const appended = await commitDurableEntries(dispatchEntries, async () => { });
365
+ if (!syntheticMessage) {
366
+ return { record, message: null, entries: appended };
367
+ }
368
+ const deliveries = runtime.planMessage(syntheticMessage, { localOnly: true });
369
+ const messageEntries = await commitDurableEntries([
370
+ { kind: "message.record", message: syntheticMessage },
371
+ { kind: "deliveries.record", deliveries },
372
+ ], async () => {
373
+ await runtime.commitMessage(syntheticMessage, deliveries);
374
+ });
375
+ return { record, message: syntheticMessage, entries: [...appended, ...messageEntries] };
376
+ });
377
+ }
287
378
  async function recordInvocationDurably(invocation, options = {}) {
288
379
  return runDurableWrite(async () => {
289
380
  const flight = options.flight ?? runtime.planInvocation(invocation);
@@ -304,6 +395,11 @@ async function recordFlightDurably(flight) {
304
395
  });
305
396
  });
306
397
  }
398
+ async function recordDeliveryDurably(delivery) {
399
+ await runDurableWrite(async () => {
400
+ await commitDurableEntries({ kind: "deliveries.record", deliveries: [delivery] }, async () => { });
401
+ });
402
+ }
307
403
  async function recordDeliveryAttemptDurably(attempt) {
308
404
  await runDurableWrite(async () => {
309
405
  await commitDurableEntries({
@@ -388,44 +484,33 @@ async function applyMeshBundleDurably(bundle, options = {}) {
388
484
  assertValidCollaborationEvent(bundle.collaborationEvent, record);
389
485
  }
390
486
  const entries = buildMeshBundleEntries(bundle);
391
- return commitDurableEntries(entries, async () => {
392
- const appliedActorIds = new Set();
393
- const appliedAgentIds = new Set();
394
- const appliedBindingIds = new Set();
395
- await runtime.upsertNode(bundle.originNode);
396
- for (const actor of bundle.actors) {
397
- if (appliedActorIds.has(actor.id)) {
398
- continue;
399
- }
400
- appliedActorIds.add(actor.id);
401
- await runtime.upsertActor(actor);
402
- }
403
- for (const agent of bundle.agents) {
404
- if (appliedAgentIds.has(agent.id)) {
405
- continue;
406
- }
407
- appliedAgentIds.add(agent.id);
408
- if (!appliedActorIds.has(agent.id)) {
409
- appliedActorIds.add(agent.id);
410
- await runtime.upsertActor(agent);
411
- }
412
- await runtime.upsertAgent(agent);
413
- }
414
- if (bundle.conversation) {
415
- await runtime.upsertConversation(bundle.conversation);
416
- }
417
- for (const binding of bundle.bindings ?? []) {
418
- if (appliedBindingIds.has(binding.id)) {
419
- continue;
487
+ return commitDurableEntries(entries, async (retainedEntries) => {
488
+ for (const entry of retainedEntries) {
489
+ switch (entry.kind) {
490
+ case "node.upsert":
491
+ await runtime.upsertNode(entry.node);
492
+ break;
493
+ case "actor.upsert":
494
+ await runtime.upsertActor(entry.actor);
495
+ break;
496
+ case "agent.upsert":
497
+ await runtime.upsertAgent(entry.agent);
498
+ break;
499
+ case "conversation.upsert":
500
+ await runtime.upsertConversation(entry.conversation);
501
+ break;
502
+ case "binding.upsert":
503
+ await runtime.upsertBinding(entry.binding);
504
+ break;
505
+ case "collaboration.record":
506
+ await runtime.upsertCollaboration(entry.record);
507
+ break;
508
+ case "collaboration.event.record":
509
+ await runtime.appendCollaborationEvent(entry.event);
510
+ break;
511
+ default:
512
+ break;
420
513
  }
421
- appliedBindingIds.add(binding.id);
422
- await runtime.upsertBinding(binding);
423
- }
424
- if (bundle.collaborationRecord) {
425
- await runtime.upsertCollaboration(bundle.collaborationRecord);
426
- }
427
- if (bundle.collaborationEvent) {
428
- await runtime.appendCollaborationEvent(bundle.collaborationEvent);
429
514
  }
430
515
  }, options);
431
516
  }
@@ -1211,27 +1296,6 @@ async function forwardPeerBrokerCollaborationEvent(event) {
1211
1296
  }
1212
1297
  return { forwarded, failed };
1213
1298
  }
1214
- async function maybeForwardInvocation(invocation) {
1215
- const targetAgent = runtime.agent(invocation.targetAgentId);
1216
- if (!targetAgent) {
1217
- throw new Error(`unknown agent ${invocation.targetAgentId}`);
1218
- }
1219
- if (targetAgent.authorityNodeId === nodeId) {
1220
- return { forwarded: false };
1221
- }
1222
- const authorityNode = runtime.node(targetAgent.authorityNodeId);
1223
- if (!authorityNode?.brokerUrl) {
1224
- throw new Error(`authority node ${targetAgent.authorityNodeId} is not reachable`);
1225
- }
1226
- const bundle = buildMeshInvocationBundle(runtime.peek(), currentLocalNode(), invocation);
1227
- const result = await forwardMeshInvocation(authorityNode.brokerUrl, bundle);
1228
- const { entries } = await recordInvocationDurably(invocation, {
1229
- flight: result.flight,
1230
- enqueueProjection: false,
1231
- });
1232
- projection.enqueueEntries(entries);
1233
- return { forwarded: true, flight: result.flight };
1234
- }
1235
1299
  function parseLimit(url) {
1236
1300
  const limit = Number.parseInt(url.searchParams.get("limit") ?? "100", 10);
1237
1301
  if (!Number.isFinite(limit) || limit <= 0)
@@ -1346,23 +1410,20 @@ async function handleCommand(command) {
1346
1410
  return { ok: true, message: command.message, deliveries, mesh };
1347
1411
  }
1348
1412
  case "agent.invoke": {
1349
- const forwarded = await maybeForwardInvocation(command.invocation);
1350
- if (forwarded.forwarded) {
1351
- console.log(`[openscout-runtime] invocation ${command.invocation.id} forwarded to ${command.invocation.targetAgentId}`);
1352
- return { ok: true, flight: forwarded.flight, forwarded: true };
1353
- }
1354
- const { flight, entries } = await recordInvocationDurably(command.invocation, {
1355
- 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);
1356
1417
  });
1357
- console.log(`[openscout-runtime] invocation ${command.invocation.id} -> ${command.invocation.targetAgentId} is ${flight.state}${flight.summary ? ` (${flight.summary})` : ""}`);
1358
- if (flight.state === "failed") {
1359
- await postInvocationStatusMessage(command.invocation, flight);
1360
- }
1361
- else {
1362
- launchLocalInvocation(command.invocation, flight);
1363
- }
1364
- projection.enqueueEntries(entries);
1365
- 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
+ };
1366
1427
  }
1367
1428
  case "agent.ensure_awake":
1368
1429
  await runtime.dispatch(command);
@@ -1375,6 +1436,76 @@ async function handleCommand(command) {
1375
1436
  }
1376
1437
  }
1377
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
+ });
1378
1509
  async function routeRequest(request, response) {
1379
1510
  const method = request.method ?? "GET";
1380
1511
  const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `${host}:${port}`}`);
@@ -1488,6 +1619,75 @@ async function routeRequest(request, response) {
1488
1619
  json(response, 200, runtime.snapshot().nodes);
1489
1620
  return;
1490
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
+ }
1491
1691
  if (method === "GET" && url.pathname === "/v1/events/stream") {
1492
1692
  response.writeHead(200, {
1493
1693
  "content-type": "text/event-stream",
@@ -1817,9 +2017,38 @@ async function routeRequest(request, response) {
1817
2017
  }
1818
2018
  if (method === "POST" && url.pathname === "/v1/invocations") {
1819
2019
  try {
1820
- const invocation = await readRequestBody(request);
1821
- const result = await handleCommand({ kind: "agent.invoke", invocation });
1822
- json(response, 200, result);
2020
+ const payload = await readRequestBody(request);
2021
+ const resolved = resolveInvocationTarget(payload);
2022
+ if (resolved.kind !== "resolved") {
2023
+ const envelope = buildDispatchEnvelope(resolved, payload.targetLabel?.trim() || payload.targetAgentId || "", nodeId, runtime.snapshot(), { homeEndpointFor: homeEndpointForAgent });
2024
+ const { record } = await recordScoutDispatchDurably(envelope, {
2025
+ invocationId: payload.id,
2026
+ conversationId: payload.conversationId,
2027
+ requesterId: payload.requesterId,
2028
+ });
2029
+ json(response, 202, {
2030
+ accepted: true,
2031
+ invocationId: payload.id,
2032
+ dispatch: record,
2033
+ });
2034
+ return;
2035
+ }
2036
+ const invocation = {
2037
+ ...payload,
2038
+ targetAgentId: resolved.agent.id,
2039
+ };
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
+ });
1823
2052
  }
1824
2053
  catch (error) {
1825
2054
  badRequest(response, error);
@@ -1828,6 +2057,24 @@ async function routeRequest(request, response) {
1828
2057
  }
1829
2058
  notFound(response);
1830
2059
  }
2060
+ function resolveInvocationTarget(payload) {
2061
+ const snapshot = runtime.snapshot();
2062
+ const directId = payload.targetAgentId?.trim();
2063
+ if (directId) {
2064
+ const agent = snapshot.agents[directId];
2065
+ if (agent && !isStaleLocalAgent(agent)) {
2066
+ return { kind: "resolved", agent };
2067
+ }
2068
+ }
2069
+ const label = payload.targetLabel?.trim() || directId || "";
2070
+ if (!label) {
2071
+ return { kind: "unparseable", label: "" };
2072
+ }
2073
+ return resolveAgentLabel(snapshot, label, {
2074
+ preferLocalNodeId: nodeId,
2075
+ helpers: { isStale: isStaleLocalAgent },
2076
+ });
2077
+ }
1831
2078
  const server = createServer((request, response) => {
1832
2079
  routeRequest(request, response).catch((error) => {
1833
2080
  json(response, 500, {
@@ -1838,7 +2085,11 @@ const server = createServer((request, response) => {
1838
2085
  });
1839
2086
  try {
1840
2087
  await listen(server);
1841
- 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
+ }
1842
2093
  console.log(`[openscout-runtime] node ${nodeId} in mesh ${meshId}`);
1843
2094
  console.log(`[openscout-runtime] journal ${journalPath}`);
1844
2095
  console.log(`[openscout-runtime] sqlite ${sqliteDisabled ? "disabled" : dbPath}`);
@@ -1878,9 +2129,16 @@ if (Number.isFinite(discoveryIntervalMs) && discoveryIntervalMs > 0) {
1878
2129
  }
1879
2130
  for (const signal of ["SIGINT", "SIGTERM"]) {
1880
2131
  process.on(signal, () => {
2132
+ peerDelivery.stop();
1881
2133
  for (const client of eventClients) {
1882
2134
  client.end();
1883
2135
  }
2136
+ for (const subscribers of invocationStreamClients.values()) {
2137
+ for (const client of subscribers) {
2138
+ client.end();
2139
+ }
2140
+ }
2141
+ invocationStreamClients.clear();
1884
2142
  projection.close();
1885
2143
  server.close(() => process.exit(0));
1886
2144
  });
@@ -1,4 +1,4 @@
1
- import type { ActorIdentity, AgentDefinition, AgentEndpoint, CollaborationEvent, CollaborationRecord, ConversationBinding, ConversationDefinition, DeliveryAttempt, DeliveryIntent, FlightRecord, InvocationRequest, MessageRecord, NodeDefinition } from "@openscout/protocol";
1
+ import type { ActorIdentity, AgentDefinition, AgentEndpoint, CollaborationEvent, CollaborationRecord, ConversationBinding, ConversationDefinition, DeliveryAttempt, DeliveryIntent, FlightRecord, InvocationRequest, MessageRecord, NodeDefinition, ScoutDispatchRecord } from "@openscout/protocol";
2
2
  import { type RuntimeRegistrySnapshot } from "./registry.js";
3
3
  export type BrokerJournalEntry = {
4
4
  kind: "node.upsert";
@@ -46,6 +46,9 @@ export type BrokerJournalEntry = {
46
46
  metadata?: Record<string, unknown>;
47
47
  leaseOwner?: string | null;
48
48
  leaseExpiresAt?: number | null;
49
+ } | {
50
+ kind: "scout.dispatch.record";
51
+ dispatch: ScoutDispatchRecord;
49
52
  };
50
53
  export declare class FileBackedBrokerJournal {
51
54
  private readonly filePath;
@@ -57,7 +60,7 @@ export declare class FileBackedBrokerJournal {
57
60
  readEntries(): Promise<BrokerJournalEntry[]>;
58
61
  replay(visitor: (entry: BrokerJournalEntry) => void | Promise<void>): Promise<void>;
59
62
  snapshot(): RuntimeRegistrySnapshot;
60
- appendEntries(entriesInput: BrokerJournalEntry | BrokerJournalEntry[]): Promise<void>;
63
+ appendEntries(entriesInput: BrokerJournalEntry | BrokerJournalEntry[]): Promise<BrokerJournalEntry[]>;
61
64
  listCollaborationRecords(options?: {
62
65
  limit?: number;
63
66
  kind?: CollaborationRecord["kind"];
@@ -75,5 +78,13 @@ export declare class FileBackedBrokerJournal {
75
78
  limit?: number;
76
79
  }): DeliveryIntent[];
77
80
  listDeliveryAttempts(deliveryId: string): DeliveryAttempt[];
81
+ private rewriteEntries;
82
+ private selectEntriesToAppend;
83
+ private shouldAppendEntry;
84
+ private applyToSnapshot;
78
85
  private apply;
86
+ listScoutDispatches(options?: {
87
+ limit?: number;
88
+ askedLabel?: string;
89
+ }): ScoutDispatchRecord[];
79
90
  }