@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.
- package/dist/broker-daemon.js +353 -95
- package/dist/broker-journal.d.ts +13 -2
- package/dist/broker-journal.js +156 -6
- package/dist/broker-service.d.ts +7 -0
- package/dist/broker-service.js +68 -21
- 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/dist/schema.d.ts +1 -1
- package/dist/schema.js +17 -0
- package/dist/scout-dispatcher.d.ts +34 -0
- package/dist/scout-dispatcher.js +125 -0
- package/dist/sqlite-projection.js +147 -2
- package/dist/sqlite-store.d.ts +2 -1
- package/dist/sqlite-store.js +6 -0
- package/package.json +2 -2
- package/src/broker-daemon.test.ts +179 -7
- package/src/broker-daemon.ts +414 -112
- package/src/broker-journal.test.ts +127 -0
- package/src/broker-journal.ts +190 -8
- package/src/broker-service.test.ts +1 -0
- package/src/broker-service.ts +68 -18
- package/src/mesh-forwarding.ts +66 -9
- package/src/peer-delivery.test.ts +394 -0
- package/src/peer-delivery.ts +597 -0
- package/src/schema.ts +17 -0
- package/src/scout-broker.ts +5 -1
- package/src/scout-dispatcher.test.ts +198 -0
- package/src/scout-dispatcher.ts +192 -0
- package/src/sqlite-projection.ts +162 -3
- package/src/sqlite-store.ts +21 -0
package/dist/broker-daemon.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
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
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
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
|
|
1821
|
-
const
|
|
1822
|
-
|
|
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
|
-
|
|
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
|
});
|
package/dist/broker-journal.d.ts
CHANGED
|
@@ -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<
|
|
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
|
}
|