@intx/hub-sessions 0.1.2 → 0.3.0

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.
Files changed (113) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +82 -1
  3. package/dist/agent-repo.d.ts +93 -0
  4. package/dist/agent-repo.js +109 -0
  5. package/dist/agent-state-kind.d.ts +12 -0
  6. package/dist/agent-state-kind.js +189 -0
  7. package/dist/asset-service.d.ts +104 -0
  8. package/dist/asset-service.js +267 -0
  9. package/dist/committed-source-tree.d.ts +10 -0
  10. package/dist/committed-source-tree.js +35 -0
  11. package/dist/credential-push.d.ts +33 -0
  12. package/dist/credential-push.js +109 -0
  13. package/dist/event-collector-registry.d.ts +20 -0
  14. package/dist/event-collector-registry.js +115 -0
  15. package/dist/event-collector.d.ts +39 -0
  16. package/dist/event-collector.js +365 -0
  17. package/dist/hub-session-lookups.d.ts +135 -0
  18. package/dist/hub-session-lookups.js +663 -0
  19. package/dist/hub-session-orchestrator.d.ts +25 -0
  20. package/dist/hub-session-orchestrator.js +87 -0
  21. package/dist/index.d.ts +27 -0
  22. package/dist/index.js +24 -0
  23. package/dist/package-registry-kind.d.ts +70 -0
  24. package/dist/package-registry-kind.js +260 -0
  25. package/dist/repo-store/index.d.ts +4 -0
  26. package/dist/repo-store/index.js +3 -0
  27. package/dist/repo-store/store.d.ts +41 -0
  28. package/dist/repo-store/store.js +1829 -0
  29. package/dist/repo-store/subscribe-kind.d.ts +56 -0
  30. package/dist/repo-store/subscribe-kind.js +144 -0
  31. package/dist/repo-store/types.d.ts +571 -0
  32. package/dist/repo-store/types.js +42 -0
  33. package/dist/session-service.d.ts +416 -0
  34. package/dist/session-service.js +1191 -0
  35. package/dist/sidecar-allocation/contracts.d.ts +78 -0
  36. package/dist/sidecar-allocation/contracts.js +21 -0
  37. package/dist/sidecar-allocation/index.d.ts +4 -0
  38. package/dist/sidecar-allocation/index.js +3 -0
  39. package/dist/sidecar-allocation/placement-policy.d.ts +11 -0
  40. package/dist/sidecar-allocation/placement-policy.js +21 -0
  41. package/dist/sidecar-allocation/plugin-registry.d.ts +11 -0
  42. package/dist/sidecar-allocation/plugin-registry.js +37 -0
  43. package/dist/sidecar-allocation/reconciler.d.ts +42 -0
  44. package/dist/sidecar-allocation/reconciler.js +431 -0
  45. package/dist/skill-kind.d.ts +41 -0
  46. package/dist/skill-kind.js +292 -0
  47. package/dist/substrate.d.ts +8 -0
  48. package/dist/substrate.js +21 -0
  49. package/dist/workflow-allocation-service.d.ts +58 -0
  50. package/dist/workflow-allocation-service.js +239 -0
  51. package/dist/workflow-closure-resolution.d.ts +106 -0
  52. package/dist/workflow-closure-resolution.js +123 -0
  53. package/dist/workflow-definition-ensure.d.ts +24 -0
  54. package/dist/workflow-definition-ensure.js +75 -0
  55. package/dist/workflow-dispatch-service.d.ts +40 -0
  56. package/dist/workflow-dispatch-service.js +146 -0
  57. package/dist/workflow-dispatch-settlement.d.ts +29 -0
  58. package/dist/workflow-dispatch-settlement.js +140 -0
  59. package/dist/workflow-kind.d.ts +37 -0
  60. package/dist/workflow-kind.js +310 -0
  61. package/dist/workflow-probe-gate.d.ts +214 -0
  62. package/dist/workflow-probe-gate.js +207 -0
  63. package/dist/workflow-run-event-log.d.ts +21 -0
  64. package/dist/workflow-run-event-log.js +51 -0
  65. package/dist/workflow-run-kind.d.ts +440 -0
  66. package/dist/workflow-run-kind.js +2916 -0
  67. package/dist/workflow-run-reader.d.ts +47 -0
  68. package/dist/workflow-run-reader.js +153 -0
  69. package/dist/workflow-run-restore.d.ts +15 -0
  70. package/dist/workflow-run-restore.js +26 -0
  71. package/dist/workflow-source-closure.d.ts +35 -0
  72. package/dist/workflow-source-closure.js +342 -0
  73. package/dist/ws/index.d.ts +3 -0
  74. package/dist/ws/index.js +3 -0
  75. package/dist/ws/sidecar-events.d.ts +222 -0
  76. package/dist/ws/sidecar-events.js +72 -0
  77. package/dist/ws/sidecar-handler.d.ts +305 -0
  78. package/dist/ws/sidecar-handler.js +2537 -0
  79. package/dist/ws/sidecar-token-authenticator.d.ts +17 -0
  80. package/dist/ws/sidecar-token-authenticator.js +81 -0
  81. package/package.json +35 -12
  82. package/src/agent-repo.test.ts +0 -310
  83. package/src/agent-repo.ts +0 -165
  84. package/src/agent-state-kind.test.ts +0 -247
  85. package/src/agent-state-kind.ts +0 -204
  86. package/src/asset-service.test.ts +0 -540
  87. package/src/asset-service.ts +0 -378
  88. package/src/available-skills-stanza.test.ts +0 -87
  89. package/src/available-skills-stanza.ts +0 -47
  90. package/src/credential-push.ts +0 -65
  91. package/src/event-collector-registry.test.ts +0 -73
  92. package/src/event-collector-registry.ts +0 -171
  93. package/src/event-collector.test.ts +0 -1387
  94. package/src/event-collector.ts +0 -424
  95. package/src/hub-session-lookups.ts +0 -206
  96. package/src/hub-session-orchestrator.test.ts +0 -510
  97. package/src/hub-session-orchestrator.ts +0 -213
  98. package/src/index.ts +0 -78
  99. package/src/repo-store/index.ts +0 -15
  100. package/src/repo-store/store.test.ts +0 -1169
  101. package/src/repo-store/store.ts +0 -428
  102. package/src/repo-store/types.ts +0 -253
  103. package/src/session-service.test.ts +0 -895
  104. package/src/session-service.ts +0 -464
  105. package/src/skill-kind.test.ts +0 -599
  106. package/src/skill-kind.ts +0 -350
  107. package/src/ws/index.ts +0 -18
  108. package/src/ws/sidecar-events.test.ts +0 -96
  109. package/src/ws/sidecar-events.ts +0 -231
  110. package/src/ws/sidecar-handler.test.ts +0 -2217
  111. package/src/ws/sidecar-handler.ts +0 -1574
  112. package/tsconfig.json +0 -4
  113. package/tsconfig.tsbuildinfo +0 -1
@@ -1,424 +0,0 @@
1
- // Persists assistant inference turns from the InferenceEvent stream.
2
- //
3
- // One collector per active session. Events are written eagerly to the DB
4
- // so data survives crashes. The collector does not block the websocket
5
- // message loop — callers should fire-and-forget and log errors.
6
-
7
- import { eq } from "drizzle-orm";
8
-
9
- import { inferenceTurn, turnPart } from "@intx/db/schema";
10
- import { getLogger } from "@intx/log";
11
- import type { InferenceEvent, ContentBlock } from "@intx/types/runtime";
12
- import { type DB, parseTurnPartType } from "@intx/db";
13
-
14
- import { generateId } from "@intx/hub-common";
15
-
16
- const log = getLogger(["hub", "event-collector"]);
17
-
18
- export type TurnToolCall = {
19
- name: string;
20
- arguments: Record<string, unknown>;
21
- result: string;
22
- isError: boolean;
23
- };
24
-
25
- export type TurnFinalized = {
26
- turnId: string;
27
- status: "completed" | "failed";
28
- text: string;
29
- hadReply: boolean;
30
- hadError: boolean;
31
- errors: { category: string; message: string }[];
32
- toolCalls: TurnToolCall[];
33
- toolErrors: { name: string; content: string }[];
34
- };
35
-
36
- export type EventCollector = {
37
- onEvent(event: InferenceEvent): Promise<void>;
38
- abandon(): Promise<void>;
39
- getAccumulatedText(): string;
40
- getCurrentTurnId(): string | null;
41
- getLastTurnId(): string | null;
42
- };
43
-
44
- export type EventCollectorConfig = {
45
- db: DB["db"];
46
- sessionId: string;
47
- instanceId: string;
48
- tenantId: string;
49
- onTurnFinalized?: (turn: TurnFinalized) => void;
50
- };
51
-
52
- export function createEventCollector(
53
- config: EventCollectorConfig,
54
- ): EventCollector {
55
- const { db, sessionId, instanceId, tenantId, onTurnFinalized } = config;
56
-
57
- // Current inference turn being accumulated. A new turn is created on each
58
- // inference.start. Finalized on connector.reply, reactor.done,
59
- // reactor.error (fatal), or abandon. Null when no turn is active.
60
- let currentTurnId: string | null = null;
61
- // Most recent turn ID, set in beginTurn. Unlike currentTurnId this is NOT
62
- // cleared on finalization — the SSE replay endpoint needs the turn ID
63
- // after the turn commits but before the collector is removed.
64
- let lastTurnId: string | null = null;
65
- let ordinal = 0;
66
- // Prevents double-finalization when reactor.done and abandon() race.
67
- let finalized = false;
68
- // Set when inference.error fires so connector.reply knows to persist its
69
- // content (on normal turns, inference.done already persisted the text).
70
- let pendingError = false;
71
- // Accumulated visible text content for the current turn. Only text blocks
72
- // from inference.done (not thinking/reasoning) are included. Reset on each
73
- // new turn.
74
- let accumulatedText = "";
75
- // In-progress text from inference.text.delta events during the current
76
- // inference step. Reset on inference.done (when accumulatedText takes over).
77
- let streamingText = "";
78
- // Set when inference.error fires. Unlike pendingError (which resets on
79
- // connector.reply), this persists until finalization so the callback can
80
- // report whether an inference error occurred during the turn.
81
- let turnHadError = false;
82
- // Structured error details accumulated during the turn for inclusion in
83
- // TurnFinalized. Reset on each new turn.
84
- let accumulatedErrors: { category: string; message: string }[] = [];
85
- // Maps tool call IDs to tool names for correlating tool results with their
86
- // originating calls. Populated from inference.done tool_call blocks.
87
- const callNames = new Map<string, string>();
88
- const callArgs = new Map<string, Record<string, unknown>>();
89
- // Tool calls accumulated for TurnFinalized.
90
- let accumulatedToolCalls: TurnToolCall[] = [];
91
- // Tool results that reported isError, accumulated for TurnFinalized.
92
- let accumulatedToolErrors: { name: string; content: string }[] = [];
93
-
94
- async function onEvent(event: InferenceEvent): Promise<void> {
95
- switch (event.type) {
96
- case "inference.start":
97
- await beginTurn(event.data.model);
98
- await insertPart("step-start", null, { model: event.data.model });
99
- break;
100
- case "inference.text.delta":
101
- streamingText += event.data.token;
102
- break;
103
- case "inference.done":
104
- await handleInferenceDone(event.data.turn.content);
105
- streamingText = "";
106
- break;
107
- case "tool.done": {
108
- const callId = event.data.result.callId;
109
- const isError = event.data.result.isError ?? false;
110
- await insertPart("tool", null, {
111
- kind: "result",
112
- callId,
113
- content: event.data.result.content,
114
- isError,
115
- });
116
- const name = callNames.get(callId) ?? callId;
117
- const raw = event.data.result.content;
118
- const content = typeof raw === "string" ? raw : JSON.stringify(raw);
119
- accumulatedToolCalls.push({
120
- name,
121
- arguments: callArgs.get(callId) ?? {},
122
- result: content,
123
- isError,
124
- });
125
- if (isError) {
126
- accumulatedToolErrors.push({ name, content });
127
- }
128
- break;
129
- }
130
- case "inference.error":
131
- pendingError = true;
132
- turnHadError = true;
133
- await insertPart("error", event.data.error.message, {
134
- category: event.data.error.category,
135
- ...(event.data.error.statusCode !== undefined
136
- ? { statusCode: event.data.error.statusCode }
137
- : {}),
138
- });
139
- accumulatedErrors.push({
140
- category: event.data.error.category,
141
- message: event.data.error.message,
142
- });
143
- break;
144
- case "connector.reply":
145
- if (finalized) break;
146
- // Only persist reply content when it originated from an error path.
147
- // On normal turns inference.done already persisted the text parts.
148
- if (pendingError) {
149
- accumulatedText += event.data.content;
150
- await insertPart("text", event.data.content, null);
151
- pendingError = false;
152
- }
153
- await finalizeTurn(turnHadError ? "failed" : "completed", true, true);
154
- break;
155
- case "reactor.done":
156
- await finalizeTurn("completed", true, false);
157
- break;
158
- case "reactor.error":
159
- if (event.data.fatal && !finalized) {
160
- // The reactor failed before any inference started (e.g., context
161
- // store load failure), but the user needs to see why their agent
162
- // failed, and without a turn there is no container for the error.
163
- if (currentTurnId === null) {
164
- await beginTurn("unknown");
165
- }
166
- turnHadError = true;
167
- // Push after beginTurn so the error survives the array reset.
168
- accumulatedErrors.push({
169
- category: "reactor_error",
170
- message: event.data.error,
171
- });
172
- await insertPart("error", event.data.error, {
173
- category: "reactor_error",
174
- });
175
- await finalizeTurn("failed", true, false);
176
- } else if (!event.data.fatal && !finalized) {
177
- if (currentTurnId === null) {
178
- await beginTurn("unknown");
179
- }
180
- turnHadError = true;
181
- accumulatedErrors.push({
182
- category: "reactor_error",
183
- message: event.data.error,
184
- });
185
- await insertPart("error", event.data.error, {
186
- category: "reactor_error",
187
- });
188
- }
189
- break;
190
- default:
191
- // reactor.start, streaming deltas, usage, and other events are
192
- // not persisted.
193
- break;
194
- }
195
- }
196
-
197
- async function beginTurn(model: string): Promise<void> {
198
- // A previous turn is still open — this is normal in multi-step tool-use
199
- // loops where inference.start fires again after tools return.
200
- if (currentTurnId !== null && !finalized) {
201
- await finalizeTurn("completed", true, false);
202
- }
203
-
204
- currentTurnId = generateId("inferenceTurn");
205
- lastTurnId = currentTurnId;
206
- ordinal = 0;
207
- finalized = false;
208
- pendingError = false;
209
- accumulatedText = "";
210
- streamingText = "";
211
- turnHadError = false;
212
- accumulatedErrors = [];
213
- callNames.clear();
214
- callArgs.clear();
215
- accumulatedToolCalls = [];
216
- accumulatedToolErrors = [];
217
-
218
- await db.insert(inferenceTurn).values({
219
- id: currentTurnId,
220
- sessionId,
221
- instanceId,
222
- tenantId,
223
- model,
224
- status: "running",
225
- startedAt: new Date(),
226
- });
227
- }
228
-
229
- async function handleInferenceDone(content: ContentBlock[]): Promise<void> {
230
- for (const block of content) {
231
- switch (block.type) {
232
- case "text":
233
- accumulatedText += block.text;
234
- await insertPart("text", block.text, null);
235
- break;
236
- case "thinking":
237
- await insertPart("reasoning", block.thinking, null);
238
- break;
239
- case "redacted_thinking":
240
- // The opaque `data` blob is meaningless to humans and
241
- // must be preserved verbatim for echo-back on follow-up
242
- // turns; persisting it as a reasoning row would invite
243
- // truncation or display. Skip and let the adapter layer
244
- // own the round-trip.
245
- break;
246
- case "refusal":
247
- // Refusal blocks carry human-readable text the model
248
- // emitted when it declined a structured-output request.
249
- // A dedicated `refusal` part kind keeps the signal
250
- // distinct from ordinary `text` (the model produced
251
- // schema-conformant content) and from `error` (the HTTP
252
- // call failed or the protocol mismatched). Session
253
- // readers can branch on the part type to render policy
254
- // declines differently from regular assistant output.
255
- await insertPart("refusal", block.reason, null);
256
- break;
257
- case "tool_call":
258
- callNames.set(block.id, block.name);
259
- callArgs.set(block.id, block.arguments);
260
- await insertPart("tool", null, {
261
- kind: "call",
262
- callId: block.id,
263
- name: block.name,
264
- arguments: block.arguments,
265
- });
266
- break;
267
- case "tool_result":
268
- // Tool results in the content block are echoes of earlier
269
- // tool.done events. Skip to avoid duplication.
270
- break;
271
- case "citation":
272
- // Citations annotate model output by reference (URI or
273
- // document index). Persistence semantics — whether the
274
- // cited source warrants an audit row of its own — are not
275
- // yet settled; skip until they are.
276
- break;
277
- case "code_execution_request":
278
- case "code_execution_result":
279
- // Server-side code execution requests and results.
280
- // Persistence semantics — whether the code and its output
281
- // warrant audit rows of their own, and how to relate the
282
- // pair through the requestId back-pointer — are not yet
283
- // settled; skip until they are.
284
- break;
285
- case "image":
286
- case "audio":
287
- case "video":
288
- case "document": {
289
- // All media block variants persist into the generic "file"
290
- // part bucket. The block's own `type` distinguishes the
291
- // semantic role; `mimeType` distinguishes the encoding; the
292
- // MediaSource discriminant distinguishes inline vs reference.
293
- const source = block.source;
294
- if (source.kind === "base64") {
295
- await insertPart("file", null, {
296
- kind: "base64",
297
- mimeType: source.mimeType,
298
- dataLength: source.data.length,
299
- });
300
- } else if (source.kind === "file-reference") {
301
- await insertPart("file", null, {
302
- kind: "file-reference",
303
- mimeType: source.mimeType,
304
- reference: source.reference,
305
- });
306
- } else if (source.kind === "url") {
307
- // The `url` MediaSource variant carries a self-contained
308
- // dereferenceable HTTP(S) URL (Gemini accepts these in
309
- // `fileData/fileUri`; other adapters route similarly).
310
- // The session record keeps the URL on a distinct `url`
311
- // field rather than reusing the `reference` slot that
312
- // `file-reference` uses: a session reader that filters
313
- // or joins on `metadata->>'reference'` should see
314
- // provider-opaque file ids only, with public URLs
315
- // surfaced under their own field. The discriminant
316
- // `kind` is the structural source of truth, and the
317
- // field-per-variant shape keeps naive substring queries
318
- // honest.
319
- await insertPart("file", null, {
320
- kind: "url",
321
- mimeType: source.mimeType,
322
- url: source.url,
323
- });
324
- } else {
325
- source satisfies never;
326
- throw new Error(`unreachable: unknown MediaSource kind`);
327
- }
328
- break;
329
- }
330
- }
331
- }
332
-
333
- // Mark the end of this inference step.
334
- await insertPart("step-finish", null, null);
335
- }
336
-
337
- async function finalizeTurn(
338
- status: "completed" | "failed",
339
- notify: boolean,
340
- hadReply: boolean,
341
- ): Promise<void> {
342
- if (currentTurnId === null || finalized) return;
343
- finalized = true;
344
-
345
- const turnId = currentTurnId;
346
-
347
- await db
348
- .update(inferenceTurn)
349
- .set({ status, endedAt: new Date() })
350
- .where(eq(inferenceTurn.id, turnId));
351
-
352
- if (notify && onTurnFinalized) {
353
- onTurnFinalized({
354
- turnId,
355
- status,
356
- text: accumulatedText,
357
- hadReply,
358
- hadError: turnHadError,
359
- errors: [...accumulatedErrors],
360
- toolCalls: [...accumulatedToolCalls],
361
- toolErrors: [...accumulatedToolErrors],
362
- });
363
- }
364
-
365
- currentTurnId = null;
366
- }
367
-
368
- async function abandon(): Promise<void> {
369
- if (currentTurnId === null || finalized) return;
370
-
371
- log.warn`Abandoning running turn ${currentTurnId} for session ${sessionId}`;
372
-
373
- await finalizeTurn("failed", false, false);
374
- }
375
-
376
- async function insertPart(
377
- partType: string,
378
- content: string | null,
379
- metadata: Record<string, unknown> | null,
380
- ): Promise<void> {
381
- if (currentTurnId === null) {
382
- log.warn`Dropping ${partType} part: no active turn for session ${sessionId}`;
383
- return;
384
- }
385
-
386
- const values: typeof turnPart.$inferInsert = {
387
- id: generateId("turnPart"),
388
- turnId: currentTurnId,
389
- sessionId,
390
- type: parseTurnPartType(partType),
391
- ordinal: ordinal++,
392
- };
393
-
394
- if (content !== null) {
395
- values.content = content;
396
- }
397
-
398
- if (metadata !== null) {
399
- values.metadata = metadata;
400
- }
401
-
402
- await db.insert(turnPart).values(values);
403
- }
404
-
405
- function getAccumulatedText(): string {
406
- return accumulatedText + streamingText;
407
- }
408
-
409
- function getCurrentTurnId(): string | null {
410
- return currentTurnId;
411
- }
412
-
413
- function getLastTurnId(): string | null {
414
- return lastTurnId;
415
- }
416
-
417
- return {
418
- onEvent,
419
- abandon,
420
- getAccumulatedText,
421
- getCurrentTurnId,
422
- getLastTurnId,
423
- };
424
- }
@@ -1,206 +0,0 @@
1
- // Lookups that the sidecar wire layer issues against host state. These
2
- // are queries (one answer per question) rather than events (broadcast
3
- // notifications), so they live separately from the event emitter.
4
- //
5
- // Each lookup is a stateless DB or repo call. They are gathered into a
6
- // single struct that the hub app passes to `createSidecarRouter` as
7
- // `lookups`.
8
-
9
- import { eq, and, isNull } from "drizzle-orm";
10
- import type { DB } from "@intx/db";
11
- import { agentInstance, sessionMail } from "@intx/db/schema";
12
- import { getLogger } from "@intx/log";
13
-
14
- import type { AgentRepoStore } from "./agent-repo";
15
- import { generateId } from "@intx/hub-common";
16
- import type { SidecarLookups } from "./ws/sidecar-events";
17
-
18
- const logger = getLogger(["hub", "lookups"]);
19
-
20
- export type HubSessionLookupsDeps = {
21
- db: DB["db"];
22
- agentRepoStore: AgentRepoStore;
23
- };
24
-
25
- export function createHubSessionLookups(
26
- deps: HubSessionLookupsDeps,
27
- ): Required<SidecarLookups> {
28
- const { db, agentRepoStore } = deps;
29
-
30
- return {
31
- async lookupPublicKey(agentAddress) {
32
- const row = await db
33
- .select({ publicKey: agentInstance.publicKey })
34
- .from(agentInstance)
35
- .where(
36
- and(
37
- eq(agentInstance.address, agentAddress),
38
- isNull(agentInstance.endedAt),
39
- ),
40
- )
41
- .limit(1)
42
- .then((rows) => rows[0]);
43
- if (!row) {
44
- return null;
45
- }
46
- return row.publicKey;
47
- },
48
-
49
- async lookupDeployRef(agentAddress) {
50
- const agentId = parseAgentId(agentAddress);
51
- return agentRepoStore.getDeployRef(agentId);
52
- },
53
-
54
- async persistMail({ senderAddress, recipients, raw }) {
55
- const senderInstance = await requireInstance(db, senderAddress);
56
- if (!senderInstance.sessionId) {
57
- throw new Error(
58
- `Instance ${senderInstance.id} has no session for address "${senderAddress}"`,
59
- );
60
- }
61
- const createdAt = new Date();
62
-
63
- // Outbound record on the sender's session.
64
- const outboundId = generateId("sessionMail");
65
- const outboundRecord = {
66
- id: outboundId,
67
- sessionId: senderInstance.sessionId,
68
- instanceId: senderInstance.id,
69
- tenantId: senderInstance.tenantId,
70
- direction: "outbound" as const,
71
- status: "delivered" as const,
72
- raw,
73
- createdAt,
74
- };
75
-
76
- // Inbound records for each recipient that has an active agent
77
- // instance. Recipients that are not agent instances (e.g. human
78
- // user addresses) are skipped.
79
- const recipientResults = await Promise.all(
80
- recipients.map(async (addr) => {
81
- const row = await db.query.agentInstance.findFirst({
82
- where: and(
83
- eq(agentInstance.address, addr),
84
- isNull(agentInstance.endedAt),
85
- ),
86
- });
87
- if (row === undefined) {
88
- return null;
89
- }
90
- if (row.sessionId === null) {
91
- logger.warn`Active instance ${row.id} for "${addr}" has no session; skipping inbound record`;
92
- return null;
93
- }
94
- return { addr, instance: row, sessionId: row.sessionId };
95
- }),
96
- );
97
- const recipientInstances = recipientResults.filter(
98
- (r): r is NonNullable<typeof r> => r !== null,
99
- );
100
-
101
- const inboundEntries = recipientInstances.map(
102
- ({ addr, instance, sessionId }) => {
103
- const id = generateId("sessionMail");
104
- return {
105
- record: {
106
- id,
107
- sessionId,
108
- instanceId: instance.id,
109
- tenantId: instance.tenantId,
110
- direction: "inbound" as const,
111
- status: "delivered" as const,
112
- raw,
113
- createdAt,
114
- },
115
- result: {
116
- id,
117
- direction: "inbound" as const,
118
- instanceId: instance.id,
119
- address: addr,
120
- createdAt,
121
- },
122
- };
123
- },
124
- );
125
-
126
- await db
127
- .insert(sessionMail)
128
- .values([outboundRecord, ...inboundEntries.map((e) => e.record)]);
129
-
130
- return [
131
- {
132
- id: outboundId,
133
- direction: "outbound" as const,
134
- instanceId: senderInstance.id,
135
- address: senderInstance.address,
136
- createdAt,
137
- },
138
- ...inboundEntries.map((e) => e.result),
139
- ];
140
- },
141
-
142
- async receiveStatePack(repoId, pack, ref, commitSha) {
143
- if (repoId.kind !== "agent-state") {
144
- throw new Error(
145
- `hub-session lookups received unsupported repo kind ${JSON.stringify(repoId.kind)}`,
146
- );
147
- }
148
- const agentAddress = repoId.id;
149
- const agentId = parseAgentId(agentAddress);
150
- try {
151
- await agentRepoStore.receiveStatePack(
152
- { kind: "agent-state", id: agentId },
153
- pack,
154
- ref,
155
- commitSha,
156
- );
157
- } catch (err) {
158
- const msg = err instanceof Error ? err.message : String(err);
159
- if (msg.startsWith("path_violation")) {
160
- logger.warn`State pack rejected for ${agentAddress}: ${msg}`;
161
- return { accepted: false, reason: "path_violation" as const };
162
- }
163
- // Any other failure from the repo subsystem reaches the
164
- // WebSocket handler as an unhandled rejection unless we catch
165
- // it here. Transient failures during receivePack (the agent
166
- // directory being torn down concurrently with an in-flight
167
- // pack write, filesystem errors mid-rename, etc.) are
168
- // recoverable from the sender's perspective — the sender can
169
- // re-push. Surface every such failure as a structured pack
170
- // rejection (`corrupt` is the closest existing reason — from
171
- // the sender's perspective the pack failed to index) and log
172
- // the underlying error so the cause stays traceable on the
173
- // hub side.
174
- logger.error`State pack receive failed for ${agentAddress}: ${msg}`;
175
- return { accepted: false, reason: "corrupt" as const };
176
- }
177
- return { accepted: true };
178
- },
179
- };
180
- }
181
-
182
- export function parseAgentId(agentAddress: string): string {
183
- const atIdx = agentAddress.indexOf("@");
184
- if (atIdx === -1) {
185
- throw new Error(`Invalid agent address: "${agentAddress}"`);
186
- }
187
- return agentAddress.substring(0, atIdx);
188
- }
189
-
190
- export async function requireInstance(
191
- db: DB["db"],
192
- agentAddress: string,
193
- ): Promise<
194
- NonNullable<Awaited<ReturnType<typeof db.query.agentInstance.findFirst>>>
195
- > {
196
- const row = await db.query.agentInstance.findFirst({
197
- where: and(
198
- eq(agentInstance.address, agentAddress),
199
- isNull(agentInstance.endedAt),
200
- ),
201
- });
202
- if (!row) {
203
- throw new Error(`No active instance found for address "${agentAddress}"`);
204
- }
205
- return row;
206
- }