@intx/hub-api 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.
Files changed (55) hide show
  1. package/README.md +29 -0
  2. package/package.json +28 -0
  3. package/src/app.test.ts +225 -0
  4. package/src/app.ts +382 -0
  5. package/src/auth.ts +21 -0
  6. package/src/context.ts +38 -0
  7. package/src/format.ts +9 -0
  8. package/src/git-http/advertise-refs.test.ts +459 -0
  9. package/src/git-http/advertise-refs.ts +226 -0
  10. package/src/git-http/pkt-line.test.ts +220 -0
  11. package/src/git-http/pkt-line.ts +235 -0
  12. package/src/git-http/receive-pack.test.ts +397 -0
  13. package/src/git-http/receive-pack.ts +261 -0
  14. package/src/git-http/side-band-64k.test.ts +181 -0
  15. package/src/git-http/side-band-64k.ts +134 -0
  16. package/src/git-http/upload-pack.test.ts +545 -0
  17. package/src/git-http/upload-pack.ts +396 -0
  18. package/src/index.ts +23 -0
  19. package/src/middleware/git-token-auth.test.ts +587 -0
  20. package/src/middleware/git-token-auth.ts +315 -0
  21. package/src/middleware/grant.ts +106 -0
  22. package/src/middleware/session.ts +13 -0
  23. package/src/middleware/tenant.test.ts +192 -0
  24. package/src/middleware/tenant.ts +101 -0
  25. package/src/openapi.ts +66 -0
  26. package/src/pagination.ts +117 -0
  27. package/src/routes/agent-data.ts +179 -0
  28. package/src/routes/agent-state-git.ts +562 -0
  29. package/src/routes/agents.test.ts +337 -0
  30. package/src/routes/agents.ts +704 -0
  31. package/src/routes/approvals.ts +130 -0
  32. package/src/routes/assets.test.ts +567 -0
  33. package/src/routes/assets.ts +592 -0
  34. package/src/routes/credentials.ts +435 -0
  35. package/src/routes/git-tokens.test.ts +709 -0
  36. package/src/routes/git-tokens.ts +771 -0
  37. package/src/routes/grants.ts +509 -0
  38. package/src/routes/instances.test.ts +1103 -0
  39. package/src/routes/instances.ts +1797 -0
  40. package/src/routes/me.ts +405 -0
  41. package/src/routes/oauth-clients.ts +349 -0
  42. package/src/routes/observability.ts +146 -0
  43. package/src/routes/offerings.ts +382 -0
  44. package/src/routes/principals.ts +515 -0
  45. package/src/routes/providers.ts +351 -0
  46. package/src/routes/roles.ts +452 -0
  47. package/src/routes/sidecars.ts +221 -0
  48. package/src/routes/tenant-federation.ts +225 -0
  49. package/src/routes/tenants.ts +369 -0
  50. package/src/routes/wallets.ts +370 -0
  51. package/src/session.ts +44 -0
  52. package/src/timeline-reconstruction.test.ts +786 -0
  53. package/src/timeline-reconstruction.ts +383 -0
  54. package/tsconfig.json +4 -0
  55. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,1797 @@
1
+ import { eq, and, inArray, asc } from "drizzle-orm";
2
+ import { Hono } from "hono";
3
+ import { describeRoute, resolver, validator } from "hono-openapi";
4
+ import { streamSSE } from "hono/streaming";
5
+ import { type } from "arktype";
6
+
7
+ import {
8
+ agent,
9
+ agentInstance,
10
+ agentRole,
11
+ agentSession,
12
+ grant as grantTable,
13
+ inferenceTurn,
14
+ offering,
15
+ principal as principalTable,
16
+ principalRole,
17
+ sessionMail,
18
+ turnPart,
19
+ } from "@intx/db/schema";
20
+ import { resolveOneCredential } from "@intx/db";
21
+ import type { DB } from "@intx/db";
22
+ import { evaluateGrants, authorize } from "@intx/authz";
23
+ import type { ConditionRegistry, GrantStore } from "@intx/types/authz";
24
+ import { parseMailToEmail, extractPartByPath } from "@intx/mime";
25
+
26
+ import { generateKeyPair, createNodeCrypto } from "@intx/crypto-node";
27
+ import {
28
+ CreateAgentInstance,
29
+ AgentInstanceResponse,
30
+ AgentHealth,
31
+ CredentialRequirement,
32
+ OfferingDetail,
33
+ GrantRequirement,
34
+ SendMessage,
35
+ MailResponse,
36
+ InferenceTurnResponse,
37
+ ErrorResponse,
38
+ formatAgentAddress,
39
+ paginatedSchema,
40
+ } from "@intx/types";
41
+ import type { GrantEffect, GrantOrigin } from "@intx/types";
42
+ import type { CryptoProvider, InferenceSource } from "@intx/types/runtime";
43
+ import {
44
+ SessionLaunchError,
45
+ type EventCollectorRegistry,
46
+ type SessionService,
47
+ type SidecarRouter,
48
+ } from "@intx/hub-sessions";
49
+ import { formatOffering } from "./offerings";
50
+
51
+ import type { TenantEnv } from "../context";
52
+ import { idResource } from "../middleware/grant";
53
+ import type { RequireGrant } from "../middleware/grant";
54
+ import { generateId } from "@intx/hub-common";
55
+ import { first, ts } from "../format";
56
+ import {
57
+ parsePageParams,
58
+ cursorCondition,
59
+ pageOrder,
60
+ paginatedResponse,
61
+ pageParameters,
62
+ } from "../pagination";
63
+
64
+ const CredentialRequirements = CredentialRequirement.array();
65
+ const GrantRequirements = GrantRequirement.array();
66
+
67
+ const ModelConfig = type({
68
+ defaultModel: "string",
69
+ });
70
+
71
+ const AbortBody = type({
72
+ "reason?":
73
+ "'user_disconnect' | 'wallet_exhaustion' | 'admin_kill' | 'session_timeout' | 'credential_revocation'",
74
+ });
75
+
76
+ function formatInstance(
77
+ row: typeof agentInstance.$inferSelect,
78
+ agentName: string,
79
+ ) {
80
+ return {
81
+ id: row.id,
82
+ agentId: row.agentId,
83
+ agentName,
84
+ tenantId: row.tenantId,
85
+ address: row.address,
86
+ status: row.status,
87
+ publicKey: row.publicKey ?? null,
88
+ kernelId: row.kernelId ?? null,
89
+ sidecarId: row.sidecarId ?? null,
90
+ createdAt: ts(row.createdAt),
91
+ updatedAt: ts(row.updatedAt),
92
+ endedAt: row.endedAt ? ts(row.endedAt) : null,
93
+ };
94
+ }
95
+
96
+ export type CreateInstanceRoutesDeps = {
97
+ db: DB["db"];
98
+ sessionService: SessionService;
99
+ sidecarRouter: SidecarRouter;
100
+ eventCollectors: EventCollectorRegistry;
101
+ grantStore: GrantStore;
102
+ conditionRegistry: ConditionRegistry;
103
+ requireGrant: RequireGrant;
104
+ };
105
+
106
+ export function createInstanceRoutes({
107
+ db,
108
+ sessionService,
109
+ sidecarRouter,
110
+ eventCollectors,
111
+ grantStore,
112
+ conditionRegistry,
113
+ requireGrant,
114
+ }: CreateInstanceRoutesDeps): Hono<TenantEnv> {
115
+ const app = new Hono<TenantEnv>();
116
+
117
+ app.post(
118
+ "/",
119
+ requireGrant("instance:*", "create"),
120
+ describeRoute({
121
+ tags: ["Instances"],
122
+ summary: "Deploy an agent instance",
123
+ description:
124
+ "Creates a new running instance of the specified agent definition. Resolves the definition's credential and grant requirements, materializes grants on a new agent principal, provisions the agent on a sidecar, and starts it. The invoker can provide invokerGrants to delegate additional capabilities, resolved against the invoker's own authority at launch.",
125
+ responses: {
126
+ 201: {
127
+ description: "Instance deployed",
128
+ content: {
129
+ "application/json": { schema: resolver(AgentInstanceResponse) },
130
+ },
131
+ },
132
+ 404: {
133
+ description: "Agent definition not found",
134
+ content: {
135
+ "application/json": { schema: resolver(ErrorResponse) },
136
+ },
137
+ },
138
+ 409: {
139
+ description: "Agent not launchable",
140
+ content: {
141
+ "application/json": { schema: resolver(ErrorResponse) },
142
+ },
143
+ },
144
+ 502: {
145
+ description: "Sidecar unavailable",
146
+ content: {
147
+ "application/json": { schema: resolver(ErrorResponse) },
148
+ },
149
+ },
150
+ },
151
+ }),
152
+ validator("json", CreateAgentInstance),
153
+ async (c) => {
154
+ const tenant = c.get("tenant");
155
+ const principal = c.get("principal");
156
+ const body = c.req.valid("json");
157
+
158
+ const row = await db.query.agent.findFirst({
159
+ where: and(eq(agent.id, body.agentId), eq(agent.tenantId, tenant.id)),
160
+ });
161
+
162
+ if (!row) {
163
+ return c.json(
164
+ { error: { code: "not_found", message: "Agent not found" } },
165
+ 404,
166
+ );
167
+ }
168
+
169
+ if (row.status !== "deployed") {
170
+ return c.json(
171
+ {
172
+ error: {
173
+ code: "conflict",
174
+ message: `Agent is not in a launchable state (status: ${row.status})`,
175
+ },
176
+ },
177
+ 409,
178
+ );
179
+ }
180
+
181
+ if (!row.systemPrompt) {
182
+ return c.json(
183
+ {
184
+ error: {
185
+ code: "not_launchable",
186
+ message:
187
+ "Agent cannot be launched without a system prompt configured",
188
+ },
189
+ },
190
+ 409,
191
+ );
192
+ }
193
+
194
+ const instanceId = generateId("instance");
195
+ const agentAddress = formatAgentAddress(instanceId, tenant.domain);
196
+
197
+ // --- Credential resolution ---
198
+
199
+ const parsedRequirements = CredentialRequirements(
200
+ row.credentialRequirements ?? [],
201
+ );
202
+ if (parsedRequirements instanceof type.errors) {
203
+ return c.json(
204
+ {
205
+ error: {
206
+ code: "not_launchable",
207
+ message: `Invalid credential requirements: ${parsedRequirements.summary}`,
208
+ },
209
+ },
210
+ 409,
211
+ );
212
+ }
213
+
214
+ const creatorPrincipalId = row.creatorPrincipalId;
215
+
216
+ // Parse modelConfig up front: the model identity is part of every
217
+ // resolved InferenceSource (pre-catalog the same `defaultModel`
218
+ // gets stamped on each one), and the resolution loop below needs
219
+ // it. Surfacing an invalid modelConfig before the credential
220
+ // resolution avoids partial work that would have to be unwound.
221
+ const modelConfig = ModelConfig(row.modelConfig ?? {});
222
+ if (modelConfig instanceof type.errors) {
223
+ return c.json(
224
+ {
225
+ error: {
226
+ code: "not_launchable",
227
+ message: `Agent model configuration is invalid: ${modelConfig.summary}`,
228
+ },
229
+ },
230
+ 409,
231
+ );
232
+ }
233
+ const defaultModel = modelConfig.defaultModel;
234
+
235
+ const sources: InferenceSource[] = [];
236
+ for (const req of parsedRequirements) {
237
+ const outcome = await resolveOneCredential(
238
+ db,
239
+ tenant.id,
240
+ req,
241
+ creatorPrincipalId,
242
+ principal.id,
243
+ defaultModel,
244
+ );
245
+ if (outcome.ok) {
246
+ sources.push(outcome.source);
247
+ continue;
248
+ }
249
+ switch (outcome.reason) {
250
+ case "skipped":
251
+ // Requirement targets a principal that does not exist
252
+ // (invoker without a session principal). Skip silently;
253
+ // the resulting empty sources[] is surfaced as a
254
+ // `not_launchable` 409 below.
255
+ continue;
256
+ case "credential_error":
257
+ return c.json(
258
+ {
259
+ error: { code: "credential_error", message: outcome.message },
260
+ },
261
+ 409,
262
+ );
263
+ case "credential_missing":
264
+ return c.json(
265
+ {
266
+ error: {
267
+ code: "credential_missing",
268
+ message: `No credential found for provider "${outcome.requirement.providerName}" (source: ${outcome.requirement.source})`,
269
+ },
270
+ },
271
+ 409,
272
+ );
273
+ case "provider_missing":
274
+ return c.json(
275
+ {
276
+ error: {
277
+ code: "provider_missing",
278
+ message: `Provider not found for credential "${outcome.credentialId}"`,
279
+ },
280
+ },
281
+ 409,
282
+ );
283
+ case "provider_misconfigured":
284
+ return c.json(
285
+ {
286
+ error: {
287
+ code: "provider_misconfigured",
288
+ message: `Provider "${outcome.providerName}" metadata is invalid: ${outcome.summary}`,
289
+ },
290
+ },
291
+ 409,
292
+ );
293
+ }
294
+ }
295
+
296
+ // The first resolved source becomes the active one. Pre-catalog
297
+ // every source carries the same `defaultModel`, so the id of the
298
+ // first source is the canonical `defaultSource` reference.
299
+ const [firstSource] = sources;
300
+ if (firstSource === undefined) {
301
+ // An agent with no credentialRequirements has no resolvable
302
+ // sources, so the inference runtime has nothing to call. Surface
303
+ // this as a 409 here rather than letting the launch reach the
304
+ // sidecar with an empty sources[] only to fail with a less
305
+ // direct error.
306
+ return c.json(
307
+ {
308
+ error: {
309
+ code: "not_launchable",
310
+ message:
311
+ "Agent has no credential requirements; cannot resolve any inference sources",
312
+ },
313
+ },
314
+ 409,
315
+ );
316
+ }
317
+ const defaultSource = firstSource.id;
318
+
319
+ // --- Grant requirement resolution (creator/invoker delegation) ---
320
+
321
+ const instancePrincipalId = generateId("principal");
322
+
323
+ // Collect invoker's grants once — used for both creator and invoker resolution.
324
+ const invokerGrants = await grantStore.collectGrants(
325
+ principal.id,
326
+ tenant.id,
327
+ );
328
+ // Only system/role/creator grants can be delegated. Invoker-sourced
329
+ // grants cannot be transitively re-delegated.
330
+ const delegatableInvokerGrants = invokerGrants.filter(
331
+ (g) => g.origin !== "invoker",
332
+ );
333
+
334
+ // Accumulate grant rows in memory; write to DB only after all
335
+ // requirements resolve. This avoids orphaned rows on partial failure.
336
+ const grantRows: {
337
+ id: string;
338
+ tenantId: string;
339
+ principalId: string;
340
+ resource: string;
341
+ action: string;
342
+ effect: GrantEffect;
343
+ conditions: Record<string, unknown> | null;
344
+ origin: GrantOrigin;
345
+ expiresAt: Date | null;
346
+ createdAt: Date;
347
+ updatedAt: Date;
348
+ }[] = [];
349
+
350
+ const now = new Date();
351
+ const INVOKER_GRANT_TTL_MS = 24 * 60 * 60 * 1000;
352
+ const invokerExpiresAt = new Date(now.getTime() + INVOKER_GRANT_TTL_MS);
353
+
354
+ const parsedGrantReqs = GrantRequirements(row.grantRequirements ?? []);
355
+ if (parsedGrantReqs instanceof type.errors) {
356
+ return c.json(
357
+ {
358
+ error: {
359
+ code: "not_launchable",
360
+ message: `Invalid grant requirements: ${parsedGrantReqs.summary}`,
361
+ },
362
+ },
363
+ 409,
364
+ );
365
+ }
366
+
367
+ // Collect creator's grants once for all creator-sourced requirements.
368
+ const hasCreatorReqs = parsedGrantReqs.some(
369
+ (r) => r.source === "creator",
370
+ );
371
+ const creatorGrants = hasCreatorReqs
372
+ ? await grantStore.collectGrants(creatorPrincipalId, tenant.id)
373
+ : [];
374
+
375
+ for (const req of parsedGrantReqs) {
376
+ const effect = req.effect ?? "allow";
377
+
378
+ if (req.source === "creator") {
379
+ const result = await evaluateGrants(
380
+ creatorGrants,
381
+ req.resource,
382
+ req.action,
383
+ );
384
+ if (result.effect !== "allow") {
385
+ return c.json(
386
+ {
387
+ error: {
388
+ code: "insufficient_grants",
389
+ message: `Creator lacks authority to delegate ${req.resource}/${req.action}`,
390
+ },
391
+ },
392
+ 403,
393
+ );
394
+ }
395
+ grantRows.push({
396
+ id: generateId("grant"),
397
+ tenantId: tenant.id,
398
+ principalId: instancePrincipalId,
399
+ resource: req.resource,
400
+ action: req.action,
401
+ effect,
402
+ conditions: req.conditions ?? null,
403
+ origin: "creator",
404
+ expiresAt: null,
405
+ createdAt: now,
406
+ updatedAt: now,
407
+ });
408
+ } else if (req.source === "invoker") {
409
+ const result = await evaluateGrants(
410
+ delegatableInvokerGrants,
411
+ req.resource,
412
+ req.action,
413
+ );
414
+ if (result.effect !== "allow") {
415
+ return c.json(
416
+ {
417
+ error: {
418
+ code: "insufficient_grants",
419
+ message: `Invoker lacks authority for ${req.resource}/${req.action}`,
420
+ },
421
+ },
422
+ 403,
423
+ );
424
+ }
425
+ grantRows.push({
426
+ id: generateId("grant"),
427
+ tenantId: tenant.id,
428
+ principalId: instancePrincipalId,
429
+ resource: req.resource,
430
+ action: req.action,
431
+ effect,
432
+ conditions: req.conditions ?? null,
433
+ origin: "invoker",
434
+ expiresAt: invokerExpiresAt,
435
+ createdAt: now,
436
+ updatedAt: now,
437
+ });
438
+ } else {
439
+ return c.json(
440
+ {
441
+ error: {
442
+ code: "not_launchable",
443
+ message: `Unknown grant requirement source: ${req.source}`,
444
+ },
445
+ },
446
+ 409,
447
+ );
448
+ }
449
+ }
450
+
451
+ // Process ad-hoc invoker grants from the launch request.
452
+ if (body.invokerGrants) {
453
+ for (const ig of body.invokerGrants) {
454
+ const effect = ig.effect ?? "allow";
455
+ const result = await evaluateGrants(
456
+ delegatableInvokerGrants,
457
+ ig.resource,
458
+ ig.action,
459
+ );
460
+ if (result.effect !== "allow") {
461
+ return c.json(
462
+ {
463
+ error: {
464
+ code: "insufficient_grants",
465
+ message: `Invoker lacks authority for ${ig.resource}/${ig.action}`,
466
+ },
467
+ },
468
+ 403,
469
+ );
470
+ }
471
+ grantRows.push({
472
+ id: generateId("grant"),
473
+ tenantId: tenant.id,
474
+ principalId: instancePrincipalId,
475
+ resource: ig.resource,
476
+ action: ig.action,
477
+ effect,
478
+ conditions: ig.conditions ?? null,
479
+ origin: "invoker",
480
+ expiresAt: invokerExpiresAt,
481
+ createdAt: now,
482
+ updatedAt: now,
483
+ });
484
+ }
485
+ }
486
+
487
+ // --- Resolve agent role assignments for the instance principal ---
488
+
489
+ const agentRoleRows = await db.query.agentRole.findMany({
490
+ where: eq(agentRole.agentId, row.id),
491
+ });
492
+ const agentRoleIds = agentRoleRows.map((a) => a.roleId);
493
+ const agentRoleAssignments =
494
+ agentRoleIds.length > 0
495
+ ? (
496
+ await db.query.role.findMany({
497
+ where: (r, { inArray, and: a }) =>
498
+ a(inArray(r.id, agentRoleIds), eq(r.tenantId, tenant.id)),
499
+ columns: { id: true },
500
+ })
501
+ ).map((r) => ({ roleId: r.id }))
502
+ : [];
503
+
504
+ // --- Write all DB rows in a transaction ---
505
+
506
+ const sessionId = generateId("session");
507
+
508
+ await db.transaction(async (tx) => {
509
+ // Create per-instance principal
510
+ await tx.insert(principalTable).values({
511
+ id: instancePrincipalId,
512
+ tenantId: tenant.id,
513
+ kind: "agent",
514
+ refId: instanceId,
515
+ status: "active",
516
+ createdAt: now,
517
+ updatedAt: now,
518
+ });
519
+
520
+ // Assign the agent definition's roles to the instance principal so
521
+ // that grants flow through the existing RBAC path (collectGrants).
522
+ for (const { roleId } of agentRoleAssignments) {
523
+ await tx.insert(principalRole).values({
524
+ principalId: instancePrincipalId,
525
+ roleId,
526
+ createdAt: now,
527
+ });
528
+ }
529
+
530
+ // Materialize grants on the instance principal
531
+ for (const g of grantRows) {
532
+ await tx.insert(grantTable).values(g);
533
+ }
534
+
535
+ // Transitional agentSession row (FK requirement)
536
+ await tx.insert(agentSession).values({
537
+ id: sessionId,
538
+ tenantId: tenant.id,
539
+ agentId: row.id,
540
+ principalId: principal.id,
541
+ status: "active",
542
+ createdAt: now,
543
+ updatedAt: now,
544
+ });
545
+
546
+ // Create instance row
547
+ await tx.insert(agentInstance).values({
548
+ id: instanceId,
549
+ agentId: row.id,
550
+ tenantId: tenant.id,
551
+ principalId: instancePrincipalId,
552
+ address: agentAddress,
553
+ sessionId,
554
+ status: "deployed",
555
+ createdAt: now,
556
+ updatedAt: now,
557
+ });
558
+
559
+ // Seed a creator-level read grant on the per-instance
560
+ // agent-state repo so the definition creator can read runtime
561
+ // state out of the box. The definition seed point covers the
562
+ // deploy-artifact repo; this covers the runtime repo.
563
+ await tx.insert(grantTable).values({
564
+ id: generateId("grant"),
565
+ tenantId: tenant.id,
566
+ principalId: creatorPrincipalId,
567
+ resource: `agent-state:${instanceId}`,
568
+ action: "read",
569
+ effect: "allow",
570
+ origin: "creator",
571
+ createdAt: now,
572
+ updatedAt: now,
573
+ });
574
+ });
575
+
576
+ // Collect the materialized grants for the deploy frame
577
+ const grants = await grantStore.collectGrants(
578
+ instancePrincipalId,
579
+ tenant.id,
580
+ );
581
+
582
+ eventCollectors.create(agentAddress, tenant.id, sessionId, instanceId);
583
+
584
+ try {
585
+ await sessionService.launchSession({
586
+ agentAddress,
587
+ agentId: row.id,
588
+ instanceId,
589
+ config: {
590
+ sessionId,
591
+ agentId: row.id,
592
+ tenantId: tenant.id,
593
+ principalId: instancePrincipalId,
594
+ agentAddress,
595
+ systemPrompt: row.systemPrompt,
596
+ tools: [],
597
+ grants,
598
+ sources,
599
+ defaultSource,
600
+ },
601
+ deployContent: {
602
+ systemPrompt: row.systemPrompt,
603
+ },
604
+ });
605
+ } catch (err) {
606
+ eventCollectors.abandon(agentAddress);
607
+
608
+ const failedAt = new Date();
609
+
610
+ await db
611
+ .update(agentSession)
612
+ .set({ status: "ended", endedAt: failedAt, updatedAt: failedAt })
613
+ .where(eq(agentSession.id, sessionId));
614
+
615
+ const leaked = err instanceof SessionLaunchError && err.leakedAgent;
616
+
617
+ if (leaked) {
618
+ await db
619
+ .update(agentInstance)
620
+ .set({ status: "error", updatedAt: failedAt })
621
+ .where(eq(agentInstance.id, instanceId));
622
+ } else {
623
+ await db
624
+ .delete(agentInstance)
625
+ .where(eq(agentInstance.id, instanceId));
626
+ }
627
+
628
+ // Deactivate the instance principal created during this launch
629
+ await db
630
+ .update(principalTable)
631
+ .set({ status: "deactivated", updatedAt: failedAt })
632
+ .where(eq(principalTable.id, instancePrincipalId));
633
+
634
+ return c.json(
635
+ {
636
+ error: {
637
+ code: "sidecar_unavailable",
638
+ message:
639
+ err instanceof Error
640
+ ? err.message
641
+ : "Failed to dispatch agent to sidecar",
642
+ },
643
+ },
644
+ 502,
645
+ );
646
+ }
647
+
648
+ const launchedAt = new Date();
649
+
650
+ const launched = first(
651
+ await db
652
+ .update(agentInstance)
653
+ .set({ status: "running", updatedAt: launchedAt })
654
+ .where(eq(agentInstance.id, instanceId))
655
+ .returning(),
656
+ );
657
+
658
+ return c.json(formatInstance(launched, row.name), 201);
659
+ },
660
+ );
661
+
662
+ app.get(
663
+ "/",
664
+ requireGrant("instance:*", "read"),
665
+ describeRoute({
666
+ tags: ["Instances"],
667
+ summary: "List agent instances",
668
+ description:
669
+ "Lists agent instances in the tenant. Filterable by agentId and status.",
670
+ parameters: [
671
+ { name: "agentId", in: "query", schema: { type: "string" } },
672
+ {
673
+ name: "status",
674
+ in: "query",
675
+ schema: {
676
+ type: "string",
677
+ enum: ["deployed", "running", "updating", "error", "stopped"],
678
+ },
679
+ },
680
+ ...pageParameters,
681
+ ],
682
+ responses: {
683
+ 200: {
684
+ description: "List of instances",
685
+ content: {
686
+ "application/json": {
687
+ schema: resolver(paginatedSchema(AgentInstanceResponse)),
688
+ },
689
+ },
690
+ },
691
+ },
692
+ }),
693
+ async (c) => {
694
+ const tenantCtx = c.get("tenant");
695
+ const agentId = c.req.query("agentId");
696
+ const status = c.req.query("status");
697
+ const { limit, cursor } = parsePageParams({
698
+ cursor: c.req.query("cursor"),
699
+ limit: c.req.query("limit"),
700
+ });
701
+
702
+ const conditions = [eq(agentInstance.tenantId, tenantCtx.id)];
703
+ if (agentId !== undefined) {
704
+ conditions.push(eq(agentInstance.agentId, agentId));
705
+ }
706
+ if (
707
+ status === "deployed" ||
708
+ status === "running" ||
709
+ status === "updating" ||
710
+ status === "error" ||
711
+ status === "stopped"
712
+ ) {
713
+ conditions.push(eq(agentInstance.status, status));
714
+ }
715
+ if (cursor) {
716
+ conditions.push(
717
+ cursorCondition(agentInstance.createdAt, agentInstance.id, cursor),
718
+ );
719
+ }
720
+
721
+ const rows = await db
722
+ .select({
723
+ instance: agentInstance,
724
+ agentName: agent.name,
725
+ })
726
+ .from(agentInstance)
727
+ .innerJoin(agent, eq(agentInstance.agentId, agent.id))
728
+ .where(and(...conditions))
729
+ .orderBy(...pageOrder(agentInstance.createdAt, agentInstance.id))
730
+ .limit(limit);
731
+
732
+ return c.json(
733
+ paginatedResponse(
734
+ rows.map((r) => formatInstance(r.instance, r.agentName)),
735
+ rows.map((r) => r.instance),
736
+ limit,
737
+ ),
738
+ );
739
+ },
740
+ );
741
+
742
+ app.get(
743
+ "/blobs/:blobId",
744
+ describeRoute({
745
+ tags: ["Instances"],
746
+ summary: "Fetch a blob by ID",
747
+ description:
748
+ "Returns raw bytes for a MIME part. Blob IDs are issued by the mail parsing layer.",
749
+ responses: {
750
+ 200: {
751
+ description: "Blob bytes",
752
+ content: { "application/octet-stream": {} },
753
+ },
754
+ 400: {
755
+ description: "Invalid blob ID",
756
+ content: {
757
+ "application/json": { schema: resolver(ErrorResponse) },
758
+ },
759
+ },
760
+ 403: {
761
+ description: "Forbidden",
762
+ content: {
763
+ "application/json": { schema: resolver(ErrorResponse) },
764
+ },
765
+ },
766
+ 404: {
767
+ description: "Blob not found",
768
+ content: {
769
+ "application/json": { schema: resolver(ErrorResponse) },
770
+ },
771
+ },
772
+ },
773
+ }),
774
+ async (c) => {
775
+ const blobId = c.req.param("blobId");
776
+
777
+ // Blob IDs have the format: blob_<mailId>_<partPath>
778
+ // where partPath is an IMAP-style section specifier (digits and dots only).
779
+ // mailId may itself contain underscores, so we match the suffix.
780
+ const blobMatch = /^blob_(.+?)_(\d[\d.]*)$/.exec(blobId);
781
+ if (!blobMatch) {
782
+ return c.json(
783
+ { error: { code: "bad_request", message: "Invalid blob ID format" } },
784
+ 400,
785
+ );
786
+ }
787
+
788
+ const mailId = blobMatch[1];
789
+ const partPath = blobMatch[2];
790
+
791
+ if (!mailId || !partPath) {
792
+ return c.json(
793
+ { error: { code: "bad_request", message: "Invalid blob ID format" } },
794
+ 400,
795
+ );
796
+ }
797
+
798
+ const tenant = c.get("tenant");
799
+
800
+ const mailRow = await db.query.sessionMail.findFirst({
801
+ where: and(
802
+ eq(sessionMail.id, mailId),
803
+ eq(sessionMail.tenantId, tenant.id),
804
+ ),
805
+ });
806
+
807
+ if (!mailRow) {
808
+ return c.json(
809
+ { error: { code: "not_found", message: "Blob not found" } },
810
+ 404,
811
+ );
812
+ }
813
+
814
+ const resolvedInstanceId = mailRow.instanceId;
815
+ if (!resolvedInstanceId) {
816
+ return c.json(
817
+ { error: { code: "not_found", message: "Blob not found" } },
818
+ 404,
819
+ );
820
+ }
821
+
822
+ const principal = c.get("principal");
823
+
824
+ const authResult = await authorize(
825
+ grantStore,
826
+ principal.id,
827
+ tenant.id,
828
+ `instance:${resolvedInstanceId}`,
829
+ "read",
830
+ conditionRegistry,
831
+ );
832
+
833
+ if (authResult.effect !== "allow") {
834
+ return c.json(
835
+ {
836
+ error: {
837
+ code: "forbidden",
838
+ message: "You do not have permission to perform this action",
839
+ },
840
+ },
841
+ 403,
842
+ );
843
+ }
844
+
845
+ let partBytes: Uint8Array;
846
+ try {
847
+ partBytes = extractPartByPath(mailRow.raw, partPath);
848
+ } catch {
849
+ return c.json(
850
+ { error: { code: "not_found", message: "Blob not found" } },
851
+ 404,
852
+ );
853
+ }
854
+
855
+ return c.body(
856
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Uint8Array.buffer.slice always returns ArrayBuffer
857
+ partBytes.buffer.slice(
858
+ partBytes.byteOffset,
859
+ partBytes.byteOffset + partBytes.byteLength,
860
+ ) as ArrayBuffer,
861
+ 200,
862
+ {
863
+ "Content-Type": "application/octet-stream",
864
+ },
865
+ );
866
+ },
867
+ );
868
+
869
+ app.get(
870
+ "/:instanceId",
871
+ requireGrant(idResource("instance", "instanceId"), "read"),
872
+ describeRoute({
873
+ tags: ["Instances"],
874
+ summary: "Get instance detail",
875
+ description:
876
+ "Returns instance runtime state including status, public key, and sidecar assignment.",
877
+ responses: {
878
+ 200: {
879
+ description: "Instance detail",
880
+ content: {
881
+ "application/json": { schema: resolver(AgentInstanceResponse) },
882
+ },
883
+ },
884
+ 404: {
885
+ description: "Instance not found",
886
+ content: {
887
+ "application/json": { schema: resolver(ErrorResponse) },
888
+ },
889
+ },
890
+ },
891
+ }),
892
+ async (c) => {
893
+ const tenantCtx = c.get("tenant");
894
+ const instanceId = c.req.param("instanceId");
895
+
896
+ const [row] = await db
897
+ .select({
898
+ instance: agentInstance,
899
+ agentName: agent.name,
900
+ })
901
+ .from(agentInstance)
902
+ .innerJoin(agent, eq(agentInstance.agentId, agent.id))
903
+ .where(
904
+ and(
905
+ eq(agentInstance.id, instanceId),
906
+ eq(agentInstance.tenantId, tenantCtx.id),
907
+ ),
908
+ )
909
+ .limit(1);
910
+
911
+ if (!row) {
912
+ return c.json(
913
+ { error: { code: "not_found", message: "Instance not found" } },
914
+ 404,
915
+ );
916
+ }
917
+
918
+ const result = formatInstance(row.instance, row.agentName) as Record<
919
+ string,
920
+ unknown
921
+ >;
922
+
923
+ // Enrich with runtime status from the event collector if available.
924
+ const runtimeStatus = eventCollectors.getStatus(row.instance.address);
925
+ if (runtimeStatus !== undefined) {
926
+ result["runtimeStatus"] = runtimeStatus.status;
927
+ }
928
+
929
+ return c.json(result);
930
+ },
931
+ );
932
+
933
+ app.get(
934
+ "/:instanceId/health",
935
+ requireGrant(idResource("instance", "instanceId"), "read"),
936
+ describeRoute({
937
+ tags: ["Instances"],
938
+ summary: "Get instance health",
939
+ description:
940
+ "Returns liveness and readiness for a running instance. Liveness reflects whether the instance's sidecar connection is active. Readiness reflects whether the instance has an active event collector and can process work.",
941
+ responses: {
942
+ 200: {
943
+ description: "Health status",
944
+ content: {
945
+ "application/json": { schema: resolver(AgentHealth) },
946
+ },
947
+ },
948
+ 404: {
949
+ description: "Instance not found",
950
+ content: {
951
+ "application/json": { schema: resolver(ErrorResponse) },
952
+ },
953
+ },
954
+ 410: {
955
+ description: "Instance stopped",
956
+ content: {
957
+ "application/json": { schema: resolver(ErrorResponse) },
958
+ },
959
+ },
960
+ },
961
+ }),
962
+ async (c) => {
963
+ const tenantCtx = c.get("tenant");
964
+ const instanceId = c.req.param("instanceId");
965
+
966
+ const row = await db.query.agentInstance.findFirst({
967
+ where: and(
968
+ eq(agentInstance.id, instanceId),
969
+ eq(agentInstance.tenantId, tenantCtx.id),
970
+ ),
971
+ });
972
+
973
+ if (!row) {
974
+ return c.json(
975
+ { error: { code: "not_found", message: "Instance not found" } },
976
+ 404,
977
+ );
978
+ }
979
+
980
+ if (row.status === "stopped") {
981
+ return c.json(
982
+ { error: { code: "gone", message: "Instance has stopped" } },
983
+ 410,
984
+ );
985
+ }
986
+
987
+ const routableAddresses = sidecarRouter.getRoutableAddresses();
988
+ const liveness = routableAddresses.includes(row.address)
989
+ ? "ok"
990
+ : "unhealthy";
991
+
992
+ const status = eventCollectors.getStatus(row.address);
993
+ const readiness = status !== undefined ? "ok" : "not_ready";
994
+
995
+ return c.json({ liveness, readiness, lastCheckedAt: null });
996
+ },
997
+ );
998
+
999
+ app.get(
1000
+ "/:instanceId/offerings",
1001
+ requireGrant(idResource("instance", "instanceId"), "read"),
1002
+ describeRoute({
1003
+ tags: ["Instances"],
1004
+ summary: "List instance offerings",
1005
+ description:
1006
+ "Returns the offerings associated with the instance's agent definition. These represent the capabilities the instance can provide.",
1007
+ responses: {
1008
+ 200: {
1009
+ description: "List of offerings",
1010
+ content: {
1011
+ "application/json": {
1012
+ schema: resolver(OfferingDetail.array()),
1013
+ },
1014
+ },
1015
+ },
1016
+ 404: {
1017
+ description: "Instance not found",
1018
+ content: {
1019
+ "application/json": { schema: resolver(ErrorResponse) },
1020
+ },
1021
+ },
1022
+ },
1023
+ }),
1024
+ async (c) => {
1025
+ const tenantCtx = c.get("tenant");
1026
+ const instanceId = c.req.param("instanceId");
1027
+
1028
+ const [row] = await db
1029
+ .select({
1030
+ instance: agentInstance,
1031
+ agentName: agent.name,
1032
+ })
1033
+ .from(agentInstance)
1034
+ .innerJoin(agent, eq(agentInstance.agentId, agent.id))
1035
+ .where(
1036
+ and(
1037
+ eq(agentInstance.id, instanceId),
1038
+ eq(agentInstance.tenantId, tenantCtx.id),
1039
+ ),
1040
+ )
1041
+ .limit(1);
1042
+
1043
+ if (!row) {
1044
+ return c.json(
1045
+ { error: { code: "not_found", message: "Instance not found" } },
1046
+ 404,
1047
+ );
1048
+ }
1049
+
1050
+ const offerings = await db.query.offering.findMany({
1051
+ where: and(
1052
+ eq(offering.agentId, row.instance.agentId),
1053
+ eq(offering.tenantId, tenantCtx.id),
1054
+ ),
1055
+ });
1056
+
1057
+ return c.json(offerings.map((o) => formatOffering(o, row.agentName)));
1058
+ },
1059
+ );
1060
+
1061
+ app.delete(
1062
+ "/:instanceId",
1063
+ requireGrant(idResource("instance", "instanceId"), "manage"),
1064
+ describeRoute({
1065
+ tags: ["Instances"],
1066
+ summary: "Stop an instance",
1067
+ description:
1068
+ "Stops the running instance and undeploys the agent from the sidecar.",
1069
+ responses: {
1070
+ 204: {
1071
+ description: "Instance stopped",
1072
+ },
1073
+ 404: {
1074
+ description: "Instance not found",
1075
+ content: {
1076
+ "application/json": { schema: resolver(ErrorResponse) },
1077
+ },
1078
+ },
1079
+ 409: {
1080
+ description: "Instance already stopped",
1081
+ content: {
1082
+ "application/json": { schema: resolver(ErrorResponse) },
1083
+ },
1084
+ },
1085
+ 502: {
1086
+ description: "Sidecar unavailable",
1087
+ content: {
1088
+ "application/json": { schema: resolver(ErrorResponse) },
1089
+ },
1090
+ },
1091
+ },
1092
+ }),
1093
+ async (c) => {
1094
+ const tenantCtx = c.get("tenant");
1095
+ const instanceId = c.req.param("instanceId");
1096
+
1097
+ const row = await db.query.agentInstance.findFirst({
1098
+ where: and(
1099
+ eq(agentInstance.id, instanceId),
1100
+ eq(agentInstance.tenantId, tenantCtx.id),
1101
+ ),
1102
+ });
1103
+
1104
+ if (!row) {
1105
+ return c.json(
1106
+ { error: { code: "not_found", message: "Instance not found" } },
1107
+ 404,
1108
+ );
1109
+ }
1110
+
1111
+ if (row.status === "stopped") {
1112
+ return c.json(
1113
+ {
1114
+ error: {
1115
+ code: "conflict",
1116
+ message: "Instance is already stopped",
1117
+ },
1118
+ },
1119
+ 409,
1120
+ );
1121
+ }
1122
+
1123
+ try {
1124
+ await sessionService.endSession(row.address, "instance_stopped");
1125
+ } catch (err) {
1126
+ return c.json(
1127
+ {
1128
+ error: {
1129
+ code: "sidecar_unavailable",
1130
+ message:
1131
+ err instanceof Error
1132
+ ? err.message
1133
+ : "Failed to reach sidecar for instance teardown",
1134
+ },
1135
+ },
1136
+ 502,
1137
+ );
1138
+ }
1139
+
1140
+ const endedAt = new Date();
1141
+
1142
+ await db
1143
+ .update(agentInstance)
1144
+ .set({
1145
+ status: "stopped",
1146
+ sessionId: null,
1147
+ updatedAt: endedAt,
1148
+ endedAt,
1149
+ })
1150
+ .where(eq(agentInstance.id, instanceId));
1151
+
1152
+ // Deactivate the per-instance principal. The refId guard ensures we
1153
+ // only deactivate the principal created for this specific instance.
1154
+ await db
1155
+ .update(principalTable)
1156
+ .set({ status: "deactivated", updatedAt: endedAt })
1157
+ .where(
1158
+ and(
1159
+ eq(principalTable.id, row.principalId),
1160
+ eq(principalTable.refId, instanceId),
1161
+ ),
1162
+ );
1163
+
1164
+ // End associated session rows.
1165
+ if (row.sessionId) {
1166
+ await db
1167
+ .update(agentSession)
1168
+ .set({ status: "ended", endedAt, updatedAt: endedAt })
1169
+ .where(eq(agentSession.id, row.sessionId));
1170
+ }
1171
+
1172
+ eventCollectors.abandon(row.address);
1173
+ instanceKeyCache.delete(instanceId);
1174
+
1175
+ sidecarRouter.dispatchAgentEvent(row.address, {
1176
+ type: "session.ended",
1177
+ });
1178
+
1179
+ return c.body(null, 204);
1180
+ },
1181
+ );
1182
+
1183
+ app.get(
1184
+ "/:instanceId/events",
1185
+ requireGrant(idResource("instance", "instanceId"), "read"),
1186
+ describeRoute({
1187
+ tags: ["Instances"],
1188
+ summary: "SSE event stream",
1189
+ description:
1190
+ "Server-Sent Events stream for agent events. Use POST .../messages for client-to-server messaging.",
1191
+ responses: {
1192
+ 200: {
1193
+ description: "SSE event stream",
1194
+ content: {
1195
+ "text/event-stream": {},
1196
+ },
1197
+ },
1198
+ 404: {
1199
+ description: "Instance not found",
1200
+ content: {
1201
+ "application/json": { schema: resolver(ErrorResponse) },
1202
+ },
1203
+ },
1204
+ 410: {
1205
+ description: "Instance stopped",
1206
+ content: {
1207
+ "application/json": { schema: resolver(ErrorResponse) },
1208
+ },
1209
+ },
1210
+ },
1211
+ }),
1212
+ async (c) => {
1213
+ const tenantCtx = c.get("tenant");
1214
+ const instanceId = c.req.param("instanceId");
1215
+
1216
+ const row = await db.query.agentInstance.findFirst({
1217
+ where: and(
1218
+ eq(agentInstance.id, instanceId),
1219
+ eq(agentInstance.tenantId, tenantCtx.id),
1220
+ ),
1221
+ });
1222
+
1223
+ if (!row) {
1224
+ return c.json(
1225
+ { error: { code: "not_found", message: "Instance not found" } },
1226
+ 404,
1227
+ );
1228
+ }
1229
+
1230
+ if (row.status === "stopped") {
1231
+ return c.json(
1232
+ { error: { code: "gone", message: "Instance has stopped" } },
1233
+ 410,
1234
+ );
1235
+ }
1236
+
1237
+ return streamSSE(c, async (stream) => {
1238
+ const noop = () => undefined;
1239
+
1240
+ // Emit the replay before subscribing to live events so that a
1241
+ // delta arriving between subscribe() and the replay write cannot
1242
+ // beat the catch-up text onto the stream.
1243
+ const status = eventCollectors.getStatus(row.address);
1244
+ if (status?.status === "busy") {
1245
+ await stream.writeSSE({
1246
+ event: "agent.event",
1247
+ data: JSON.stringify({
1248
+ type: "inference.start",
1249
+ seq: 0,
1250
+ data: { model: "unknown" },
1251
+ }),
1252
+ });
1253
+ }
1254
+ const accumulatedText = eventCollectors.getAccumulatedText(row.address);
1255
+ if (accumulatedText !== undefined && accumulatedText !== "") {
1256
+ const turnId = eventCollectors.getLastTurnId(row.address);
1257
+ await stream.writeSSE({
1258
+ event: "agent.event",
1259
+ data: JSON.stringify({
1260
+ type: "inference.text.replay",
1261
+ data: { turnId, text: accumulatedText },
1262
+ }),
1263
+ });
1264
+ }
1265
+
1266
+ const unsubscribe = sidecarRouter.subscribeAgent(
1267
+ row.address,
1268
+ (event) => {
1269
+ stream
1270
+ .writeSSE({
1271
+ event: "agent.event",
1272
+ data: JSON.stringify(event),
1273
+ })
1274
+ .catch(noop);
1275
+ },
1276
+ );
1277
+
1278
+ const keepalive = setInterval(() => {
1279
+ stream.write(": keepalive\n\n").catch(noop);
1280
+ }, 30_000);
1281
+
1282
+ stream.onAbort(() => {
1283
+ clearInterval(keepalive);
1284
+ unsubscribe();
1285
+ });
1286
+
1287
+ // Keep the stream open until the client disconnects.
1288
+ await new Promise<void>(noop);
1289
+ });
1290
+ },
1291
+ );
1292
+
1293
+ app.post(
1294
+ "/:instanceId/abort",
1295
+ requireGrant(idResource("instance", "instanceId"), "manage"),
1296
+ describeRoute({
1297
+ tags: ["Instances"],
1298
+ summary: "Abort current operation",
1299
+ description: "Aborts the agent's current inference or tool execution.",
1300
+ responses: {
1301
+ 204: {
1302
+ description: "Abort signal sent",
1303
+ },
1304
+ 404: {
1305
+ description: "Instance not found",
1306
+ content: {
1307
+ "application/json": { schema: resolver(ErrorResponse) },
1308
+ },
1309
+ },
1310
+ 409: {
1311
+ description: "Instance not running",
1312
+ content: {
1313
+ "application/json": { schema: resolver(ErrorResponse) },
1314
+ },
1315
+ },
1316
+ 502: {
1317
+ description: "Sidecar unavailable",
1318
+ content: {
1319
+ "application/json": { schema: resolver(ErrorResponse) },
1320
+ },
1321
+ },
1322
+ },
1323
+ }),
1324
+ validator("json", AbortBody),
1325
+ async (c) => {
1326
+ const tenantCtx = c.get("tenant");
1327
+ const instanceId = c.req.param("instanceId");
1328
+ const body = c.req.valid("json");
1329
+
1330
+ const row = await db.query.agentInstance.findFirst({
1331
+ where: and(
1332
+ eq(agentInstance.id, instanceId),
1333
+ eq(agentInstance.tenantId, tenantCtx.id),
1334
+ ),
1335
+ });
1336
+
1337
+ if (!row) {
1338
+ return c.json(
1339
+ { error: { code: "not_found", message: "Instance not found" } },
1340
+ 404,
1341
+ );
1342
+ }
1343
+
1344
+ if (row.status !== "running") {
1345
+ return c.json(
1346
+ {
1347
+ error: {
1348
+ code: "conflict",
1349
+ message: `Instance is not running (status: ${row.status})`,
1350
+ },
1351
+ },
1352
+ 409,
1353
+ );
1354
+ }
1355
+
1356
+ try {
1357
+ await sidecarRouter.sendSessionAbort(
1358
+ row.address,
1359
+ body.reason ?? "user_disconnect",
1360
+ );
1361
+ } catch (err) {
1362
+ return c.json(
1363
+ {
1364
+ error: {
1365
+ code: "sidecar_unavailable",
1366
+ message:
1367
+ err instanceof Error
1368
+ ? err.message
1369
+ : "Failed to reach sidecar for abort",
1370
+ },
1371
+ },
1372
+ 502,
1373
+ );
1374
+ }
1375
+
1376
+ return c.body(null, 204);
1377
+ },
1378
+ );
1379
+
1380
+ // Crypto providers for signing outbound messages, keyed by instance ID.
1381
+ // Evicted when an instance is stopped. The cache is per-factory call,
1382
+ // not per-process; two createInstanceRoutes() calls in the same process
1383
+ // do not share signing keys, which is intentional — each router owns
1384
+ // its own crypto state and lifecycle.
1385
+ const instanceKeyCache = new Map<string, Promise<CryptoProvider>>();
1386
+
1387
+ function getInstanceCryptoProvider(
1388
+ instanceId: string,
1389
+ ): Promise<CryptoProvider> {
1390
+ let pending = instanceKeyCache.get(instanceId);
1391
+ if (pending !== undefined) return pending;
1392
+ pending = generateKeyPair().then((kp) => createNodeCrypto(kp));
1393
+ instanceKeyCache.set(instanceId, pending);
1394
+ return pending;
1395
+ }
1396
+
1397
+ app.post(
1398
+ "/:instanceId/mail",
1399
+ requireGrant(idResource("instance", "instanceId"), "write"),
1400
+ describeRoute({
1401
+ tags: ["Instances"],
1402
+ summary: "Send mail to the agent",
1403
+ description:
1404
+ "Persists the user message as a mail record and dispatches it to the running agent. Returns JMAP Email-shaped response.",
1405
+ responses: {
1406
+ 201: {
1407
+ description: "Mail sent",
1408
+ content: {
1409
+ "application/json": { schema: resolver(MailResponse) },
1410
+ },
1411
+ },
1412
+ 400: {
1413
+ description: "Validation error",
1414
+ content: {
1415
+ "application/json": { schema: resolver(ErrorResponse) },
1416
+ },
1417
+ },
1418
+ 404: {
1419
+ description: "Instance not found",
1420
+ content: {
1421
+ "application/json": { schema: resolver(ErrorResponse) },
1422
+ },
1423
+ },
1424
+ 409: {
1425
+ description: "Instance not running",
1426
+ content: {
1427
+ "application/json": { schema: resolver(ErrorResponse) },
1428
+ },
1429
+ },
1430
+ 502: {
1431
+ description: "Sidecar unavailable",
1432
+ content: {
1433
+ "application/json": { schema: resolver(ErrorResponse) },
1434
+ },
1435
+ },
1436
+ },
1437
+ }),
1438
+ validator("json", SendMessage),
1439
+ async (c) => {
1440
+ const tenant = c.get("tenant");
1441
+ const principal = c.get("principal");
1442
+ const instanceId = c.req.param("instanceId");
1443
+ const body = c.req.valid("json");
1444
+
1445
+ const row = await db.query.agentInstance.findFirst({
1446
+ where: and(
1447
+ eq(agentInstance.id, instanceId),
1448
+ eq(agentInstance.tenantId, tenant.id),
1449
+ ),
1450
+ });
1451
+
1452
+ if (!row) {
1453
+ return c.json(
1454
+ { error: { code: "not_found", message: "Instance not found" } },
1455
+ 404,
1456
+ );
1457
+ }
1458
+
1459
+ if (row.status !== "running") {
1460
+ return c.json(
1461
+ {
1462
+ error: {
1463
+ code: "conflict",
1464
+ message: `Instance is not running (status: ${row.status})`,
1465
+ },
1466
+ },
1467
+ 409,
1468
+ );
1469
+ }
1470
+
1471
+ if (!row.sessionId) {
1472
+ return c.json(
1473
+ {
1474
+ error: {
1475
+ code: "conflict",
1476
+ message: "Instance has no active session",
1477
+ },
1478
+ },
1479
+ 409,
1480
+ );
1481
+ }
1482
+
1483
+ const mailId = generateId("sessionMail");
1484
+ const now = new Date();
1485
+
1486
+ const user = c.get("user");
1487
+ const fromAddr = `${principal.refId}@${tenant.domain}`;
1488
+ const from = user?.name ? `"${user.name}" <${fromAddr}>` : fromAddr;
1489
+ const mimeMessageId = `<${mailId}@${tenant.domain}>`;
1490
+
1491
+ // Fetch recent delivered inbound mail for the MIME References chain.
1492
+ const priorMail = await db
1493
+ .select({ id: sessionMail.id })
1494
+ .from(sessionMail)
1495
+ .where(
1496
+ and(
1497
+ eq(sessionMail.instanceId, instanceId),
1498
+ eq(sessionMail.direction, "inbound"),
1499
+ eq(sessionMail.status, "delivered"),
1500
+ ),
1501
+ )
1502
+ .orderBy(asc(sessionMail.createdAt), asc(sessionMail.id))
1503
+ .limit(100);
1504
+
1505
+ const priorIds = priorMail.map((m) => `<${m.id}@${tenant.domain}>`);
1506
+ const lastIdFromSession = priorIds[priorIds.length - 1];
1507
+
1508
+ // Threading-header policy:
1509
+ // 1. Session history (the user's prior mail to this instance)
1510
+ // wins whenever it exists. inReplyTo points at the user's
1511
+ // most recent message, references lists the chain.
1512
+ // 2. With no session history, fall back to the agent's active
1513
+ // connector thread. The connector is one durable shared
1514
+ // thread per agent — anyone with a session joins whatever
1515
+ // thread is active. Stamp inReplyTo and references from the
1516
+ // cached state so the harness routes the message as
1517
+ // `continue` and adds the user to the participant set.
1518
+ // 3. With no session history and no active connector, send
1519
+ // threading-less mail. The harness routes it as `start`,
1520
+ // establishing this user as the first participant on a new
1521
+ // thread.
1522
+ let inReplyTo: string | undefined;
1523
+ let references: string[] | undefined;
1524
+ if (lastIdFromSession !== undefined) {
1525
+ inReplyTo = lastIdFromSession;
1526
+ references = priorIds;
1527
+ } else {
1528
+ const connectorState = sidecarRouter.getConnectorState(row.address);
1529
+ if (connectorState !== null) {
1530
+ inReplyTo = connectorState.lastMessageId;
1531
+ references = [connectorState.threadRoot];
1532
+ }
1533
+ }
1534
+
1535
+ const cryptoProvider = await getInstanceCryptoProvider(instanceId);
1536
+
1537
+ let rawMIME: Uint8Array;
1538
+ try {
1539
+ rawMIME = await sessionService.sendUserMessage({
1540
+ agentAddress: row.address,
1541
+ from,
1542
+ messageId: mimeMessageId,
1543
+ date: now,
1544
+ content: body.content,
1545
+ ...(inReplyTo !== undefined ? { inReplyTo } : {}),
1546
+ ...(references !== undefined && references.length > 0
1547
+ ? { references }
1548
+ : {}),
1549
+ sessionId: row.sessionId,
1550
+ tenantId: tenant.id,
1551
+ cryptoProvider,
1552
+ });
1553
+ } catch (err) {
1554
+ return c.json(
1555
+ {
1556
+ error: {
1557
+ code: "sidecar_unavailable",
1558
+ message:
1559
+ err instanceof Error
1560
+ ? err.message
1561
+ : "Failed to deliver message to sidecar",
1562
+ },
1563
+ },
1564
+ 502,
1565
+ );
1566
+ }
1567
+
1568
+ const mailCreatedAt = new Date();
1569
+
1570
+ await db.insert(sessionMail).values({
1571
+ id: mailId,
1572
+ sessionId: row.sessionId,
1573
+ instanceId,
1574
+ tenantId: tenant.id,
1575
+ direction: "inbound",
1576
+ status: "delivered",
1577
+ raw: rawMIME,
1578
+ createdAt: mailCreatedAt,
1579
+ });
1580
+
1581
+ const parsed = parseMailToEmail(rawMIME, mailId);
1582
+ sidecarRouter.dispatchAgentEvent(row.address, {
1583
+ type: "mail.delivered",
1584
+ data: {
1585
+ ...parsed,
1586
+ id: mailId,
1587
+ direction: "inbound" as const,
1588
+ receivedAt: mailCreatedAt.toISOString(),
1589
+ },
1590
+ });
1591
+
1592
+ return c.json(
1593
+ {
1594
+ id: mailId,
1595
+ sessionId: row.sessionId,
1596
+ instanceId,
1597
+ direction: "inbound" as const,
1598
+ status: "delivered" as const,
1599
+ receivedAt: mailCreatedAt.toISOString(),
1600
+ ...parsed,
1601
+ },
1602
+ 201,
1603
+ );
1604
+ },
1605
+ );
1606
+
1607
+ app.get(
1608
+ "/:instanceId/mail",
1609
+ requireGrant(idResource("instance", "instanceId"), "read"),
1610
+ describeRoute({
1611
+ tags: ["Instances"],
1612
+ summary: "List mail for an instance",
1613
+ description:
1614
+ "Returns parsed JMAP Email objects in reverse chronological order. Cursor-paginated.",
1615
+ parameters: [...pageParameters],
1616
+ responses: {
1617
+ 200: {
1618
+ description: "List of mail",
1619
+ content: {
1620
+ "application/json": {
1621
+ schema: resolver(paginatedSchema(MailResponse)),
1622
+ },
1623
+ },
1624
+ },
1625
+ 404: {
1626
+ description: "Instance not found",
1627
+ content: {
1628
+ "application/json": { schema: resolver(ErrorResponse) },
1629
+ },
1630
+ },
1631
+ },
1632
+ }),
1633
+ async (c) => {
1634
+ const tenant = c.get("tenant");
1635
+ const instanceId = c.req.param("instanceId");
1636
+ const { limit, cursor } = parsePageParams({
1637
+ cursor: c.req.query("cursor"),
1638
+ limit: c.req.query("limit"),
1639
+ });
1640
+
1641
+ const row = await db.query.agentInstance.findFirst({
1642
+ where: and(
1643
+ eq(agentInstance.id, instanceId),
1644
+ eq(agentInstance.tenantId, tenant.id),
1645
+ ),
1646
+ });
1647
+
1648
+ if (!row) {
1649
+ return c.json(
1650
+ { error: { code: "not_found", message: "Instance not found" } },
1651
+ 404,
1652
+ );
1653
+ }
1654
+
1655
+ const conditions = [eq(sessionMail.instanceId, instanceId)];
1656
+ if (cursor) {
1657
+ conditions.push(
1658
+ cursorCondition(sessionMail.createdAt, sessionMail.id, cursor),
1659
+ );
1660
+ }
1661
+
1662
+ const rows = await db
1663
+ .select()
1664
+ .from(sessionMail)
1665
+ .where(and(...conditions))
1666
+ .orderBy(...pageOrder(sessionMail.createdAt, sessionMail.id))
1667
+ .limit(limit);
1668
+
1669
+ const items = rows.map((m) => {
1670
+ const parsed = parseMailToEmail(m.raw, m.id);
1671
+ return {
1672
+ id: m.id,
1673
+ sessionId: m.sessionId,
1674
+ instanceId: m.instanceId ?? null,
1675
+ direction: m.direction,
1676
+ status: m.status,
1677
+ receivedAt: m.createdAt.toISOString(),
1678
+ ...parsed,
1679
+ };
1680
+ });
1681
+
1682
+ return c.json(paginatedResponse(items, rows, limit));
1683
+ },
1684
+ );
1685
+
1686
+ app.get(
1687
+ "/:instanceId/turns",
1688
+ requireGrant(idResource("instance", "instanceId"), "read"),
1689
+ describeRoute({
1690
+ tags: ["Instances"],
1691
+ summary: "List inference turns for an instance",
1692
+ description:
1693
+ "Returns inference turns with their parts in reverse chronological order. Cursor-paginated.",
1694
+ parameters: [...pageParameters],
1695
+ responses: {
1696
+ 200: {
1697
+ description: "List of inference turns",
1698
+ content: {
1699
+ "application/json": {
1700
+ schema: resolver(paginatedSchema(InferenceTurnResponse)),
1701
+ },
1702
+ },
1703
+ },
1704
+ 404: {
1705
+ description: "Instance not found",
1706
+ content: {
1707
+ "application/json": { schema: resolver(ErrorResponse) },
1708
+ },
1709
+ },
1710
+ },
1711
+ }),
1712
+ async (c) => {
1713
+ const tenant = c.get("tenant");
1714
+ const instanceId = c.req.param("instanceId");
1715
+ const { limit, cursor } = parsePageParams({
1716
+ cursor: c.req.query("cursor"),
1717
+ limit: c.req.query("limit"),
1718
+ });
1719
+
1720
+ const row = await db.query.agentInstance.findFirst({
1721
+ where: and(
1722
+ eq(agentInstance.id, instanceId),
1723
+ eq(agentInstance.tenantId, tenant.id),
1724
+ ),
1725
+ });
1726
+
1727
+ if (!row) {
1728
+ return c.json(
1729
+ { error: { code: "not_found", message: "Instance not found" } },
1730
+ 404,
1731
+ );
1732
+ }
1733
+
1734
+ const conditions = [eq(inferenceTurn.instanceId, instanceId)];
1735
+ if (cursor) {
1736
+ conditions.push(
1737
+ cursorCondition(inferenceTurn.startedAt, inferenceTurn.id, cursor),
1738
+ );
1739
+ }
1740
+
1741
+ const turns = await db
1742
+ .select()
1743
+ .from(inferenceTurn)
1744
+ .where(and(...conditions))
1745
+ .orderBy(...pageOrder(inferenceTurn.startedAt, inferenceTurn.id))
1746
+ .limit(limit);
1747
+
1748
+ const turnIds = turns.map((t) => t.id);
1749
+
1750
+ const parts =
1751
+ turnIds.length > 0
1752
+ ? await db
1753
+ .select()
1754
+ .from(turnPart)
1755
+ .where(inArray(turnPart.turnId, turnIds))
1756
+ .orderBy(asc(turnPart.ordinal))
1757
+ : [];
1758
+
1759
+ const partsByTurn = new Map<string, typeof parts>();
1760
+ for (const part of parts) {
1761
+ let list = partsByTurn.get(part.turnId);
1762
+ if (list === undefined) {
1763
+ list = [];
1764
+ partsByTurn.set(part.turnId, list);
1765
+ }
1766
+ list.push(part);
1767
+ }
1768
+
1769
+ const items = turns.map((t) => ({
1770
+ id: t.id,
1771
+ sessionId: t.sessionId,
1772
+ instanceId: t.instanceId,
1773
+ model: t.model,
1774
+ status: t.status,
1775
+ startedAt: t.startedAt.toISOString(),
1776
+ endedAt: t.endedAt ? t.endedAt.toISOString() : null,
1777
+ parts: (partsByTurn.get(t.id) ?? []).map((p) => ({
1778
+ id: p.id,
1779
+ type: p.type,
1780
+ content: p.content ?? null,
1781
+ metadata: p.metadata ?? null,
1782
+ ordinal: p.ordinal,
1783
+ })),
1784
+ }));
1785
+
1786
+ return c.json(
1787
+ paginatedResponse(
1788
+ items,
1789
+ turns.map((t) => ({ createdAt: t.startedAt, id: t.id })),
1790
+ limit,
1791
+ ),
1792
+ );
1793
+ },
1794
+ );
1795
+
1796
+ return app;
1797
+ }