Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

agents.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

agents.tsBlame304 lines · 1 contributor
e75eddcClaude1/**
2 * Agent multiplayer API — `/api/v2/agents/*`.
3 *
4 * Endpoints:
5 * - POST /api/v2/agents/sessions create new agent (regular user auth)
6 * - GET /api/v2/agents/sessions list the authed user's agents
7 * - DELETE /api/v2/agents/sessions/:id revoke
8 * - POST /api/v2/agents/leases acquire a lease (agent auth)
9 * - DELETE /api/v2/agents/leases/:id release
10 * - GET /api/v2/agents/usage per-agent budget/spend
11 *
12 * Auth model: the session-mgmt endpoints (`/sessions`) require a
13 * regular Gluecron user (session cookie OR PAT). The lease + usage
14 * endpoints require an agent Bearer token. We mount `agentAuth`
15 * before `apiAuth` so the request context picks up either flavour.
16 */
17
18import { Hono } from "hono";
19import { eq } from "drizzle-orm";
20import { db } from "../db";
21import { agentLeases, agentSessions } from "../db/schema";
22import { apiAuth, requireApiAuth } from "../middleware/api-auth";
23import type { ApiAuthEnv } from "../middleware/api-auth";
24import { agentAuth, requireAgentAuth } from "../middleware/agent-auth";
25import type { AgentAuthEnv } from "../middleware/agent-auth";
26import {
27 createAgentSession,
28 revokeAgentSession,
29 listAgentSessionsForOwner,
30 acquireLease,
31 releaseLease,
32 getAgentUsage,
33 LEASE_TARGET_TYPES,
34 DEFAULT_LEASE_DURATION_MS,
35} from "../lib/agent-multiplayer";
36
37// Combined env so handlers can read both `user` and `agent`.
38type Env = ApiAuthEnv & AgentAuthEnv;
39
40const agents = new Hono<Env>().basePath("/api/v2/agents");
41
42// Order matters: agentAuth attempts to resolve an agent Bearer token
43// first; if absent, apiAuth resolves a regular user/PAT/session.
44agents.use("*", agentAuth);
45agents.use("*", apiAuth);
46
47// ---------------------------------------------------------------------------
48// Sessions
49// ---------------------------------------------------------------------------
50
51agents.post("/sessions", requireApiAuth, async (c) => {
52 const user = c.get("user")!;
53 let body: {
54 name?: unknown;
55 repository_id?: unknown;
56 repositoryId?: unknown;
57 branch_namespace?: unknown;
58 branchNamespace?: unknown;
59 budget_cents_per_day?: unknown;
60 budgetCentsPerDay?: unknown;
61 } = {};
62 try {
63 body = await c.req.json();
64 } catch {
65 body = {};
66 }
67
68 const name = typeof body.name === "string" ? body.name.trim() : "";
69 if (!name || name.length > 64 || !/^[a-zA-Z0-9_-]+$/.test(name)) {
70 return c.json(
71 {
72 error:
73 "name is required (1-64 chars; letters, digits, '-' and '_' only)",
74 },
75 400
76 );
77 }
78
79 const repositoryId =
80 typeof body.repository_id === "string"
81 ? body.repository_id
82 : typeof body.repositoryId === "string"
83 ? body.repositoryId
84 : null;
85
86 const rawNs =
87 typeof body.branch_namespace === "string"
88 ? body.branch_namespace
89 : typeof body.branchNamespace === "string"
90 ? body.branchNamespace
91 : undefined;
92
93 const rawBudget =
94 typeof body.budget_cents_per_day === "number"
95 ? body.budget_cents_per_day
96 : typeof body.budgetCentsPerDay === "number"
97 ? body.budgetCentsPerDay
98 : undefined;
99
100 const created = await createAgentSession({
101 ownerUserId: user.id,
102 name,
103 repositoryId,
104 branchNamespace: rawNs,
105 budgetCentsPerDay: rawBudget,
106 });
107 if (!created) {
108 return c.json(
109 {
110 error:
111 "Failed to create agent session — name may already be in use for this user",
112 },
113 409
114 );
115 }
116
117 return c.json(
118 {
119 token: created.token,
120 session: {
121 id: created.session.id,
122 name: created.session.name,
123 branch_namespace: created.session.branchNamespace,
124 repository_id: created.session.repositoryId,
125 budget_cents_per_day: created.session.budgetCentsPerDay,
126 spent_cents_today: created.session.spentCentsToday,
127 created_at: created.session.createdAt,
128 },
129 },
130 201
131 );
132});
133
134agents.get("/sessions", requireApiAuth, async (c) => {
135 const user = c.get("user")!;
136 const rows = await listAgentSessionsForOwner(user.id);
137 return c.json({
138 sessions: rows.map((r) => ({
139 id: r.id,
140 name: r.name,
141 branch_namespace: r.branchNamespace,
142 repository_id: r.repositoryId,
143 budget_cents_per_day: r.budgetCentsPerDay,
144 spent_cents_today: r.spentCentsToday,
145 last_active_at: r.lastActiveAt,
146 created_at: r.createdAt,
147 })),
148 });
149});
150
151agents.delete("/sessions/:id", requireApiAuth, async (c) => {
152 const user = c.get("user")!;
153 const id = c.req.param("id");
154 const ok = await revokeAgentSession(id, user.id);
155 if (!ok) return c.json({ error: "Not found" }, 404);
156 return c.json({ ok: true });
157});
158
159// ---------------------------------------------------------------------------
160// Leases
161// ---------------------------------------------------------------------------
162
163agents.post("/leases", requireAgentAuth, async (c) => {
164 const agent = c.get("agent")!;
165 let body: {
166 target_type?: unknown;
167 targetType?: unknown;
168 target_id?: unknown;
169 targetId?: unknown;
170 duration_ms?: unknown;
171 durationMs?: unknown;
172 } = {};
173 try {
174 body = await c.req.json();
175 } catch {
176 body = {};
177 }
178
179 const targetType =
180 typeof body.target_type === "string"
181 ? body.target_type
182 : typeof body.targetType === "string"
183 ? body.targetType
184 : "";
185 const targetId =
186 typeof body.target_id === "string"
187 ? body.target_id
188 : typeof body.targetId === "string"
189 ? body.targetId
190 : "";
191
192 if (!targetType || !targetId) {
193 return c.json({ error: "target_type and target_id are required" }, 400);
194 }
195 if (!(LEASE_TARGET_TYPES as readonly string[]).includes(targetType)) {
196 return c.json(
197 {
198 error: `target_type must be one of ${LEASE_TARGET_TYPES.join(", ")}`,
199 },
200 400
201 );
202 }
203
204 const rawDur =
205 typeof body.duration_ms === "number"
206 ? body.duration_ms
207 : typeof body.durationMs === "number"
208 ? body.durationMs
209 : DEFAULT_LEASE_DURATION_MS;
210 const durationMs = Math.max(
211 1000,
212 Math.min(60 * 60 * 1000, Math.floor(rawDur))
213 );
214
215 const lease = await acquireLease(agent.id, targetType, targetId, durationMs);
216 if (!lease) {
217 return c.json(
218 {
219 error: "Lease unavailable — another agent holds it",
220 target_type: targetType,
221 target_id: targetId,
222 },
223 409
224 );
225 }
226
227 return c.json(
228 {
229 lease: {
230 id: lease.id,
231 target_type: lease.targetType,
232 target_id: lease.targetId,
233 acquired_at: lease.acquiredAt,
234 expires_at: lease.expiresAt,
235 status: lease.status,
236 },
237 },
238 201
239 );
240});
241
242agents.delete("/leases/:id", requireAgentAuth, async (c) => {
243 const agent = c.get("agent")!;
244 const id = c.req.param("id");
245 // Only let the holder release the lease.
246 try {
247 const [row] = await db
248 .select()
249 .from(agentLeases)
250 .where(eq(agentLeases.id, id))
251 .limit(1);
252 if (!row || row.agentSessionId !== agent.id) {
253 return c.json({ error: "Not found" }, 404);
254 }
255 } catch {
256 return c.json({ error: "Not found" }, 404);
257 }
258 const ok = await releaseLease(id);
259 if (!ok) return c.json({ error: "Lease was not active" }, 409);
260 return c.json({ ok: true });
261});
262
263// ---------------------------------------------------------------------------
264// Usage
265// ---------------------------------------------------------------------------
266
267agents.get("/usage", async (c) => {
268 // Two modes:
269 // - Agent-authed call: return that agent's usage only.
270 // - User-authed call: return all of the user's agents.
271 const agent = c.get("agent");
272 if (agent) {
273 const usage = await getAgentUsage(agent.id);
274 return c.json({
275 agent_id: agent.id,
276 name: agent.name,
277 branch_namespace: agent.branchNamespace,
278 spent_cents_today: usage.spent,
279 budget_cents_per_day: usage.cap,
280 remaining_cents: usage.remaining,
281 });
282 }
283
284 const user = c.get("user");
285 if (!user) {
286 return c.json({ error: "Authentication required" }, 401);
287 }
288 const rows = await db
289 .select()
290 .from(agentSessions)
291 .where(eq(agentSessions.ownerUserId, user.id));
292 return c.json({
293 sessions: rows.map((r) => ({
294 id: r.id,
295 name: r.name,
296 branch_namespace: r.branchNamespace,
297 spent_cents_today: r.spentCentsToday,
298 budget_cents_per_day: r.budgetCentsPerDay,
299 remaining_cents: Math.max(0, r.budgetCentsPerDay - r.spentCentsToday),
300 })),
301 });
302});
303
304export default agents;