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

workflows.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.

workflows.tsxBlame626 lines · 1 contributor
eafe8c6Claude1/**
2 * Actions-equivalent workflow UI (Block C1).
3 *
4 * GET /:owner/:repo/actions — workflows + recent runs
5 * GET /:owner/:repo/actions/runs/:runId — run detail + job logs
6 * POST /:owner/:repo/actions/:workflowId/run — manual trigger (auth)
7 * POST /:owner/:repo/actions/runs/:runId/cancel — cancel a running run (auth)
8 *
9 * Render philosophy: keep the view shallow — the real execution happens in
10 * the runner (src/lib/workflow-runner.ts). This file is just navigation +
11 * manual triggers. Logs for each job are displayed inline (v1 has no
12 * streaming; workers write the final logs blob to the row).
13 */
14
15import { Hono } from "hono";
16import { and, desc, eq } from "drizzle-orm";
17import { db } from "../db";
18import {
19 repositories,
20 users,
21 workflowJobs,
22 workflowRuns,
23 workflows,
24} from "../db/schema";
25import { Layout } from "../views/layout";
26import { RepoHeader, RepoNav } from "../views/components";
abfa9adClaude27import { LogTail } from "../views/log-tail";
eafe8c6Claude28import { softAuth, requireAuth } from "../middleware/auth";
29import type { AuthEnv } from "../middleware/auth";
30import { getUnreadCount } from "../lib/unread";
31import { audit } from "../lib/notify";
32import { enqueueRun } from "../lib/workflow-runner";
33
34const actions = new Hono<AuthEnv>();
35actions.use("*", softAuth);
36
37async function loadRepo(owner: string, repo: string) {
38 const [row] = await db
39 .select({
40 id: repositories.id,
41 name: repositories.name,
42 defaultBranch: repositories.defaultBranch,
43 ownerId: repositories.ownerId,
44 starCount: repositories.starCount,
45 forkCount: repositories.forkCount,
46 })
47 .from(repositories)
48 .innerJoin(users, eq(repositories.ownerId, users.id))
49 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
50 .limit(1);
51 return row;
52}
53
54function relTime(d: Date | string | null): string {
55 if (!d) return "—";
56 const t = typeof d === "string" ? new Date(d) : d;
57 const diffMs = Date.now() - t.getTime();
58 const mins = Math.floor(diffMs / 60000);
59 if (mins < 1) return "just now";
60 if (mins < 60) return `${mins}m ago`;
61 const hrs = Math.floor(mins / 60);
62 if (hrs < 24) return `${hrs}h ago`;
63 const days = Math.floor(hrs / 24);
64 if (days < 30) return `${days}d ago`;
65 return t.toLocaleDateString();
66}
67
68function durationMs(start: Date | string | null, end: Date | string | null): string {
69 if (!start) return "";
70 const s = typeof start === "string" ? new Date(start) : start;
71 const e = end ? (typeof end === "string" ? new Date(end) : end) : new Date();
72 const ms = e.getTime() - s.getTime();
73 if (ms < 1000) return `${ms}ms`;
74 if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
75 const m = Math.floor(ms / 60_000);
76 const s2 = Math.floor((ms % 60_000) / 1000);
77 return `${m}m ${s2}s`;
78}
79
80function statusColor(status: string, conclusion: string | null): string {
81 if (status === "running") return "var(--yellow, #e3b341)";
82 if (status === "queued") return "var(--text-muted)";
83 if (status === "cancelled") return "var(--text-muted)";
84 const concl = conclusion || status;
85 if (concl === "success") return "var(--green)";
86 if (concl === "failure") return "var(--red)";
87 return "var(--text-muted)";
88}
89
90function statusGlyph(status: string, conclusion: string | null): string {
91 if (status === "running") return "\u25D0"; // half-circle
92 if (status === "queued") return "\u25CB"; // hollow circle
93 if (status === "cancelled") return "\u2715"; // x
94 const concl = conclusion || status;
95 if (concl === "success") return "\u2713"; // check
96 if (concl === "failure") return "\u2717"; // heavy x
97 if (concl === "skipped") return "\u2013"; // en dash
98 return "\u25CF";
99}
100
101// ---------- List workflows + recent runs ----------
102
103actions.get("/:owner/:repo/actions", async (c) => {
104 const user = c.get("user");
105 const { owner, repo } = c.req.param();
106 const repoRow = await loadRepo(owner, repo);
107 if (!repoRow) return c.notFound();
108
109 let wfs: (typeof workflows.$inferSelect)[] = [];
110 let runs: (typeof workflowRuns.$inferSelect & { workflowName: string | null })[] =
111 [];
112 try {
113 wfs = await db
114 .select()
115 .from(workflows)
116 .where(eq(workflows.repositoryId, repoRow.id))
117 .orderBy(desc(workflows.updatedAt));
118
119 const joined = await db
120 .select({
121 id: workflowRuns.id,
122 workflowId: workflowRuns.workflowId,
123 repositoryId: workflowRuns.repositoryId,
124 runNumber: workflowRuns.runNumber,
125 event: workflowRuns.event,
126 ref: workflowRuns.ref,
127 commitSha: workflowRuns.commitSha,
128 triggeredBy: workflowRuns.triggeredBy,
129 status: workflowRuns.status,
130 conclusion: workflowRuns.conclusion,
131 queuedAt: workflowRuns.queuedAt,
132 startedAt: workflowRuns.startedAt,
133 finishedAt: workflowRuns.finishedAt,
134 createdAt: workflowRuns.createdAt,
135 workflowName: workflows.name,
136 })
137 .from(workflowRuns)
138 .leftJoin(workflows, eq(workflowRuns.workflowId, workflows.id))
139 .where(eq(workflowRuns.repositoryId, repoRow.id))
140 .orderBy(desc(workflowRuns.queuedAt))
141 .limit(50);
142 runs = joined as typeof runs;
143 } catch (err) {
144 console.error("[actions] list:", err);
145 }
146
147 const unread = user ? await getUnreadCount(user.id) : 0;
148 const canRun = !!user && user.id === repoRow.ownerId;
149
150 return c.html(
151 <Layout
152 title={`Actions — ${owner}/${repo}`}
153 user={user}
154 notificationCount={unread}
155 >
156 <RepoHeader
157 owner={owner}
158 repo={repo}
159 starCount={repoRow.starCount}
160 forkCount={repoRow.forkCount}
161 currentUser={user?.username || null}
162 />
163 <RepoNav owner={owner} repo={repo} active="actions" />
164
165 <div style="display: grid; grid-template-columns: 280px 1fr; gap: 20px">
166 <aside>
167 <h4 style="margin: 0 0 12px 0; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
168 Workflows
169 </h4>
170 {wfs.length === 0 ? (
171 <div class="panel" style="padding: 12px; font-size: 12px; color: var(--text-muted)">
172 No workflows yet. Add a YAML file under
173 {" "}
174 <code>.gluecron/workflows/</code> on your default branch.
175 </div>
176 ) : (
177 <div class="panel" style="overflow: hidden">
178 {wfs.map((w) => (
179 <div
180 style={`padding: 10px 12px; border-bottom: 1px solid var(--border); ${w.disabled ? "opacity: 0.5" : ""}`}
181 >
182 <div style="display: flex; justify-content: space-between; align-items: center; gap: 8px">
183 <div style="flex: 1; min-width: 0">
184 <div style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
185 {w.name}
186 </div>
187 <div style="font-size: 11px; color: var(--text-muted); font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
188 {w.path}
189 </div>
190 </div>
191 {canRun && !w.disabled && (
192 <form
001af43Claude193 method="post"
eafe8c6Claude194 action={`/${owner}/${repo}/actions/${w.id}/run`}
195 style="margin: 0"
196 >
197 <button type="submit" class="btn btn-sm" title="Trigger manual run">
198 Run
199 </button>
200 </form>
201 )}
202 </div>
203 </div>
204 ))}
205 </div>
206 )}
207 </aside>
208
209 <section>
210 <h4 style="margin: 0 0 12px 0; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
211 Recent runs
212 </h4>
213 {runs.length === 0 ? (
214 <div class="empty-state">
215 <p>No workflow runs yet. Push a commit or trigger one manually.</p>
216 </div>
217 ) : (
218 <div class="panel" style="overflow: hidden">
219 {runs.map((r) => (
220 <a
221 href={`/${owner}/${repo}/actions/runs/${r.id}`}
222 style="display: flex; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--border); text-decoration: none; color: inherit"
223 >
224 <span
225 style={`display: inline-block; min-width: 18px; text-align: center; color: ${statusColor(r.status, r.conclusion)}; font-weight: 700`}
226 title={r.conclusion || r.status}
227 >
228 {statusGlyph(r.status, r.conclusion)}
229 </span>
230 <div style="flex: 1; min-width: 0">
231 <div style="font-weight: 500">
232 {r.workflowName || "(workflow deleted)"}
233 {" "}
234 <span style="color: var(--text-muted); font-weight: 400">
235 #{r.runNumber}
236 </span>
237 </div>
238 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
239 <span>{r.event}</span>
240 {r.ref && (
241 <>
242 {" · "}
243 <span>{r.ref.replace(/^refs\/heads\//, "")}</span>
244 </>
245 )}
246 {r.commitSha && (
247 <>
248 {" · "}
249 <code>{r.commitSha.slice(0, 7)}</code>
250 </>
251 )}
252 {" · "}
253 <span>{relTime(r.queuedAt)}</span>
254 {r.startedAt && r.finishedAt && (
255 <>
256 {" · "}
257 <span>{durationMs(r.startedAt, r.finishedAt)}</span>
258 </>
259 )}
260 </div>
261 </div>
262 </a>
263 ))}
264 </div>
265 )}
266 </section>
267 </div>
268 </Layout>
269 );
270});
271
272// ---------- Run detail ----------
273
274actions.get("/:owner/:repo/actions/runs/:runId", async (c) => {
275 const user = c.get("user");
276 const { owner, repo, runId } = c.req.param();
277 const repoRow = await loadRepo(owner, repo);
278 if (!repoRow) return c.notFound();
279
280 let run: typeof workflowRuns.$inferSelect | null = null;
281 let workflowRow: typeof workflows.$inferSelect | null = null;
282 let jobs: (typeof workflowJobs.$inferSelect)[] = [];
283 try {
284 const [r] = await db
285 .select()
286 .from(workflowRuns)
287 .where(
288 and(
289 eq(workflowRuns.id, runId),
290 eq(workflowRuns.repositoryId, repoRow.id)
291 )
292 )
293 .limit(1);
294 run = r || null;
295 if (run) {
296 const [w] = await db
297 .select()
298 .from(workflows)
299 .where(eq(workflows.id, run.workflowId))
300 .limit(1);
301 workflowRow = w || null;
302 jobs = await db
303 .select()
304 .from(workflowJobs)
305 .where(eq(workflowJobs.runId, run.id))
306 .orderBy(workflowJobs.jobOrder);
307 }
308 } catch (err) {
309 console.error("[actions] run detail:", err);
310 }
311
312 if (!run) return c.notFound();
313
314 const unread = user ? await getUnreadCount(user.id) : 0;
315 const canCancel =
316 !!user &&
317 user.id === repoRow.ownerId &&
318 (run.status === "queued" || run.status === "running");
319
320 return c.html(
321 <Layout
322 title={`Run #${run.runNumber} — ${owner}/${repo}`}
323 user={user}
324 notificationCount={unread}
325 >
326 <RepoHeader
327 owner={owner}
328 repo={repo}
329 starCount={repoRow.starCount}
330 forkCount={repoRow.forkCount}
331 currentUser={user?.username || null}
332 />
333 <RepoNav owner={owner} repo={repo} active="actions" />
334
335 <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 16px; gap: 12px">
336 <div>
337 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px">
338 <a href={`/${owner}/${repo}/actions`}>Actions</a>
339 </div>
340 <h3 style="margin: 0">
341 <span
342 style={`color: ${statusColor(run.status, run.conclusion)}; margin-right: 6px`}
343 >
344 {statusGlyph(run.status, run.conclusion)}
345 </span>
346 {workflowRow?.name || "(deleted workflow)"}
347 {" "}
348 <span style="color: var(--text-muted); font-weight: 400">
349 #{run.runNumber}
350 </span>
351 </h3>
352 <div style="font-size: 12px; color: var(--text-muted); margin-top: 6px">
353 <span>{run.event}</span>
354 {run.ref && (
355 <>
356 {" · "}
357 <span>{run.ref.replace(/^refs\/heads\//, "")}</span>
358 </>
359 )}
360 {run.commitSha && (
361 <>
362 {" · "}
363 <a href={`/${owner}/${repo}/commit/${run.commitSha}`}>
364 <code>{run.commitSha.slice(0, 7)}</code>
365 </a>
366 </>
367 )}
368 {" · queued "}
369 <span>{relTime(run.queuedAt)}</span>
370 {run.startedAt && run.finishedAt && (
371 <>
372 {" · duration "}
373 <span>{durationMs(run.startedAt, run.finishedAt)}</span>
374 </>
375 )}
376 </div>
377 </div>
378 {canCancel && (
379 <form
001af43Claude380 method="post"
eafe8c6Claude381 action={`/${owner}/${repo}/actions/runs/${run.id}/cancel`}
382 onsubmit="return confirm('Cancel this run?')"
383 >
384 <button type="submit" class="btn btn-sm btn-danger">
385 Cancel
386 </button>
387 </form>
388 )}
389 </div>
390
391 {jobs.length === 0 ? (
392 <div class="empty-state">
393 <p>
394 {run.status === "queued"
395 ? "Queued — jobs will appear once the runner picks this up."
396 : "No jobs recorded for this run."}
397 </p>
398 </div>
399 ) : (
400 <div>
401 {jobs.map((j) => {
402 let steps: Array<{
403 name?: string;
404 run?: string;
405 status?: string;
406 exitCode?: number | null;
407 durationMs?: number;
408 stdout?: string;
409 stderr?: string;
410 }> = [];
411 try {
412 steps = JSON.parse(j.steps || "[]");
413 } catch {
414 steps = [];
415 }
416 return (
417 <details class="panel" style="margin-bottom: 16px; overflow: hidden" open>
418 <summary
419 style="padding: 10px 14px; cursor: pointer; display: flex; gap: 10px; align-items: center; background: var(--bg-tertiary)"
420 >
421 <span
422 style={`color: ${statusColor(j.status, j.conclusion)}; font-weight: 700`}
423 >
424 {statusGlyph(j.status, j.conclusion)}
425 </span>
426 <span style="flex: 1; font-weight: 500">{j.name}</span>
427 <span style="font-size: 12px; color: var(--text-muted)">
428 {j.startedAt && j.finishedAt
429 ? durationMs(j.startedAt, j.finishedAt)
430 : j.status}
431 </span>
432 </summary>
433 {steps.length > 0 && (
434 <div style="padding: 8px 14px; border-top: 1px solid var(--border)">
435 {steps.map((s, i) => (
436 <div
437 style="padding: 6px 0; border-bottom: 1px solid var(--border); display: flex; gap: 10px; font-size: 13px"
438 >
439 <span
440 style={`color: ${statusColor(s.status || "", null)}; font-weight: 700; min-width: 18px`}
441 >
442 {statusGlyph(s.status || "", null)}
443 </span>
444 <div style="flex: 1; min-width: 0">
445 <div style="font-weight: 500">
446 {s.name || `Step ${i + 1}`}
447 </div>
448 {s.run && (
449 <code
450 style="display: block; font-size: 11px; color: var(--text-muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap"
451 >
452 $ {s.run}
453 </code>
454 )}
455 </div>
456 {typeof s.durationMs === "number" && (
457 <span style="font-size: 11px; color: var(--text-muted)">
458 {s.durationMs < 1000
459 ? `${s.durationMs}ms`
460 : `${(s.durationMs / 1000).toFixed(1)}s`}
461 </span>
462 )}
463 {typeof s.exitCode === "number" && s.exitCode !== 0 && (
464 <span style="font-size: 11px; color: var(--red)">
465 exit {s.exitCode}
466 </span>
467 )}
468 </div>
469 ))}
470 </div>
471 )}
abfa9adClaude472 {(() => {
473 const runLive =
474 run.status === "running" || run.status === "queued";
475 const jobTerminal =
476 j.status === "success" ||
477 j.status === "failure" ||
478 j.status === "cancelled" ||
479 j.status === "skipped" ||
480 j.conclusion === "success" ||
481 j.conclusion === "failure" ||
482 j.conclusion === "cancelled" ||
483 j.conclusion === "skipped";
484 if (runLive && !jobTerminal) {
485 return (
486 <LogTail
487 runId={run.id}
488 jobId={j.id}
489 fallbackLogs={j.logs || ""}
490 />
491 );
492 }
493 if (j.logs && j.logs.length > 0) {
494 return (
495 <pre
4127ecfClaude496 style="margin: 0; padding: 12px 14px; background: var(--bg-tertiary); color: var(--text); font-size: 12px; line-height: 1.45; overflow-x: auto; max-height: 480px; border-top: 1px solid var(--border)"
abfa9adClaude497 >
498 {j.logs}
499 </pre>
500 );
501 }
502 return null;
503 })()}
eafe8c6Claude504 </details>
505 );
506 })}
507 </div>
508 )}
509 </Layout>
510 );
511});
512
513// ---------- Manual trigger ----------
514
515actions.post("/:owner/:repo/actions/:workflowId/run", requireAuth, async (c) => {
516 const user = c.get("user")!;
517 const { owner, repo, workflowId } = c.req.param();
518 const repoRow = await loadRepo(owner, repo);
519 if (!repoRow) return c.notFound();
520 if (repoRow.ownerId !== user.id) {
521 return c.redirect(`/${owner}/${repo}/actions`);
522 }
523
524 let workflowRow: typeof workflows.$inferSelect | null = null;
525 try {
526 const [w] = await db
527 .select()
528 .from(workflows)
529 .where(
530 and(
531 eq(workflows.id, workflowId),
532 eq(workflows.repositoryId, repoRow.id)
533 )
534 )
535 .limit(1);
536 workflowRow = w || null;
537 } catch (err) {
538 console.error("[actions] manual trigger lookup:", err);
539 }
540 if (!workflowRow) return c.notFound();
541 if (workflowRow.disabled) {
542 return c.redirect(`/${owner}/${repo}/actions`);
543 }
544
545 const ref = `refs/heads/${repoRow.defaultBranch || "main"}`;
546
547 const runId = await enqueueRun({
548 workflowId: workflowRow.id,
549 repositoryId: repoRow.id,
550 event: "manual",
551 ref,
552 commitSha: null,
553 triggeredBy: user.id,
554 });
555
556 await audit({
557 userId: user.id,
558 repositoryId: repoRow.id,
559 action: "workflow.manual_trigger",
560 targetType: "workflow",
561 targetId: workflowRow.id,
562 metadata: { runId },
563 });
564
565 if (runId) {
566 return c.redirect(`/${owner}/${repo}/actions/runs/${runId}`);
567 }
568 return c.redirect(`/${owner}/${repo}/actions`);
569});
570
571// ---------- Cancel a run ----------
572
573actions.post(
574 "/:owner/:repo/actions/runs/:runId/cancel",
575 requireAuth,
576 async (c) => {
577 const user = c.get("user")!;
578 const { owner, repo, runId } = c.req.param();
579 const repoRow = await loadRepo(owner, repo);
580 if (!repoRow) return c.notFound();
581 if (repoRow.ownerId !== user.id) {
582 return c.redirect(`/${owner}/${repo}/actions`);
583 }
584
585 try {
586 await db
587 .update(workflowRuns)
588 .set({
589 status: "cancelled",
590 conclusion: "cancelled",
591 finishedAt: new Date(),
592 })
593 .where(
594 and(
595 eq(workflowRuns.id, runId),
596 eq(workflowRuns.repositoryId, repoRow.id)
597 )
598 );
599 // Mark any queued/running jobs as cancelled for display. The worker
600 // will observe the parent run's status on its next check, but v1 runs
601 // a step to completion before checking.
602 await db
603 .update(workflowJobs)
604 .set({
605 status: "cancelled",
606 conclusion: "cancelled",
607 finishedAt: new Date(),
608 })
609 .where(eq(workflowJobs.runId, runId));
610 } catch (err) {
611 console.error("[actions] cancel:", err);
612 }
613
614 await audit({
615 userId: user.id,
616 repositoryId: repoRow.id,
617 action: "workflow.cancel",
618 targetType: "workflow_run",
619 targetId: runId,
620 });
621
622 return c.redirect(`/${owner}/${repo}/actions/runs/${runId}`);
623 }
624);
625
626export default actions;