@intx/hub-sessions 0.1.2 → 0.2.2

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 (87) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +84 -1
  3. package/dist/agent-repo.d.ts +89 -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 +185 -0
  7. package/dist/asset-service.d.ts +123 -0
  8. package/dist/asset-service.js +349 -0
  9. package/dist/available-skills-stanza.d.ts +21 -0
  10. package/dist/available-skills-stanza.js +32 -0
  11. package/dist/credential-push.d.ts +32 -0
  12. package/dist/credential-push.js +85 -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 +357 -0
  17. package/dist/hub-session-lookups.d.ts +17 -0
  18. package/dist/hub-session-lookups.js +204 -0
  19. package/dist/hub-session-orchestrator.d.ts +25 -0
  20. package/dist/hub-session-orchestrator.js +122 -0
  21. package/dist/index.d.ts +18 -0
  22. package/dist/index.js +16 -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 +1692 -0
  29. package/dist/repo-store/subscribe-kind.d.ts +53 -0
  30. package/dist/repo-store/subscribe-kind.js +179 -0
  31. package/dist/repo-store/types.d.ts +483 -0
  32. package/dist/repo-store/types.js +42 -0
  33. package/dist/session-service.d.ts +235 -0
  34. package/dist/session-service.js +997 -0
  35. package/dist/skill-kind.d.ts +41 -0
  36. package/dist/skill-kind.js +288 -0
  37. package/dist/substrate.d.ts +8 -0
  38. package/dist/substrate.js +21 -0
  39. package/dist/workflow-kind.d.ts +21 -0
  40. package/dist/workflow-kind.js +263 -0
  41. package/dist/workflow-run-event-log.d.ts +21 -0
  42. package/dist/workflow-run-event-log.js +51 -0
  43. package/dist/workflow-run-kind.d.ts +326 -0
  44. package/dist/workflow-run-kind.js +2646 -0
  45. package/dist/workflow-run-reader.d.ts +47 -0
  46. package/dist/workflow-run-reader.js +157 -0
  47. package/dist/ws/index.d.ts +3 -0
  48. package/dist/ws/index.js +3 -0
  49. package/dist/ws/sidecar-events.d.ts +134 -0
  50. package/dist/ws/sidecar-events.js +70 -0
  51. package/dist/ws/sidecar-handler.d.ts +184 -0
  52. package/dist/ws/sidecar-handler.js +1603 -0
  53. package/dist/ws/sidecar-token-authenticator.d.ts +15 -0
  54. package/dist/ws/sidecar-token-authenticator.js +24 -0
  55. package/package.json +34 -12
  56. package/src/agent-repo.test.ts +0 -310
  57. package/src/agent-repo.ts +0 -165
  58. package/src/agent-state-kind.test.ts +0 -247
  59. package/src/agent-state-kind.ts +0 -204
  60. package/src/asset-service.test.ts +0 -540
  61. package/src/asset-service.ts +0 -378
  62. package/src/available-skills-stanza.test.ts +0 -87
  63. package/src/available-skills-stanza.ts +0 -47
  64. package/src/credential-push.ts +0 -65
  65. package/src/event-collector-registry.test.ts +0 -73
  66. package/src/event-collector-registry.ts +0 -171
  67. package/src/event-collector.test.ts +0 -1387
  68. package/src/event-collector.ts +0 -424
  69. package/src/hub-session-lookups.ts +0 -206
  70. package/src/hub-session-orchestrator.test.ts +0 -510
  71. package/src/hub-session-orchestrator.ts +0 -213
  72. package/src/index.ts +0 -78
  73. package/src/repo-store/index.ts +0 -15
  74. package/src/repo-store/store.test.ts +0 -1169
  75. package/src/repo-store/store.ts +0 -428
  76. package/src/repo-store/types.ts +0 -253
  77. package/src/session-service.test.ts +0 -895
  78. package/src/session-service.ts +0 -464
  79. package/src/skill-kind.test.ts +0 -599
  80. package/src/skill-kind.ts +0 -350
  81. package/src/ws/index.ts +0 -18
  82. package/src/ws/sidecar-events.test.ts +0 -96
  83. package/src/ws/sidecar-events.ts +0 -231
  84. package/src/ws/sidecar-handler.test.ts +0 -2217
  85. package/src/ws/sidecar-handler.ts +0 -1574
  86. package/tsconfig.json +0 -4
  87. 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
- }