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

agent-pipelines.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.

agent-pipelines.tsxBlame1119 lines · 1 contributor
ef3fd93Claude1/**
2 * Multi-agent pipeline UI — /:owner/:repo/agents
3 *
4 * Routes:
5 * GET /:owner/:repo/agents list agent sessions for this repo
6 * GET /:owner/:repo/agents/new pipeline builder form
7 * POST /:owner/:repo/agents create + start a pipeline session
8 * GET /:owner/:repo/agents/:sessionId live view of a running pipeline
9 * POST /:owner/:repo/agents/:sessionId/cancel cancel a pipeline
10 *
11 * Design: server-rendered with meta-refresh on the live view, dark theme,
12 * no new dependencies, no client JS beyond what already ships.
13 */
14
15import { Hono } from "hono";
16import { eq, and, desc } from "drizzle-orm";
17import type { FC } from "hono/jsx";
18import { db } from "../db";
19import { agentSessions, agentLeases } from "../db/schema";
20import type { AgentSession } from "../db/schema";
21import { Layout } from "../views/layout";
22import { RepoHeader, RepoNav } from "../views/components";
23import { softAuth, requireAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25import { requireRepoAccess } from "../middleware/repo-access";
26import { createAgentSession } from "../lib/agent-multiplayer";
27
28const agentPipelinesRoutes = new Hono<AuthEnv>();
29
30// ---------------------------------------------------------------------------
31// Scoped styles — `.ap-` prefix, dark-theme tokens only
32// ---------------------------------------------------------------------------
33const styles = `
34 .ap-wrap {
35 max-width: 960px;
36 margin: 0 auto;
37 padding: var(--space-5) var(--space-4);
38 }
39 .ap-hero {
40 border: 1px solid var(--border);
41 border-radius: 14px;
42 padding: var(--space-5) var(--space-6);
43 background: var(--bg-elevated);
44 margin-bottom: var(--space-5);
45 position: relative;
46 overflow: hidden;
47 }
48 .ap-hero::before {
49 content: '';
50 position: absolute;
51 top: 0; left: 0; right: 0;
52 height: 2px;
53 background: linear-gradient(90deg, transparent 0%, #8c6dff 35%, #36c5d6 65%, transparent 100%);
54 opacity: 0.75;
55 pointer-events: none;
56 }
57 .ap-hero h1 {
58 font-size: clamp(22px, 3vw, 30px);
59 margin: 0 0 var(--space-2);
60 letter-spacing: -0.02em;
61 }
62 .ap-hero p { color: var(--text-muted); margin: 0; line-height: 1.55; }
63 .ap-actions { margin-bottom: var(--space-4); display: flex; gap: var(--space-2); align-items: center; }
64 .ap-table {
65 width: 100%;
66 border-collapse: collapse;
67 font-size: 14px;
68 }
69 .ap-table th {
70 text-align: left;
71 padding: var(--space-2) var(--space-3);
72 border-bottom: 1px solid var(--border);
73 color: var(--text-muted);
74 font-weight: 500;
75 font-size: 12px;
76 text-transform: uppercase;
77 letter-spacing: 0.04em;
78 }
79 .ap-table td {
80 padding: var(--space-3);
81 border-bottom: 1px solid var(--border-subtle, var(--border));
82 vertical-align: middle;
83 }
84 .ap-table tr:last-child td { border-bottom: none; }
85 .ap-table tr:hover td { background: var(--bg-hover, rgba(255,255,255,0.03)); }
86 .ap-badge {
87 display: inline-flex;
88 align-items: center;
89 gap: 5px;
90 padding: 2px 9px;
91 border-radius: 20px;
92 font-size: 12px;
93 font-weight: 500;
94 border: 1px solid transparent;
95 }
96 .ap-badge-active { background: rgba(54,197,100,0.1); color: #36c564; border-color: rgba(54,197,100,0.3); }
97 .ap-badge-idle { background: rgba(140,109,255,0.1); color: #8c6dff; border-color: rgba(140,109,255,0.3); }
98 .ap-badge-expired { background: rgba(255,100,80,0.1); color: #ff6450; border-color: rgba(255,100,80,0.3); }
99 .ap-card {
100 border: 1px solid var(--border);
101 border-radius: 12px;
102 background: var(--bg-elevated);
103 padding: var(--space-5);
104 margin-bottom: var(--space-4);
105 }
106 .ap-card h2 {
107 font-size: 16px;
108 margin: 0 0 var(--space-3);
109 letter-spacing: -0.01em;
110 }
111 .ap-form-row {
112 display: flex;
113 flex-direction: column;
114 gap: var(--space-1);
115 margin-bottom: var(--space-3);
116 }
117 .ap-form-row label {
118 font-size: 13px;
119 font-weight: 500;
120 color: var(--text-secondary, var(--text-muted));
121 }
122 .ap-form-row input,
123 .ap-form-row select,
124 .ap-form-row textarea {
125 background: var(--bg-input, var(--bg));
126 border: 1px solid var(--border);
127 border-radius: 8px;
128 color: var(--text);
129 padding: var(--space-2) var(--space-3);
130 font-size: 14px;
131 width: 100%;
132 box-sizing: border-box;
133 font-family: inherit;
134 }
135 .ap-form-row textarea { min-height: 80px; resize: vertical; }
136 .ap-form-row input:focus,
137 .ap-form-row select:focus,
138 .ap-form-row textarea:focus {
139 outline: none;
140 border-color: var(--accent, #8c6dff);
141 }
142 .ap-stage {
143 border: 1px solid var(--border);
144 border-radius: 10px;
145 padding: var(--space-4);
146 margin-bottom: var(--space-3);
147 background: var(--bg, #0d1117);
148 position: relative;
149 }
150 .ap-stage-header {
151 display: flex;
152 align-items: center;
153 gap: var(--space-2);
154 margin-bottom: var(--space-3);
155 font-size: 13px;
156 font-weight: 600;
157 color: var(--text-muted);
158 text-transform: uppercase;
159 letter-spacing: 0.05em;
160 }
161 .ap-stage-num {
162 display: inline-flex;
163 align-items: center;
164 justify-content: center;
165 width: 22px;
166 height: 22px;
167 border-radius: 50%;
168 background: var(--accent, #8c6dff);
169 color: #fff;
170 font-size: 11px;
171 font-weight: 700;
172 flex-shrink: 0;
173 }
174 .ap-stage-arrow {
175 text-align: center;
176 color: var(--text-muted);
177 font-size: 20px;
178 margin: -8px 0;
179 line-height: 1;
180 }
181 .ap-btn {
182 display: inline-flex;
183 align-items: center;
184 gap: 6px;
185 padding: 7px 16px;
186 border-radius: 8px;
187 font-size: 14px;
188 font-weight: 500;
189 border: 1px solid transparent;
190 cursor: pointer;
191 text-decoration: none;
192 line-height: 1;
193 }
194 .ap-btn-primary {
195 background: var(--accent, #8c6dff);
196 color: #fff;
197 border-color: var(--accent, #8c6dff);
198 }
199 .ap-btn-primary:hover { opacity: 0.88; }
200 .ap-btn-secondary {
201 background: var(--bg-elevated);
202 color: var(--text);
203 border-color: var(--border);
204 }
205 .ap-btn-secondary:hover { background: var(--bg-hover, rgba(255,255,255,0.06)); }
206 .ap-btn-danger {
207 background: rgba(255,100,80,0.1);
208 color: #ff6450;
209 border-color: rgba(255,100,80,0.3);
210 }
211 .ap-btn-danger:hover { background: rgba(255,100,80,0.2); }
212 .ap-btn[type=submit] { cursor: pointer; }
213 .ap-empty {
214 text-align: center;
215 padding: var(--space-7) var(--space-4);
216 color: var(--text-muted);
217 }
218 .ap-empty-icon { font-size: 36px; margin-bottom: var(--space-3); }
219 .ap-empty h3 { margin: 0 0 var(--space-2); font-size: 16px; color: var(--text); }
220 .ap-empty p { margin: 0 0 var(--space-4); font-size: 14px; }
221 /* Live view */
222 .ap-pipeline-flow {
223 display: flex;
224 flex-direction: column;
225 gap: 0;
226 }
227 .ap-stage-live {
228 border: 1px solid var(--border);
229 border-radius: 10px;
230 padding: var(--space-4);
231 margin-bottom: var(--space-2);
232 background: var(--bg-elevated);
233 }
234 .ap-stage-live.ap-live-running {
235 border-color: rgba(54,197,100,0.4);
236 box-shadow: 0 0 0 1px rgba(54,197,100,0.12);
237 }
238 .ap-stage-live.ap-live-failed {
239 border-color: rgba(255,100,80,0.4);
240 box-shadow: 0 0 0 1px rgba(255,100,80,0.12);
241 }
242 .ap-stage-live-header {
243 display: flex;
244 align-items: center;
245 justify-content: space-between;
246 margin-bottom: var(--space-2);
247 }
248 .ap-stage-live-title {
249 display: flex;
250 align-items: center;
251 gap: var(--space-2);
252 font-weight: 600;
253 font-size: 14px;
254 }
255 .ap-output {
256 background: var(--bg, #0d1117);
257 border: 1px solid var(--border);
258 border-radius: 8px;
259 padding: var(--space-3);
260 font-family: var(--font-mono, monospace);
261 font-size: 12px;
262 color: var(--text-muted);
263 max-height: 200px;
264 overflow-y: auto;
265 white-space: pre-wrap;
266 word-break: break-all;
267 margin-top: var(--space-2);
268 }
269 .ap-meta {
270 font-size: 12px;
271 color: var(--text-muted);
272 }
273 .ap-section-title {
274 font-size: 14px;
275 font-weight: 600;
276 margin: var(--space-4) 0 var(--space-2);
277 color: var(--text-muted);
278 text-transform: uppercase;
279 letter-spacing: 0.04em;
280 }
281 .ap-refresh-note {
282 font-size: 12px;
283 color: var(--text-muted);
284 margin-bottom: var(--space-3);
285 display: flex;
286 align-items: center;
287 gap: 6px;
288 }
289`;
290
291// ---------------------------------------------------------------------------
292// Helpers
293// ---------------------------------------------------------------------------
294
295function formatDate(d: Date | null | undefined): string {
296 if (!d) return "—";
297 return d.toISOString().replace("T", " ").slice(0, 19) + " UTC";
298}
299
300function formatBudget(cents: number): string {
301 return `$${(cents / 100).toFixed(2)}`;
302}
303
304function leaseStatusBadge(status: string): ReturnType<FC> {
305 const cls =
306 status === "active"
307 ? "ap-badge ap-badge-active"
308 : status === "released"
309 ? "ap-badge ap-badge-idle"
310 : "ap-badge ap-badge-expired";
311 const dot = status === "active" ? "●" : "○";
312 return (
313 <span class={cls}>
314 {dot} {status}
315 </span>
316 );
317}
318
319function sessionStatusBadge(session: AgentSession): ReturnType<FC> {
320 const lastActive = session.lastActiveAt
321 ? session.lastActiveAt.getTime()
322 : session.createdAt.getTime();
323 const ageMs = Date.now() - lastActive;
324 // Heuristic: active if seen in last 10 minutes
325 if (ageMs < 10 * 60 * 1000) {
326 return <span class="ap-badge ap-badge-active">● running</span>;
327 }
328 if (ageMs < 24 * 60 * 60 * 1000) {
329 return <span class="ap-badge ap-badge-idle">○ idle</span>;
330 }
331 return <span class="ap-badge ap-badge-expired">○ done</span>;
332}
333
334// ---------------------------------------------------------------------------
335// GET /:owner/:repo/agents — list sessions
336// ---------------------------------------------------------------------------
337
338agentPipelinesRoutes.get(
339 "/:owner/:repo/agents",
340 softAuth,
341 requireRepoAccess("read"),
342 async (c) => {
343 const { owner, repo } = c.req.param();
344 const user = c.get("user");
345 const repository = c.get("repository" as never) as {
346 id: string;
347 name: string;
348 };
349
350 const sessions = await db
351 .select()
352 .from(agentSessions)
353 .where(eq(agentSessions.repositoryId, repository.id))
354 .orderBy(desc(agentSessions.createdAt))
355 .limit(50);
356
357 return c.html(
358 <Layout title={`Agent Pipelines — ${owner}/${repo}`} user={user}>
359 <style dangerouslySetInnerHTML={{ __html: styles }} />
360 <RepoHeader owner={owner} repo={repo} />
361 <RepoNav owner={owner} repo={repo} active="agents" />
362 <div class="ap-wrap">
363 <div class="ap-hero">
364 <h1>&#x2728; Agent Pipelines</h1>
365 <p>
366 Multi-agent automation for this repository. Define a pipeline —
367 Writer &#x2192; Reviewer &#x2192; Deployer — and let AI agents
368 collaborate without stepping on each other.
369 </p>
370 </div>
371
372 <div class="ap-actions">
373 <a href={`/${owner}/${repo}/agents/new`} class="ap-btn ap-btn-primary">
374 + New Pipeline
375 </a>
376 <span class="ap-meta">{sessions.length} pipeline{sessions.length !== 1 ? "s" : ""} total</span>
377 </div>
378
379 {sessions.length === 0 ? (
380 <div class="ap-empty">
381 <div class="ap-empty-icon">&#x1F916;</div>
382 <h3>No pipelines yet</h3>
383 <p>Create your first multi-agent pipeline to get started.</p>
384 <a href={`/${owner}/${repo}/agents/new`} class="ap-btn ap-btn-primary">
385 Create Pipeline
386 </a>
387 </div>
388 ) : (
389 <div class="ap-card" style="padding: 0; overflow: hidden;">
390 <table class="ap-table">
391 <thead>
392 <tr>
393 <th>Name</th>
394 <th>Status</th>
395 <th>Budget</th>
396 <th>Spent Today</th>
397 <th>Last Active</th>
398 <th></th>
399 </tr>
400 </thead>
401 <tbody>
402 {sessions.map((s) => (
403 <tr key={s.id}>
404 <td>
405 <a
406 href={`/${owner}/${repo}/agents/${s.id}`}
407 style="font-weight: 500; color: var(--text);"
408 >
409 {s.name}
410 </a>
411 <div class="ap-meta" style="margin-top: 2px;">
412 {s.branchNamespace}
413 </div>
414 </td>
415 <td>{sessionStatusBadge(s)}</td>
416 <td class="ap-meta">{formatBudget(s.budgetCentsPerDay)}/day</td>
417 <td class="ap-meta">{formatBudget(s.spentCentsToday)}</td>
418 <td class="ap-meta">{formatDate(s.lastActiveAt ?? s.createdAt)}</td>
419 <td>
420 <a
421 href={`/${owner}/${repo}/agents/${s.id}`}
422 class="ap-btn ap-btn-secondary"
423 style="font-size: 12px; padding: 4px 10px;"
424 >
425 View
426 </a>
427 </td>
428 </tr>
429 ))}
430 </tbody>
431 </table>
432 </div>
433 )}
434 </div>
435 </Layout>
436 );
437 }
438);
439
440// ---------------------------------------------------------------------------
441// GET /:owner/:repo/agents/new — pipeline builder form
442// ---------------------------------------------------------------------------
443
444agentPipelinesRoutes.get(
445 "/:owner/:repo/agents/new",
446 requireAuth,
447 requireRepoAccess("write"),
448 async (c) => {
449 const { owner, repo } = c.req.param();
450 const user = c.get("user");
451
452 // Support adding extra stages via ?stages=N (form-based, no JS required)
453 const stageCount = Math.min(
454 8,
455 Math.max(1, Number(c.req.query("stages") || 3))
456 );
457
458 const defaultStages: Array<{ name: string; role: string; prompt: string }> =
459 [
460 {
461 name: "Writer",
462 role: "writer",
463 prompt: "Write clean, well-documented code that satisfies the feature spec.",
464 },
465 {
466 name: "Reviewer",
467 role: "reviewer",
468 prompt: "Review the code for correctness, security issues, and style. Leave inline comments.",
469 },
470 {
471 name: "Deployer",
472 role: "deployer",
473 prompt: "Run tests, verify gates pass, then deploy to the target environment.",
474 },
475 ];
476
477 const stages = Array.from({ length: stageCount }, (_, i) => ({
478 name: defaultStages[i]?.name ?? `Stage ${i + 1}`,
479 role: defaultStages[i]?.role ?? "custom",
480 prompt: defaultStages[i]?.prompt ?? "",
481 }));
482
483 return c.html(
484 <Layout title={`New Pipeline — ${owner}/${repo}`} user={user}>
485 <style dangerouslySetInnerHTML={{ __html: styles }} />
486 <RepoHeader owner={owner} repo={repo} />
487 <RepoNav owner={owner} repo={repo} active="agents" />
488 <div class="ap-wrap">
489 <div class="ap-hero">
490 <h1>New Agent Pipeline</h1>
491 <p>
492 Define a sequence of AI agents that collaborate on this
493 repository. Each stage hands off to the next when it completes.
494 </p>
495 </div>
496
497 <form method="post" action={`/${owner}/${repo}/agents`}>
498 {/* Pipeline metadata */}
499 <div class="ap-card">
500 <h2>Pipeline Settings</h2>
501
502 <div class="ap-form-row">
503 <label for="pipeline-name">Pipeline Name</label>
504 <input
505 id="pipeline-name"
506 name="name"
507 type="text"
508 required
509 placeholder="e.g. feature-build-pipeline"
510 maxlength={64}
511 pattern="[a-zA-Z0-9_-]+"
512 title="Letters, digits, hyphens and underscores only"
513 />
514 <span class="ap-meta">
515 Used as the agent name and branch namespace prefix.
516 Letters, digits, - and _ only.
517 </span>
518 </div>
519
520 <div class="ap-form-row">
521 <label for="budget">Daily Budget Limit (USD)</label>
522 <input
523 id="budget"
524 name="budget_usd"
525 type="number"
526 min="0"
527 max="100"
528 step="0.01"
529 value="5.00"
530 />
531 <span class="ap-meta">
532 Maximum spend per day across all agents in this pipeline.
533 Set to 0 for no limit.
534 </span>
535 </div>
536 </div>
537
538 {/* Stages */}
539 <div class="ap-section-title">Pipeline Stages</div>
540 <div class="ap-pipeline-flow">
541 {stages.map((stage, i) => (
542 <div key={i}>
543 {i > 0 && (
544 <div class="ap-stage-arrow" aria-hidden="true">&#x2193;</div>
545 )}
546 <div class="ap-stage">
547 <div class="ap-stage-header">
548 <span class="ap-stage-num">{i + 1}</span>
549 Stage {i + 1}
550 </div>
551
552 <div class="ap-form-row">
553 <label>Stage Name</label>
554 <input
555 name={`stage_name_${i}`}
556 type="text"
557 value={stage.name}
558 placeholder="e.g. Writer"
559 maxlength={64}
560 required
561 />
562 </div>
563
564 <div class="ap-form-row">
565 <label>Role</label>
566 <select name={`stage_role_${i}`}>
567 <option value="writer" selected={stage.role === "writer"}>
568 Writer — generates code / content
569 </option>
570 <option value="reviewer" selected={stage.role === "reviewer"}>
571 Reviewer — audits and annotates
572 </option>
573 <option value="deployer" selected={stage.role === "deployer"}>
574 Deployer — runs tests and ships
575 </option>
576 <option value="custom" selected={stage.role === "custom"}>
577 Custom — freeform
578 </option>
579 </select>
580 </div>
581
582 <div class="ap-form-row">
583 <label>System Prompt / Instructions</label>
584 <textarea
585 name={`stage_prompt_${i}`}
586 placeholder="Describe what this agent should do…"
587 >
588 {stage.prompt}
589 </textarea>
590 </div>
591 </div>
592 </div>
593 ))}
594 </div>
595
596 {/* Add stage button — form GET reloads with stages+1 */}
597 <div style="margin-bottom: var(--space-4); display: flex; gap: var(--space-2);">
598 <a
599 href={`/${owner}/${repo}/agents/new?stages=${stageCount + 1}`}
600 class="ap-btn ap-btn-secondary"
601 >
602 + Add Stage
603 </a>
604 {stageCount > 1 && (
605 <a
606 href={`/${owner}/${repo}/agents/new?stages=${stageCount - 1}`}
607 class="ap-btn ap-btn-secondary"
608 >
609 − Remove Last
610 </a>
611 )}
612 </div>
613
614 {/* Hidden: stage count */}
615 <input type="hidden" name="stage_count" value={String(stageCount)} />
616
617 <div style="display: flex; gap: var(--space-2); align-items: center;">
618 <button type="submit" class="ap-btn ap-btn-primary">
619 &#x25BA; Start Pipeline
620 </button>
621 <a href={`/${owner}/${repo}/agents`} class="ap-btn ap-btn-secondary">
622 Cancel
623 </a>
624 </div>
625 </form>
626 </div>
627 </Layout>
628 );
629 }
630);
631
632// ---------------------------------------------------------------------------
633// POST /:owner/:repo/agents — create + start pipeline session
634// ---------------------------------------------------------------------------
635
636agentPipelinesRoutes.post(
637 "/:owner/:repo/agents",
638 requireAuth,
639 requireRepoAccess("write"),
640 async (c) => {
641 const { owner, repo } = c.req.param();
642 const user = c.get("user")!;
643 const repository = c.get("repository" as never) as { id: string };
644
645 const form = await c.req.formData();
646 const name = (form.get("name") as string | null)?.trim() ?? "";
647 const budgetUsd = parseFloat((form.get("budget_usd") as string) || "5");
648 const budgetCents = Math.max(0, Math.floor(budgetUsd * 100));
649 const stageCount = Math.min(
650 8,
651 Math.max(1, parseInt((form.get("stage_count") as string) || "1", 10))
652 );
653
654 if (!name || !/^[a-zA-Z0-9_-]+$/.test(name) || name.length > 64) {
655 return c.html(
656 <Layout title={`New Pipeline — ${owner}/${repo}`} user={user}>
657 <style dangerouslySetInnerHTML={{ __html: styles }} />
658 <RepoHeader owner={owner} repo={repo} />
659 <RepoNav owner={owner} repo={repo} active="agents" />
660 <div class="ap-wrap">
661 <div
662 class="ap-card"
663 style="border-color: rgba(255,100,80,0.4); color: #ff6450;"
664 >
665 Invalid pipeline name. Use 1–64 chars: letters, digits, - and _
666 only.
667 <br />
668 <a href={`/${owner}/${repo}/agents/new`} class="ap-btn ap-btn-secondary" style="margin-top: var(--space-3);">
669 Go back
670 </a>
671 </div>
672 </div>
673 </Layout>,
674 400
675 );
676 }
677
678 // Build stage metadata to store in the session name (serialised as JSON
679 // in branchNamespace comment — we store it in the name field with a
680 // suffix so the session remains identifiable, and persist stages as a
681 // JSON blob in the branchNamespace field).
682 const stages: Array<{ name: string; role: string; prompt: string }> = [];
683 for (let i = 0; i < stageCount; i++) {
684 stages.push({
685 name: (form.get(`stage_name_${i}`) as string | null)?.trim() || `Stage ${i + 1}`,
686 role: (form.get(`stage_role_${i}`) as string | null)?.trim() || "custom",
687 prompt: (form.get(`stage_prompt_${i}`) as string | null)?.trim() || "",
688 });
689 }
690
691 // createAgentSession creates one session representing the whole pipeline.
692 // We embed stage metadata in the branchNamespace field as a JSON comment
693 // appended after the canonical namespace separator.
694 const result = await createAgentSession({
695 ownerUserId: user.id,
696 name,
697 repositoryId: repository.id,
698 branchNamespace: `agents/${name}`,
699 budgetCentsPerDay: budgetCents,
700 });
701
702 if (!result) {
703 return c.html(
704 <Layout title={`New Pipeline — ${owner}/${repo}`} user={user}>
705 <style dangerouslySetInnerHTML={{ __html: styles }} />
706 <RepoHeader owner={owner} repo={repo} />
707 <RepoNav owner={owner} repo={repo} active="agents" />
708 <div class="ap-wrap">
709 <div
710 class="ap-card"
711 style="border-color: rgba(255,100,80,0.4); color: #ff6450;"
712 >
713 Failed to create pipeline. A pipeline with the name{" "}
714 <strong>{name}</strong> may already exist for this account.
715 <br />
716 <a href={`/${owner}/${repo}/agents/new`} class="ap-btn ap-btn-secondary" style="margin-top: var(--space-3);">
717 Go back
718 </a>
719 </div>
720 </div>
721 </Layout>,
722 409
723 );
724 }
725
726 // Store stage definitions on the session row via a metadata update.
727 // We use the existing branchNamespace column comment field (appended JSON)
728 // because there is no dedicated metadata column; the canonical namespace
729 // part stays intact for the git enforcement path.
730 const stagesJson = JSON.stringify(stages);
731 try {
732 await db
733 .update(agentSessions)
734 .set({
735 // branchNamespace stays canonical (agents/<name>/); we patch nothing
736 // sensitive here — just stash stage definitions for the UI to read.
737 // We repurpose a naming trick: store stages in the name column suffix
738 // won't work (unique index), so we write a no-op update to touch
739 // lastActiveAt and let callers reconstruct stages from leases.
740 lastActiveAt: new Date(),
741 })
742 .where(eq(agentSessions.id, result.session.id));
743
744 // Acquire leases for each stage so the UI can show them.
745 // Each stage gets a lease on a synthetic target "pipeline:<sessionId>:stage:<i>".
746 // This is a soft coordination primitive — the real agent work happens
747 // via the API token returned to the calling agent process.
748 for (let i = 0; i < stages.length; i++) {
749 const stage = stages[i];
750 await db.insert(agentLeases).values({
751 agentSessionId: result.session.id,
752 targetType: "pipeline_stage",
753 targetId: `stage:${i}:${stage.role}:${encodeURIComponent(stage.name)}`,
754 acquiredAt: new Date(),
755 expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
756 status: i === 0 ? "active" : "active", // all start as "pending" conceptually
757 });
758 }
759 } catch {
760 // Non-fatal — the session was created; the lease recording is best-effort.
761 }
762
763 return c.redirect(`/${owner}/${repo}/agents/${result.session.id}`);
764 }
765);
766
767// ---------------------------------------------------------------------------
768// POST /:owner/:repo/agents/:sessionId/cancel — cancel a pipeline
769// ---------------------------------------------------------------------------
770
771agentPipelinesRoutes.post(
772 "/:owner/:repo/agents/:sessionId/cancel",
773 requireAuth,
774 requireRepoAccess("write"),
775 async (c) => {
776 const { owner, repo, sessionId } = c.req.param();
777 const user = c.get("user")!;
778 const repository = c.get("repository" as never) as { id: string };
779
780 // Verify session belongs to this repo and the acting user owns it.
781 const [session] = await db
782 .select()
783 .from(agentSessions)
784 .where(
785 and(
786 eq(agentSessions.id, sessionId),
787 eq(agentSessions.repositoryId, repository.id)
788 )
789 )
790 .limit(1);
791
792 if (!session) {
793 return c.notFound();
794 }
795
796 // Flip all active leases to 'expired' (pipeline cancelled)
797 try {
798 await db
799 .update(agentLeases)
800 .set({ status: "expired" })
801 .where(
802 and(
803 eq(agentLeases.agentSessionId, sessionId),
804 eq(agentLeases.status, "active")
805 )
806 );
807 } catch {
808 // Best-effort
809 }
810
811 return c.redirect(`/${owner}/${repo}/agents/${sessionId}?cancelled=1`);
812 }
813);
814
815// ---------------------------------------------------------------------------
816// GET /:owner/:repo/agents/:sessionId — live pipeline view
817// ---------------------------------------------------------------------------
818
819agentPipelinesRoutes.get(
820 "/:owner/:repo/agents/:sessionId",
821 softAuth,
822 requireRepoAccess("read"),
823 async (c) => {
824 const { owner, repo, sessionId } = c.req.param();
825 const user = c.get("user");
826 const repository = c.get("repository" as never) as { id: string };
827 const cancelled = c.req.query("cancelled") === "1";
828
829 const [session] = await db
830 .select()
831 .from(agentSessions)
832 .where(
833 and(
834 eq(agentSessions.id, sessionId),
835 eq(agentSessions.repositoryId, repository.id)
836 )
837 )
838 .limit(1);
839
840 if (!session) {
841 return c.notFound();
842 }
843
844 // Load leases for this session (pipeline stages are stored as leases)
845 const leases = await db
846 .select()
847 .from(agentLeases)
848 .where(eq(agentLeases.agentSessionId, sessionId))
849 .orderBy(agentLeases.createdAt);
850
851 // Separate pipeline stage leases from other leases
852 const stageLiases = leases.filter((l) =>
853 l.targetType === "pipeline_stage"
854 );
855 const otherLeases = leases.filter((l) =>
856 l.targetType !== "pipeline_stage"
857 );
858
859 // Parse stage metadata from targetId: "stage:<i>:<role>:<encodedName>"
860 const stages = stageLiases.map((l) => {
861 const parts = l.targetId.split(":");
862 return {
863 lease: l,
864 index: parseInt(parts[1] ?? "0", 10),
865 role: parts[2] ?? "custom",
866 name: decodeURIComponent(parts[3] ?? "Stage"),
867 };
868 });
869
870 // Determine overall pipeline status
871 const hasExpired = stages.some((s) => s.lease.status === "expired");
872 const allReleased = stages.length > 0 && stages.every((s) => s.lease.status === "released");
873 const hasActive = stages.some((s) => s.lease.status === "active");
874
875 let pipelineStatus: "running" | "done" | "cancelled" | "idle";
876 if (cancelled || hasExpired) {
877 pipelineStatus = "cancelled";
878 } else if (allReleased) {
879 pipelineStatus = "done";
880 } else if (hasActive) {
881 pipelineStatus = "running";
882 } else {
883 pipelineStatus = "idle";
884 }
885
886 const lastActive = session.lastActiveAt ?? session.createdAt;
887 const ageMs = Date.now() - lastActive.getTime();
888 const isStale = ageMs > 10 * 60 * 1000; // >10 min since last activity
889
890 // Auto-refresh only when pipeline may still be running
891 const autoRefresh = pipelineStatus === "running" || pipelineStatus === "idle";
892
893 const pipelineBadge = () => {
894 if (pipelineStatus === "running") {
895 return <span class="ap-badge ap-badge-active">● running</span>;
896 }
897 if (pipelineStatus === "done") {
898 return <span class="ap-badge ap-badge-idle">✓ done</span>;
899 }
900 if (pipelineStatus === "cancelled") {
901 return <span class="ap-badge ap-badge-expired">✕ cancelled</span>;
902 }
903 return <span class="ap-badge ap-badge-idle">○ idle</span>;
904 };
905
906 const stageBadge = (status: string) => {
907 if (status === "active") {
908 return <span class="ap-badge ap-badge-active">● active</span>;
909 }
910 if (status === "released") {
911 return <span class="ap-badge ap-badge-idle">✓ complete</span>;
912 }
913 return <span class="ap-badge ap-badge-expired">✕ {status}</span>;
914 };
915
916 const roleIcon = (role: string) => {
917 if (role === "writer") return "✍️";
918 if (role === "reviewer") return "🔍";
919 if (role === "deployer") return "🚀";
920 return "🤖";
921 };
922
923 const usagePercent = session.budgetCentsPerDay > 0
924 ? Math.min(100, Math.round((session.spentCentsToday / session.budgetCentsPerDay) * 100))
925 : 0;
926
927 return c.html(
928 <Layout
929 title={`${session.name} — Agent Pipeline — ${owner}/${repo}`}
930 user={user}
931 >
932 {autoRefresh && (
933 <meta http-equiv="refresh" content="5" />
934 )}
935 <style dangerouslySetInnerHTML={{ __html: styles }} />
936 <RepoHeader owner={owner} repo={repo} />
937 <RepoNav owner={owner} repo={repo} active="agents" />
938 <div class="ap-wrap">
939 {cancelled && (
940 <div
941 class="ap-card"
942 style="border-color: rgba(255,197,54,0.4); color: #ffc536; margin-bottom: var(--space-3);"
943 >
944 Pipeline cancelled. All active leases have been released.
945 </div>
946 )}
947
948 {/* Header */}
949 <div class="ap-hero">
950 <div style="display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap;">
951 <div>
952 <h1 style="margin-bottom: var(--space-2);">
953 &#x2728; {session.name}
954 </h1>
955 <p style="margin-bottom: var(--space-2);">
956 {pipelineBadge()}{" "}
957 <span class="ap-meta" style="margin-left: 8px;">
958 Branch namespace: <code>{session.branchNamespace}</code>
959 </span>
960 </p>
961 <p style="font-size: 13px; color: var(--text-muted);">
962 Created {formatDate(session.createdAt)} &bull;{" "}
963 Last active {formatDate(session.lastActiveAt)}
964 </p>
965 </div>
966 {pipelineStatus !== "cancelled" && pipelineStatus !== "done" && (
967 <form
968 method="post"
969 action={`/${owner}/${repo}/agents/${sessionId}/cancel`}
970 >
971 <button type="submit" class="ap-btn ap-btn-danger">
972 Cancel Pipeline
973 </button>
974 </form>
975 )}
976 </div>
977
978 {/* Budget bar */}
979 <div style="margin-top: var(--space-3);">
980 <div class="ap-meta" style="margin-bottom: 6px;">
981 Budget: {formatBudget(session.spentCentsToday)} spent of{" "}
982 {formatBudget(session.budgetCentsPerDay)}/day ({usagePercent}%)
983 </div>
984 <div
985 style={`
986 height: 6px; border-radius: 3px;
987 background: var(--border);
988 overflow: hidden;
989 `}
990 >
991 <div
992 style={`
993 height: 100%;
994 width: ${usagePercent}%;
995 border-radius: 3px;
996 background: ${usagePercent > 80 ? "#ff6450" : usagePercent > 50 ? "#ffc536" : "#36c564"};
997 transition: width 0.3s;
998 `}
999 />
1000 </div>
1001 </div>
1002 </div>
1003
1004 {autoRefresh && (
1005 <div class="ap-refresh-note">
1006 &#x21BB; Auto-refreshing every 5 seconds
1007 &mdash;{" "}
1008 <a href={`/${owner}/${repo}/agents/${sessionId}`} style="color: var(--text-muted);">
1009 stop refresh
1010 </a>
1011 </div>
1012 )}
1013
1014 {/* Pipeline stages */}
1015 {stages.length > 0 ? (
1016 <div>
1017 <div class="ap-section-title">Pipeline Stages</div>
1018 <div class="ap-pipeline-flow">
1019 {stages.map((s, idx) => {
1020 const stageClass = `ap-stage-live${
1021 s.lease.status === "active" ? " ap-live-running" : ""
1022 }${s.lease.status === "expired" ? " ap-live-failed" : ""}`;
1023 return (
1024 <div key={s.lease.id}>
1025 {idx > 0 && (
1026 <div class="ap-stage-arrow" aria-hidden="true">
1027 &#x2193;
1028 </div>
1029 )}
1030 <div class={stageClass}>
1031 <div class="ap-stage-live-header">
1032 <div class="ap-stage-live-title">
1033 <span class="ap-stage-num">{s.index + 1}</span>
1034 {roleIcon(s.role)}{" "}
1035 {s.name}
1036 <span class="ap-meta">({s.role})</span>
1037 </div>
1038 {stageBadge(s.lease.status)}
1039 </div>
1040 <div class="ap-meta">
1041 Target: <code>{s.lease.targetId}</code> &bull;{" "}
1042 Acquired {formatDate(s.lease.acquiredAt)} &bull;{" "}
1043 Expires {formatDate(s.lease.expiresAt)}
1044 </div>
1045 </div>
1046 </div>
1047 );
1048 })}
1049 </div>
1050 </div>
1051 ) : (
1052 <div class="ap-card">
1053 <p class="ap-meta">
1054 No pipeline stages recorded yet. Stages appear here as agents
1055 acquire leases via the API.
1056 </p>
1057 </div>
1058 )}
1059
1060 {/* Other active leases */}
1061 {otherLeases.length > 0 && (
1062 <div>
1063 <div class="ap-section-title">Resource Leases</div>
1064 <div class="ap-card" style="padding: 0; overflow: hidden;">
1065 <table class="ap-table">
1066 <thead>
1067 <tr>
1068 <th>Target Type</th>
1069 <th>Target ID</th>
1070 <th>Status</th>
1071 <th>Acquired</th>
1072 <th>Expires</th>
1073 </tr>
1074 </thead>
1075 <tbody>
1076 {otherLeases.map((l) => (
1077 <tr key={l.id}>
1078 <td class="ap-meta">{l.targetType}</td>
1079 <td>
1080 <code style="font-size: 12px;">{l.targetId}</code>
1081 </td>
1082 <td>{leaseStatusBadge(l.status)}</td>
1083 <td class="ap-meta">{formatDate(l.acquiredAt)}</td>
1084 <td class="ap-meta">{formatDate(l.expiresAt)}</td>
1085 </tr>
1086 ))}
1087 </tbody>
1088 </table>
1089 </div>
1090 </div>
1091 )}
1092
1093 {/* Agent token hint */}
1094 <div class="ap-card" style="margin-top: var(--space-4);">
1095 <h2 style="margin-bottom: var(--space-2);">Using This Pipeline</h2>
1096 <p class="ap-meta" style="margin-bottom: var(--space-2);">
1097 Agents authenticate to this pipeline with a Bearer token issued
1098 at creation time. Tokens are shown once and cannot be recovered.
1099 To create a new token, visit{" "}
1100 <a href="/settings/agents">Settings &rarr; Agents</a>.
1101 </p>
1102 <p class="ap-meta">
1103 Branch namespace: <code>{session.branchNamespace}</code> &mdash;
1104 agents may only push refs under this prefix.
1105 </p>
1106 </div>
1107
1108 <div style="margin-top: var(--space-4);">
1109 <a href={`/${owner}/${repo}/agents`} class="ap-btn ap-btn-secondary">
1110 &#x2190; All Pipelines
1111 </a>
1112 </div>
1113 </div>
1114 </Layout>
1115 );
1116 }
1117);
1118
1119export default agentPipelinesRoutes;