@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.
- package/README.md +29 -0
- package/package.json +28 -0
- package/src/app.test.ts +225 -0
- package/src/app.ts +382 -0
- package/src/auth.ts +21 -0
- package/src/context.ts +38 -0
- package/src/format.ts +9 -0
- package/src/git-http/advertise-refs.test.ts +459 -0
- package/src/git-http/advertise-refs.ts +226 -0
- package/src/git-http/pkt-line.test.ts +220 -0
- package/src/git-http/pkt-line.ts +235 -0
- package/src/git-http/receive-pack.test.ts +397 -0
- package/src/git-http/receive-pack.ts +261 -0
- package/src/git-http/side-band-64k.test.ts +181 -0
- package/src/git-http/side-band-64k.ts +134 -0
- package/src/git-http/upload-pack.test.ts +545 -0
- package/src/git-http/upload-pack.ts +396 -0
- package/src/index.ts +23 -0
- package/src/middleware/git-token-auth.test.ts +587 -0
- package/src/middleware/git-token-auth.ts +315 -0
- package/src/middleware/grant.ts +106 -0
- package/src/middleware/session.ts +13 -0
- package/src/middleware/tenant.test.ts +192 -0
- package/src/middleware/tenant.ts +101 -0
- package/src/openapi.ts +66 -0
- package/src/pagination.ts +117 -0
- package/src/routes/agent-data.ts +179 -0
- package/src/routes/agent-state-git.ts +562 -0
- package/src/routes/agents.test.ts +337 -0
- package/src/routes/agents.ts +704 -0
- package/src/routes/approvals.ts +130 -0
- package/src/routes/assets.test.ts +567 -0
- package/src/routes/assets.ts +592 -0
- package/src/routes/credentials.ts +435 -0
- package/src/routes/git-tokens.test.ts +709 -0
- package/src/routes/git-tokens.ts +771 -0
- package/src/routes/grants.ts +509 -0
- package/src/routes/instances.test.ts +1103 -0
- package/src/routes/instances.ts +1797 -0
- package/src/routes/me.ts +405 -0
- package/src/routes/oauth-clients.ts +349 -0
- package/src/routes/observability.ts +146 -0
- package/src/routes/offerings.ts +382 -0
- package/src/routes/principals.ts +515 -0
- package/src/routes/providers.ts +351 -0
- package/src/routes/roles.ts +452 -0
- package/src/routes/sidecars.ts +221 -0
- package/src/routes/tenant-federation.ts +225 -0
- package/src/routes/tenants.ts +369 -0
- package/src/routes/wallets.ts +370 -0
- package/src/session.ts +44 -0
- package/src/timeline-reconstruction.test.ts +786 -0
- package/src/timeline-reconstruction.ts +383 -0
- package/tsconfig.json +4 -0
- 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
|
+
}
|