@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,704 @@
|
|
|
1
|
+
import { eq, and, isNull } from "drizzle-orm";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { describeRoute, resolver, validator } from "hono-openapi";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
agent,
|
|
7
|
+
agentInstance,
|
|
8
|
+
agentRole,
|
|
9
|
+
agentVersion,
|
|
10
|
+
grant as grantTable,
|
|
11
|
+
} from "@intx/db/schema";
|
|
12
|
+
import { parseAgentRow, parseAgentVersionRow } from "@intx/db";
|
|
13
|
+
import type { DB } from "@intx/db";
|
|
14
|
+
import {
|
|
15
|
+
CreateAgent,
|
|
16
|
+
UpdateAgent,
|
|
17
|
+
AgentResponse,
|
|
18
|
+
AgentVersion,
|
|
19
|
+
RollbackRequest,
|
|
20
|
+
ErrorResponse,
|
|
21
|
+
paginatedSchema,
|
|
22
|
+
} from "@intx/types";
|
|
23
|
+
|
|
24
|
+
import type { TenantEnv } from "../context";
|
|
25
|
+
import { first, ts } from "../format";
|
|
26
|
+
import { generateId } from "@intx/hub-common";
|
|
27
|
+
import { idResource } from "../middleware/grant";
|
|
28
|
+
import type { RequireGrant } from "../middleware/grant";
|
|
29
|
+
import {
|
|
30
|
+
parsePageParams,
|
|
31
|
+
cursorCondition,
|
|
32
|
+
pageOrder,
|
|
33
|
+
paginatedResponse,
|
|
34
|
+
pageParameters,
|
|
35
|
+
} from "../pagination";
|
|
36
|
+
|
|
37
|
+
function formatAgent(
|
|
38
|
+
row: typeof agent.$inferSelect,
|
|
39
|
+
roles: { id: string; name: string }[],
|
|
40
|
+
) {
|
|
41
|
+
const parsed = parseAgentRow(row);
|
|
42
|
+
return {
|
|
43
|
+
id: parsed.id,
|
|
44
|
+
tenantId: parsed.tenantId,
|
|
45
|
+
creatorPrincipalId: parsed.creatorPrincipalId,
|
|
46
|
+
name: parsed.name,
|
|
47
|
+
description: parsed.description ?? null,
|
|
48
|
+
systemPrompt: parsed.systemPrompt ?? null,
|
|
49
|
+
contextConfig: parsed.contextConfig ?? undefined,
|
|
50
|
+
initialState: parsed.initialState ?? undefined,
|
|
51
|
+
modelConfig: parsed.modelConfig ?? undefined,
|
|
52
|
+
currentVersion: parsed.currentVersion,
|
|
53
|
+
status: parsed.status,
|
|
54
|
+
capabilities: parsed.capabilities ?? undefined,
|
|
55
|
+
credentialRequirements: parsed.credentialRequirements ?? undefined,
|
|
56
|
+
grantRequirements: parsed.grantRequirements ?? undefined,
|
|
57
|
+
roles,
|
|
58
|
+
createdAt: ts(parsed.createdAt),
|
|
59
|
+
updatedAt: ts(parsed.updatedAt),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function loadAgentRoles(
|
|
64
|
+
db: DB["db"],
|
|
65
|
+
agentId: string,
|
|
66
|
+
tenantId: string,
|
|
67
|
+
): Promise<{ id: string; name: string }[]> {
|
|
68
|
+
const assignments = await db.query.agentRole.findMany({
|
|
69
|
+
where: eq(agentRole.agentId, agentId),
|
|
70
|
+
});
|
|
71
|
+
if (assignments.length === 0) return [];
|
|
72
|
+
const roleIds = assignments.map((a) => a.roleId);
|
|
73
|
+
const roles = await db.query.role.findMany({
|
|
74
|
+
where: (r, { inArray, and: a }) =>
|
|
75
|
+
a(inArray(r.id, roleIds), eq(r.tenantId, tenantId)),
|
|
76
|
+
});
|
|
77
|
+
return roles.map((r) => ({ id: r.id, name: r.name }));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type CreateAgentRoutesDeps = {
|
|
81
|
+
db: DB["db"];
|
|
82
|
+
requireGrant: RequireGrant;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function createAgentRoutes({
|
|
86
|
+
db,
|
|
87
|
+
requireGrant,
|
|
88
|
+
}: CreateAgentRoutesDeps): Hono<TenantEnv> {
|
|
89
|
+
const app = new Hono<TenantEnv>();
|
|
90
|
+
|
|
91
|
+
app.get(
|
|
92
|
+
"/",
|
|
93
|
+
requireGrant("agent:*", "read"),
|
|
94
|
+
describeRoute({
|
|
95
|
+
tags: ["Agents"],
|
|
96
|
+
summary: "List agents in the tenant",
|
|
97
|
+
description: "Filterable by offering and status.",
|
|
98
|
+
parameters: [
|
|
99
|
+
{ name: "offering", in: "query", schema: { type: "string" } },
|
|
100
|
+
{
|
|
101
|
+
name: "status",
|
|
102
|
+
in: "query",
|
|
103
|
+
schema: {
|
|
104
|
+
type: "string",
|
|
105
|
+
enum: ["deployed", "stopped"],
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
...pageParameters,
|
|
109
|
+
],
|
|
110
|
+
responses: {
|
|
111
|
+
200: {
|
|
112
|
+
description: "List of agents",
|
|
113
|
+
content: {
|
|
114
|
+
"application/json": {
|
|
115
|
+
schema: resolver(paginatedSchema(AgentResponse)),
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
async (c) => {
|
|
122
|
+
const tenantCtx = c.get("tenant");
|
|
123
|
+
const status = c.req.query("status");
|
|
124
|
+
const { limit, cursor } = parsePageParams({
|
|
125
|
+
cursor: c.req.query("cursor"),
|
|
126
|
+
limit: c.req.query("limit"),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const conditions = [eq(agent.tenantId, tenantCtx.id)];
|
|
130
|
+
if (status === "deployed" || status === "stopped") {
|
|
131
|
+
conditions.push(eq(agent.status, status));
|
|
132
|
+
}
|
|
133
|
+
if (cursor) {
|
|
134
|
+
conditions.push(cursorCondition(agent.createdAt, agent.id, cursor));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const rows = await db.query.agent.findMany({
|
|
138
|
+
where: and(...conditions),
|
|
139
|
+
orderBy: pageOrder(agent.createdAt, agent.id),
|
|
140
|
+
limit,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const allAssignments =
|
|
144
|
+
rows.length > 0
|
|
145
|
+
? await db.query.agentRole.findMany({
|
|
146
|
+
where: (ar, { inArray }) =>
|
|
147
|
+
inArray(
|
|
148
|
+
ar.agentId,
|
|
149
|
+
rows.map((r) => r.id),
|
|
150
|
+
),
|
|
151
|
+
})
|
|
152
|
+
: [];
|
|
153
|
+
|
|
154
|
+
const roleIds = [...new Set(allAssignments.map((a) => a.roleId))];
|
|
155
|
+
const allRoles =
|
|
156
|
+
roleIds.length > 0
|
|
157
|
+
? await db.query.role.findMany({
|
|
158
|
+
where: (r, { inArray, and: a }) =>
|
|
159
|
+
a(inArray(r.id, roleIds), eq(r.tenantId, tenantCtx.id)),
|
|
160
|
+
})
|
|
161
|
+
: [];
|
|
162
|
+
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
|
|
163
|
+
|
|
164
|
+
const rolesByAgent = new Map<string, { id: string; name: string }[]>();
|
|
165
|
+
for (const a of allAssignments) {
|
|
166
|
+
const r = roleMap.get(a.roleId);
|
|
167
|
+
if (!r) continue;
|
|
168
|
+
const list = rolesByAgent.get(a.agentId) ?? [];
|
|
169
|
+
list.push({ id: r.id, name: r.name });
|
|
170
|
+
rolesByAgent.set(a.agentId, list);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return c.json(
|
|
174
|
+
paginatedResponse(
|
|
175
|
+
rows.map((r) => formatAgent(r, rolesByAgent.get(r.id) ?? [])),
|
|
176
|
+
rows,
|
|
177
|
+
limit,
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
app.post(
|
|
184
|
+
"/",
|
|
185
|
+
requireGrant("agent:*", "create"),
|
|
186
|
+
describeRoute({
|
|
187
|
+
tags: ["Agents"],
|
|
188
|
+
summary: "Create an agent",
|
|
189
|
+
description:
|
|
190
|
+
"Creates an agent definition. Grant requirements are stored as a manifest and resolved at instance launch time.",
|
|
191
|
+
responses: {
|
|
192
|
+
201: {
|
|
193
|
+
description: "Agent created",
|
|
194
|
+
content: {
|
|
195
|
+
"application/json": { schema: resolver(AgentResponse) },
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
400: {
|
|
199
|
+
description: "Validation error",
|
|
200
|
+
content: {
|
|
201
|
+
"application/json": { schema: resolver(ErrorResponse) },
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
}),
|
|
206
|
+
validator("json", CreateAgent),
|
|
207
|
+
async (c) => {
|
|
208
|
+
const tenantCtx = c.get("tenant");
|
|
209
|
+
const creatorPrincipal = c.get("principal");
|
|
210
|
+
const body = c.req.valid("json");
|
|
211
|
+
|
|
212
|
+
const now = new Date();
|
|
213
|
+
const agentId = generateId("agent");
|
|
214
|
+
|
|
215
|
+
// Validate role IDs before writing anything.
|
|
216
|
+
const uniqueRoleIds = [...new Set(body.roleIds ?? [])];
|
|
217
|
+
let validRoles: { id: string; name: string }[] = [];
|
|
218
|
+
if (uniqueRoleIds.length > 0) {
|
|
219
|
+
const found = await db.query.role.findMany({
|
|
220
|
+
where: (r, { inArray, and: a }) =>
|
|
221
|
+
a(inArray(r.id, uniqueRoleIds), eq(r.tenantId, tenantCtx.id)),
|
|
222
|
+
});
|
|
223
|
+
if (found.length !== uniqueRoleIds.length) {
|
|
224
|
+
const validIds = new Set(found.map((r) => r.id));
|
|
225
|
+
const invalid = uniqueRoleIds.filter((id) => !validIds.has(id));
|
|
226
|
+
return c.json(
|
|
227
|
+
{
|
|
228
|
+
error: {
|
|
229
|
+
code: "bad_request",
|
|
230
|
+
message: `Roles not found in tenant: ${invalid.join(", ")}`,
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
400,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
validRoles = found.map((r) => ({ id: r.id, name: r.name }));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const agentRow = await db.transaction(async (tx) => {
|
|
240
|
+
const row = first(
|
|
241
|
+
await tx
|
|
242
|
+
.insert(agent)
|
|
243
|
+
.values({
|
|
244
|
+
id: agentId,
|
|
245
|
+
tenantId: tenantCtx.id,
|
|
246
|
+
creatorPrincipalId: creatorPrincipal.id,
|
|
247
|
+
name: body.name,
|
|
248
|
+
description: body.description ?? null,
|
|
249
|
+
systemPrompt: body.systemPrompt ?? null,
|
|
250
|
+
contextConfig: body.contextConfig ?? null,
|
|
251
|
+
initialState: body.initialState ?? null,
|
|
252
|
+
modelConfig: body.modelConfig ?? null,
|
|
253
|
+
capabilities: body.capabilities ?? null,
|
|
254
|
+
credentialRequirements: body.credentialRequirements ?? null,
|
|
255
|
+
grantRequirements: body.grantRequirements ?? null,
|
|
256
|
+
currentVersion: "1",
|
|
257
|
+
status: "deployed",
|
|
258
|
+
createdAt: now,
|
|
259
|
+
updatedAt: now,
|
|
260
|
+
})
|
|
261
|
+
.returning(),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
await tx.insert(agentVersion).values({
|
|
265
|
+
id: generateId("agentVersion"),
|
|
266
|
+
agentId,
|
|
267
|
+
version: "1",
|
|
268
|
+
status: "active",
|
|
269
|
+
createdAt: now,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Seed a creator-level read grant on the agent-state repo for
|
|
273
|
+
// this definition so the creator can read deploy-artifact state
|
|
274
|
+
// out of the box. Admins are already covered by their existing
|
|
275
|
+
// *:read system grants; this fills the creator half.
|
|
276
|
+
await tx.insert(grantTable).values({
|
|
277
|
+
id: generateId("grant"),
|
|
278
|
+
tenantId: tenantCtx.id,
|
|
279
|
+
principalId: creatorPrincipal.id,
|
|
280
|
+
resource: `agent-state:${agentId}`,
|
|
281
|
+
action: "read",
|
|
282
|
+
effect: "allow",
|
|
283
|
+
origin: "creator",
|
|
284
|
+
createdAt: now,
|
|
285
|
+
updatedAt: now,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
if (uniqueRoleIds.length > 0) {
|
|
289
|
+
await tx.insert(agentRole).values(
|
|
290
|
+
uniqueRoleIds.map((roleId) => ({
|
|
291
|
+
agentId,
|
|
292
|
+
roleId,
|
|
293
|
+
createdAt: now,
|
|
294
|
+
})),
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return row;
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
return c.json(formatAgent(agentRow, validRoles), 201);
|
|
302
|
+
},
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
app.get(
|
|
306
|
+
"/:agentId",
|
|
307
|
+
requireGrant(idResource("agent", "agentId"), "read"),
|
|
308
|
+
describeRoute({
|
|
309
|
+
tags: ["Agents"],
|
|
310
|
+
summary: "Get agent details",
|
|
311
|
+
description:
|
|
312
|
+
"Returns the agent definition, status, health, and capabilities.",
|
|
313
|
+
responses: {
|
|
314
|
+
200: {
|
|
315
|
+
description: "Agent details",
|
|
316
|
+
content: {
|
|
317
|
+
"application/json": { schema: resolver(AgentResponse) },
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
404: {
|
|
321
|
+
description: "Agent not found",
|
|
322
|
+
content: {
|
|
323
|
+
"application/json": { schema: resolver(ErrorResponse) },
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
}),
|
|
328
|
+
async (c) => {
|
|
329
|
+
const tenantCtx = c.get("tenant");
|
|
330
|
+
const agentId = c.req.param("agentId");
|
|
331
|
+
|
|
332
|
+
const row = await db.query.agent.findFirst({
|
|
333
|
+
where: and(eq(agent.id, agentId), eq(agent.tenantId, tenantCtx.id)),
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
if (!row) {
|
|
337
|
+
return c.json(
|
|
338
|
+
{ error: { code: "not_found", message: "Agent not found" } },
|
|
339
|
+
404,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const roles = await loadAgentRoles(db, agentId, tenantCtx.id);
|
|
344
|
+
return c.json(formatAgent(row, roles));
|
|
345
|
+
},
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
app.patch(
|
|
349
|
+
"/:agentId",
|
|
350
|
+
requireGrant(idResource("agent", "agentId"), "manage"),
|
|
351
|
+
describeRoute({
|
|
352
|
+
tags: ["Agents"],
|
|
353
|
+
summary: "Update agent definition",
|
|
354
|
+
description:
|
|
355
|
+
"Updates the agent definition and creates a new version. The new version is deployed alongside the current version until health checks pass.",
|
|
356
|
+
responses: {
|
|
357
|
+
200: {
|
|
358
|
+
description: "Agent updated",
|
|
359
|
+
content: {
|
|
360
|
+
"application/json": { schema: resolver(AgentResponse) },
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
400: {
|
|
364
|
+
description: "Validation error",
|
|
365
|
+
content: {
|
|
366
|
+
"application/json": { schema: resolver(ErrorResponse) },
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
}),
|
|
371
|
+
validator("json", UpdateAgent),
|
|
372
|
+
async (c) => {
|
|
373
|
+
const tenantCtx = c.get("tenant");
|
|
374
|
+
const agentId = c.req.param("agentId");
|
|
375
|
+
const body = c.req.valid("json");
|
|
376
|
+
|
|
377
|
+
const existing = await db.query.agent.findFirst({
|
|
378
|
+
where: and(eq(agent.id, agentId), eq(agent.tenantId, tenantCtx.id)),
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
if (!existing) {
|
|
382
|
+
return c.json(
|
|
383
|
+
{ error: { code: "not_found", message: "Agent not found" } },
|
|
384
|
+
404,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const now = new Date();
|
|
389
|
+
const newVersion = String(Number(existing.currentVersion) + 1);
|
|
390
|
+
|
|
391
|
+
// Validate role IDs before writing anything.
|
|
392
|
+
let validRoles: { id: string; name: string }[] | undefined;
|
|
393
|
+
if (body.roleIds !== undefined) {
|
|
394
|
+
const uniqueRoleIds = [...new Set(body.roleIds)];
|
|
395
|
+
if (uniqueRoleIds.length > 0) {
|
|
396
|
+
const found = await db.query.role.findMany({
|
|
397
|
+
where: (r, { inArray, and: a }) =>
|
|
398
|
+
a(inArray(r.id, uniqueRoleIds), eq(r.tenantId, tenantCtx.id)),
|
|
399
|
+
});
|
|
400
|
+
if (found.length !== uniqueRoleIds.length) {
|
|
401
|
+
const validIds = new Set(found.map((r) => r.id));
|
|
402
|
+
const invalid = uniqueRoleIds.filter((id) => !validIds.has(id));
|
|
403
|
+
return c.json(
|
|
404
|
+
{
|
|
405
|
+
error: {
|
|
406
|
+
code: "bad_request",
|
|
407
|
+
message: `Roles not found in tenant: ${invalid.join(", ")}`,
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
400,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
validRoles = found.map((r) => ({ id: r.id, name: r.name }));
|
|
414
|
+
} else {
|
|
415
|
+
validRoles = [];
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const updates: Record<string, unknown> = {
|
|
420
|
+
updatedAt: now,
|
|
421
|
+
currentVersion: newVersion,
|
|
422
|
+
};
|
|
423
|
+
if (body.name !== undefined) updates["name"] = body.name;
|
|
424
|
+
if (body.description !== undefined)
|
|
425
|
+
updates["description"] = body.description;
|
|
426
|
+
if (body.systemPrompt !== undefined)
|
|
427
|
+
updates["systemPrompt"] = body.systemPrompt;
|
|
428
|
+
if (body.contextConfig !== undefined)
|
|
429
|
+
updates["contextConfig"] = body.contextConfig;
|
|
430
|
+
if (body.initialState !== undefined)
|
|
431
|
+
updates["initialState"] = body.initialState;
|
|
432
|
+
if (body.modelConfig !== undefined)
|
|
433
|
+
updates["modelConfig"] = body.modelConfig;
|
|
434
|
+
if (body.capabilities !== undefined)
|
|
435
|
+
updates["capabilities"] = body.capabilities;
|
|
436
|
+
if (body.credentialRequirements !== undefined)
|
|
437
|
+
updates["credentialRequirements"] = body.credentialRequirements;
|
|
438
|
+
if (body.grantRequirements !== undefined)
|
|
439
|
+
updates["grantRequirements"] = body.grantRequirements;
|
|
440
|
+
|
|
441
|
+
const updated = await db.transaction(async (tx) => {
|
|
442
|
+
const row = first(
|
|
443
|
+
await tx
|
|
444
|
+
.update(agent)
|
|
445
|
+
.set(updates)
|
|
446
|
+
.where(eq(agent.id, agentId))
|
|
447
|
+
.returning(),
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
await tx
|
|
451
|
+
.update(agentVersion)
|
|
452
|
+
.set({ status: "inactive" })
|
|
453
|
+
.where(
|
|
454
|
+
and(
|
|
455
|
+
eq(agentVersion.agentId, agentId),
|
|
456
|
+
eq(agentVersion.version, existing.currentVersion),
|
|
457
|
+
),
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
await tx.insert(agentVersion).values({
|
|
461
|
+
id: generateId("agentVersion"),
|
|
462
|
+
agentId,
|
|
463
|
+
version: newVersion,
|
|
464
|
+
status: "active",
|
|
465
|
+
createdAt: now,
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
if (validRoles !== undefined) {
|
|
469
|
+
await tx.delete(agentRole).where(eq(agentRole.agentId, agentId));
|
|
470
|
+
if (validRoles.length > 0) {
|
|
471
|
+
await tx.insert(agentRole).values(
|
|
472
|
+
validRoles.map((r) => ({
|
|
473
|
+
agentId,
|
|
474
|
+
roleId: r.id,
|
|
475
|
+
createdAt: now,
|
|
476
|
+
})),
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return row;
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const roles =
|
|
485
|
+
validRoles ?? (await loadAgentRoles(db, agentId, tenantCtx.id));
|
|
486
|
+
|
|
487
|
+
return c.json(formatAgent(updated, roles));
|
|
488
|
+
},
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
app.delete(
|
|
492
|
+
"/:agentId",
|
|
493
|
+
requireGrant(idResource("agent", "agentId"), "manage"),
|
|
494
|
+
describeRoute({
|
|
495
|
+
tags: ["Agents"],
|
|
496
|
+
summary: "Retire an agent",
|
|
497
|
+
description:
|
|
498
|
+
"Retires the agent definition and marks all running instances as stopped. Does not signal running sidecars; in-flight sessions continue until the sidecar disconnects.",
|
|
499
|
+
responses: {
|
|
500
|
+
204: {
|
|
501
|
+
description: "Agent retirement initiated",
|
|
502
|
+
},
|
|
503
|
+
404: {
|
|
504
|
+
description: "Agent not found",
|
|
505
|
+
content: {
|
|
506
|
+
"application/json": { schema: resolver(ErrorResponse) },
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
}),
|
|
511
|
+
async (c) => {
|
|
512
|
+
const tenantCtx = c.get("tenant");
|
|
513
|
+
const agentId = c.req.param("agentId");
|
|
514
|
+
|
|
515
|
+
const existing = await db.query.agent.findFirst({
|
|
516
|
+
where: and(eq(agent.id, agentId), eq(agent.tenantId, tenantCtx.id)),
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
if (!existing) {
|
|
520
|
+
return c.json(
|
|
521
|
+
{ error: { code: "not_found", message: "Agent not found" } },
|
|
522
|
+
404,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const retiredAt = new Date();
|
|
527
|
+
|
|
528
|
+
// TODO: This is DB-only — it does not signal the sidecar to stop
|
|
529
|
+
// or end the agentSession. A running sidecar will continue until
|
|
530
|
+
// it disconnects naturally. Add sidecar teardown coordination.
|
|
531
|
+
await db
|
|
532
|
+
.update(agentInstance)
|
|
533
|
+
.set({
|
|
534
|
+
status: "stopped",
|
|
535
|
+
sessionId: null,
|
|
536
|
+
updatedAt: retiredAt,
|
|
537
|
+
endedAt: retiredAt,
|
|
538
|
+
})
|
|
539
|
+
.where(
|
|
540
|
+
and(
|
|
541
|
+
eq(agentInstance.agentId, agentId),
|
|
542
|
+
isNull(agentInstance.endedAt),
|
|
543
|
+
),
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
// Set agent status to stopped
|
|
547
|
+
await db
|
|
548
|
+
.update(agent)
|
|
549
|
+
.set({ status: "stopped", updatedAt: retiredAt })
|
|
550
|
+
.where(eq(agent.id, agentId));
|
|
551
|
+
|
|
552
|
+
return c.body(null, 204);
|
|
553
|
+
},
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
app.get(
|
|
557
|
+
"/:agentId/versions",
|
|
558
|
+
requireGrant(idResource("agent", "agentId"), "read"),
|
|
559
|
+
describeRoute({
|
|
560
|
+
tags: ["Agents"],
|
|
561
|
+
summary: "List agent versions",
|
|
562
|
+
description: "Lists all versions with their deployment status.",
|
|
563
|
+
parameters: [...pageParameters],
|
|
564
|
+
responses: {
|
|
565
|
+
200: {
|
|
566
|
+
description: "List of versions",
|
|
567
|
+
content: {
|
|
568
|
+
"application/json": {
|
|
569
|
+
schema: resolver(paginatedSchema(AgentVersion)),
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
}),
|
|
575
|
+
async (c) => {
|
|
576
|
+
const agentId = c.req.param("agentId");
|
|
577
|
+
const { limit, cursor } = parsePageParams({
|
|
578
|
+
cursor: c.req.query("cursor"),
|
|
579
|
+
limit: c.req.query("limit"),
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
const conditions = [eq(agentVersion.agentId, agentId)];
|
|
583
|
+
if (cursor) {
|
|
584
|
+
conditions.push(
|
|
585
|
+
cursorCondition(agentVersion.createdAt, agentVersion.id, cursor),
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const rows = await db.query.agentVersion.findMany({
|
|
590
|
+
where: and(...conditions),
|
|
591
|
+
orderBy: pageOrder(agentVersion.createdAt, agentVersion.id),
|
|
592
|
+
limit,
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
const items = rows.map((v) => {
|
|
596
|
+
const parsed = parseAgentVersionRow(v);
|
|
597
|
+
return {
|
|
598
|
+
version: parsed.version,
|
|
599
|
+
status: parsed.status,
|
|
600
|
+
createdAt: ts(parsed.createdAt),
|
|
601
|
+
};
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
return c.json(paginatedResponse(items, rows, limit));
|
|
605
|
+
},
|
|
606
|
+
);
|
|
607
|
+
|
|
608
|
+
app.post(
|
|
609
|
+
"/:agentId/rollback",
|
|
610
|
+
requireGrant(idResource("agent", "agentId"), "manage"),
|
|
611
|
+
describeRoute({
|
|
612
|
+
tags: ["Agents"],
|
|
613
|
+
summary: "Rollback to a previous version",
|
|
614
|
+
description:
|
|
615
|
+
"Shifts traffic back to the specified version. The current version is stopped.",
|
|
616
|
+
responses: {
|
|
617
|
+
200: {
|
|
618
|
+
description: "Rollback initiated",
|
|
619
|
+
content: {
|
|
620
|
+
"application/json": { schema: resolver(AgentResponse) },
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
400: {
|
|
624
|
+
description: "Invalid version",
|
|
625
|
+
content: {
|
|
626
|
+
"application/json": { schema: resolver(ErrorResponse) },
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
},
|
|
630
|
+
}),
|
|
631
|
+
validator("json", RollbackRequest),
|
|
632
|
+
async (c) => {
|
|
633
|
+
const tenantCtx = c.get("tenant");
|
|
634
|
+
const agentId = c.req.param("agentId");
|
|
635
|
+
const body = c.req.valid("json");
|
|
636
|
+
|
|
637
|
+
const existing = await db.query.agent.findFirst({
|
|
638
|
+
where: and(eq(agent.id, agentId), eq(agent.tenantId, tenantCtx.id)),
|
|
639
|
+
});
|
|
640
|
+
if (!existing) {
|
|
641
|
+
return c.json(
|
|
642
|
+
{ error: { code: "not_found", message: "Agent not found" } },
|
|
643
|
+
404,
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const targetVersion = await db.query.agentVersion.findFirst({
|
|
648
|
+
where: and(
|
|
649
|
+
eq(agentVersion.agentId, agentId),
|
|
650
|
+
eq(agentVersion.version, body.version),
|
|
651
|
+
),
|
|
652
|
+
});
|
|
653
|
+
if (!targetVersion) {
|
|
654
|
+
return c.json(
|
|
655
|
+
{
|
|
656
|
+
error: {
|
|
657
|
+
code: "bad_request",
|
|
658
|
+
message: "Target version not found",
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
400,
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const now = new Date();
|
|
666
|
+
|
|
667
|
+
// Deactivate current version
|
|
668
|
+
await db
|
|
669
|
+
.update(agentVersion)
|
|
670
|
+
.set({ status: "inactive" })
|
|
671
|
+
.where(
|
|
672
|
+
and(
|
|
673
|
+
eq(agentVersion.agentId, agentId),
|
|
674
|
+
eq(agentVersion.version, existing.currentVersion),
|
|
675
|
+
),
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
// Activate target version
|
|
679
|
+
await db
|
|
680
|
+
.update(agentVersion)
|
|
681
|
+
.set({ status: "active" })
|
|
682
|
+
.where(
|
|
683
|
+
and(
|
|
684
|
+
eq(agentVersion.agentId, agentId),
|
|
685
|
+
eq(agentVersion.version, body.version),
|
|
686
|
+
),
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
// Update agent
|
|
690
|
+
const updated = first(
|
|
691
|
+
await db
|
|
692
|
+
.update(agent)
|
|
693
|
+
.set({ currentVersion: body.version, updatedAt: now })
|
|
694
|
+
.where(eq(agent.id, agentId))
|
|
695
|
+
.returning(),
|
|
696
|
+
);
|
|
697
|
+
|
|
698
|
+
const roles = await loadAgentRoles(db, updated.id, tenantCtx.id);
|
|
699
|
+
return c.json(formatAgent(updated, roles));
|
|
700
|
+
},
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
return app;
|
|
704
|
+
}
|