@intx/hub-sessions 0.1.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.
@@ -0,0 +1,464 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { and, eq } from "drizzle-orm";
4
+
5
+ import { getLogger } from "@intx/log";
6
+ import {
7
+ assembleMessage,
8
+ assembleSignedContent,
9
+ createDetachedSignatureFromProvider,
10
+ type MessageHeaders,
11
+ } from "@intx/mime";
12
+ import type { DB } from "@intx/db";
13
+ import { sessionAsset as sessionAssetTable } from "@intx/db/schema";
14
+ import type { CryptoProvider, HarnessConfig } from "@intx/types/runtime";
15
+ import type { AgentRepoStore, DeployContent } from "./agent-repo";
16
+ import type { AgentAssetWithAsset, AssetService } from "./asset-service";
17
+ import {
18
+ buildAvailableSkillsStanza,
19
+ type AvailableSkillEntry,
20
+ } from "./available-skills-stanza";
21
+ import { getSkillIndex } from "./skill-kind";
22
+ import type { SidecarRouter } from "./ws/sidecar-handler";
23
+ import type { Principal, RepoId } from "./repo-store";
24
+
25
+ const logger = getLogger(["interchange", "hub", "session-service"]);
26
+
27
+ export class SessionLaunchError extends Error {
28
+ /** Which phase failed: "write", "provision", "pack", or "start". */
29
+ readonly phase: string;
30
+ /** True if the sidecar has a provisioned agent that could not be cleaned up. */
31
+ readonly leakedAgent: boolean;
32
+
33
+ constructor(phase: string, cause: unknown, leakedAgent: boolean) {
34
+ const msg =
35
+ cause instanceof Error ? cause.message : "Session launch failed";
36
+ super(msg, { cause });
37
+ this.name = "SessionLaunchError";
38
+ this.phase = phase;
39
+ this.leakedAgent = leakedAgent;
40
+ }
41
+ }
42
+
43
+ export type SessionService = {
44
+ /**
45
+ * Orchestrate the full deploy lifecycle:
46
+ * 1. Write deploy tree to hub repo and produce packfile
47
+ * 2. Provision agent on sidecar (sendAgentDeploy)
48
+ * 3. Deliver deploy packfile (sendPack)
49
+ * 4. Fan-out attached asset packs: for each row returned by
50
+ * `assetService.listAgentAssets(agentId)`, resolve the
51
+ * mountPath, resolve the ref to a source commit SHA, build a
52
+ * pack, insert a `session_asset` row, and send the pack to the
53
+ * sidecar with `mountPath` set on the `repo.pack.done` frame.
54
+ * The manifest insert MUST precede the pack send.
55
+ * 5. Start session (sendSessionStart)
56
+ *
57
+ * On partial failure after provision, attempts cleanup via
58
+ * sendAgentUndeploy before re-throwing.
59
+ */
60
+ launchSession(params: {
61
+ agentAddress: string;
62
+ agentId: string;
63
+ instanceId: string;
64
+ config: HarnessConfig;
65
+ deployContent: DeployContent;
66
+ }): Promise<void>;
67
+
68
+ /**
69
+ * Compose a signed RFC 2822 message from the user and deliver it to the
70
+ * agent via the mail transport. Throws if the agent is unreachable.
71
+ * Returns the raw MIME bytes of the assembled message.
72
+ */
73
+ sendUserMessage(params: UserMessageParams): Promise<Uint8Array>;
74
+
75
+ /**
76
+ * Undeploy an agent and wait for the sidecar to acknowledge.
77
+ */
78
+ endSession(agentAddress: string, reason: string): Promise<void>;
79
+ };
80
+
81
+ export type UserMessageParams = {
82
+ agentAddress: string;
83
+ from: string;
84
+ messageId: string;
85
+ date: Date;
86
+ content: string;
87
+ inReplyTo?: string;
88
+ references?: string[];
89
+ sessionId: string;
90
+ tenantId: string;
91
+ cryptoProvider: CryptoProvider;
92
+ };
93
+
94
+ export type SessionServiceDeps = {
95
+ sidecarRouter: SidecarRouter;
96
+ agentRepoStore: AgentRepoStore;
97
+ /**
98
+ * Optional asset attachment integration. When set, `launchSession`
99
+ * fans out per-attachment packs after the deploy pack lands and
100
+ * inserts a `session_asset` row per attachment. When unset, only
101
+ * the deploy pack is sent — the legacy single-pack path is
102
+ * preserved bit-for-bit.
103
+ */
104
+ assetService?: AssetService;
105
+ /** DB handle used for `session_asset` manifest inserts. Required
106
+ * iff `assetService` is set. */
107
+ db?: DB["db"];
108
+ };
109
+
110
+ // Hub-side principal for reading skill repos. Skills are signed by the
111
+ // hub itself, and listAgentAssets is being called on the hub to assemble
112
+ // packs for delivery to a sidecar — so the hub principal is correct.
113
+ const HUB_PRINCIPAL: Principal = { kind: "hub" };
114
+
115
+ type ResolvedAttachment = {
116
+ agentAssetId: string;
117
+ /** Asset `name` column. Used to build the qualified `<asset.name>/<skill-name>`
118
+ * prefix in the `<available_skills>` stanza. */
119
+ assetName: string;
120
+ /** Asset `kind` column, used to gate skill-index lookups. */
121
+ assetKind: AgentAssetWithAsset["asset"]["kind"];
122
+ mountPath: string;
123
+ sourceCommitSha: string;
124
+ repoId: RepoId;
125
+ pack: Uint8Array;
126
+ ref: string;
127
+ };
128
+
129
+ function createPackSha(pack: Uint8Array): string {
130
+ return createHash("sha256").update(pack).digest("hex");
131
+ }
132
+
133
+ /**
134
+ * Compute the materialization path for an attachment from the asset's
135
+ * kind and name. v1 does not let users override the path — the path is
136
+ * a function of the asset, full stop. Today only `skill` has a defined
137
+ * mapping (`skills/<asset.name>/`); other kinds reach this code path
138
+ * via the `never` branch and throw, per the defensive-coding rule that
139
+ * we never silently invent a default for an unhandled kind.
140
+ *
141
+ * Asset names are validated lowercase-kebab at `createAsset`, which is
142
+ * the only entry path into this function, so the resulting path is
143
+ * safe under `applyAssetPack`'s per-segment validator.
144
+ */
145
+ function resolveMountPath(row: AgentAssetWithAsset): string {
146
+ switch (row.asset.kind) {
147
+ case "skill":
148
+ return `skills/${row.asset.name}/`;
149
+ case "agent-state":
150
+ throw new Error(
151
+ `mount_path_required: agent_asset row ${row.id} references agent-state asset ${row.asset.id}; agent-state attachments are not supported`,
152
+ );
153
+ default: {
154
+ const exhaustive: never = row.asset.kind;
155
+ throw new Error(
156
+ `mount_path_required: no default mountPath for asset kind ${String(exhaustive)} on row ${row.id}`,
157
+ );
158
+ }
159
+ }
160
+ }
161
+
162
+ export function createSessionService(deps: SessionServiceDeps): SessionService {
163
+ const { sidecarRouter, agentRepoStore, assetService, db } = deps;
164
+
165
+ if (assetService !== undefined && db === undefined) {
166
+ throw new Error(
167
+ "createSessionService: db is required when assetService is set",
168
+ );
169
+ }
170
+
171
+ async function launchSession(params: {
172
+ agentAddress: string;
173
+ agentId: string;
174
+ instanceId: string;
175
+ config: HarnessConfig;
176
+ deployContent: DeployContent;
177
+ }): Promise<void> {
178
+ const { agentAddress, agentId, instanceId, config, deployContent } = params;
179
+
180
+ // Phase 0: Resolve attached assets first so the skill index is in
181
+ // hand before the deploy tree is written. The `<available_skills>`
182
+ // stanza describing every attached skill must land in
183
+ // `deploy/prompt.md`, so it has to be composed before
184
+ // `writeDeployTree` produces the on-disk tree.
185
+ let attachments: ResolvedAttachment[] = [];
186
+ let availableSkills: AvailableSkillEntry[] = [];
187
+ if (assetService !== undefined) {
188
+ try {
189
+ attachments = await resolveAttachments(assetService, agentId);
190
+ availableSkills = collectAvailableSkills(attachments);
191
+ } catch (err) {
192
+ throw new SessionLaunchError("write", err, false);
193
+ }
194
+ }
195
+
196
+ const stanza = buildAvailableSkillsStanza(availableSkills);
197
+ const effectiveDeployContent: DeployContent =
198
+ stanza.length === 0
199
+ ? deployContent
200
+ : {
201
+ ...deployContent,
202
+ systemPrompt: `${deployContent.systemPrompt}\n\n${stanza}\n`,
203
+ };
204
+
205
+ // Phase 0b: Write deploy tree and produce packfile (hub-local, no
206
+ // sidecar state to clean up if this fails).
207
+ let pack: Uint8Array;
208
+ let commitSha: string;
209
+ let ref: string;
210
+ try {
211
+ await agentRepoStore.writeDeployTree(agentId, effectiveDeployContent);
212
+ ({ pack, commitSha, ref } =
213
+ await agentRepoStore.createDeployPack(agentId));
214
+ } catch (err) {
215
+ throw new SessionLaunchError("write", err, false);
216
+ }
217
+
218
+ // Phase 1: Provision on sidecar.
219
+ try {
220
+ await sidecarRouter.sendAgentDeploy(agentAddress, config);
221
+ } catch (err) {
222
+ throw new SessionLaunchError("provision", err, false);
223
+ }
224
+
225
+ // Phases 2-3: Pack delivery and session start. If either fails,
226
+ // attempt cleanup so the sidecar doesn't retain a zombie agent.
227
+ try {
228
+ await sidecarRouter.sendPack(agentAddress, pack, ref, commitSha);
229
+ } catch (err) {
230
+ await attemptCleanup(agentAddress, "pack", err);
231
+ throw new SessionLaunchError("pack", err, false);
232
+ }
233
+
234
+ // Phase 2b: Asset-pack fan-out. For each attached asset, build a
235
+ // pack, insert the manifest row, then send the pack. The manifest
236
+ // insert MUST happen before the pack send: if the sidecar acks
237
+ // but the row is missing, the session has materialization without
238
+ // a recorded manifest. If the row insert fails, the pack send
239
+ // must not happen.
240
+ if (assetService !== undefined && attachments.length > 0) {
241
+ for (const att of attachments) {
242
+ try {
243
+ await sendAttachmentPack(instanceId, agentAddress, att);
244
+ } catch (err) {
245
+ await attemptCleanup(agentAddress, "pack", err);
246
+ throw new SessionLaunchError("pack", err, false);
247
+ }
248
+ }
249
+ }
250
+
251
+ try {
252
+ await sidecarRouter.sendSessionStart(agentAddress);
253
+ } catch (err) {
254
+ await attemptCleanup(agentAddress, "start", err);
255
+ throw new SessionLaunchError("start", err, false);
256
+ }
257
+ }
258
+
259
+ async function sendAttachmentPack(
260
+ instanceId: string,
261
+ agentAddress: string,
262
+ attachment: ResolvedAttachment,
263
+ ): Promise<void> {
264
+ if (db === undefined) {
265
+ // Guarded at construction; reassert defensively so the
266
+ // narrowing is visible to readers and a future refactor cannot
267
+ // accidentally invoke this without a db.
268
+ throw new Error("sendAttachmentPack invoked without a db handle");
269
+ }
270
+
271
+ const { agentAssetId, mountPath, sourceCommitSha, repoId, pack, ref } =
272
+ attachment;
273
+
274
+ const assetPackSha = createPackSha(pack);
275
+
276
+ // Insert manifest row before the pack send so we never end up in
277
+ // the materialized-without-manifest state.
278
+ await db.insert(sessionAssetTable).values({
279
+ instanceId,
280
+ agentAssetId,
281
+ mountPath,
282
+ assetPackSha,
283
+ sourceCommitSha,
284
+ materializedAt: new Date(),
285
+ });
286
+
287
+ try {
288
+ await sidecarRouter.sendPack(agentAddress, pack, ref, sourceCommitSha, {
289
+ mountPath,
290
+ repoId,
291
+ });
292
+ } catch (err) {
293
+ // Roll back the manifest row when the send fails so the manifest
294
+ // and the materialized state on the sidecar can never disagree.
295
+ // The forensic value of a manifest-without-materialization row is
296
+ // negligible because no agent will read against it. Wrap the
297
+ // rollback in its own try/catch so a rollback failure (DB gone,
298
+ // connection killed mid-launch) is logged rather than masking the
299
+ // primary sendPack error — the caller needs to see the original
300
+ // failure, not the secondary one.
301
+ try {
302
+ await db
303
+ .delete(sessionAssetTable)
304
+ .where(
305
+ and(
306
+ eq(sessionAssetTable.instanceId, instanceId),
307
+ eq(sessionAssetTable.agentAssetId, agentAssetId),
308
+ ),
309
+ );
310
+ } catch (rollbackErr) {
311
+ const msg =
312
+ rollbackErr instanceof Error
313
+ ? rollbackErr.message
314
+ : String(rollbackErr);
315
+ logger.warn`session_asset rollback failed for instance=${instanceId} agentAsset=${agentAssetId}: ${msg}`;
316
+ }
317
+ throw err;
318
+ }
319
+ }
320
+
321
+ async function resolveAttachments(
322
+ service: AssetService,
323
+ agentId: string,
324
+ ): Promise<ResolvedAttachment[]> {
325
+ const rows = await service.listAgentAssets(agentId);
326
+ const resolved: ResolvedAttachment[] = [];
327
+ for (const row of rows) {
328
+ resolved.push(await resolveAttachment(row));
329
+ }
330
+ return resolved;
331
+ }
332
+
333
+ async function resolveAttachment(
334
+ row: AgentAssetWithAsset,
335
+ ): Promise<ResolvedAttachment> {
336
+ const mountPath = resolveMountPath(row);
337
+ const repoId: RepoId = { kind: row.asset.kind, id: row.asset.id };
338
+
339
+ const sourceCommitSha = await agentRepoStore.repoStore.resolveRef(
340
+ HUB_PRINCIPAL,
341
+ repoId,
342
+ row.ref,
343
+ );
344
+ if (sourceCommitSha === null) {
345
+ throw new Error(
346
+ `attachment_ref_unresolved: ${row.asset.kind}/${row.asset.id} has no commit on ${row.ref}`,
347
+ );
348
+ }
349
+
350
+ const { pack, ref: returnedRef } =
351
+ await agentRepoStore.repoStore.createPack(HUB_PRINCIPAL, repoId, row.ref);
352
+
353
+ return {
354
+ agentAssetId: row.id,
355
+ assetName: row.asset.name,
356
+ assetKind: row.asset.kind,
357
+ mountPath,
358
+ sourceCommitSha,
359
+ repoId,
360
+ pack,
361
+ ref: returnedRef,
362
+ };
363
+ }
364
+
365
+ function collectAvailableSkills(
366
+ resolved: ResolvedAttachment[],
367
+ ): AvailableSkillEntry[] {
368
+ const entries: AvailableSkillEntry[] = [];
369
+ for (const att of resolved) {
370
+ if (att.assetKind !== "skill") continue;
371
+ const index = getSkillIndex(att.repoId.id, att.ref);
372
+ for (const entry of index) {
373
+ entries.push({
374
+ qualifiedName: `${att.assetName}/${entry.name}`,
375
+ description: entry.description,
376
+ workspacePath: `workspace/${att.mountPath}${entry.workspaceSubpath}`,
377
+ });
378
+ }
379
+ }
380
+ return entries;
381
+ }
382
+
383
+ async function attemptCleanup(
384
+ agentAddress: string,
385
+ failedPhase: string,
386
+ originalErr: unknown,
387
+ ): Promise<void> {
388
+ try {
389
+ await sidecarRouter.sendAgentUndeploy(agentAddress, failedPhase);
390
+ } catch (cleanupErr) {
391
+ logger.error`Failed to clean up agent ${agentAddress} after ${failedPhase} failure: ${String(cleanupErr)}`;
392
+ // Preserve the original error as cause so the root cause is not
393
+ // lost when the cleanup also fails.
394
+ throw new SessionLaunchError(failedPhase, originalErr, true);
395
+ }
396
+ }
397
+
398
+ async function sendUserMessage(
399
+ params: UserMessageParams,
400
+ ): Promise<Uint8Array> {
401
+ const {
402
+ agentAddress,
403
+ from,
404
+ messageId,
405
+ date,
406
+ content,
407
+ inReplyTo,
408
+ references,
409
+ sessionId,
410
+ tenantId,
411
+ cryptoProvider,
412
+ } = params;
413
+
414
+ const headers: MessageHeaders = {
415
+ from,
416
+ to: [agentAddress],
417
+ cc: undefined,
418
+ date,
419
+ messageId,
420
+ subject: undefined,
421
+ inReplyTo,
422
+ references,
423
+ mimeVersion: "1.0",
424
+ interchangeType: "conversation.message",
425
+ interchangeCorrelationId: undefined,
426
+ interchangeTenantId: tenantId,
427
+ interchangeAgentId: undefined,
428
+ interchangeSessionId: sessionId,
429
+ interchangeOfferingId: undefined,
430
+ interchangeSchemaVersion: undefined,
431
+ traceparent: undefined,
432
+ tracestate: undefined,
433
+ };
434
+
435
+ const signedContent = assembleSignedContent({
436
+ kind: "conversation",
437
+ text: content,
438
+ });
439
+ const signature = await createDetachedSignatureFromProvider(
440
+ signedContent,
441
+ cryptoProvider,
442
+ );
443
+ const rawMessage = assembleMessage(headers, signedContent, signature);
444
+ const base64 = Buffer.from(rawMessage).toString("base64");
445
+
446
+ const delivered = sidecarRouter.routeMail(agentAddress, base64);
447
+ if (!delivered) {
448
+ throw new Error(
449
+ `Failed to deliver message to ${agentAddress}: agent is unreachable`,
450
+ );
451
+ }
452
+
453
+ return rawMessage;
454
+ }
455
+
456
+ async function endSession(
457
+ agentAddress: string,
458
+ reason: string,
459
+ ): Promise<void> {
460
+ await sidecarRouter.sendAgentUndeploy(agentAddress, reason);
461
+ }
462
+
463
+ return { launchSession, sendUserMessage, endSession };
464
+ }