Commiteafe8c6unknown_key
feat(BLOCK-C1): Actions-equivalent workflow runner
feat(BLOCK-C1): Actions-equivalent workflow runner
- 0008 migration + drizzle schema: workflows, workflow_runs, workflow_jobs,
workflow_artifacts
- src/lib/workflow-parser.ts: hand-rolled YAML subset parser (598 LoC).
Accepts minimal GH-Actions-shaped YAML, normalises jobs/steps, rejects
malformed workflows; returns { ok, workflow | error }.
- src/lib/workflow-runner.ts: shell executor (732 LoC). Clones bare repo
to tmpdir, checks out target sha detached, runs each job sequentially
via Bun.spawn bash -c, enforces SIGTERM->SIGKILL timeouts, size-caps
stdout/stderr, skips remaining steps/jobs on first failure, cleans up
tmpdir in finally. Exports executeRun, drainOneRun, enqueueRun,
startWorker (background poll loop).
- src/hooks/post-receive.ts: on default-branch push, discover
.gluecron/workflows/*.yml, upsert in workflows table, soft-delete
removed files, enqueue a run for every workflow with on:push.
- src/routes/workflows.tsx: UI — list workflows + recent runs, run
detail with per-job expandable log panels, manual trigger (auth),
cancel (auth).
- Worker starts at boot in src/index.ts; route mounted in src/app.tsx;
RepoNav gains an Actions tab.
- Tests: 12 parser units + 2 unauthed route guards. 187 total pass.10 files changed+2449−1eafe8c689797d78e16fc66a5ec63c1ee282fde65
10 changed files+2449−1
Addeddrizzle/0008_workflows.sql+93−0View fileUnifiedSplit
@@ -0,0 +1,93 @@
1-- Gluecron migration 0008: Block C1 — Actions-equivalent workflow runner.
2--
3-- Tables:
4-- workflows — parsed workflow YAML files discovered in a repo
5-- workflow_runs — one execution of a workflow, triggered by an event
6-- workflow_jobs — jobs within a run (each is a sequence of steps)
7-- workflow_artifacts — files uploaded by a run (stored in bytea for v1)
8
9--> statement-breakpoint
10CREATE TABLE IF NOT EXISTS "workflows" (
11 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
12 "repository_id" uuid NOT NULL,
13 "name" text NOT NULL,
14 "path" text NOT NULL,
15 "yaml" text NOT NULL,
16 "parsed" text NOT NULL,
17 "on_events" text NOT NULL DEFAULT '[]',
18 "disabled" boolean DEFAULT false NOT NULL,
19 "created_at" timestamp DEFAULT now() NOT NULL,
20 "updated_at" timestamp DEFAULT now() NOT NULL,
21 CONSTRAINT "workflows_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade
22);
23
24--> statement-breakpoint
25CREATE INDEX IF NOT EXISTS "workflows_repo" ON "workflows" ("repository_id");
26--> statement-breakpoint
27CREATE UNIQUE INDEX IF NOT EXISTS "workflows_repo_path" ON "workflows" ("repository_id", "path");
28
29--> statement-breakpoint
30CREATE TABLE IF NOT EXISTS "workflow_runs" (
31 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
32 "workflow_id" uuid NOT NULL,
33 "repository_id" uuid NOT NULL,
34 "run_number" integer NOT NULL,
35 "event" text NOT NULL,
36 "ref" text,
37 "commit_sha" text,
38 "triggered_by" uuid,
39 "status" text NOT NULL DEFAULT 'queued',
40 "conclusion" text,
41 "queued_at" timestamp DEFAULT now() NOT NULL,
42 "started_at" timestamp,
43 "finished_at" timestamp,
44 "created_at" timestamp DEFAULT now() NOT NULL,
45 CONSTRAINT "workflow_runs_workflow_fk" FOREIGN KEY ("workflow_id") REFERENCES "workflows"("id") ON DELETE cascade,
46 CONSTRAINT "workflow_runs_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade,
47 CONSTRAINT "workflow_runs_user_fk" FOREIGN KEY ("triggered_by") REFERENCES "users"("id") ON DELETE set null
48);
49
50--> statement-breakpoint
51CREATE INDEX IF NOT EXISTS "workflow_runs_repo" ON "workflow_runs" ("repository_id");
52--> statement-breakpoint
53CREATE INDEX IF NOT EXISTS "workflow_runs_status" ON "workflow_runs" ("status");
54--> statement-breakpoint
55CREATE INDEX IF NOT EXISTS "workflow_runs_workflow" ON "workflow_runs" ("workflow_id");
56
57--> statement-breakpoint
58CREATE TABLE IF NOT EXISTS "workflow_jobs" (
59 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
60 "run_id" uuid NOT NULL,
61 "name" text NOT NULL,
62 "job_order" integer NOT NULL DEFAULT 0,
63 "runs_on" text NOT NULL DEFAULT 'default',
64 "status" text NOT NULL DEFAULT 'queued',
65 "conclusion" text,
66 "exit_code" integer,
67 "steps" text NOT NULL DEFAULT '[]',
68 "logs" text NOT NULL DEFAULT '',
69 "started_at" timestamp,
70 "finished_at" timestamp,
71 "created_at" timestamp DEFAULT now() NOT NULL,
72 CONSTRAINT "workflow_jobs_run_fk" FOREIGN KEY ("run_id") REFERENCES "workflow_runs"("id") ON DELETE cascade
73);
74
75--> statement-breakpoint
76CREATE INDEX IF NOT EXISTS "workflow_jobs_run" ON "workflow_jobs" ("run_id");
77
78--> statement-breakpoint
79CREATE TABLE IF NOT EXISTS "workflow_artifacts" (
80 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
81 "run_id" uuid NOT NULL,
82 "job_id" uuid,
83 "name" text NOT NULL,
84 "size_bytes" integer NOT NULL DEFAULT 0,
85 "content_type" text DEFAULT 'application/octet-stream' NOT NULL,
86 "content" bytea,
87 "created_at" timestamp DEFAULT now() NOT NULL,
88 CONSTRAINT "workflow_artifacts_run_fk" FOREIGN KEY ("run_id") REFERENCES "workflow_runs"("id") ON DELETE cascade,
89 CONSTRAINT "workflow_artifacts_job_fk" FOREIGN KEY ("job_id") REFERENCES "workflow_jobs"("id") ON DELETE set null
90);
91
92--> statement-breakpoint
93CREATE INDEX IF NOT EXISTS "workflow_artifacts_run" ON "workflow_artifacts" ("run_id");
Addedsrc/__tests__/workflows.test.ts+186−0View fileUnifiedSplit
@@ -0,0 +1,186 @@
1/**
2 * Tests for Block C1 — Actions-equivalent workflow runner.
3 *
4 * Covers the pure-function parser + route-level unauthed guards. The
5 * shell-executor itself is exercised by higher-level integration tests
6 * once a real test DB is wired — for now we only verify that the
7 * exported surface exists and the route shell is correct.
8 */
9
10import { describe, it, expect } from "bun:test";
11import app from "../app";
12import { parseWorkflow } from "../lib/workflow-parser";
13
14describe("workflow parser (C1)", () => {
15 it("parses a minimal workflow", () => {
16 const result = parseWorkflow(`name: CI
17on: [push]
18jobs:
19 test:
20 runs-on: default
21 steps:
22 - run: echo hello
23`);
24 expect(result.ok).toBe(true);
25 if (!result.ok) return;
26 expect(result.workflow.name).toBe("CI");
27 expect(result.workflow.on).toContain("push");
28 expect(result.workflow.jobs).toHaveLength(1);
29 expect(result.workflow.jobs[0].name).toBe("test");
30 expect(result.workflow.jobs[0].steps).toHaveLength(1);
31 expect(result.workflow.jobs[0].steps[0].run).toBe("echo hello");
32 });
33
34 it("handles scalar 'on' trigger", () => {
35 const result = parseWorkflow(`name: scalar
36on: push
37jobs:
38 test:
39 steps:
40 - run: pwd
41`);
42 expect(result.ok).toBe(true);
43 if (!result.ok) return;
44 expect(result.workflow.on).toEqual(["push"]);
45 });
46
47 it("handles list 'on' triggers", () => {
48 const result = parseWorkflow(`name: multi
49on: [push, pull_request]
50jobs:
51 a:
52 steps:
53 - run: true
54`);
55 expect(result.ok).toBe(true);
56 if (!result.ok) return;
57 expect(result.workflow.on).toContain("push");
58 expect(result.workflow.on).toContain("pull_request");
59 });
60
61 it("auto-names steps that only have a run field", () => {
62 const result = parseWorkflow(`name: n
63on: [push]
64jobs:
65 test:
66 steps:
67 - run: echo x
68`);
69 expect(result.ok).toBe(true);
70 if (!result.ok) return;
71 expect(result.workflow.jobs[0].steps[0].name).toBeTruthy();
72 });
73
74 it("preserves explicit step names", () => {
75 const result = parseWorkflow(`name: n
76on: [push]
77jobs:
78 test:
79 steps:
80 - name: Install
81 run: bun install
82 - name: Test
83 run: bun test
84`);
85 expect(result.ok).toBe(true);
86 if (!result.ok) return;
87 const names = result.workflow.jobs[0].steps.map((s) => s.name);
88 expect(names).toContain("Install");
89 expect(names).toContain("Test");
90 });
91
92 it("defaults runs-on to 'default' when omitted", () => {
93 const result = parseWorkflow(`name: n
94on: [push]
95jobs:
96 test:
97 steps:
98 - run: true
99`);
100 expect(result.ok).toBe(true);
101 if (!result.ok) return;
102 expect(result.workflow.jobs[0].runsOn).toBe("default");
103 });
104
105 it("rejects workflows with no 'on' trigger", () => {
106 const result = parseWorkflow(`name: bad
107jobs:
108 test:
109 steps:
110 - run: true
111`);
112 expect(result.ok).toBe(false);
113 if (result.ok) return;
114 expect(result.error.toLowerCase()).toContain("on");
115 });
116
117 it("rejects workflows with no jobs", () => {
118 const result = parseWorkflow(`name: bad
119on: [push]
120`);
121 expect(result.ok).toBe(false);
122 });
123
124 it("rejects jobs with no steps", () => {
125 const result = parseWorkflow(`name: bad
126on: [push]
127jobs:
128 test:
129 runs-on: default
130`);
131 expect(result.ok).toBe(false);
132 });
133
134 it("rejects steps without a 'run' command", () => {
135 const result = parseWorkflow(`name: bad
136on: [push]
137jobs:
138 test:
139 steps:
140 - name: no-op
141`);
142 expect(result.ok).toBe(false);
143 });
144
145 it("returns a default name when 'name' is missing", () => {
146 const result = parseWorkflow(`on: [push]
147jobs:
148 test:
149 steps:
150 - run: true
151`);
152 expect(result.ok).toBe(true);
153 if (!result.ok) return;
154 expect(typeof result.workflow.name).toBe("string");
155 expect(result.workflow.name.length).toBeGreaterThan(0);
156 });
157
158 it("never throws on malformed input", () => {
159 const inputs = ["", " ", "not:\nyaml\n-\n:", "{]}", "jobs: oh no"];
160 for (const i of inputs) {
161 expect(() => parseWorkflow(i)).not.toThrow();
162 }
163 });
164});
165
166describe("workflow routes (C1) — unauthed behaviour", () => {
167 it("POST /:owner/:repo/actions/:workflowId/run requires auth", async () => {
168 const res = await app.request("/alice/project/actions/abc/run", {
169 method: "POST",
170 headers: { "content-type": "application/x-www-form-urlencoded" },
171 body: "",
172 });
173 // Either a redirect to /login (repo exists, auth required), or 404
174 // (repo doesn't exist in DB-less tests), or 503 on DB failure.
175 expect([301, 302, 303, 307, 404, 503]).toContain(res.status);
176 });
177
178 it("POST /:owner/:repo/actions/runs/:id/cancel requires auth", async () => {
179 const res = await app.request("/alice/project/actions/runs/xyz/cancel", {
180 method: "POST",
181 headers: { "content-type": "application/x-www-form-urlencoded" },
182 body: "",
183 });
184 expect([301, 302, 303, 307, 404, 503]).toContain(res.status);
185 });
186});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -38,6 +38,7 @@ import orgRoutes from "./routes/orgs";
3838import passkeyRoutes from "./routes/passkeys";
3939import oauthRoutes from "./routes/oauth";
4040import developerAppsRoutes from "./routes/developer-apps";
41import workflowRoutes from "./routes/workflows";
4142import webRoutes from "./routes/web";
4243
4344const app = new Hono();
@@ -148,6 +149,9 @@ app.route("/", releaseRoutes);
148149// Gates (history + settings + branch protection)
149150app.route("/", gateRoutes);
150151
152// Actions-equivalent workflow runner (Block C1)
153app.route("/", workflowRoutes);
154
151155// Insights + milestones
152156app.route("/", insightsRoutes);
153157
Modifiedsrc/db/schema.ts+122−0View fileUnifiedSplit
@@ -973,3 +973,125 @@ export const oauthAccessTokens = pgTable(
973973export type OauthApp = typeof oauthApps.$inferSelect;
974974export type OauthAuthorization = typeof oauthAuthorizations.$inferSelect;
975975export type OauthAccessToken = typeof oauthAccessTokens.$inferSelect;
976
977/**
978 * Actions-equivalent workflow runner (Block C1).
979 *
980 * `workflows` rows are the YAML files discovered at `.gluecron/workflows/*.yml`
981 * on the repo's default branch. `parsed` is the normalised JSON form used by
982 * the runner so we don't re-parse on every trigger.
983 *
984 * `workflow_runs` is one execution: one row per trigger event. Status
985 * progression: queued → running → success|failure|cancelled. `conclusion`
986 * stays null until `status` is terminal.
987 *
988 * `workflow_jobs` is a single job within a run — each has its own steps
989 * array and concatenated logs. We keep logs inline for v1 (no streaming)
990 * to avoid a fifth table; they're truncated at the runner.
991 *
992 * `workflow_artifacts` persist files a job uploaded. `content` is a bytea;
993 * we'll move this to object storage once we hit size limits.
994 */
995export const workflows = pgTable(
996 "workflows",
997 {
998 id: uuid("id").primaryKey().defaultRandom(),
999 repositoryId: uuid("repository_id")
1000 .notNull()
1001 .references(() => repositories.id, { onDelete: "cascade" }),
1002 name: text("name").notNull(),
1003 path: text("path").notNull(), // e.g. ".gluecron/workflows/ci.yml"
1004 yaml: text("yaml").notNull(),
1005 parsed: text("parsed").notNull(), // JSON string
1006 onEvents: text("on_events").notNull().default("[]"), // JSON array of event names
1007 disabled: boolean("disabled").default(false).notNull(),
1008 createdAt: timestamp("created_at").defaultNow().notNull(),
1009 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1010 },
1011 (table) => [
1012 index("workflows_repo").on(table.repositoryId),
1013 uniqueIndex("workflows_repo_path").on(table.repositoryId, table.path),
1014 ]
1015);
1016
1017export const workflowRuns = pgTable(
1018 "workflow_runs",
1019 {
1020 id: uuid("id").primaryKey().defaultRandom(),
1021 workflowId: uuid("workflow_id")
1022 .notNull()
1023 .references(() => workflows.id, { onDelete: "cascade" }),
1024 repositoryId: uuid("repository_id")
1025 .notNull()
1026 .references(() => repositories.id, { onDelete: "cascade" }),
1027 runNumber: integer("run_number").notNull(),
1028 event: text("event").notNull(), // "push" | "pull_request" | "manual" | ...
1029 ref: text("ref"),
1030 commitSha: text("commit_sha"),
1031 triggeredBy: uuid("triggered_by").references(() => users.id, {
1032 onDelete: "set null",
1033 }),
1034 status: text("status").notNull().default("queued"), // queued|running|success|failure|cancelled
1035 conclusion: text("conclusion"),
1036 queuedAt: timestamp("queued_at").defaultNow().notNull(),
1037 startedAt: timestamp("started_at"),
1038 finishedAt: timestamp("finished_at"),
1039 createdAt: timestamp("created_at").defaultNow().notNull(),
1040 },
1041 (table) => [
1042 index("workflow_runs_repo").on(table.repositoryId),
1043 index("workflow_runs_status").on(table.status),
1044 index("workflow_runs_workflow").on(table.workflowId),
1045 ]
1046);
1047
1048export const workflowJobs = pgTable(
1049 "workflow_jobs",
1050 {
1051 id: uuid("id").primaryKey().defaultRandom(),
1052 runId: uuid("run_id")
1053 .notNull()
1054 .references(() => workflowRuns.id, { onDelete: "cascade" }),
1055 name: text("name").notNull(),
1056 jobOrder: integer("job_order").default(0).notNull(),
1057 runsOn: text("runs_on").notNull().default("default"),
1058 status: text("status").notNull().default("queued"),
1059 conclusion: text("conclusion"),
1060 exitCode: integer("exit_code"),
1061 steps: text("steps").notNull().default("[]"), // JSON array of step results
1062 logs: text("logs").notNull().default(""),
1063 startedAt: timestamp("started_at"),
1064 finishedAt: timestamp("finished_at"),
1065 createdAt: timestamp("created_at").defaultNow().notNull(),
1066 },
1067 (table) => [index("workflow_jobs_run").on(table.runId)]
1068);
1069
1070export const workflowArtifacts = pgTable(
1071 "workflow_artifacts",
1072 {
1073 id: uuid("id").primaryKey().defaultRandom(),
1074 runId: uuid("run_id")
1075 .notNull()
1076 .references(() => workflowRuns.id, { onDelete: "cascade" }),
1077 jobId: uuid("job_id").references(() => workflowJobs.id, {
1078 onDelete: "set null",
1079 }),
1080 name: text("name").notNull(),
1081 sizeBytes: integer("size_bytes").default(0).notNull(),
1082 contentType: text("content_type")
1083 .default("application/octet-stream")
1084 .notNull(),
1085 // bytea — drizzle doesn't have a built-in bytea type at the level we use
1086 // elsewhere; store as text (base64) for v1. Migration uses real bytea so
1087 // we can swap the column type later.
1088 content: text("content"),
1089 createdAt: timestamp("created_at").defaultNow().notNull(),
1090 },
1091 (table) => [index("workflow_artifacts_run").on(table.runId)]
1092);
1093
1094export type Workflow = typeof workflows.$inferSelect;
1095export type WorkflowRun = typeof workflowRuns.$inferSelect;
1096export type WorkflowJob = typeof workflowJobs.$inferSelect;
1097export type WorkflowArtifact = typeof workflowArtifacts.$inferSelect;
Modifiedsrc/hooks/post-receive.ts+102−1View fileUnifiedSplit
@@ -24,9 +24,12 @@ import {
2424 runSecretAndSecurityScan,
2525} from "../lib/gate";
2626import { getOrCreateSettings } from "../lib/repo-bootstrap";
27import { getBlob, getDefaultBranch } from "../git/repository";
27import { getBlob, getDefaultBranch, getTree } from "../git/repository";
2828import { parseCodeowners, syncCodeowners } from "../lib/codeowners";
2929import { notify } from "../lib/notify";
30import { workflows } from "../db/schema";
31import { parseWorkflow } from "../lib/workflow-parser";
32import { enqueueRun } from "../lib/workflow-runner";
3033
3134interface PushRef {
3235 oldSha: string;
@@ -108,6 +111,104 @@ export async function onPostReceive(
108111 }
109112 }
110113
114 // --- 2b. Workflow sync + trigger (Block C1) ---
115 // On pushes to the default branch, discover `.gluecron/workflows/*.yml`,
116 // upsert them in the workflows table, and enqueue a run for each workflow
117 // whose `on` triggers include `push`.
118 if (mainRef && repoRow) {
119 try {
120 const entries = await getTree(
121 owner,
122 repo,
123 defaultBranch,
124 ".gluecron/workflows"
125 );
126 const existing = await db
127 .select({ id: workflows.id, path: workflows.path })
128 .from(workflows)
129 .where(eq(workflows.repositoryId, repoRow.id));
130 const existingByPath = new Map(existing.map((e) => [e.path, e.id]));
131 const seenPaths = new Set<string>();
132
133 for (const entry of entries) {
134 if (entry.type !== "blob") continue;
135 if (!/\.ya?ml$/i.test(entry.name)) continue;
136 const path = `.gluecron/workflows/${entry.name}`;
137 seenPaths.add(path);
138 const blob = await getBlob(owner, repo, defaultBranch, path);
139 if (!blob || blob.isBinary) continue;
140 const parsed = parseWorkflow(blob.content);
141 if (!parsed.ok) {
142 console.error(
143 `[workflow-sync] ${owner}/${repo}:${path} invalid — ${parsed.error}`
144 );
145 continue;
146 }
147 const onEvents = JSON.stringify(parsed.workflow.on);
148 const parsedJson = JSON.stringify(parsed.workflow);
149 const existingId = existingByPath.get(path);
150 let workflowId: string;
151 if (existingId) {
152 await db
153 .update(workflows)
154 .set({
155 name: parsed.workflow.name,
156 yaml: blob.content,
157 parsed: parsedJson,
158 onEvents,
159 updatedAt: new Date(),
160 })
161 .where(eq(workflows.id, existingId));
162 workflowId = existingId;
163 } else {
164 const [row] = await db
165 .insert(workflows)
166 .values({
167 repositoryId: repoRow.id,
168 name: parsed.workflow.name,
169 path,
170 yaml: blob.content,
171 parsed: parsedJson,
172 onEvents,
173 })
174 .returning({ id: workflows.id });
175 workflowId = row.id;
176 }
177
178 // Enqueue a run if this workflow subscribes to the push event.
179 if (parsed.workflow.on.includes("push")) {
180 try {
181 await enqueueRun({
182 workflowId,
183 repositoryId: repoRow.id,
184 event: "push",
185 ref: mainRef.refName,
186 commitSha: mainRef.newSha,
187 triggeredBy: ownerRow?.id || null,
188 });
189 } catch (err) {
190 console.error(
191 `[workflow-enqueue] ${owner}/${repo}:${path}:`,
192 err
193 );
194 }
195 }
196 }
197
198 // Mark workflows whose files have been removed as disabled (soft-delete).
199 for (const [p, id] of existingByPath) {
200 if (!seenPaths.has(p)) {
201 await db
202 .update(workflows)
203 .set({ disabled: true, updatedAt: new Date() })
204 .where(eq(workflows.id, id));
205 }
206 }
207 } catch (err) {
208 console.error("[post-receive] workflow sync:", err);
209 }
210 }
211
111212 // --- 3. Gates ---
112213 const settings = repoRow ? await getOrCreateSettings(repoRow.id) : null;
113214
Modifiedsrc/index.ts+5−0View fileUnifiedSplit
@@ -1,10 +1,15 @@
11import { mkdir } from "fs/promises";
22import app from "./app";
33import { config } from "./lib/config";
4import { startWorker } from "./lib/workflow-runner";
45
56// Ensure repos directory exists
67await mkdir(config.gitReposPath, { recursive: true });
78
9// Start the Actions-equivalent workflow worker (Block C1). Polls
10// workflow_runs for queued rows and executes them sequentially.
11startWorker();
12
813console.log(`
914 gluecron v0.1.0
1015 ──────────────────────
Addedsrc/lib/workflow-parser.ts+598−0View fileUnifiedSplit
@@ -0,0 +1,598 @@
1/**
2 * Minimal GitHub-Actions-compatible workflow YAML parser.
3 *
4 * Block C1. Pure function — no DB, no file I/O, no external calls.
5 * Input: YAML text. Output: normalised workflow object, or error.
6 *
7 * Supported subset:
8 * name: <scalar>
9 * on: <scalar> | [list] | { mapping } (mapping is flattened to its top-level keys)
10 * jobs:
11 * <job-key>:
12 * runs-on: <scalar> (default "default")
13 * steps:
14 * - run: <scalar> (auto-name "Run command")
15 * - name: <scalar>
16 * run: <scalar>
17 *
18 * Quirks handled:
19 * - `#` comments (end-of-line and full-line)
20 * - Block literal strings (`|` and `>`) with indentation-stripped bodies
21 * - Inline flow arrays: [a, b, "c, still c"]
22 * - Inline flow mappings: { push: { branches: [main] } }
23 * - Single- and double-quoted scalars
24 * - Extra fields on jobs/steps (env, uses, with, matrix, …) are accepted and ignored
25 * - Job key order is preserved (Map-based accumulation)
26 * - Never throws — bad input returns { ok: false, error }
27 */
28
29export type WorkflowStep = {
30 name: string;
31 run: string;
32};
33
34export type WorkflowJob = {
35 name: string;
36 runsOn: string;
37 steps: WorkflowStep[];
38};
39
40export type ParsedWorkflow = {
41 name: string;
42 on: string[];
43 jobs: WorkflowJob[];
44};
45
46export type ParseResult =
47 | { ok: true; workflow: ParsedWorkflow }
48 | { ok: false; error: string };
49
50// ---------------------------------------------------------------------------
51// Tokeniser: split into logical lines, strip comments, record indent.
52// ---------------------------------------------------------------------------
53
54type Line = {
55 indent: number;
56 text: string; // comment-stripped, right-trimmed
57 raw: string; // original (for block-literal body preservation)
58 lineNo: number; // 1-based
59};
60
61function lex(source: string): Line[] {
62 const out: Line[] = [];
63 const rawLines = source.replace(/\r\n?/g, "\n").split("\n");
64 for (let i = 0; i < rawLines.length; i++) {
65 const raw = rawLines[i] ?? "";
66 // Compute indent (expand tabs as 1 space — we disallow tabs for YAML indent anyway)
67 let indent = 0;
68 while (indent < raw.length && (raw[indent] === " " || raw[indent] === "\t")) {
69 indent++;
70 }
71 const body = raw.slice(indent);
72 // Skip pure blank / pure comment lines
73 if (body.length === 0 || body.startsWith("#")) continue;
74 // Strip trailing comment (respecting quotes)
75 const stripped = stripTrailingComment(body).replace(/\s+$/, "");
76 if (stripped.length === 0) continue;
77 out.push({ indent, text: stripped, raw, lineNo: i + 1 });
78 }
79 return out;
80}
81
82function stripTrailingComment(s: string): string {
83 let inSingle = false;
84 let inDouble = false;
85 for (let i = 0; i < s.length; i++) {
86 const c = s[i];
87 if (c === "\\" && inDouble) {
88 i++;
89 continue;
90 }
91 if (c === "'" && !inDouble) inSingle = !inSingle;
92 else if (c === '"' && !inSingle) inDouble = !inDouble;
93 else if (c === "#" && !inSingle && !inDouble) {
94 // Must be preceded by whitespace (or start of line) to count as a comment
95 if (i === 0 || /\s/.test(s[i - 1]!)) return s.slice(0, i);
96 }
97 }
98 return s;
99}
100
101// ---------------------------------------------------------------------------
102// Scalar + flow-value parsing.
103// ---------------------------------------------------------------------------
104
105function unquote(s: string): string {
106 s = s.trim();
107 if (s.length >= 2) {
108 if (s.startsWith('"') && s.endsWith('"')) {
109 return s
110 .slice(1, -1)
111 .replace(/\\n/g, "\n")
112 .replace(/\\t/g, "\t")
113 .replace(/\\"/g, '"')
114 .replace(/\\\\/g, "\\");
115 }
116 if (s.startsWith("'") && s.endsWith("'")) {
117 return s.slice(1, -1).replace(/''/g, "'");
118 }
119 }
120 return s;
121}
122
123/**
124 * Parse a flow-style value starting at `s[i]`. Returns the parsed JS value
125 * and the index just after it. Supports nested [..] and {..}, quoted strings,
126 * plain scalars, and comma separators.
127 */
128function parseFlow(s: string, i: number): { value: unknown; next: number } {
129 i = skipWs(s, i);
130 if (i >= s.length) return { value: "", next: i };
131 const c = s[i];
132 if (c === "[") return parseFlowSeq(s, i);
133 if (c === "{") return parseFlowMap(s, i);
134 if (c === '"' || c === "'") {
135 const end = findQuoteEnd(s, i);
136 return { value: unquote(s.slice(i, end + 1)), next: end + 1 };
137 }
138 // plain scalar — read until , ] } or end
139 let j = i;
140 while (j < s.length && s[j] !== "," && s[j] !== "]" && s[j] !== "}") j++;
141 return { value: unquote(s.slice(i, j).trim()), next: j };
142}
143
144function parseFlowSeq(s: string, i: number): { value: unknown[]; next: number } {
145 // assumes s[i] === '['
146 const out: unknown[] = [];
147 i++;
148 i = skipWs(s, i);
149 if (s[i] === "]") return { value: out, next: i + 1 };
150 while (i < s.length) {
151 const { value, next } = parseFlow(s, i);
152 out.push(value);
153 i = skipWs(s, next);
154 if (s[i] === ",") {
155 i++;
156 i = skipWs(s, i);
157 continue;
158 }
159 if (s[i] === "]") return { value: out, next: i + 1 };
160 break; // malformed — bail out gracefully
161 }
162 return { value: out, next: i };
163}
164
165function parseFlowMap(
166 s: string,
167 i: number,
168): { value: Record<string, unknown>; next: number } {
169 // assumes s[i] === '{'
170 const out: Record<string, unknown> = {};
171 i++;
172 i = skipWs(s, i);
173 if (s[i] === "}") return { value: out, next: i + 1 };
174 while (i < s.length) {
175 // key
176 let keyEnd = i;
177 if (s[i] === '"' || s[i] === "'") keyEnd = findQuoteEnd(s, i) + 1;
178 else {
179 while (keyEnd < s.length && s[keyEnd] !== ":" && s[keyEnd] !== ",") keyEnd++;
180 }
181 const key = unquote(s.slice(i, keyEnd).trim());
182 i = skipWs(s, keyEnd);
183 if (s[i] === ":") {
184 i++;
185 i = skipWs(s, i);
186 const { value, next } = parseFlow(s, i);
187 out[key] = value;
188 i = skipWs(s, next);
189 } else {
190 // bare key with no value — treat as true (rare in our subset)
191 out[key] = true;
192 }
193 if (s[i] === ",") {
194 i++;
195 i = skipWs(s, i);
196 continue;
197 }
198 if (s[i] === "}") return { value: out, next: i + 1 };
199 break;
200 }
201 return { value: out, next: i };
202}
203
204function findQuoteEnd(s: string, i: number): number {
205 const q = s[i];
206 let j = i + 1;
207 while (j < s.length) {
208 if (q === '"' && s[j] === "\\") {
209 j += 2;
210 continue;
211 }
212 if (s[j] === q) {
213 if (q === "'" && s[j + 1] === "'") {
214 j += 2;
215 continue;
216 }
217 return j;
218 }
219 j++;
220 }
221 return s.length - 1;
222}
223
224function skipWs(s: string, i: number): number {
225 while (i < s.length && (s[i] === " " || s[i] === "\t")) i++;
226 return i;
227}
228
229// ---------------------------------------------------------------------------
230// Block-scalar (| and >) assembly: consumes continuation lines indented deeper
231// than `parentIndent` and joins them per the YAML block-scalar rules.
232// ---------------------------------------------------------------------------
233
234function readBlockScalar(
235 lines: Line[],
236 idx: number,
237 parentIndent: number,
238 style: "literal" | "folded",
239): { text: string; next: number } {
240 const parts: string[] = [];
241 let blockIndent = -1;
242 let i = idx;
243 while (i < lines.length) {
244 const line = lines[i]!;
245 if (line.indent <= parentIndent) break;
246 if (blockIndent < 0) blockIndent = line.indent;
247 // Use the raw line to preserve inner whitespace but strip the common indent.
248 const raw = line.raw;
249 const stripped = raw.slice(Math.min(blockIndent, raw.length));
250 parts.push(stripped);
251 i++;
252 }
253 const text =
254 style === "literal"
255 ? parts.join("\n")
256 : parts
257 .map((p) => p.trim())
258 .filter((p) => p.length > 0)
259 .join(" ");
260 return { text, next: i };
261}
262
263// ---------------------------------------------------------------------------
264// Block-style YAML parser. Recursive-descent over indented Line[] array.
265// Returns a plain JS value (object / array / string).
266// ---------------------------------------------------------------------------
267
268type Cursor = { i: number };
269
270function parseBlock(lines: Line[], cur: Cursor, indent: number): unknown {
271 if (cur.i >= lines.length) return null;
272 const first = lines[cur.i]!;
273 if (first.indent < indent) return null;
274 if (first.text.startsWith("- ") || first.text === "-") {
275 return parseBlockSeq(lines, cur, first.indent);
276 }
277 return parseBlockMap(lines, cur, first.indent);
278}
279
280function parseBlockMap(
281 lines: Line[],
282 cur: Cursor,
283 indent: number,
284): Record<string, unknown> {
285 const out: Record<string, unknown> = {};
286 while (cur.i < lines.length) {
287 const line = lines[cur.i]!;
288 if (line.indent < indent) break;
289 if (line.indent > indent) {
290 // Shouldn't happen in well-formed input — skip defensively.
291 cur.i++;
292 continue;
293 }
294 const { text } = line;
295 // Must be "key: …" at this indent.
296 const colon = findMapColon(text);
297 if (colon < 0) break;
298 const key = unquote(text.slice(0, colon).trim());
299 const rest = text.slice(colon + 1).trim();
300 cur.i++;
301 if (rest.length === 0) {
302 // value is on following deeper-indented lines (or nothing -> null)
303 const child = lines[cur.i];
304 if (!child || child.indent <= indent) {
305 out[key] = null;
306 } else {
307 out[key] = parseBlock(lines, cur, child.indent);
308 }
309 } else if (rest === "|" || rest === "|-" || rest === "|+") {
310 const { text: bs, next } = readBlockScalar(lines, cur.i, indent, "literal");
311 out[key] = rest === "|-" ? bs.replace(/\n+$/, "") : bs;
312 cur.i = next;
313 } else if (rest === ">" || rest === ">-" || rest === ">+") {
314 const { text: bs, next } = readBlockScalar(lines, cur.i, indent, "folded");
315 out[key] = bs;
316 cur.i = next;
317 } else if (rest.startsWith("[") || rest.startsWith("{")) {
318 out[key] = parseFlow(rest, 0).value;
319 } else {
320 out[key] = unquote(rest);
321 }
322 }
323 return out;
324}
325
326function parseBlockSeq(lines: Line[], cur: Cursor, indent: number): unknown[] {
327 const out: unknown[] = [];
328 while (cur.i < lines.length) {
329 const line = lines[cur.i]!;
330 if (line.indent < indent) break;
331 if (line.indent > indent) {
332 cur.i++;
333 continue;
334 }
335 if (!(line.text.startsWith("- ") || line.text === "-")) break;
336 const afterDash = line.text === "-" ? "" : line.text.slice(2);
337 cur.i++;
338
339 if (afterDash.length === 0) {
340 // element is on following deeper-indented lines
341 const child = lines[cur.i];
342 if (!child || child.indent <= indent) {
343 out.push(null);
344 } else {
345 out.push(parseBlock(lines, cur, child.indent));
346 }
347 continue;
348 }
349
350 // Could be "- key: value" (start of an inline map element) or "- scalar"
351 const colon = findMapColon(afterDash);
352 if (colon >= 0) {
353 // Build a virtual map: first pair from `afterDash`, further pairs from
354 // subsequent lines indented at (indent + 2 spaces past the dash).
355 const key = unquote(afterDash.slice(0, colon).trim());
356 const rest = afterDash.slice(colon + 1).trim();
357 const elem: Record<string, unknown> = {};
358 const childIndent = indent + 2;
359 if (rest.length === 0) {
360 const child = lines[cur.i];
361 if (child && child.indent > childIndent) {
362 elem[key] = parseBlock(lines, cur, child.indent);
363 } else {
364 elem[key] = null;
365 }
366 } else if (rest === "|" || rest === "|-" || rest === "|+") {
367 const { text: bs, next } = readBlockScalar(
368 lines,
369 cur.i,
370 childIndent - 1,
371 "literal",
372 );
373 elem[key] = rest === "|-" ? bs.replace(/\n+$/, "") : bs;
374 cur.i = next;
375 } else if (rest === ">" || rest === ">-" || rest === ">+") {
376 const { text: bs, next } = readBlockScalar(
377 lines,
378 cur.i,
379 childIndent - 1,
380 "folded",
381 );
382 elem[key] = bs;
383 cur.i = next;
384 } else if (rest.startsWith("[") || rest.startsWith("{")) {
385 elem[key] = parseFlow(rest, 0).value;
386 } else {
387 elem[key] = unquote(rest);
388 }
389 // Sibling map keys for the same element: same indent as childIndent.
390 while (cur.i < lines.length) {
391 const sib = lines[cur.i]!;
392 if (sib.indent < childIndent) break;
393 if (sib.indent > childIndent) {
394 cur.i++;
395 continue;
396 }
397 if (sib.text.startsWith("- ") || sib.text === "-") break;
398 const sc = findMapColon(sib.text);
399 if (sc < 0) break;
400 const sk = unquote(sib.text.slice(0, sc).trim());
401 const sr = sib.text.slice(sc + 1).trim();
402 cur.i++;
403 if (sr.length === 0) {
404 const child = lines[cur.i];
405 if (child && child.indent > childIndent) {
406 elem[sk] = parseBlock(lines, cur, child.indent);
407 } else {
408 elem[sk] = null;
409 }
410 } else if (sr === "|" || sr === "|-" || sr === "|+") {
411 const { text: bs, next } = readBlockScalar(
412 lines,
413 cur.i,
414 childIndent - 1,
415 "literal",
416 );
417 elem[sk] = sr === "|-" ? bs.replace(/\n+$/, "") : bs;
418 cur.i = next;
419 } else if (sr === ">" || sr === ">-" || sr === ">+") {
420 const { text: bs, next } = readBlockScalar(
421 lines,
422 cur.i,
423 childIndent - 1,
424 "folded",
425 );
426 elem[sk] = bs;
427 cur.i = next;
428 } else if (sr.startsWith("[") || sr.startsWith("{")) {
429 elem[sk] = parseFlow(sr, 0).value;
430 } else {
431 elem[sk] = unquote(sr);
432 }
433 }
434 out.push(elem);
435 } else {
436 // plain scalar element
437 if (afterDash.startsWith("[") || afterDash.startsWith("{")) {
438 out.push(parseFlow(afterDash, 0).value);
439 } else {
440 out.push(unquote(afterDash));
441 }
442 }
443 }
444 return out;
445}
446
447/** Locate the ':' that ends a mapping key, skipping quoted sections. */
448function findMapColon(text: string): number {
449 let inSingle = false;
450 let inDouble = false;
451 for (let i = 0; i < text.length; i++) {
452 const c = text[i];
453 if (c === "\\" && inDouble) {
454 i++;
455 continue;
456 }
457 if (c === "'" && !inDouble) inSingle = !inSingle;
458 else if (c === '"' && !inSingle) inDouble = !inDouble;
459 else if (c === ":" && !inSingle && !inDouble) {
460 // Must be followed by space, EOL, or be at the very end.
461 if (i + 1 >= text.length || text[i + 1] === " " || text[i + 1] === "\t") {
462 return i;
463 }
464 }
465 }
466 return -1;
467}
468
469// ---------------------------------------------------------------------------
470// Normalisation: raw YAML value → ParsedWorkflow
471// ---------------------------------------------------------------------------
472
473function asString(v: unknown): string | null {
474 if (typeof v === "string") return v;
475 if (typeof v === "number" || typeof v === "boolean") return String(v);
476 return null;
477}
478
479function normaliseOn(v: unknown): string[] | null {
480 if (v == null) return null;
481 if (typeof v === "string") {
482 const s = v.trim();
483 return s.length ? [s] : null;
484 }
485 if (Array.isArray(v)) {
486 const out: string[] = [];
487 for (const item of v) {
488 const s = asString(item);
489 if (s && s.trim().length) out.push(s.trim());
490 }
491 return out.length ? out : null;
492 }
493 if (typeof v === "object") {
494 const keys = Object.keys(v as Record<string, unknown>);
495 return keys.length ? keys : null;
496 }
497 return null;
498}
499
500function normaliseStep(
501 raw: unknown,
502 jobName: string,
503): { ok: true; step: WorkflowStep } | { ok: false; error: string } {
504 if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
505 return { ok: false, error: `step in job '${jobName}' must be a mapping` };
506 }
507 const r = raw as Record<string, unknown>;
508 const run = asString(r.run);
509 if (!run || !run.trim().length) {
510 return { ok: false, error: `step in job '${jobName}' missing 'run' command` };
511 }
512 const nameVal = asString(r.name);
513 const name = nameVal && nameVal.trim().length ? nameVal.trim() : "Run command";
514 return { ok: true, step: { name, run } };
515}
516
517function normaliseJob(
518 name: string,
519 raw: unknown,
520): { ok: true; job: WorkflowJob } | { ok: false; error: string } {
521 if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
522 return { ok: false, error: `job '${name}' is not a mapping` };
523 }
524 const r = raw as Record<string, unknown>;
525 const runsOnRaw = asString(r["runs-on"]);
526 const runsOn = runsOnRaw && runsOnRaw.trim().length ? runsOnRaw.trim() : "default";
527 const stepsRaw = r.steps;
528 if (!Array.isArray(stepsRaw) || stepsRaw.length === 0) {
529 return { ok: false, error: `job '${name}' has no steps` };
530 }
531 const steps: WorkflowStep[] = [];
532 for (const s of stepsRaw) {
533 const res = normaliseStep(s, name);
534 if (!res.ok) return res;
535 steps.push(res.step);
536 }
537 return { ok: true, job: { name, runsOn, steps } };
538}
539
540// ---------------------------------------------------------------------------
541// Public entry point.
542// ---------------------------------------------------------------------------
543
544export function parseWorkflow(yaml: string): ParseResult {
545 if (typeof yaml !== "string") {
546 return { ok: false, error: "workflow input must be a string" };
547 }
548 let root: unknown;
549 try {
550 const lines = lex(yaml);
551 if (lines.length === 0) {
552 return { ok: false, error: "workflow is empty" };
553 }
554 const cur: Cursor = { i: 0 };
555 root = parseBlock(lines, cur, lines[0]!.indent);
556 } catch (err) {
557 return {
558 ok: false,
559 error: `failed to parse YAML: ${err instanceof Error ? err.message : String(err)}`,
560 };
561 }
562
563 if (!root || typeof root !== "object" || Array.isArray(root)) {
564 return { ok: false, error: "workflow root must be a mapping" };
565 }
566 const doc = root as Record<string, unknown>;
567
568 const nameRaw = asString(doc.name);
569 const name = nameRaw && nameRaw.trim().length ? nameRaw.trim() : "(unnamed)";
570
571 if (!("on" in doc) || doc.on == null) {
572 return { ok: false, error: "workflow missing 'on' trigger" };
573 }
574 const on = normaliseOn(doc.on);
575 if (!on || on.length === 0) {
576 return { ok: false, error: "workflow missing 'on' trigger" };
577 }
578
579 const jobsRaw = doc.jobs;
580 if (
581 !jobsRaw ||
582 typeof jobsRaw !== "object" ||
583 Array.isArray(jobsRaw) ||
584 Object.keys(jobsRaw as Record<string, unknown>).length === 0
585 ) {
586 return { ok: false, error: "workflow has no jobs" };
587 }
588
589 const jobs: WorkflowJob[] = [];
590 // Object.keys preserves insertion order for string keys in modern engines.
591 for (const key of Object.keys(jobsRaw as Record<string, unknown>)) {
592 const res = normaliseJob(key, (jobsRaw as Record<string, unknown>)[key]);
593 if (!res.ok) return res;
594 jobs.push(res.job);
595 }
596
597 return { ok: true, workflow: { name, on, jobs } };
598}
Addedsrc/lib/workflow-runner.ts+732−0View fileUnifiedSplit
@@ -0,0 +1,732 @@
1/**
2 * Workflow runner (Block C1) — executes queued `workflow_runs` rows by
3 * cloning the repo at the target commit into a tmpdir and running each
4 * job's steps as bash subprocesses.
5 *
6 * Philosophy (mirrors post-receive.ts): never crash the caller. Every DB
7 * call is wrapped in try/catch. All step output is size-capped so a runaway
8 * process can't blow up Postgres rows. Logs are stored inline on the job
9 * row for v1 — no streaming, no object storage. Step timeouts are enforced
10 * so workers never wedge.
11 *
12 * Public surface:
13 * - executeRun(runId) — run a specific queued run to completion
14 * - drainOneRun() — pick the oldest queued run and execute it
15 * - enqueueRun(opts) — insert a new run at the tail of the queue
16 * - startWorker({ interval }) — background poll loop (returns stop fn)
17 */
18import { and, asc, eq, sql } from "drizzle-orm";
19import { mkdtemp, rm } from "fs/promises";
20import { tmpdir } from "os";
21import { join } from "path";
22import { config } from "./config";
23import { db } from "../db";
24import {
25 repositories,
26 workflowJobs,
27 workflowRuns,
28 workflows,
29} from "../db/schema";
30
31// ---------------------------------------------------------------------------
32// Tunables
33// ---------------------------------------------------------------------------
34
35/** Per-step subprocess timeout. */
36const STEP_TIMEOUT_MS = 600_000; // 10 minutes
37
38/** Grace period between SIGTERM and SIGKILL when killing a step. */
39const KILL_GRACE_MS = 5_000;
40
41/** Cap on full `workflow_jobs.logs` field. */
42const JOB_LOG_CAP_BYTES = 64 * 1024;
43
44/** Cap on per-step stdout/stderr excerpts stored in `steps` JSON. */
45const STEP_STREAM_CAP_BYTES = 16 * 1024;
46
47/** Default worker poll interval. */
48const DEFAULT_POLL_INTERVAL_MS = 2_000;
49
50// ---------------------------------------------------------------------------
51// Types
52// ---------------------------------------------------------------------------
53
54interface ParsedStep {
55 name?: string;
56 run?: string;
57 // `uses` / `with` etc. tolerated but ignored in v1.
58 [key: string]: unknown;
59}
60
61interface ParsedJob {
62 name?: string;
63 "runs-on"?: string;
64 runsOn?: string;
65 steps?: ParsedStep[];
66 [key: string]: unknown;
67}
68
69interface ParsedWorkflow {
70 name?: string;
71 on?: unknown;
72 jobs?: Record<string, ParsedJob> | ParsedJob[];
73 [key: string]: unknown;
74}
75
76interface StepResult {
77 name: string;
78 run: string;
79 exitCode: number | null;
80 durationMs: number;
81 stdout: string;
82 stderr: string;
83 status: "success" | "failure" | "skipped";
84}
85
86// ---------------------------------------------------------------------------
87// Small helpers
88// ---------------------------------------------------------------------------
89
90function truncate(value: string, limit: number): string {
91 if (value.length <= limit) return value;
92 return value.slice(0, limit) + "\n[... truncated ...]";
93}
94
95/**
96 * Normalise the parsed workflow JSON into an ordered array of jobs.
97 * Accepts either the object form (`jobs: { build: {...} }`) or an array.
98 */
99function extractJobs(parsed: ParsedWorkflow): Array<{ key: string; job: ParsedJob }> {
100 const out: Array<{ key: string; job: ParsedJob }> = [];
101 const jobs = parsed.jobs;
102 if (!jobs) return out;
103 if (Array.isArray(jobs)) {
104 jobs.forEach((job, i) => {
105 if (job && typeof job === "object") {
106 out.push({ key: String(job.name || `job-${i + 1}`), job });
107 }
108 });
109 return out;
110 }
111 if (typeof jobs === "object") {
112 for (const [key, job] of Object.entries(jobs)) {
113 if (job && typeof job === "object") {
114 out.push({ key, job: job as ParsedJob });
115 }
116 }
117 }
118 return out;
119}
120
121function parseWorkflow(parsed: string): ParsedWorkflow | null {
122 try {
123 const value = JSON.parse(parsed);
124 if (value && typeof value === "object") return value as ParsedWorkflow;
125 } catch (err) {
126 console.error("[workflow-runner] failed to parse workflow JSON:", err);
127 }
128 return null;
129}
130
131// ---------------------------------------------------------------------------
132// Terminal-state helpers — all wrap DB calls in try/catch.
133// ---------------------------------------------------------------------------
134
135async function markRunFailed(
136 runId: string,
137 conclusion: string
138): Promise<void> {
139 try {
140 await db
141 .update(workflowRuns)
142 .set({
143 status: "failure",
144 conclusion,
145 finishedAt: new Date(),
146 })
147 .where(eq(workflowRuns.id, runId));
148 } catch (err) {
149 console.error("[workflow-runner] markRunFailed:", err);
150 }
151}
152
153async function markRunRunning(runId: string): Promise<void> {
154 try {
155 await db
156 .update(workflowRuns)
157 .set({
158 status: "running",
159 startedAt: new Date(),
160 })
161 .where(eq(workflowRuns.id, runId));
162 } catch (err) {
163 console.error("[workflow-runner] markRunRunning:", err);
164 }
165}
166
167async function markRunDone(
168 runId: string,
169 anyFailed: boolean
170): Promise<void> {
171 try {
172 await db
173 .update(workflowRuns)
174 .set({
175 status: anyFailed ? "failure" : "success",
176 conclusion: anyFailed ? "failure" : "success",
177 finishedAt: new Date(),
178 })
179 .where(eq(workflowRuns.id, runId));
180 } catch (err) {
181 console.error("[workflow-runner] markRunDone:", err);
182 }
183}
184
185// ---------------------------------------------------------------------------
186// Subprocess primitive
187// ---------------------------------------------------------------------------
188
189/**
190 * Run a single step via `bash -c`. Captures stdout/stderr (capped), enforces
191 * a hard timeout with SIGTERM → SIGKILL escalation, and returns a StepResult
192 * shaped for persistence.
193 */
194async function runStep(
195 step: ParsedStep,
196 checkoutDir: string,
197 runId: string
198): Promise<StepResult> {
199 const name =
200 typeof step.name === "string" && step.name.length > 0
201 ? step.name
202 : (typeof step.run === "string" ? step.run.split("\n")[0] : "") ||
203 "step";
204 const run = typeof step.run === "string" ? step.run : "";
205 const started = Date.now();
206
207 if (!run) {
208 // No `run:` — v1 treats this as skipped (we don't support `uses:` yet).
209 return {
210 name,
211 run: "",
212 exitCode: null,
213 durationMs: 0,
214 stdout: "",
215 stderr: "",
216 status: "skipped",
217 };
218 }
219
220 let proc: ReturnType<typeof Bun.spawn> | null = null;
221 let timedOut = false;
222 let killTimer: ReturnType<typeof setTimeout> | null = null;
223 let escalateTimer: ReturnType<typeof setTimeout> | null = null;
224
225 try {
226 proc = Bun.spawn(["bash", "-c", run], {
227 cwd: checkoutDir,
228 stdout: "pipe",
229 stderr: "pipe",
230 env: {
231 ...process.env,
232 CI: "true",
233 GLUECRON_RUN: runId,
234 GLUECRON_CI: "1",
235 },
236 });
237
238 killTimer = setTimeout(() => {
239 timedOut = true;
240 try {
241 proc?.kill("SIGTERM");
242 } catch {
243 /* ignore */
244 }
245 escalateTimer = setTimeout(() => {
246 try {
247 proc?.kill("SIGKILL");
248 } catch {
249 /* ignore */
250 }
251 }, KILL_GRACE_MS);
252 }, STEP_TIMEOUT_MS);
253
254 const stdoutPromise = proc.stdout
255 ? new Response(proc.stdout as ReadableStream).text()
256 : Promise.resolve("");
257 const stderrPromise = proc.stderr
258 ? new Response(proc.stderr as ReadableStream).text()
259 : Promise.resolve("");
260
261 const [stdoutRaw, stderrRaw] = await Promise.all([
262 stdoutPromise.catch(() => ""),
263 stderrPromise.catch(() => ""),
264 ]);
265 const exitCode = await proc.exited;
266
267 if (killTimer) clearTimeout(killTimer);
268 if (escalateTimer) clearTimeout(escalateTimer);
269
270 const stdout = truncate(stdoutRaw, STEP_STREAM_CAP_BYTES);
271 const stderr = truncate(
272 timedOut
273 ? `${stderrRaw}\n[step killed after ${STEP_TIMEOUT_MS}ms timeout]`
274 : stderrRaw,
275 STEP_STREAM_CAP_BYTES
276 );
277
278 return {
279 name,
280 run,
281 exitCode,
282 durationMs: Date.now() - started,
283 stdout,
284 stderr,
285 status: exitCode === 0 && !timedOut ? "success" : "failure",
286 };
287 } catch (err) {
288 if (killTimer) clearTimeout(killTimer);
289 if (escalateTimer) clearTimeout(escalateTimer);
290 return {
291 name,
292 run,
293 exitCode: null,
294 durationMs: Date.now() - started,
295 stdout: "",
296 stderr: truncate(
297 `[workflow-runner] step failed to launch: ${(err as Error).message}`,
298 STEP_STREAM_CAP_BYTES
299 ),
300 status: "failure",
301 };
302 }
303}
304
305// ---------------------------------------------------------------------------
306// Repo checkout — clone the bare repo shallow, then `git checkout <sha>`
307// ---------------------------------------------------------------------------
308
309async function cloneAt(
310 bareRepoPath: string,
311 commitSha: string | null,
312 ref: string | null
313): Promise<{ dir: string } | { error: string }> {
314 let dir: string;
315 try {
316 dir = await mkdtemp(join(tmpdir(), "gluecron-run-"));
317 } catch (err) {
318 return { error: `mkdtemp failed: ${(err as Error).message}` };
319 }
320 const checkoutDir = join(dir, "checkout");
321
322 // Strategy: if we have a sha, clone with no depth restriction to guarantee
323 // the sha is reachable (shallow clone of a specific sha requires protocol
324 // v2 + uploadpack.allowReachableSHA1InWant on the server). For v1 we
325 // prefer correctness over size. Callers can switch to `--depth 1 --branch`
326 // once we wire config.
327 try {
328 const cloneProc = Bun.spawn(
329 ["git", "clone", "--quiet", bareRepoPath, checkoutDir],
330 { stdout: "pipe", stderr: "pipe" }
331 );
332 const cloneTimer = setTimeout(() => {
333 try {
334 cloneProc.kill("SIGKILL");
335 } catch {
336 /* ignore */
337 }
338 }, STEP_TIMEOUT_MS);
339 const cloneErr = await new Response(cloneProc.stderr as ReadableStream)
340 .text()
341 .catch(() => "");
342 const cloneExit = await cloneProc.exited;
343 clearTimeout(cloneTimer);
344 if (cloneExit !== 0) {
345 await rm(dir, { recursive: true, force: true }).catch(() => {});
346 return { error: `git clone failed: ${truncate(cloneErr, 2048)}` };
347 }
348 } catch (err) {
349 await rm(dir, { recursive: true, force: true }).catch(() => {});
350 return { error: `git clone spawn failed: ${(err as Error).message}` };
351 }
352
353 // Check out the exact sha if given; otherwise if a ref is given, try it;
354 // otherwise leave the default branch checked out.
355 const target = commitSha || ref;
356 if (target) {
357 try {
358 const coProc = Bun.spawn(
359 ["git", "checkout", "--quiet", "--detach", target],
360 { cwd: checkoutDir, stdout: "pipe", stderr: "pipe" }
361 );
362 const coErr = await new Response(coProc.stderr as ReadableStream)
363 .text()
364 .catch(() => "");
365 const coExit = await coProc.exited;
366 if (coExit !== 0) {
367 await rm(dir, { recursive: true, force: true }).catch(() => {});
368 return { error: `git checkout ${target} failed: ${truncate(coErr, 2048)}` };
369 }
370 } catch (err) {
371 await rm(dir, { recursive: true, force: true }).catch(() => {});
372 return { error: `git checkout spawn failed: ${(err as Error).message}` };
373 }
374 }
375
376 return { dir: checkoutDir };
377}
378
379// ---------------------------------------------------------------------------
380// Core: execute a single job (insert row, run steps, persist result)
381// ---------------------------------------------------------------------------
382
383async function executeJob(opts: {
384 runId: string;
385 jobKey: string;
386 job: ParsedJob;
387 jobOrder: number;
388 checkoutDir: string;
389}): Promise<{ success: boolean }> {
390 const { runId, jobKey, job, jobOrder, checkoutDir } = opts;
391 const name = typeof job.name === "string" && job.name ? job.name : jobKey;
392 const runsOn =
393 (typeof job["runs-on"] === "string" && job["runs-on"]) ||
394 (typeof job.runsOn === "string" && job.runsOn) ||
395 "default";
396
397 let jobId: string | null = null;
398 try {
399 const [row] = await db
400 .insert(workflowJobs)
401 .values({
402 runId,
403 name,
404 jobOrder,
405 runsOn,
406 status: "running",
407 steps: "[]",
408 logs: "",
409 startedAt: new Date(),
410 })
411 .returning();
412 jobId = row?.id || null;
413 } catch (err) {
414 console.error("[workflow-runner] insert job:", err);
415 // No job row = can't record results. Treat as failure so the run fails.
416 return { success: false };
417 }
418
419 const stepResults: StepResult[] = [];
420 const logParts: string[] = [];
421 let anyFailed = false;
422 let lastExit: number | null = null;
423
424 const steps = Array.isArray(job.steps) ? job.steps : [];
425 for (const step of steps) {
426 if (anyFailed) {
427 // Subsequent steps marked skipped to mirror Actions semantics.
428 stepResults.push({
429 name:
430 (typeof step.name === "string" && step.name) ||
431 (typeof step.run === "string"
432 ? step.run.split("\n")[0]
433 : "") ||
434 "step",
435 run: typeof step.run === "string" ? step.run : "",
436 exitCode: null,
437 durationMs: 0,
438 stdout: "",
439 stderr: "",
440 status: "skipped",
441 });
442 continue;
443 }
444 const result = await runStep(step, checkoutDir, runId);
445 stepResults.push(result);
446 logParts.push(
447 `==> ${result.name}\n$ ${result.run}\n${result.stdout}${
448 result.stderr ? "\n[stderr]\n" + result.stderr : ""
449 }\n[exit ${result.exitCode ?? "null"} in ${result.durationMs}ms]\n`
450 );
451 if (result.status === "failure") {
452 anyFailed = true;
453 lastExit = result.exitCode;
454 } else if (result.status === "success") {
455 lastExit = result.exitCode;
456 }
457 }
458
459 const combinedLogs = truncate(logParts.join("\n"), JOB_LOG_CAP_BYTES);
460 const status = anyFailed ? "failure" : "success";
461
462 if (jobId) {
463 try {
464 await db
465 .update(workflowJobs)
466 .set({
467 status,
468 conclusion: status,
469 exitCode: lastExit,
470 steps: JSON.stringify(stepResults),
471 logs: combinedLogs,
472 finishedAt: new Date(),
473 })
474 .where(eq(workflowJobs.id, jobId));
475 } catch (err) {
476 console.error("[workflow-runner] update job:", err);
477 }
478 }
479
480 return { success: !anyFailed };
481}
482
483// ---------------------------------------------------------------------------
484// Public: executeRun
485// ---------------------------------------------------------------------------
486
487export async function executeRun(runId: string): Promise<void> {
488 // --- Load run row ---
489 let run: Awaited<ReturnType<typeof loadRun>>;
490 try {
491 run = await loadRun(runId);
492 } catch (err) {
493 console.error("[workflow-runner] loadRun:", err);
494 await markRunFailed(runId, "internal_error");
495 return;
496 }
497 if (!run) {
498 await markRunFailed(runId, "run_not_found");
499 return;
500 }
501
502 // --- Load workflow + repo rows ---
503 let workflowRow: typeof workflows.$inferSelect | null = null;
504 let repoRow: typeof repositories.$inferSelect | null = null;
505 try {
506 const [w] = await db
507 .select()
508 .from(workflows)
509 .where(eq(workflows.id, run.workflowId))
510 .limit(1);
511 workflowRow = w || null;
512 } catch (err) {
513 console.error("[workflow-runner] load workflow:", err);
514 }
515 try {
516 const [r] = await db
517 .select()
518 .from(repositories)
519 .where(eq(repositories.id, run.repositoryId))
520 .limit(1);
521 repoRow = r || null;
522 } catch (err) {
523 console.error("[workflow-runner] load repo:", err);
524 }
525
526 if (!workflowRow || !repoRow) {
527 await markRunFailed(runId, "workflow_not_found");
528 return;
529 }
530
531 // --- Parse workflow JSON ---
532 const parsed = parseWorkflow(workflowRow.parsed);
533 if (!parsed) {
534 await markRunFailed(runId, "workflow_parse_error");
535 return;
536 }
537 const jobs = extractJobs(parsed);
538 if (jobs.length === 0) {
539 await markRunFailed(runId, "no_jobs");
540 return;
541 }
542
543 // --- Transition to running ---
544 await markRunRunning(runId);
545
546 // --- Clone repo at target sha ---
547 const bareRepoPath = repoRow.diskPath;
548 const clone = await cloneAt(bareRepoPath, run.commitSha, run.ref);
549 if ("error" in clone) {
550 console.error(`[workflow-runner] clone failed for run ${runId}: ${clone.error}`);
551 await markRunFailed(runId, "checkout_failed");
552 return;
553 }
554 const checkoutDir = clone.dir;
555 const tmpRoot = join(checkoutDir, "..");
556
557 // --- Run jobs sequentially ---
558 let anyJobFailed = false;
559 try {
560 for (let i = 0; i < jobs.length; i++) {
561 const { key, job } = jobs[i]!;
562 const result = await executeJob({
563 runId,
564 jobKey: key,
565 job,
566 jobOrder: i,
567 checkoutDir,
568 });
569 if (!result.success) {
570 anyJobFailed = true;
571 // Per-v1 semantics: stop on first failure. Subsequent jobs aren't
572 // created, matching Actions' default needs-less pipeline.
573 break;
574 }
575 }
576 } catch (err) {
577 console.error("[workflow-runner] job loop:", err);
578 anyJobFailed = true;
579 } finally {
580 // Cleanup always runs.
581 await rm(tmpRoot, { recursive: true, force: true }).catch((err) => {
582 console.error("[workflow-runner] tmpdir cleanup:", err);
583 });
584 }
585
586 await markRunDone(runId, anyJobFailed);
587}
588
589async function loadRun(runId: string) {
590 const [row] = await db
591 .select()
592 .from(workflowRuns)
593 .where(eq(workflowRuns.id, runId))
594 .limit(1);
595 return row || null;
596}
597
598// ---------------------------------------------------------------------------
599// Public: drainOneRun — pick + execute the oldest queued row.
600// ---------------------------------------------------------------------------
601
602export async function drainOneRun(): Promise<boolean> {
603 let candidateId: string | null = null;
604 try {
605 const [row] = await db
606 .select({ id: workflowRuns.id })
607 .from(workflowRuns)
608 .where(eq(workflowRuns.status, "queued"))
609 .orderBy(asc(workflowRuns.queuedAt))
610 .limit(1);
611 candidateId = row?.id || null;
612 } catch (err) {
613 console.error("[workflow-runner] drain select:", err);
614 return false;
615 }
616 if (!candidateId) return false;
617
618 // Best-effort claim: flip queued → running. If another worker beat us,
619 // updated rowcount will be 0 — neon-http doesn't surface rowcount the
620 // same way so we re-select after.
621 try {
622 await db
623 .update(workflowRuns)
624 .set({ status: "running", startedAt: new Date() })
625 .where(
626 and(
627 eq(workflowRuns.id, candidateId),
628 eq(workflowRuns.status, "queued")
629 )
630 );
631 } catch (err) {
632 console.error("[workflow-runner] drain claim:", err);
633 return false;
634 }
635
636 // Verify we actually own the claim (status is now running and startedAt
637 // is very recent). If another worker beat us they'll have set startedAt
638 // earlier; accept either way — executeRun is idempotent enough for v1.
639 try {
640 await executeRun(candidateId);
641 } catch (err) {
642 console.error("[workflow-runner] executeRun threw (shouldn't):", err);
643 }
644 return true;
645}
646
647// ---------------------------------------------------------------------------
648// Public: enqueueRun
649// ---------------------------------------------------------------------------
650
651export async function enqueueRun(opts: {
652 workflowId: string;
653 repositoryId: string;
654 event: string;
655 ref?: string | null;
656 commitSha?: string | null;
657 triggeredBy?: string | null;
658}): Promise<string> {
659 // Compute next run_number scoped to this workflow.
660 let nextRunNumber = 1;
661 try {
662 const [row] = await db
663 .select({ n: sql<number>`coalesce(max(${workflowRuns.runNumber}), 0)` })
664 .from(workflowRuns)
665 .where(eq(workflowRuns.workflowId, opts.workflowId));
666 nextRunNumber = Number(row?.n ?? 0) + 1;
667 } catch (err) {
668 console.error("[workflow-runner] enqueue max:", err);
669 // Fall back to a coarse timestamp-derived number so the insert still
670 // succeeds; uniqueness isn't enforced in the schema.
671 nextRunNumber = Math.floor(Date.now() / 1000);
672 }
673
674 try {
675 const [row] = await db
676 .insert(workflowRuns)
677 .values({
678 workflowId: opts.workflowId,
679 repositoryId: opts.repositoryId,
680 runNumber: nextRunNumber,
681 event: opts.event,
682 ref: opts.ref ?? null,
683 commitSha: opts.commitSha ?? null,
684 triggeredBy: opts.triggeredBy ?? null,
685 status: "queued",
686 })
687 .returning({ id: workflowRuns.id });
688 return row?.id || "";
689 } catch (err) {
690 console.error("[workflow-runner] enqueue insert:", err);
691 return "";
692 }
693}
694
695// ---------------------------------------------------------------------------
696// Public: startWorker — background poll loop.
697// ---------------------------------------------------------------------------
698
699export function startWorker(opts?: { intervalMs?: number }): () => void {
700 const intervalMs = opts?.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
701 let stopped = false;
702 let active = false;
703
704 const tick = async () => {
705 if (stopped || active) return;
706 active = true;
707 try {
708 // Drain as many runs as we can in one tick (serial). If there's
709 // nothing queued we exit quickly and wait for the next interval.
710 let picked = true;
711 while (picked && !stopped) {
712 picked = await drainOneRun();
713 }
714 } catch (err) {
715 console.error("[workflow-runner] worker tick:", err);
716 } finally {
717 active = false;
718 }
719 };
720
721 const handle = setInterval(() => {
722 void tick();
723 }, intervalMs);
724
725 // Kick off an immediate tick so the first queued run doesn't wait.
726 void tick();
727
728 return () => {
729 stopped = true;
730 clearInterval(handle);
731 };
732}
Addedsrc/routes/workflows.tsx+600−0View fileUnifiedSplit
@@ -0,0 +1,600 @@
1/**
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";
27import { softAuth, requireAuth } from "../middleware/auth";
28import type { AuthEnv } from "../middleware/auth";
29import { getUnreadCount } from "../lib/unread";
30import { audit } from "../lib/notify";
31import { enqueueRun } from "../lib/workflow-runner";
32
33const actions = new Hono<AuthEnv>();
34actions.use("*", softAuth);
35
36async function loadRepo(owner: string, repo: string) {
37 const [row] = await db
38 .select({
39 id: repositories.id,
40 name: repositories.name,
41 defaultBranch: repositories.defaultBranch,
42 ownerId: repositories.ownerId,
43 starCount: repositories.starCount,
44 forkCount: repositories.forkCount,
45 })
46 .from(repositories)
47 .innerJoin(users, eq(repositories.ownerId, users.id))
48 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
49 .limit(1);
50 return row;
51}
52
53function relTime(d: Date | string | null): string {
54 if (!d) return "—";
55 const t = typeof d === "string" ? new Date(d) : d;
56 const diffMs = Date.now() - t.getTime();
57 const mins = Math.floor(diffMs / 60000);
58 if (mins < 1) return "just now";
59 if (mins < 60) return `${mins}m ago`;
60 const hrs = Math.floor(mins / 60);
61 if (hrs < 24) return `${hrs}h ago`;
62 const days = Math.floor(hrs / 24);
63 if (days < 30) return `${days}d ago`;
64 return t.toLocaleDateString();
65}
66
67function durationMs(start: Date | string | null, end: Date | string | null): string {
68 if (!start) return "";
69 const s = typeof start === "string" ? new Date(start) : start;
70 const e = end ? (typeof end === "string" ? new Date(end) : end) : new Date();
71 const ms = e.getTime() - s.getTime();
72 if (ms < 1000) return `${ms}ms`;
73 if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
74 const m = Math.floor(ms / 60_000);
75 const s2 = Math.floor((ms % 60_000) / 1000);
76 return `${m}m ${s2}s`;
77}
78
79function statusColor(status: string, conclusion: string | null): string {
80 if (status === "running") return "var(--yellow, #e3b341)";
81 if (status === "queued") return "var(--text-muted)";
82 if (status === "cancelled") return "var(--text-muted)";
83 const concl = conclusion || status;
84 if (concl === "success") return "var(--green)";
85 if (concl === "failure") return "var(--red)";
86 return "var(--text-muted)";
87}
88
89function statusGlyph(status: string, conclusion: string | null): string {
90 if (status === "running") return "\u25D0"; // half-circle
91 if (status === "queued") return "\u25CB"; // hollow circle
92 if (status === "cancelled") return "\u2715"; // x
93 const concl = conclusion || status;
94 if (concl === "success") return "\u2713"; // check
95 if (concl === "failure") return "\u2717"; // heavy x
96 if (concl === "skipped") return "\u2013"; // en dash
97 return "\u25CF";
98}
99
100// ---------- List workflows + recent runs ----------
101
102actions.get("/:owner/:repo/actions", async (c) => {
103 const user = c.get("user");
104 const { owner, repo } = c.req.param();
105 const repoRow = await loadRepo(owner, repo);
106 if (!repoRow) return c.notFound();
107
108 let wfs: (typeof workflows.$inferSelect)[] = [];
109 let runs: (typeof workflowRuns.$inferSelect & { workflowName: string | null })[] =
110 [];
111 try {
112 wfs = await db
113 .select()
114 .from(workflows)
115 .where(eq(workflows.repositoryId, repoRow.id))
116 .orderBy(desc(workflows.updatedAt));
117
118 const joined = await db
119 .select({
120 id: workflowRuns.id,
121 workflowId: workflowRuns.workflowId,
122 repositoryId: workflowRuns.repositoryId,
123 runNumber: workflowRuns.runNumber,
124 event: workflowRuns.event,
125 ref: workflowRuns.ref,
126 commitSha: workflowRuns.commitSha,
127 triggeredBy: workflowRuns.triggeredBy,
128 status: workflowRuns.status,
129 conclusion: workflowRuns.conclusion,
130 queuedAt: workflowRuns.queuedAt,
131 startedAt: workflowRuns.startedAt,
132 finishedAt: workflowRuns.finishedAt,
133 createdAt: workflowRuns.createdAt,
134 workflowName: workflows.name,
135 })
136 .from(workflowRuns)
137 .leftJoin(workflows, eq(workflowRuns.workflowId, workflows.id))
138 .where(eq(workflowRuns.repositoryId, repoRow.id))
139 .orderBy(desc(workflowRuns.queuedAt))
140 .limit(50);
141 runs = joined as typeof runs;
142 } catch (err) {
143 console.error("[actions] list:", err);
144 }
145
146 const unread = user ? await getUnreadCount(user.id) : 0;
147 const canRun = !!user && user.id === repoRow.ownerId;
148
149 return c.html(
150 <Layout
151 title={`Actions — ${owner}/${repo}`}
152 user={user}
153 notificationCount={unread}
154 >
155 <RepoHeader
156 owner={owner}
157 repo={repo}
158 starCount={repoRow.starCount}
159 forkCount={repoRow.forkCount}
160 currentUser={user?.username || null}
161 />
162 <RepoNav owner={owner} repo={repo} active="actions" />
163
164 <div style="display: grid; grid-template-columns: 280px 1fr; gap: 20px">
165 <aside>
166 <h4 style="margin: 0 0 12px 0; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
167 Workflows
168 </h4>
169 {wfs.length === 0 ? (
170 <div class="panel" style="padding: 12px; font-size: 12px; color: var(--text-muted)">
171 No workflows yet. Add a YAML file under
172 {" "}
173 <code>.gluecron/workflows/</code> on your default branch.
174 </div>
175 ) : (
176 <div class="panel" style="overflow: hidden">
177 {wfs.map((w) => (
178 <div
179 style={`padding: 10px 12px; border-bottom: 1px solid var(--border); ${w.disabled ? "opacity: 0.5" : ""}`}
180 >
181 <div style="display: flex; justify-content: space-between; align-items: center; gap: 8px">
182 <div style="flex: 1; min-width: 0">
183 <div style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
184 {w.name}
185 </div>
186 <div style="font-size: 11px; color: var(--text-muted); font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap">
187 {w.path}
188 </div>
189 </div>
190 {canRun && !w.disabled && (
191 <form
192 method="POST"
193 action={`/${owner}/${repo}/actions/${w.id}/run`}
194 style="margin: 0"
195 >
196 <button type="submit" class="btn btn-sm" title="Trigger manual run">
197 Run
198 </button>
199 </form>
200 )}
201 </div>
202 </div>
203 ))}
204 </div>
205 )}
206 </aside>
207
208 <section>
209 <h4 style="margin: 0 0 12px 0; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
210 Recent runs
211 </h4>
212 {runs.length === 0 ? (
213 <div class="empty-state">
214 <p>No workflow runs yet. Push a commit or trigger one manually.</p>
215 </div>
216 ) : (
217 <div class="panel" style="overflow: hidden">
218 {runs.map((r) => (
219 <a
220 href={`/${owner}/${repo}/actions/runs/${r.id}`}
221 style="display: flex; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--border); text-decoration: none; color: inherit"
222 >
223 <span
224 style={`display: inline-block; min-width: 18px; text-align: center; color: ${statusColor(r.status, r.conclusion)}; font-weight: 700`}
225 title={r.conclusion || r.status}
226 >
227 {statusGlyph(r.status, r.conclusion)}
228 </span>
229 <div style="flex: 1; min-width: 0">
230 <div style="font-weight: 500">
231 {r.workflowName || "(workflow deleted)"}
232 {" "}
233 <span style="color: var(--text-muted); font-weight: 400">
234 #{r.runNumber}
235 </span>
236 </div>
237 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
238 <span>{r.event}</span>
239 {r.ref && (
240 <>
241 {" · "}
242 <span>{r.ref.replace(/^refs\/heads\//, "")}</span>
243 </>
244 )}
245 {r.commitSha && (
246 <>
247 {" · "}
248 <code>{r.commitSha.slice(0, 7)}</code>
249 </>
250 )}
251 {" · "}
252 <span>{relTime(r.queuedAt)}</span>
253 {r.startedAt && r.finishedAt && (
254 <>
255 {" · "}
256 <span>{durationMs(r.startedAt, r.finishedAt)}</span>
257 </>
258 )}
259 </div>
260 </div>
261 </a>
262 ))}
263 </div>
264 )}
265 </section>
266 </div>
267 </Layout>
268 );
269});
270
271// ---------- Run detail ----------
272
273actions.get("/:owner/:repo/actions/runs/:runId", async (c) => {
274 const user = c.get("user");
275 const { owner, repo, runId } = c.req.param();
276 const repoRow = await loadRepo(owner, repo);
277 if (!repoRow) return c.notFound();
278
279 let run: typeof workflowRuns.$inferSelect | null = null;
280 let workflowRow: typeof workflows.$inferSelect | null = null;
281 let jobs: (typeof workflowJobs.$inferSelect)[] = [];
282 try {
283 const [r] = await db
284 .select()
285 .from(workflowRuns)
286 .where(
287 and(
288 eq(workflowRuns.id, runId),
289 eq(workflowRuns.repositoryId, repoRow.id)
290 )
291 )
292 .limit(1);
293 run = r || null;
294 if (run) {
295 const [w] = await db
296 .select()
297 .from(workflows)
298 .where(eq(workflows.id, run.workflowId))
299 .limit(1);
300 workflowRow = w || null;
301 jobs = await db
302 .select()
303 .from(workflowJobs)
304 .where(eq(workflowJobs.runId, run.id))
305 .orderBy(workflowJobs.jobOrder);
306 }
307 } catch (err) {
308 console.error("[actions] run detail:", err);
309 }
310
311 if (!run) return c.notFound();
312
313 const unread = user ? await getUnreadCount(user.id) : 0;
314 const canCancel =
315 !!user &&
316 user.id === repoRow.ownerId &&
317 (run.status === "queued" || run.status === "running");
318
319 return c.html(
320 <Layout
321 title={`Run #${run.runNumber} — ${owner}/${repo}`}
322 user={user}
323 notificationCount={unread}
324 >
325 <RepoHeader
326 owner={owner}
327 repo={repo}
328 starCount={repoRow.starCount}
329 forkCount={repoRow.forkCount}
330 currentUser={user?.username || null}
331 />
332 <RepoNav owner={owner} repo={repo} active="actions" />
333
334 <div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 16px; gap: 12px">
335 <div>
336 <div style="font-size: 12px; color: var(--text-muted); margin-bottom: 4px">
337 <a href={`/${owner}/${repo}/actions`}>Actions</a>
338 </div>
339 <h3 style="margin: 0">
340 <span
341 style={`color: ${statusColor(run.status, run.conclusion)}; margin-right: 6px`}
342 >
343 {statusGlyph(run.status, run.conclusion)}
344 </span>
345 {workflowRow?.name || "(deleted workflow)"}
346 {" "}
347 <span style="color: var(--text-muted); font-weight: 400">
348 #{run.runNumber}
349 </span>
350 </h3>
351 <div style="font-size: 12px; color: var(--text-muted); margin-top: 6px">
352 <span>{run.event}</span>
353 {run.ref && (
354 <>
355 {" · "}
356 <span>{run.ref.replace(/^refs\/heads\//, "")}</span>
357 </>
358 )}
359 {run.commitSha && (
360 <>
361 {" · "}
362 <a href={`/${owner}/${repo}/commit/${run.commitSha}`}>
363 <code>{run.commitSha.slice(0, 7)}</code>
364 </a>
365 </>
366 )}
367 {" · queued "}
368 <span>{relTime(run.queuedAt)}</span>
369 {run.startedAt && run.finishedAt && (
370 <>
371 {" · duration "}
372 <span>{durationMs(run.startedAt, run.finishedAt)}</span>
373 </>
374 )}
375 </div>
376 </div>
377 {canCancel && (
378 <form
379 method="POST"
380 action={`/${owner}/${repo}/actions/runs/${run.id}/cancel`}
381 onsubmit="return confirm('Cancel this run?')"
382 >
383 <button type="submit" class="btn btn-sm btn-danger">
384 Cancel
385 </button>
386 </form>
387 )}
388 </div>
389
390 {jobs.length === 0 ? (
391 <div class="empty-state">
392 <p>
393 {run.status === "queued"
394 ? "Queued — jobs will appear once the runner picks this up."
395 : "No jobs recorded for this run."}
396 </p>
397 </div>
398 ) : (
399 <div>
400 {jobs.map((j) => {
401 let steps: Array<{
402 name?: string;
403 run?: string;
404 status?: string;
405 exitCode?: number | null;
406 durationMs?: number;
407 stdout?: string;
408 stderr?: string;
409 }> = [];
410 try {
411 steps = JSON.parse(j.steps || "[]");
412 } catch {
413 steps = [];
414 }
415 return (
416 <details class="panel" style="margin-bottom: 16px; overflow: hidden" open>
417 <summary
418 style="padding: 10px 14px; cursor: pointer; display: flex; gap: 10px; align-items: center; background: var(--bg-tertiary)"
419 >
420 <span
421 style={`color: ${statusColor(j.status, j.conclusion)}; font-weight: 700`}
422 >
423 {statusGlyph(j.status, j.conclusion)}
424 </span>
425 <span style="flex: 1; font-weight: 500">{j.name}</span>
426 <span style="font-size: 12px; color: var(--text-muted)">
427 {j.startedAt && j.finishedAt
428 ? durationMs(j.startedAt, j.finishedAt)
429 : j.status}
430 </span>
431 </summary>
432 {steps.length > 0 && (
433 <div style="padding: 8px 14px; border-top: 1px solid var(--border)">
434 {steps.map((s, i) => (
435 <div
436 style="padding: 6px 0; border-bottom: 1px solid var(--border); display: flex; gap: 10px; font-size: 13px"
437 >
438 <span
439 style={`color: ${statusColor(s.status || "", null)}; font-weight: 700; min-width: 18px`}
440 >
441 {statusGlyph(s.status || "", null)}
442 </span>
443 <div style="flex: 1; min-width: 0">
444 <div style="font-weight: 500">
445 {s.name || `Step ${i + 1}`}
446 </div>
447 {s.run && (
448 <code
449 style="display: block; font-size: 11px; color: var(--text-muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap"
450 >
451 $ {s.run}
452 </code>
453 )}
454 </div>
455 {typeof s.durationMs === "number" && (
456 <span style="font-size: 11px; color: var(--text-muted)">
457 {s.durationMs < 1000
458 ? `${s.durationMs}ms`
459 : `${(s.durationMs / 1000).toFixed(1)}s`}
460 </span>
461 )}
462 {typeof s.exitCode === "number" && s.exitCode !== 0 && (
463 <span style="font-size: 11px; color: var(--red)">
464 exit {s.exitCode}
465 </span>
466 )}
467 </div>
468 ))}
469 </div>
470 )}
471 {j.logs && j.logs.length > 0 && (
472 <pre
473 style="margin: 0; padding: 12px 14px; background: #0b0d0f; color: #c7ccd1; font-size: 12px; line-height: 1.45; overflow-x: auto; max-height: 480px; border-top: 1px solid var(--border)"
474 >
475 {j.logs}
476 </pre>
477 )}
478 </details>
479 );
480 })}
481 </div>
482 )}
483 </Layout>
484 );
485});
486
487// ---------- Manual trigger ----------
488
489actions.post("/:owner/:repo/actions/:workflowId/run", requireAuth, async (c) => {
490 const user = c.get("user")!;
491 const { owner, repo, workflowId } = c.req.param();
492 const repoRow = await loadRepo(owner, repo);
493 if (!repoRow) return c.notFound();
494 if (repoRow.ownerId !== user.id) {
495 return c.redirect(`/${owner}/${repo}/actions`);
496 }
497
498 let workflowRow: typeof workflows.$inferSelect | null = null;
499 try {
500 const [w] = await db
501 .select()
502 .from(workflows)
503 .where(
504 and(
505 eq(workflows.id, workflowId),
506 eq(workflows.repositoryId, repoRow.id)
507 )
508 )
509 .limit(1);
510 workflowRow = w || null;
511 } catch (err) {
512 console.error("[actions] manual trigger lookup:", err);
513 }
514 if (!workflowRow) return c.notFound();
515 if (workflowRow.disabled) {
516 return c.redirect(`/${owner}/${repo}/actions`);
517 }
518
519 const ref = `refs/heads/${repoRow.defaultBranch || "main"}`;
520
521 const runId = await enqueueRun({
522 workflowId: workflowRow.id,
523 repositoryId: repoRow.id,
524 event: "manual",
525 ref,
526 commitSha: null,
527 triggeredBy: user.id,
528 });
529
530 await audit({
531 userId: user.id,
532 repositoryId: repoRow.id,
533 action: "workflow.manual_trigger",
534 targetType: "workflow",
535 targetId: workflowRow.id,
536 metadata: { runId },
537 });
538
539 if (runId) {
540 return c.redirect(`/${owner}/${repo}/actions/runs/${runId}`);
541 }
542 return c.redirect(`/${owner}/${repo}/actions`);
543});
544
545// ---------- Cancel a run ----------
546
547actions.post(
548 "/:owner/:repo/actions/runs/:runId/cancel",
549 requireAuth,
550 async (c) => {
551 const user = c.get("user")!;
552 const { owner, repo, runId } = c.req.param();
553 const repoRow = await loadRepo(owner, repo);
554 if (!repoRow) return c.notFound();
555 if (repoRow.ownerId !== user.id) {
556 return c.redirect(`/${owner}/${repo}/actions`);
557 }
558
559 try {
560 await db
561 .update(workflowRuns)
562 .set({
563 status: "cancelled",
564 conclusion: "cancelled",
565 finishedAt: new Date(),
566 })
567 .where(
568 and(
569 eq(workflowRuns.id, runId),
570 eq(workflowRuns.repositoryId, repoRow.id)
571 )
572 );
573 // Mark any queued/running jobs as cancelled for display. The worker
574 // will observe the parent run's status on its next check, but v1 runs
575 // a step to completion before checking.
576 await db
577 .update(workflowJobs)
578 .set({
579 status: "cancelled",
580 conclusion: "cancelled",
581 finishedAt: new Date(),
582 })
583 .where(eq(workflowJobs.runId, runId));
584 } catch (err) {
585 console.error("[actions] cancel:", err);
586 }
587
588 await audit({
589 userId: user.id,
590 repositoryId: repoRow.id,
591 action: "workflow.cancel",
592 targetType: "workflow_run",
593 targetId: runId,
594 });
595
596 return c.redirect(`/${owner}/${repo}/actions/runs/${runId}`);
597 }
598);
599
600export default actions;
Modifiedsrc/views/components.tsx+7−0View fileUnifiedSplit
@@ -66,6 +66,7 @@ export const RepoNav: FC<{
6666 | "issues"
6767 | "pulls"
6868 | "releases"
69 | "actions"
6970 | "gates"
7071 | "insights";
7172}> = ({ owner, repo, active }) => (
@@ -91,6 +92,12 @@ export const RepoNav: FC<{
9192 >
9293 Commits
9394 </a>
95 <a
96 href={`/${owner}/${repo}/actions`}
97 class={active === "actions" ? "active" : ""}
98 >
99 Actions
100 </a>
94101 <a
95102 href={`/${owner}/${repo}/releases`}
96103 class={active === "releases" ? "active" : ""}
97104