Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

agents.tsx

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.tsxBlame739 lines · 1 contributor
ddb25a6Claude1/**
2 * Block K8 — Agent inbox + controls UI.
3 *
4 * GET /:owner/:repo/agents list recent agent runs (owner or public)
5 * GET /:owner/:repo/agents/:id detail view with log + kill button
6 * POST /:owner/:repo/agents/:id/kill owner-only; flips status to killed
7 * GET /:owner/:repo/settings/agents owner-only; per-repo toggles + budgets
8 * POST /:owner/:repo/settings/agents owner-only; upserts repo_agent_settings
9 * POST /:owner/:repo/settings/agents/pause owner-only; toggles paused flag
10 * GET /admin/agents site-admin; recent runs across all repos
11 * POST /admin/agents/pause-all site-admin; sets system flag agents_paused=true
12 */
13
14import { Hono } from "hono";
15import { and, desc, eq, sql } from "drizzle-orm";
16import { db } from "../db";
17import { agentRuns, repositories, users } from "../db/schema";
18import type { AgentKind, AgentRunStatus } from "../lib/agent-runtime";
19import { killAgentRun } from "../lib/agent-runtime";
20import { isSiteAdmin, setFlag } from "../lib/admin";
21import { audit } from "../lib/notify";
22import { Layout } from "../views/layout";
23import { RepoHeader, RepoNav } from "../views/components";
24import { softAuth, requireAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
26
27const AGENT_KINDS: AgentKind[] = [
28 "triage",
29 "fix",
30 "review_response",
31 "deploy_watcher",
32 "heal_bot",
33 "custom",
34];
35
36export interface AgentSettingsInput {
37 enabled_kinds?: string | string[];
38 daily_budget_cents?: string;
39 monthly_budget_cents?: string;
40 max_runs_per_hour?: string;
41 paused?: string;
42}
43
44export interface ParsedAgentSettings {
45 enabledKinds: AgentKind[];
46 dailyBudgetCents: number;
47 monthlyBudgetCents: number;
48 maxRunsPerHour: number;
49 paused: boolean;
50}
51
52/** Pure form parser — defensive coercion + allowlist filtering. */
53export function parseAgentSettingsForm(
54 input: AgentSettingsInput
55): ParsedAgentSettings {
56 const rawKinds = input.enabled_kinds;
57 const kindsArray = Array.isArray(rawKinds)
58 ? rawKinds
59 : typeof rawKinds === "string" && rawKinds.length > 0
60 ? rawKinds.split(",")
61 : [];
62 const enabledKinds = kindsArray
63 .map((k) => String(k).trim())
64 .filter((k): k is AgentKind =>
65 AGENT_KINDS.includes(k as AgentKind)
66 );
67
68 const clampInt = (v: string | undefined, def: number, max: number) => {
69 const n = Number.parseInt(String(v ?? ""), 10);
70 if (!Number.isFinite(n) || n < 0) return def;
71 return Math.min(n, max);
72 };
73
74 return {
75 enabledKinds,
76 dailyBudgetCents: clampInt(input.daily_budget_cents, 100, 1_000_000),
77 monthlyBudgetCents: clampInt(input.monthly_budget_cents, 2000, 50_000_000),
78 maxRunsPerHour: clampInt(input.max_runs_per_hour, 20, 1000),
79 paused: input.paused === "on" || input.paused === "true",
80 };
81}
82
83async function loadRepo(ownerName: string, repoName: string) {
84 try {
85 const [row] = await db
86 .select({
87 id: repositories.id,
88 name: repositories.name,
89 ownerId: repositories.ownerId,
90 isPrivate: repositories.isPrivate,
91 starCount: repositories.starCount,
92 forkCount: repositories.forkCount,
93 })
94 .from(repositories)
95 .innerJoin(users, eq(repositories.ownerId, users.id))
96 .where(
97 and(eq(users.username, ownerName), eq(repositories.name, repoName))
98 )
99 .limit(1);
100 return row || null;
101 } catch {
102 return null;
103 }
104}
105
106async function loadSettings(repoId: string): Promise<ParsedAgentSettings> {
107 try {
108 const result = await db.execute(sql`
109 SELECT enabled_kinds, daily_budget_cents, monthly_budget_cents,
110 max_runs_per_hour, paused
111 FROM repo_agent_settings
112 WHERE repository_id = ${repoId}
113 LIMIT 1
114 `);
115 const row = (result as unknown as { rows?: unknown[] }).rows?.[0] as
116 | Record<string, unknown>
117 | undefined;
118 if (!row) {
119 return {
120 enabledKinds: [],
121 dailyBudgetCents: 100,
122 monthlyBudgetCents: 2000,
123 maxRunsPerHour: 20,
124 paused: false,
125 };
126 }
127 let enabledKinds: AgentKind[] = [];
128 try {
129 const parsed = JSON.parse(String(row.enabled_kinds ?? "[]"));
130 if (Array.isArray(parsed)) {
131 enabledKinds = parsed.filter((k): k is AgentKind =>
132 AGENT_KINDS.includes(k as AgentKind)
133 );
134 }
135 } catch {
136 /* keep empty */
137 }
138 return {
139 enabledKinds,
140 dailyBudgetCents: Number(row.daily_budget_cents ?? 100),
141 monthlyBudgetCents: Number(row.monthly_budget_cents ?? 2000),
142 maxRunsPerHour: Number(row.max_runs_per_hour ?? 20),
143 paused: row.paused === true || row.paused === "t",
144 };
145 } catch {
146 return {
147 enabledKinds: [],
148 dailyBudgetCents: 100,
149 monthlyBudgetCents: 2000,
150 maxRunsPerHour: 20,
151 paused: false,
152 };
153 }
154}
155
156async function upsertSettings(repoId: string, s: ParsedAgentSettings) {
157 await db.execute(sql`
158 INSERT INTO repo_agent_settings
159 (repository_id, enabled_kinds, daily_budget_cents,
160 monthly_budget_cents, max_runs_per_hour, paused, updated_at)
161 VALUES
162 (${repoId}, ${JSON.stringify(s.enabledKinds)}, ${s.dailyBudgetCents},
163 ${s.monthlyBudgetCents}, ${s.maxRunsPerHour}, ${s.paused}, now())
164 ON CONFLICT (repository_id) DO UPDATE SET
165 enabled_kinds = EXCLUDED.enabled_kinds,
166 daily_budget_cents = EXCLUDED.daily_budget_cents,
167 monthly_budget_cents = EXCLUDED.monthly_budget_cents,
168 max_runs_per_hour = EXCLUDED.max_runs_per_hour,
169 paused = EXCLUDED.paused,
170 updated_at = now()
171 `);
172}
173
174function statusClass(s: string): string {
175 if (s === "succeeded") return "gate-status passed";
176 if (s === "failed" || s === "timeout" || s === "killed")
177 return "gate-status failed";
178 if (s === "running") return "gate-status running";
179 return "gate-status pending";
180}
181
182function formatCost(cents: number): string {
183 return `$${(cents / 100).toFixed(2)}`;
184}
185
186function triggerLink(
187 owner: string,
188 repo: string,
189 kind: string,
190 ref: string | null
191): string | null {
192 if (!ref) return null;
193 if (kind === "triage" || kind === "fix" || kind === "review_response") {
194 const n = ref.split(":")[0];
195 if (/^\d+$/.test(n)) return `/${owner}/${repo}/pulls/${n}`;
196 }
197 return null;
198}
199
200const agents = new Hono<AuthEnv>();
201agents.use("*", softAuth);
202
203// ---------- per-repo inbox ----------
204
205agents.get("/:owner/:repo/agents", async (c) => {
206 const user = c.get("user");
207 const { owner, repo } = c.req.param();
208 const repoRow = await loadRepo(owner, repo);
209 if (!repoRow) return c.notFound();
210 if (repoRow.isPrivate && (!user || user.id !== repoRow.ownerId)) {
211 return c.redirect("/login");
212 }
213
214 const kindFilter = c.req.query("kind") as AgentKind | undefined;
215 const statusFilter = c.req.query("status") as AgentRunStatus | undefined;
216
217 const conditions = [eq(agentRuns.repositoryId, repoRow.id)];
218 if (kindFilter && AGENT_KINDS.includes(kindFilter)) {
219 conditions.push(eq(agentRuns.kind, kindFilter));
220 }
221 if (statusFilter) {
222 conditions.push(eq(agentRuns.status, statusFilter));
223 }
224
225 const runs = await db
226 .select()
227 .from(agentRuns)
228 .where(and(...conditions))
229 .orderBy(desc(agentRuns.createdAt))
230 .limit(200);
231
232 return c.html(
233 <Layout title={`Agents — ${owner}/${repo}`} user={user ?? undefined}>
234 <RepoHeader
235 owner={owner}
236 repo={repo}
237 starCount={repoRow.starCount}
238 forkCount={repoRow.forkCount}
239 currentUser={user?.username}
240 />
241 <RepoNav owner={owner} repo={repo} active="agents" />
242
243 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
244 <h3>Agent runs</h3>
245 {user && user.id === repoRow.ownerId && (
246 <a
247 href={`/${owner}/${repo}/settings/agents`}
248 class="btn btn-sm"
249 >
250 Settings
251 </a>
252 )}
253 </div>
254
255 <form method="GET" style="margin-bottom:16px;display:flex;gap:8px">
256 <select name="kind">
257 <option value="">All kinds</option>
258 {AGENT_KINDS.map((k) => (
259 <option value={k} selected={k === kindFilter}>
260 {k}
261 </option>
262 ))}
263 </select>
264 <select name="status">
265 <option value="">All statuses</option>
266 {["queued", "running", "succeeded", "failed", "killed", "timeout"].map(
267 (s) => (
268 <option value={s} selected={s === statusFilter}>
269 {s}
270 </option>
271 )
272 )}
273 </select>
274 <button type="submit" class="btn btn-sm">
275 Filter
276 </button>
277 </form>
278
279 <div class="panel">
280 {runs.length === 0 ? (
281 <div class="panel-empty">No agent runs yet.</div>
282 ) : (
283 <table style="width:100%;font-size:13px">
284 <thead>
285 <tr>
286 <th style="text-align:left;padding:8px">Kind</th>
287 <th style="text-align:left;padding:8px">Trigger</th>
288 <th style="text-align:left;padding:8px">Status</th>
289 <th style="text-align:left;padding:8px">Ref</th>
290 <th style="text-align:right;padding:8px">Cost</th>
291 <th style="text-align:left;padding:8px">Started</th>
292 <th></th>
293 </tr>
294 </thead>
295 <tbody>
296 {runs.map((r) => {
297 const link = triggerLink(owner, repo, r.kind, r.triggerRef);
298 return (
299 <tr>
300 <td style="padding:8px">
301 <code>{r.kind}</code>
302 </td>
303 <td style="padding:8px">{r.trigger}</td>
304 <td style="padding:8px">
305 <span class={statusClass(r.status)}>{r.status}</span>
306 </td>
307 <td style="padding:8px">
308 {link ? (
309 <a href={link}>{r.triggerRef}</a>
310 ) : (
311 r.triggerRef || "—"
312 )}
313 </td>
314 <td style="padding:8px;text-align:right">
315 {formatCost(r.costCents)}
316 </td>
317 <td style="padding:8px">
318 {r.createdAt?.toISOString().slice(0, 19) || "—"}
319 </td>
320 <td style="padding:8px">
321 <a
322 href={`/${owner}/${repo}/agents/${r.id}`}
323 class="btn btn-sm"
324 >
325 view
326 </a>
327 </td>
328 </tr>
329 );
330 })}
331 </tbody>
332 </table>
333 )}
334 </div>
335 </Layout>
336 );
337});
338
339agents.get("/:owner/:repo/agents/:id", async (c) => {
340 const user = c.get("user");
341 const { owner, repo, id } = c.req.param();
342 const repoRow = await loadRepo(owner, repo);
343 if (!repoRow) return c.notFound();
344 if (repoRow.isPrivate && (!user || user.id !== repoRow.ownerId)) {
345 return c.redirect("/login");
346 }
347
348 const [run] = await db
349 .select()
350 .from(agentRuns)
351 .where(
352 and(eq(agentRuns.id, id), eq(agentRuns.repositoryId, repoRow.id))
353 )
354 .limit(1);
355 if (!run) return c.notFound();
356
357 const isOwner = user?.id === repoRow.ownerId;
358 const canKill =
359 isOwner && (run.status === "queued" || run.status === "running");
360
361 const durationMs =
362 run.startedAt && run.finishedAt
363 ? run.finishedAt.getTime() - run.startedAt.getTime()
364 : null;
365
366 const link = triggerLink(owner, repo, run.kind, run.triggerRef);
367
368 return c.html(
369 <Layout title={`Agent run ${run.id}`} user={user ?? undefined}>
370 <RepoHeader
371 owner={owner}
372 repo={repo}
373 starCount={repoRow.starCount}
374 forkCount={repoRow.forkCount}
375 currentUser={user?.username}
376 />
377 <RepoNav owner={owner} repo={repo} active="agents" />
378
379 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
380 <h3>
381 Agent run · <code>{run.kind}</code>
382 </h3>
383 <a href={`/${owner}/${repo}/agents`} class="btn btn-sm">
384 Back
385 </a>
386 </div>
387
388 <div class="panel" style="padding:16px;margin-bottom:16px">
389 <div>
390 Status: <span class={statusClass(run.status)}>{run.status}</span>
391 </div>
392 <div>Trigger: {run.trigger}</div>
393 {run.triggerRef && (
394 <div>
395 Ref: {link ? <a href={link}>{run.triggerRef}</a> : run.triggerRef}
396 </div>
397 )}
398 {run.summary && <div>Summary: {run.summary}</div>}
399 <div>
400 Cost: {formatCost(run.costCents)} ({run.costInputTokens} in /{" "}
401 {run.costOutputTokens} out tokens)
402 </div>
403 {durationMs !== null && (
404 <div>Duration: {(durationMs / 1000).toFixed(1)}s</div>
405 )}
406 {run.errorMessage && (
407 <div style="color:var(--red);margin-top:8px">
408 <strong>Error:</strong>
409 <pre style="white-space:pre-wrap;margin-top:4px">
410 {run.errorMessage}
411 </pre>
412 </div>
413 )}
414 </div>
415
416 {canKill && (
417 <form
418 method="POST"
419 action={`/${owner}/${repo}/agents/${run.id}/kill`}
420 style="margin-bottom:16px"
421 onsubmit="return confirm('Kill this running agent?')"
422 >
423 <button type="submit" class="btn btn-danger">
424 Kill run
425 </button>
426 </form>
427 )}
428
429 <div class="panel">
430 <div class="panel-item" style="flex-direction:column;align-items:flex-start">
431 <strong>Log</strong>
432 <pre style="white-space:pre-wrap;font-size:12px;width:100%;margin-top:8px;max-height:400px;overflow:auto">
433 {run.log || "(empty)"}
434 </pre>
435 </div>
436 </div>
437 </Layout>
438 );
439});
440
441agents.post("/:owner/:repo/agents/:id/kill", requireAuth, async (c) => {
442 const user = c.get("user")!;
443 const { owner, repo, id } = c.req.param();
444 const repoRow = await loadRepo(owner, repo);
445 if (!repoRow) return c.notFound();
446 if (repoRow.ownerId !== user.id) {
447 return c.redirect(`/${owner}/${repo}/agents`);
448 }
449 await killAgentRun(id);
450 await audit({
451 userId: user.id,
452 repositoryId: repoRow.id,
453 action: "agent.kill",
454 targetId: id,
455 });
456 return c.redirect(`/${owner}/${repo}/agents/${id}`);
457});
458
459// ---------- per-repo settings ----------
460
461agents.get("/:owner/:repo/settings/agents", requireAuth, async (c) => {
462 const user = c.get("user")!;
463 const { owner, repo } = c.req.param();
464 const repoRow = await loadRepo(owner, repo);
465 if (!repoRow) return c.notFound();
466 if (repoRow.ownerId !== user.id) {
467 return c.redirect(`/${owner}/${repo}`);
468 }
469 const settings = await loadSettings(repoRow.id);
470
471 return c.html(
472 <Layout title={`Agent settings — ${repo}`} user={user}>
473 <RepoHeader
474 owner={owner}
475 repo={repo}
476 starCount={repoRow.starCount}
477 forkCount={repoRow.forkCount}
478 currentUser={user.username}
479 />
480 <RepoNav owner={owner} repo={repo} active="agents" />
481
482 <h3>Agent settings</h3>
483 <p style="font-size:13px;color:var(--text-muted);margin-bottom:16px">
484 Enable specific agent kinds for this repository and set budget caps.
485 Pausing stops all scheduled agents immediately.
486 </p>
487
488 <form
489 method="POST"
490 action={`/${owner}/${repo}/settings/agents`}
491 class="panel"
492 style="padding:16px"
493 >
494 <div class="form-group">
495 <label>
496 <strong>Enabled kinds</strong>
497 </label>
498 {AGENT_KINDS.map((k) => (
499 <label style="display:block;margin:4px 0">
500 <input
501 type="checkbox"
502 name="enabled_kinds"
503 value={k}
504 checked={settings.enabledKinds.includes(k)}
505 />{" "}
506 <code>{k}</code>
507 </label>
508 ))}
509 </div>
510 <div class="form-group">
511 <label>Daily budget (cents)</label>
512 <input
513 type="number"
514 name="daily_budget_cents"
515 value={String(settings.dailyBudgetCents)}
516 min="0"
517 max="1000000"
518 />
519 </div>
520 <div class="form-group">
521 <label>Monthly budget (cents)</label>
522 <input
523 type="number"
524 name="monthly_budget_cents"
525 value={String(settings.monthlyBudgetCents)}
526 min="0"
527 max="50000000"
528 />
529 </div>
530 <div class="form-group">
531 <label>Max runs per hour</label>
532 <input
533 type="number"
534 name="max_runs_per_hour"
535 value={String(settings.maxRunsPerHour)}
536 min="0"
537 max="1000"
538 />
539 </div>
540 <div class="form-group">
541 <label>
542 <input
543 type="checkbox"
544 name="paused"
545 checked={settings.paused}
546 />{" "}
547 Paused (all agents disabled)
548 </label>
549 </div>
550 <button type="submit" class="btn btn-primary">
551 Save settings
552 </button>
553 </form>
554
555 <form
556 method="POST"
557 action={`/${owner}/${repo}/settings/agents/pause`}
558 style="margin-top:16px"
559 >
560 <button type="submit" class="btn btn-sm">
561 Toggle paused
562 </button>
563 </form>
564 </Layout>
565 );
566});
567
568agents.post("/:owner/:repo/settings/agents", requireAuth, async (c) => {
569 const user = c.get("user")!;
570 const { owner, repo } = c.req.param();
571 const repoRow = await loadRepo(owner, repo);
572 if (!repoRow) return c.notFound();
573 if (repoRow.ownerId !== user.id) {
574 return c.redirect(`/${owner}/${repo}`);
575 }
576 const body = await c.req.parseBody({ all: true });
577 const parsed = parseAgentSettingsForm(
578 body as unknown as AgentSettingsInput
579 );
580
581 try {
582 await upsertSettings(repoRow.id, parsed);
583 } catch (err) {
584 console.error("[agents] upsertSettings:", err);
585 }
586
587 await audit({
588 userId: user.id,
589 repositoryId: repoRow.id,
590 action: "agent.settings.update",
591 metadata: {
592 enabledKinds: parsed.enabledKinds,
593 daily: parsed.dailyBudgetCents,
594 monthly: parsed.monthlyBudgetCents,
595 },
596 });
597
598 return c.redirect(`/${owner}/${repo}/settings/agents`);
599});
600
601agents.post("/:owner/:repo/settings/agents/pause", requireAuth, async (c) => {
602 const user = c.get("user")!;
603 const { owner, repo } = c.req.param();
604 const repoRow = await loadRepo(owner, repo);
605 if (!repoRow) return c.notFound();
606 if (repoRow.ownerId !== user.id) {
607 return c.redirect(`/${owner}/${repo}`);
608 }
609 const cur = await loadSettings(repoRow.id);
610 try {
611 await upsertSettings(repoRow.id, { ...cur, paused: !cur.paused });
612 } catch (err) {
613 console.error("[agents] pause:", err);
614 }
615 await audit({
616 userId: user.id,
617 repositoryId: repoRow.id,
618 action: "agent.settings.pause",
619 metadata: { paused: !cur.paused },
620 });
621 return c.redirect(`/${owner}/${repo}/settings/agents`);
622});
623
624// ---------- site-admin ----------
625
626agents.get("/admin/agents", requireAuth, async (c) => {
627 const user = c.get("user")!;
628 if (!(await isSiteAdmin(user.id))) return c.redirect("/");
629
630 const runs = await db
631 .select({
632 id: agentRuns.id,
633 kind: agentRuns.kind,
634 trigger: agentRuns.trigger,
635 status: agentRuns.status,
636 costCents: agentRuns.costCents,
637 createdAt: agentRuns.createdAt,
638 repoId: agentRuns.repositoryId,
639 repoName: repositories.name,
640 ownerUsername: users.username,
641 })
642 .from(agentRuns)
643 .leftJoin(repositories, eq(repositories.id, agentRuns.repositoryId))
644 .leftJoin(users, eq(users.id, repositories.ownerId))
645 .orderBy(desc(agentRuns.createdAt))
646 .limit(500);
647
648 const todayCostCents = runs
649 .filter((r) => {
650 const d = r.createdAt ? new Date(r.createdAt) : null;
651 if (!d) return false;
652 const dayAgo = Date.now() - 24 * 60 * 60 * 1000;
653 return d.getTime() >= dayAgo;
654 })
655 .reduce((sum, r) => sum + (r.costCents || 0), 0);
656
657 return c.html(
658 <Layout title="Admin — Agents" user={user}>
659 <h2>Admin · Agents</h2>
660 <div class="panel" style="padding:16px;margin-bottom:16px">
661 <div>
662 <strong>24h spend:</strong> {formatCost(todayCostCents)}
663 </div>
664 <div>
665 <strong>Recent runs:</strong> {runs.length}
666 </div>
667 </div>
668
669 <form
670 method="POST"
671 action="/admin/agents/pause-all"
672 style="margin-bottom:16px"
673 onsubmit="return confirm('Pause ALL agents platform-wide?')"
674 >
675 <button type="submit" class="btn btn-danger">
676 Pause all agents (platform kill-switch)
677 </button>
678 </form>
679
680 <div class="panel">
681 {runs.length === 0 ? (
682 <div class="panel-empty">No agent runs.</div>
683 ) : (
684 <table style="width:100%;font-size:13px">
685 <thead>
686 <tr>
687 <th style="text-align:left;padding:8px">Repo</th>
688 <th style="text-align:left;padding:8px">Kind</th>
689 <th style="text-align:left;padding:8px">Status</th>
690 <th style="text-align:right;padding:8px">Cost</th>
691 <th style="text-align:left;padding:8px">Created</th>
692 </tr>
693 </thead>
694 <tbody>
695 {runs.map((r) => (
696 <tr>
697 <td style="padding:8px">
698 {r.ownerUsername && r.repoName ? (
699 <a href={`/${r.ownerUsername}/${r.repoName}/agents/${r.id}`}>
700 {r.ownerUsername}/{r.repoName}
701 </a>
702 ) : (
703 "—"
704 )}
705 </td>
706 <td style="padding:8px">
707 <code>{r.kind}</code>
708 </td>
709 <td style="padding:8px">
710 <span class={statusClass(r.status)}>{r.status}</span>
711 </td>
712 <td style="padding:8px;text-align:right">
713 {formatCost(r.costCents || 0)}
714 </td>
715 <td style="padding:8px">
716 {r.createdAt?.toISOString().slice(0, 19) || "—"}
717 </td>
718 </tr>
719 ))}
720 </tbody>
721 </table>
722 )}
723 </div>
724 </Layout>
725 );
726});
727
728agents.post("/admin/agents/pause-all", requireAuth, async (c) => {
729 const user = c.get("user")!;
730 if (!(await isSiteAdmin(user.id))) return c.redirect("/");
731 await setFlag("agents_paused", "true", user.id);
732 await audit({
733 userId: user.id,
734 action: "admin.agents.pause_all",
735 });
736 return c.redirect("/admin/agents");
737});
738
739export default agents;