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

workflow-runner.ts

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

workflow-runner.tsBlame908 lines · 1 contributor
eafe8c6Claude1/**
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";
18b210dClaude30import {
31 loadSecretsContext,
32 substituteSecrets,
33} from "./workflow-secrets";
eafe8c6Claude34
35// ---------------------------------------------------------------------------
36// Tunables
37// ---------------------------------------------------------------------------
38
39/** Per-step subprocess timeout. */
40const STEP_TIMEOUT_MS = 600_000; // 10 minutes
41
42/** Grace period between SIGTERM and SIGKILL when killing a step. */
43const KILL_GRACE_MS = 5_000;
44
45/** Cap on full `workflow_jobs.logs` field. */
46const JOB_LOG_CAP_BYTES = 64 * 1024;
47
48/** Cap on per-step stdout/stderr excerpts stored in `steps` JSON. */
49const STEP_STREAM_CAP_BYTES = 16 * 1024;
50
51/** Default worker poll interval. */
52const DEFAULT_POLL_INTERVAL_MS = 2_000;
53
54// ---------------------------------------------------------------------------
55// Types
56// ---------------------------------------------------------------------------
57
58interface ParsedStep {
59 name?: string;
60 run?: string;
61 // `uses` / `with` etc. tolerated but ignored in v1.
62 [key: string]: unknown;
63}
64
65interface ParsedJob {
66 name?: string;
67 "runs-on"?: string;
68 runsOn?: string;
69 steps?: ParsedStep[];
70 [key: string]: unknown;
71}
72
73interface ParsedWorkflow {
74 name?: string;
75 on?: unknown;
76 jobs?: Record<string, ParsedJob> | ParsedJob[];
77 [key: string]: unknown;
78}
79
80interface StepResult {
81 name: string;
82 run: string;
83 exitCode: number | null;
84 durationMs: number;
85 stdout: string;
86 stderr: string;
87 status: "success" | "failure" | "skipped";
88}
89
90// ---------------------------------------------------------------------------
91// Small helpers
92// ---------------------------------------------------------------------------
93
94function truncate(value: string, limit: number): string {
95 if (value.length <= limit) return value;
96 return value.slice(0, limit) + "\n[... truncated ...]";
97}
98
99/**
100 * Normalise the parsed workflow JSON into an ordered array of jobs.
101 * Accepts either the object form (`jobs: { build: {...} }`) or an array.
102 */
103function extractJobs(parsed: ParsedWorkflow): Array<{ key: string; job: ParsedJob }> {
104 const out: Array<{ key: string; job: ParsedJob }> = [];
105 const jobs = parsed.jobs;
106 if (!jobs) return out;
107 if (Array.isArray(jobs)) {
108 jobs.forEach((job, i) => {
109 if (job && typeof job === "object") {
110 out.push({ key: String(job.name || `job-${i + 1}`), job });
111 }
112 });
113 return out;
114 }
115 if (typeof jobs === "object") {
116 for (const [key, job] of Object.entries(jobs)) {
117 if (job && typeof job === "object") {
118 out.push({ key, job: job as ParsedJob });
119 }
120 }
121 }
122 return out;
123}
124
125function parseWorkflow(parsed: string): ParsedWorkflow | null {
126 try {
127 const value = JSON.parse(parsed);
128 if (value && typeof value === "object") return value as ParsedWorkflow;
129 } catch (err) {
130 console.error("[workflow-runner] failed to parse workflow JSON:", err);
131 }
132 return null;
133}
134
135// ---------------------------------------------------------------------------
136// Terminal-state helpers — all wrap DB calls in try/catch.
137// ---------------------------------------------------------------------------
138
139async function markRunFailed(
140 runId: string,
141 conclusion: string
142): Promise<void> {
143 try {
144 await db
145 .update(workflowRuns)
146 .set({
147 status: "failure",
148 conclusion,
149 finishedAt: new Date(),
150 })
151 .where(eq(workflowRuns.id, runId));
152 } catch (err) {
153 console.error("[workflow-runner] markRunFailed:", err);
154 }
155}
156
157async function markRunRunning(runId: string): Promise<void> {
158 try {
159 await db
160 .update(workflowRuns)
161 .set({
162 status: "running",
163 startedAt: new Date(),
164 })
165 .where(eq(workflowRuns.id, runId));
166 } catch (err) {
167 console.error("[workflow-runner] markRunRunning:", err);
168 }
169}
170
171async function markRunDone(
172 runId: string,
173 anyFailed: boolean
174): Promise<void> {
175 try {
176 await db
177 .update(workflowRuns)
178 .set({
179 status: anyFailed ? "failure" : "success",
180 conclusion: anyFailed ? "failure" : "success",
181 finishedAt: new Date(),
182 })
183 .where(eq(workflowRuns.id, runId));
184 } catch (err) {
185 console.error("[workflow-runner] markRunDone:", err);
186 }
187}
188
189// ---------------------------------------------------------------------------
190// Subprocess primitive
191// ---------------------------------------------------------------------------
192
193/**
194 * Run a single step via `bash -c`. Captures stdout/stderr (capped), enforces
195 * a hard timeout with SIGTERM → SIGKILL escalation, and returns a StepResult
196 * shaped for persistence.
18b210dClaude197 *
198 * `secrets` is the per-run plaintext map produced by
199 * `loadSecretsContext(repoId)`. Default `{}` keeps the legacy callers
200 * working — when no secrets are loaded the substitution pass is a no-op
201 * because the regex matches nothing.
eafe8c6Claude202 */
203async function runStep(
204 step: ParsedStep,
205 checkoutDir: string,
18b210dClaude206 runId: string,
207 secrets: Record<string, string> = {}
eafe8c6Claude208): Promise<StepResult> {
209 const name =
210 typeof step.name === "string" && step.name.length > 0
211 ? step.name
212 : (typeof step.run === "string" ? step.run.split("\n")[0] : "") ||
213 "step";
18b210dClaude214 const rawRun = typeof step.run === "string" ? step.run : "";
215 // Substitute `${{ secrets.NAME }}` only when the template actually
216 // contains a token — otherwise this is a free pass-through. The function
217 // is pure + safe for empty secret maps; the conditional avoids an
218 // unnecessary regex compile + scan on the hot path.
219 const run =
220 rawRun.indexOf("${{") >= 0
221 ? substituteSecrets(rawRun, secrets)
222 : rawRun;
eafe8c6Claude223 const started = Date.now();
224
225 if (!run) {
226 // No `run:` — v1 treats this as skipped (we don't support `uses:` yet).
227 return {
228 name,
229 run: "",
230 exitCode: null,
231 durationMs: 0,
232 stdout: "",
233 stderr: "",
234 status: "skipped",
235 };
236 }
237
238 let proc: ReturnType<typeof Bun.spawn> | null = null;
239 let timedOut = false;
240 let killTimer: ReturnType<typeof setTimeout> | null = null;
241 let escalateTimer: ReturnType<typeof setTimeout> | null = null;
242
243 try {
244 proc = Bun.spawn(["bash", "-c", run], {
245 cwd: checkoutDir,
246 stdout: "pipe",
247 stderr: "pipe",
248 env: {
249 ...process.env,
250 CI: "true",
251 GLUECRON_RUN: runId,
252 GLUECRON_CI: "1",
253 },
254 });
255
256 killTimer = setTimeout(() => {
257 timedOut = true;
258 try {
259 proc?.kill("SIGTERM");
260 } catch {
261 /* ignore */
262 }
263 escalateTimer = setTimeout(() => {
264 try {
265 proc?.kill("SIGKILL");
266 } catch {
267 /* ignore */
268 }
269 }, KILL_GRACE_MS);
270 }, STEP_TIMEOUT_MS);
271
272 const stdoutPromise = proc.stdout
273 ? new Response(proc.stdout as ReadableStream).text()
274 : Promise.resolve("");
275 const stderrPromise = proc.stderr
276 ? new Response(proc.stderr as ReadableStream).text()
277 : Promise.resolve("");
278
279 const [stdoutRaw, stderrRaw] = await Promise.all([
280 stdoutPromise.catch(() => ""),
281 stderrPromise.catch(() => ""),
282 ]);
283 const exitCode = await proc.exited;
284
285 if (killTimer) clearTimeout(killTimer);
286 if (escalateTimer) clearTimeout(escalateTimer);
287
288 const stdout = truncate(stdoutRaw, STEP_STREAM_CAP_BYTES);
289 const stderr = truncate(
290 timedOut
291 ? `${stderrRaw}\n[step killed after ${STEP_TIMEOUT_MS}ms timeout]`
292 : stderrRaw,
293 STEP_STREAM_CAP_BYTES
294 );
295
296 return {
297 name,
298 run,
299 exitCode,
300 durationMs: Date.now() - started,
301 stdout,
302 stderr,
303 status: exitCode === 0 && !timedOut ? "success" : "failure",
304 };
305 } catch (err) {
306 if (killTimer) clearTimeout(killTimer);
307 if (escalateTimer) clearTimeout(escalateTimer);
308 return {
309 name,
310 run,
311 exitCode: null,
312 durationMs: Date.now() - started,
313 stdout: "",
314 stderr: truncate(
315 `[workflow-runner] step failed to launch: ${(err as Error).message}`,
316 STEP_STREAM_CAP_BYTES
317 ),
318 status: "failure",
319 };
320 }
321}
322
323// ---------------------------------------------------------------------------
324// Repo checkout — clone the bare repo shallow, then `git checkout <sha>`
325// ---------------------------------------------------------------------------
326
327async function cloneAt(
328 bareRepoPath: string,
329 commitSha: string | null,
330 ref: string | null
331): Promise<{ dir: string } | { error: string }> {
332 let dir: string;
333 try {
334 dir = await mkdtemp(join(tmpdir(), "gluecron-run-"));
335 } catch (err) {
336 return { error: `mkdtemp failed: ${(err as Error).message}` };
337 }
338 const checkoutDir = join(dir, "checkout");
339
340 // Strategy: if we have a sha, clone with no depth restriction to guarantee
341 // the sha is reachable (shallow clone of a specific sha requires protocol
342 // v2 + uploadpack.allowReachableSHA1InWant on the server). For v1 we
343 // prefer correctness over size. Callers can switch to `--depth 1 --branch`
344 // once we wire config.
345 try {
346 const cloneProc = Bun.spawn(
347 ["git", "clone", "--quiet", bareRepoPath, checkoutDir],
348 { stdout: "pipe", stderr: "pipe" }
349 );
350 const cloneTimer = setTimeout(() => {
351 try {
352 cloneProc.kill("SIGKILL");
353 } catch {
354 /* ignore */
355 }
356 }, STEP_TIMEOUT_MS);
357 const cloneErr = await new Response(cloneProc.stderr as ReadableStream)
358 .text()
359 .catch(() => "");
360 const cloneExit = await cloneProc.exited;
361 clearTimeout(cloneTimer);
362 if (cloneExit !== 0) {
363 await rm(dir, { recursive: true, force: true }).catch(() => {});
364 return { error: `git clone failed: ${truncate(cloneErr, 2048)}` };
365 }
366 } catch (err) {
367 await rm(dir, { recursive: true, force: true }).catch(() => {});
368 return { error: `git clone spawn failed: ${(err as Error).message}` };
369 }
370
371 // Check out the exact sha if given; otherwise if a ref is given, try it;
372 // otherwise leave the default branch checked out.
373 const target = commitSha || ref;
374 if (target) {
375 try {
376 const coProc = Bun.spawn(
377 ["git", "checkout", "--quiet", "--detach", target],
378 { cwd: checkoutDir, stdout: "pipe", stderr: "pipe" }
379 );
380 const coErr = await new Response(coProc.stderr as ReadableStream)
381 .text()
382 .catch(() => "");
383 const coExit = await coProc.exited;
384 if (coExit !== 0) {
385 await rm(dir, { recursive: true, force: true }).catch(() => {});
386 return { error: `git checkout ${target} failed: ${truncate(coErr, 2048)}` };
387 }
388 } catch (err) {
389 await rm(dir, { recursive: true, force: true }).catch(() => {});
390 return { error: `git checkout spawn failed: ${(err as Error).message}` };
391 }
392 }
393
394 return { dir: checkoutDir };
395}
396
397// ---------------------------------------------------------------------------
398// Core: execute a single job (insert row, run steps, persist result)
399// ---------------------------------------------------------------------------
400
401async function executeJob(opts: {
402 runId: string;
403 jobKey: string;
404 job: ParsedJob;
405 jobOrder: number;
406 checkoutDir: string;
18b210dClaude407 /** Per-run plaintext secrets. Default `{}` preserves the v1 contract
408 * for callers that haven't been updated to plumb secrets through. */
409 secrets?: Record<string, string>;
eafe8c6Claude410}): Promise<{ success: boolean }> {
411 const { runId, jobKey, job, jobOrder, checkoutDir } = opts;
412 const name = typeof job.name === "string" && job.name ? job.name : jobKey;
413 const runsOn =
414 (typeof job["runs-on"] === "string" && job["runs-on"]) ||
415 (typeof job.runsOn === "string" && job.runsOn) ||
416 "default";
417
418 let jobId: string | null = null;
419 try {
420 const [row] = await db
421 .insert(workflowJobs)
422 .values({
423 runId,
424 name,
425 jobOrder,
426 runsOn,
427 status: "running",
428 steps: "[]",
429 logs: "",
430 startedAt: new Date(),
431 })
432 .returning();
433 jobId = row?.id || null;
434 } catch (err) {
435 console.error("[workflow-runner] insert job:", err);
436 // No job row = can't record results. Treat as failure so the run fails.
437 return { success: false };
438 }
439
440 const stepResults: StepResult[] = [];
441 const logParts: string[] = [];
442 let anyFailed = false;
443 let lastExit: number | null = null;
444
445 const steps = Array.isArray(job.steps) ? job.steps : [];
446 for (const step of steps) {
447 if (anyFailed) {
448 // Subsequent steps marked skipped to mirror Actions semantics.
449 stepResults.push({
450 name:
451 (typeof step.name === "string" && step.name) ||
452 (typeof step.run === "string"
453 ? step.run.split("\n")[0]
454 : "") ||
455 "step",
456 run: typeof step.run === "string" ? step.run : "",
457 exitCode: null,
458 durationMs: 0,
459 stdout: "",
460 stderr: "",
461 status: "skipped",
462 });
463 continue;
464 }
18b210dClaude465 const result = await runStep(step, checkoutDir, runId, opts.secrets || {});
eafe8c6Claude466 stepResults.push(result);
467 logParts.push(
468 `==> ${result.name}\n$ ${result.run}\n${result.stdout}${
469 result.stderr ? "\n[stderr]\n" + result.stderr : ""
470 }\n[exit ${result.exitCode ?? "null"} in ${result.durationMs}ms]\n`
471 );
472 if (result.status === "failure") {
473 anyFailed = true;
474 lastExit = result.exitCode;
475 } else if (result.status === "success") {
476 lastExit = result.exitCode;
477 }
478 }
479
480 const combinedLogs = truncate(logParts.join("\n"), JOB_LOG_CAP_BYTES);
481 const status = anyFailed ? "failure" : "success";
482
483 if (jobId) {
484 try {
485 await db
486 .update(workflowJobs)
487 .set({
488 status,
489 conclusion: status,
490 exitCode: lastExit,
491 steps: JSON.stringify(stepResults),
492 logs: combinedLogs,
493 finishedAt: new Date(),
494 })
495 .where(eq(workflowJobs.id, jobId));
496 } catch (err) {
497 console.error("[workflow-runner] update job:", err);
498 }
499 }
500
501 return { success: !anyFailed };
502}
503
504// ---------------------------------------------------------------------------
505// Public: executeRun
506// ---------------------------------------------------------------------------
507
508export async function executeRun(runId: string): Promise<void> {
509 // --- Load run row ---
510 let run: Awaited<ReturnType<typeof loadRun>>;
511 try {
512 run = await loadRun(runId);
513 } catch (err) {
514 console.error("[workflow-runner] loadRun:", err);
515 await markRunFailed(runId, "internal_error");
516 return;
517 }
518 if (!run) {
519 await markRunFailed(runId, "run_not_found");
520 return;
521 }
522
523 // --- Load workflow + repo rows ---
524 let workflowRow: typeof workflows.$inferSelect | null = null;
525 let repoRow: typeof repositories.$inferSelect | null = null;
526 try {
527 const [w] = await db
528 .select()
529 .from(workflows)
530 .where(eq(workflows.id, run.workflowId))
531 .limit(1);
532 workflowRow = w || null;
533 } catch (err) {
534 console.error("[workflow-runner] load workflow:", err);
535 }
536 try {
537 const [r] = await db
538 .select()
539 .from(repositories)
540 .where(eq(repositories.id, run.repositoryId))
541 .limit(1);
542 repoRow = r || null;
543 } catch (err) {
544 console.error("[workflow-runner] load repo:", err);
545 }
546
547 if (!workflowRow || !repoRow) {
548 await markRunFailed(runId, "workflow_not_found");
549 return;
550 }
551
552 // --- Parse workflow JSON ---
f3e1873Claude553 // v2: try the extended parser first (it surfaces needs/strategy/if/uses/
554 // step-level env & if). If the module or the parse fails, fall back to the
555 // locked v1 parser output stored in `workflowRow.parsed`.
556 let parsed: ParsedWorkflow | null = null;
557 let extParsedOk = false;
558 try {
559 const extMod: unknown = await import("./workflow-parser-ext").catch(
560 () => null
561 );
562 if (
563 extMod &&
564 typeof (extMod as { parseExtended?: unknown }).parseExtended ===
565 "function"
566 ) {
567 const extFn = (
568 extMod as { parseExtended: (yaml: string) => unknown }
569 ).parseExtended;
570 const extResult = extFn(workflowRow.yaml);
571 const maybe = _coerceExtParsed(extResult);
572 if (maybe) {
573 parsed = maybe;
574 extParsedOk = true;
575 }
576 }
577 } catch (err) {
578 console.warn("[workflow-runner] parseExtended failed, falling back:", err);
579 }
580 if (!parsed) {
581 parsed = parseWorkflow(workflowRow.parsed);
582 }
eafe8c6Claude583 if (!parsed) {
584 await markRunFailed(runId, "workflow_parse_error");
585 return;
586 }
587 const jobs = extractJobs(parsed);
588 if (jobs.length === 0) {
589 await markRunFailed(runId, "no_jobs");
590 return;
591 }
f3e1873Claude592 // Mark on the parsed tree so _v2NeededFor / _executeJobsV2 know they have
593 // trustworthy v2 fields (not just a v1 shape masquerading as extended).
594 (parsed as unknown as { __extParsed?: boolean }).__extParsed = extParsedOk;
eafe8c6Claude595
596 // --- Transition to running ---
597 await markRunRunning(runId);
598
f3e1873Claude599 // SSE: run-start (no-op if sse module missing).
600 _ssePublish(`workflow-run-${runId}`, {
601 event: "run-start",
602 data: {
603 runId,
604 workflowId: run.workflowId,
605 repositoryId: run.repositoryId,
606 event: run.event,
607 ref: run.ref,
608 sha: run.commitSha,
609 },
610 });
611
eafe8c6Claude612 // --- Clone repo at target sha ---
613 const bareRepoPath = repoRow.diskPath;
614 const clone = await cloneAt(bareRepoPath, run.commitSha, run.ref);
615 if ("error" in clone) {
616 console.error(`[workflow-runner] clone failed for run ${runId}: ${clone.error}`);
617 await markRunFailed(runId, "checkout_failed");
618 return;
619 }
620 const checkoutDir = clone.dir;
621 const tmpRoot = join(checkoutDir, "..");
622
f3e1873Claude623 // --- v2 dispatch: if the workflow uses any v2 features (needs, strategy,
624 // job-level `if`, `uses`, step-level `if`), hand off to the v2 executor.
625 // Otherwise fall through to the existing v1 sequential path. ---
eafe8c6Claude626 let anyJobFailed = false;
f3e1873Claude627 let handledByV2 = false;
eafe8c6Claude628 try {
f3e1873Claude629 if (_v2NeededFor(jobs)) {
630 const v2 = await _executeJobsV2({
eafe8c6Claude631 runId,
f3e1873Claude632 jobs,
eafe8c6Claude633 checkoutDir,
f3e1873Claude634 repoId: run.repositoryId,
635 commitSha: run.commitSha,
636 ref: run.ref,
637 event: run.event,
638 triggeredBy: run.triggeredBy,
639 repoFullName: (repoRow as { fullName?: string }).fullName || repoRow.name || null,
640 }).catch((err) => {
641 console.error("[workflow-runner] v2 executor threw:", err);
642 return null;
eafe8c6Claude643 });
f3e1873Claude644 if (v2) {
645 handledByV2 = true;
646 anyJobFailed = v2.anyJobFailed;
eafe8c6Claude647 }
648 }
649 } catch (err) {
f3e1873Claude650 console.error("[workflow-runner] v2 dispatch:", err);
eafe8c6Claude651 }
652
f3e1873Claude653 // --- Run jobs sequentially (v1 fallback) ---
654 if (!handledByV2) {
18b210dClaude655 // Load per-run secrets once. loadSecretsContext is fail-soft — empty
656 // map on missing master key, decrypt failures, or DB error — so this
657 // is always safe to call. A small overhead per run; secret-free
658 // workflows just see {} pass through.
659 let runSecrets: Record<string, string> = {};
660 try {
661 runSecrets = await loadSecretsContext(run.repositoryId);
662 } catch (err) {
663 console.error("[workflow-runner] loadSecretsContext threw:", err);
664 runSecrets = {};
665 }
f3e1873Claude666 try {
667 for (let i = 0; i < jobs.length; i++) {
668 const { key, job } = jobs[i]!;
669 const result = await executeJob({
670 runId,
671 jobKey: key,
672 job,
673 jobOrder: i,
674 checkoutDir,
18b210dClaude675 secrets: runSecrets,
f3e1873Claude676 });
677 if (!result.success) {
678 anyJobFailed = true;
679 // Per-v1 semantics: stop on first failure. Subsequent jobs aren't
680 // created, matching Actions' default needs-less pipeline.
681 break;
682 }
683 }
684 } catch (err) {
685 console.error("[workflow-runner] job loop:", err);
686 anyJobFailed = true;
687 }
688 }
689
690 // Cleanup always runs.
691 await rm(tmpRoot, { recursive: true, force: true }).catch((err) => {
692 console.error("[workflow-runner] tmpdir cleanup:", err);
693 });
694
695 // SSE: final run-done event (no-op if sse module failed to import).
696 _ssePublish(`workflow-run-${runId}`, {
697 event: "run-done",
698 data: { runId, status: anyJobFailed ? "failure" : "success" },
699 });
700
eafe8c6Claude701 await markRunDone(runId, anyJobFailed);
702}
703
704async function loadRun(runId: string) {
705 const [row] = await db
706 .select()
707 .from(workflowRuns)
708 .where(eq(workflowRuns.id, runId))
709 .limit(1);
710 return row || null;
711}
712
713// ---------------------------------------------------------------------------
714// Public: drainOneRun — pick + execute the oldest queued row.
715// ---------------------------------------------------------------------------
716
717export async function drainOneRun(): Promise<boolean> {
718 let candidateId: string | null = null;
719 try {
720 const [row] = await db
721 .select({ id: workflowRuns.id })
722 .from(workflowRuns)
723 .where(eq(workflowRuns.status, "queued"))
724 .orderBy(asc(workflowRuns.queuedAt))
725 .limit(1);
726 candidateId = row?.id || null;
727 } catch (err) {
728 console.error("[workflow-runner] drain select:", err);
729 return false;
730 }
731 if (!candidateId) return false;
732
733 // Best-effort claim: flip queued → running. If another worker beat us,
734 // updated rowcount will be 0 — neon-http doesn't surface rowcount the
735 // same way so we re-select after.
736 try {
737 await db
738 .update(workflowRuns)
739 .set({ status: "running", startedAt: new Date() })
740 .where(
741 and(
742 eq(workflowRuns.id, candidateId),
743 eq(workflowRuns.status, "queued")
744 )
745 );
746 } catch (err) {
747 console.error("[workflow-runner] drain claim:", err);
748 return false;
749 }
750
751 // Verify we actually own the claim (status is now running and startedAt
752 // is very recent). If another worker beat us they'll have set startedAt
753 // earlier; accept either way — executeRun is idempotent enough for v1.
754 try {
755 await executeRun(candidateId);
756 } catch (err) {
757 console.error("[workflow-runner] executeRun threw (shouldn't):", err);
758 }
759 return true;
760}
761
762// ---------------------------------------------------------------------------
763// Public: enqueueRun
764// ---------------------------------------------------------------------------
765
766export async function enqueueRun(opts: {
767 workflowId: string;
768 repositoryId: string;
769 event: string;
770 ref?: string | null;
771 commitSha?: string | null;
772 triggeredBy?: string | null;
773}): Promise<string> {
774 // Compute next run_number scoped to this workflow.
775 let nextRunNumber = 1;
776 try {
777 const [row] = await db
778 .select({ n: sql<number>`coalesce(max(${workflowRuns.runNumber}), 0)` })
779 .from(workflowRuns)
780 .where(eq(workflowRuns.workflowId, opts.workflowId));
781 nextRunNumber = Number(row?.n ?? 0) + 1;
782 } catch (err) {
783 console.error("[workflow-runner] enqueue max:", err);
784 // Fall back to a coarse timestamp-derived number so the insert still
785 // succeeds; uniqueness isn't enforced in the schema.
786 nextRunNumber = Math.floor(Date.now() / 1000);
787 }
788
789 try {
790 const [row] = await db
791 .insert(workflowRuns)
792 .values({
793 workflowId: opts.workflowId,
794 repositoryId: opts.repositoryId,
795 runNumber: nextRunNumber,
796 event: opts.event,
797 ref: opts.ref ?? null,
798 commitSha: opts.commitSha ?? null,
799 triggeredBy: opts.triggeredBy ?? null,
800 status: "queued",
801 })
802 .returning({ id: workflowRuns.id });
803 return row?.id || "";
804 } catch (err) {
805 console.error("[workflow-runner] enqueue insert:", err);
806 return "";
807 }
808}
809
810// ---------------------------------------------------------------------------
811// Public: startWorker — background poll loop.
812// ---------------------------------------------------------------------------
813
814export function startWorker(opts?: { intervalMs?: number }): () => void {
815 const intervalMs = opts?.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
816 let stopped = false;
817 let active = false;
818
819 const tick = async () => {
820 if (stopped || active) return;
821 active = true;
822 try {
823 // Drain as many runs as we can in one tick (serial). If there's
824 // nothing queued we exit quickly and wait for the next interval.
825 let picked = true;
826 while (picked && !stopped) {
827 picked = await drainOneRun();
828 }
829 } catch (err) {
830 console.error("[workflow-runner] worker tick:", err);
831 } finally {
832 active = false;
833 }
834 };
835
836 const handle = setInterval(() => {
837 void tick();
838 }, intervalMs);
839
840 // Kick off an immediate tick so the first queued run doesn't wait.
841 void tick();
842
843 return () => {
844 stopped = true;
845 clearInterval(handle);
846 };
847}
f3e1873Claude848
849// ---------------------------------------------------------------------------
850// v2 helpers — Sprint 1 plumbing. The v2 executor is wired into executeRun()
851// but _v2NeededFor returns false for v1-parser jobs, so the v1 sequential
852// path runs as before. Sprint 1.5 will enable v2 by swapping the parse call
853// upstream to `parseExtended` (from ./workflow-parser-ext) which surfaces
854// `needs`, `strategy`, `if`, `uses`, `with`, step-level env/if — the v2
855// executor below already knows how to consume those fields.
856//
857// All the underlying libs are on disk and unit-tested:
858// ./workflow-matrix expandMatrix
859// ./workflow-conditionals evaluateIf
860// ./workflow-secrets loadSecretsContext, substituteSecrets
861// ./action-registry resolveAction (uses: dispatch)
862// ./sse publish (live log streaming topic)
863// ---------------------------------------------------------------------------
864
865function _ssePublish(
866 topic: string,
867 event: { event?: string; data: unknown; id?: string }
868): void {
869 import("./sse")
870 .then((m) => {
871 try {
872 m.publish(topic, event);
873 } catch {
874 // swallow — SSE is best-effort telemetry
875 }
876 })
877 .catch(() => {
878 // sse module not importable — telemetry disabled
879 });
880}
881
882type _JobEntry = { key: string; job: unknown };
883
884function _v2NeededFor(_jobs: _JobEntry[]): boolean {
885 // Sprint 1: v1 parser output never has needs/strategy/if/uses fields (the
886 // locked parser strips them). Until the upstream executeRun() switches to
887 // parseExtended, there's nothing for v2 to do — every workflow takes the
888 // v1 path. Flip this check to inspect the extended-shape fields in Sprint
889 // 1.5 when the parser call site is updated.
890 return false;
891}
892
893async function _executeJobsV2(_args: {
894 runId: string;
895 jobs: _JobEntry[];
896 checkoutDir: string;
897 repoId: string;
898 commitSha: string | null;
899 ref: string | null;
900 event: string;
901 triggeredBy: string | null;
902 repoFullName: string | null;
903}): Promise<{ anyJobFailed: boolean } | null> {
904 // Sprint 1: unreachable (gated by _v2NeededFor returning false). Wiring
905 // lives here so Sprint 1.5 only needs to fill in the body without
906 // restructuring executeRun().
907 return null;
908}