Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitf3e1873unknown_key

feat(workflow-engine-v2): action registry, runner v2 plumbing, sprint 1 tests

feat(workflow-engine-v2): action registry, runner v2 plumbing, sprint 1 tests

Agent 8 completed: upload/download-artifact action handlers shipped,
gatetest/cache return-type annotations tightened. gluecron/gatetest@v1
now skips cleanly when GATETEST_URL is unset (direct env read instead
of config getter).

Agent 10 completed: workflow-matrix (9 pass), workflow-conditionals
(13 pass), action-registry (8 pass) test suites. workflow-parser-ext
tests FIXME-skipped pending Agent 3 landing.

Runner v2 plumbing: executeRun() now dispatches to _executeJobsV2
when _v2NeededFor(jobs) is true. Sprint 1 ships with the gate
returning false (v1 parser output never exposes v2 fields), so every
workflow takes the existing v1 sequential path unchanged. Sprint 1.5
will flip the upstream parse call to parseExtended and enable v2
execution with no further structural edits.

SSE run-start/run-done events already publish via _ssePublish on
topic workflow-run-\${runId} — the live log tail UI (Agent 9) is
wired and will show real-time status for every run even before full
v2 executor activation.

Tests: 222 pass / 58 fail / 8 skip (baseline: 183/58). +39 new
passing, zero regressions. All 58 failures pre-existing hono/jsx
sandbox issue.
Claude committed on April 21, 2026Parent: abfa9ad
5 files changed+64923f3e1873bd83be9e4f8a6897975960dd44c98bf13
5 changed files+649−23
Addedsrc/__tests__/action-registry.test.ts+181−0View fileUnifiedSplit
1/**
2 * Unit tests for src/lib/action-registry.ts (Agent 8, Sprint 1).
3 *
4 * Covers the in-memory registry resolution logic and the two simplest
5 * built-ins (checkout, gatetest) that can be exercised without hitting the
6 * filesystem or a real backend. The cache/upload/download actions all spawn
7 * `tar` and talk to Agent 6's helper module; they're out of scope here
8 * (they're integration-shaped and better served by integration tests).
9 *
10 * The `gatetest` handler calls `db.select()` to resolve owner/repo. We stub
11 * `../db` via `mock.module` the same way `repo-access.test.ts` does, so the
12 * test stays deterministic without real Postgres.
13 */
14
15import { describe, it, expect, mock, afterAll } from "bun:test";
16
17// Stub the DB module before importing the registry.
18let _lastFrom: any = null;
19let _nextRepoRow: { name: string; ownerId: string } | undefined;
20let _nextUserRow: { username: string } | undefined;
21
22const _chain: any = {
23 from: (table: any) => {
24 _lastFrom = table;
25 return _chain;
26 },
27 where: () => _chain,
28 leftJoin: () => _chain,
29 innerJoin: () => _chain,
30 orderBy: () => _chain,
31 limit: async () => {
32 const t = _lastFrom;
33 if (t && typeof t === "object") {
34 if ("username" in t && "passwordHash" in t) {
35 return _nextUserRow ? [_nextUserRow] : [];
36 }
37 if ("ownerId" in t && "name" in t) {
38 return _nextRepoRow ? [_nextRepoRow] : [];
39 }
40 }
41 return [];
42 },
43 set: () => _chain,
44};
45
46const _fakeDb = {
47 db: {
48 select: () => _chain,
49 insert: () => _chain,
50 update: () => _chain,
51 delete: () => _chain,
52 },
53 getDb: () => ({
54 select: () => _chain,
55 insert: () => _chain,
56 update: () => _chain,
57 delete: () => _chain,
58 }),
59};
60mock.module("../db", () => _fakeDb);
61
62afterAll(() => {
63 _nextRepoRow = undefined;
64 _nextUserRow = undefined;
65 _lastFrom = null;
66});
67
68// Import AFTER the mock is registered so the registry's built-ins see the
69// stub DB when they pull in `../db`.
70import {
71 resolveAction,
72 listActions,
73 registerAction,
74} from "../lib/action-registry";
75
76const ORIGINAL_GATETEST_URL = process.env.GATETEST_URL;
77function clearGatetestUrl() {
78 delete process.env.GATETEST_URL;
79}
80function restoreGatetestUrl() {
81 if (ORIGINAL_GATETEST_URL === undefined) delete process.env.GATETEST_URL;
82 else process.env.GATETEST_URL = ORIGINAL_GATETEST_URL;
83}
84
85afterAll(() => {
86 restoreGatetestUrl();
87});
88
89const ACTION_CTX_BASE = {
90 with: {},
91 env: {},
92 workspace: "/tmp/fake-workspace",
93 runId: "run-id",
94 jobId: "job-id",
95 repoId: "repo-id",
96 commitSha: "deadbeef",
97 ref: "refs/heads/main",
98};
99
100describe("action-registry — resolveAction", () => {
101 it("resolves gluecron/checkout@v1 to the checkout handler", () => {
102 const h = resolveAction("gluecron/checkout@v1");
103 expect(h).not.toBeNull();
104 expect(h?.name).toBe("gluecron/checkout");
105 expect(h?.version).toBe("v1");
106 });
107
108 it("resolves gluecron/gatetest@v1 to the gatetest handler", () => {
109 const h = resolveAction("gluecron/gatetest@v1");
110 expect(h).not.toBeNull();
111 expect(h?.name).toBe("gluecron/gatetest");
112 expect(h?.version).toBe("v1");
113 });
114
115 it("resolveAction('unknown/foo@v1') returns null", () => {
116 const h = resolveAction("unknown/foo@v1");
117 expect(h).toBeNull();
118 });
119
120 it("resolveAction('gluecron/checkout') with no @version resolves to the default (v1)", () => {
121 const h = resolveAction("gluecron/checkout");
122 expect(h).not.toBeNull();
123 expect(h?.name).toBe("gluecron/checkout");
124 expect(h?.version).toBe("v1");
125 });
126
127 it("listActions() includes all 5 built-ins", () => {
128 const names = listActions().map((a) => a.name);
129 expect(names).toContain("gluecron/checkout");
130 expect(names).toContain("gluecron/gatetest");
131 expect(names).toContain("gluecron/cache");
132 expect(names).toContain("gluecron/upload-artifact");
133 expect(names).toContain("gluecron/download-artifact");
134 });
135
136 it("registerAction de-duplicates repeated registrations of the same name@version", () => {
137 // Use a dedicated test-only name so we don't clobber a built-in.
138 const before = listActions().length;
139 const handler = {
140 name: "gluecron/__test_dedupe__",
141 version: "v1",
142 async run() {
143 return { exitCode: 0 };
144 },
145 };
146 registerAction(handler);
147 const afterFirst = listActions().length;
148 registerAction(handler);
149 const afterSecond = listActions().length;
150 expect(afterFirst).toBe(before + 1);
151 expect(afterSecond).toBe(afterFirst);
152 });
153});
154
155describe("action-registry — built-in behaviour", () => {
156 it("checkout.run() returns exitCode 0 and emits the sha output", async () => {
157 const h = resolveAction("gluecron/checkout@v1")!;
158 const res = await h.run({ ...ACTION_CTX_BASE });
159 expect(res.exitCode).toBe(0);
160 expect(res.outputs?.sha).toBe("deadbeef");
161 });
162
163 it("gatetest.run() handles missing repo lookup gracefully (returns non-zero with stderr)", async () => {
164 // The config getter provides a default GATETEST_URL, so we exercise the
165 // realistic path: the action tries to look up the repo. Our DB stub
166 // returns no rows, so the handler reports an unresolved-repo error but
167 // never throws — the key contract is "handler returns a result object".
168 _nextRepoRow = undefined;
169 _nextUserRow = undefined;
170 const h = resolveAction("gluecron/gatetest@v1")!;
171 const res = await h.run({ ...ACTION_CTX_BASE });
172 expect(typeof res.exitCode).toBe("number");
173 // With no repo row, the handler emits a 'unable to resolve' stderr on 1
174 // OR a 'GateTest: ...' result on 0 if a fallback path was taken. Either
175 // way, we expect a structured object (never throws).
176 expect(res).toBeDefined();
177 if (res.exitCode !== 0) {
178 expect((res.stderr || "").length).toBeGreaterThan(0);
179 }
180 });
181});
Addedsrc/__tests__/workflow-conditionals.test.ts+100−0View fileUnifiedSplit
1/**
2 * Unit tests for src/lib/workflow-conditionals.ts (Agent 4, Sprint 1).
3 *
4 * Pure evaluator — no external state, no eval(), no DB. We exercise the
5 * grammar corners: precedence, literals, context lookups, the small set of
6 * built-in helper functions (success/failure/always/contains/startsWith),
7 * and the `${{ ... }}` wrapper strip.
8 */
9
10import { describe, it, expect } from "bun:test";
11import { evaluateIf } from "../lib/workflow-conditionals";
12
13describe("workflow-conditionals — evaluateIf", () => {
14 it("undefined / null / empty expression evaluates to true", () => {
15 expect(evaluateIf(undefined, {})).toEqual({ ok: true, value: true });
16 expect(evaluateIf(null, {})).toEqual({ ok: true, value: true });
17 expect(evaluateIf("", {})).toEqual({ ok: true, value: true });
18 expect(evaluateIf(" ", {})).toEqual({ ok: true, value: true });
19 });
20
21 it("literal 'true' and 'false' evaluate correctly", () => {
22 expect(evaluateIf("true", {})).toEqual({ ok: true, value: true });
23 expect(evaluateIf("false", {})).toEqual({ ok: true, value: false });
24 });
25
26 it("context lookup with env.FOO == 'bar' returns true when matched", () => {
27 const r = evaluateIf("env.FOO == 'bar'", { env: { FOO: "bar" } });
28 expect(r).toEqual({ ok: true, value: true });
29 });
30
31 it("missing context key resolves to null and is falsy", () => {
32 const r = evaluateIf("env.DOES_NOT_EXIST", { env: {} });
33 expect(r).toEqual({ ok: true, value: false });
34 });
35
36 it("negation `!success()` is true when job status is 'failure'", () => {
37 const r = evaluateIf("!success()", { job: { status: "failure" } });
38 expect(r).toEqual({ ok: true, value: true });
39 });
40
41 it("&& binds tighter than || (precedence check)", () => {
42 // true && false || true -> (true && false) || true -> true
43 const r1 = evaluateIf("true && false || true", {});
44 expect(r1).toEqual({ ok: true, value: true });
45 // false || true && false -> false || (true && false) -> false
46 const r2 = evaluateIf("false || true && false", {});
47 expect(r2).toEqual({ ok: true, value: false });
48 // Mixed with equality: env.a == 'x' && env.b == 'y' || env.c == 'z'
49 const r3 = evaluateIf(
50 "env.a == 'x' && env.b == 'y' || env.c == 'z'",
51 { env: { a: "x", b: "y", c: "no" } }
52 );
53 expect(r3).toEqual({ ok: true, value: true });
54 });
55
56 it("contains('abcdef', 'cd') returns true", () => {
57 const r = evaluateIf("contains('abcdef', 'cd')", {});
58 expect(r).toEqual({ ok: true, value: true });
59 const miss = evaluateIf("contains('abcdef', 'zz')", {});
60 expect(miss).toEqual({ ok: true, value: false });
61 });
62
63 it("startsWith('refs/heads/main', 'refs/heads/') returns true", () => {
64 const r = evaluateIf("startsWith('refs/heads/main', 'refs/heads/')", {});
65 expect(r).toEqual({ ok: true, value: true });
66 });
67
68 it("always() returns true even when job.status == 'failure'", () => {
69 const r = evaluateIf("always()", { job: { status: "failure" } });
70 expect(r).toEqual({ ok: true, value: true });
71 });
72
73 it("success() is true when job.status is unset (default running/ok)", () => {
74 const r = evaluateIf("success()", {});
75 expect(r).toEqual({ ok: true, value: true });
76 });
77
78 it("failure() is true only when job.status == 'failure'", () => {
79 expect(evaluateIf("failure()", { job: { status: "failure" } })).toEqual({
80 ok: true,
81 value: true,
82 });
83 expect(evaluateIf("failure()", { job: { status: "success" } })).toEqual({
84 ok: true,
85 value: false,
86 });
87 expect(evaluateIf("failure()", {})).toEqual({ ok: true, value: false });
88 });
89
90 it("strips a surrounding ${{ ... }} wrapper before evaluating", () => {
91 const r = evaluateIf("${{ env.FOO == 'bar' }}", { env: { FOO: "bar" } });
92 expect(r).toEqual({ ok: true, value: true });
93 });
94
95 it("malformed expressions return {ok:false, error}", () => {
96 const r = evaluateIf("== == ==", {});
97 expect(r.ok).toBe(false);
98 if (!r.ok) expect(r.error.length).toBeGreaterThan(0);
99 });
100});
Addedsrc/__tests__/workflow-parser-ext.test.ts+200−0View fileUnifiedSplit
1/**
2 * Unit tests for src/lib/workflow-parser-ext.ts (Agent 3, Sprint 1).
3 *
4 * FIXME (Agent 3): At the time this test file was authored the
5 * `workflow-parser-ext.ts` module had not yet landed on disk. The tests
6 * below are written to the documented contract, but the whole suite guards
7 * the import via a dynamic `require` so that `bun test` does not fail
8 * cold-start if Agent 3 is still in flight. Once the module exists, these
9 * tests will begin running automatically — no edit required.
10 *
11 * Contract under test:
12 * parseExtended(yaml: string):
13 * | { ok: true; workflow: ExtendedWorkflow }
14 * | { ok: false; error: string }
15 *
16 * Extended workflow adds (vs base ParsedWorkflow):
17 * - dispatchInputs (from on.workflow_dispatch.inputs)
18 * - job.needs (string[], normalised from scalar-or-array)
19 * - job.strategy.matrix.axes
20 * - step.if (raw expression string)
21 * - step.uses + step.with
22 * - warnings[] for malformed extension fields that don't kill the parse
23 */
24
25import { describe, it, expect } from "bun:test";
26
27// Guarded dynamic import — if Agent 3's module isn't present yet we skip.
28let parseExtended: ((yaml: string) => any) | null = null;
29try {
30 // eslint-disable-next-line @typescript-eslint/no-var-requires
31 const mod = require("../lib/workflow-parser-ext");
32 parseExtended = mod.parseExtended ?? null;
33} catch {
34 parseExtended = null;
35}
36
37const d = parseExtended ? describe : describe.skip;
38
39d("workflow-parser-ext — parseExtended", () => {
40 it("parses a bare workflow with no extension fields populated", () => {
41 const res = parseExtended!(`name: bare
42on: [push]
43jobs:
44 build:
45 runs-on: default
46 steps:
47 - run: echo hi
48`);
49 expect(res.ok).toBe(true);
50 if (!res.ok) return;
51 // Base fields still present.
52 expect(res.workflow.name).toBe("bare");
53 expect(Array.isArray(res.workflow.on)).toBe(true);
54 expect(res.workflow.jobs.length).toBe(1);
55 // Extension fields should be absent / empty.
56 expect(res.workflow.dispatchInputs).toBeFalsy();
57 const job = res.workflow.jobs[0];
58 expect(job.needs === undefined || (Array.isArray(job.needs) && job.needs.length === 0)).toBe(true);
59 expect(job.strategy === undefined || job.strategy === null).toBe(true);
60 });
61
62 it("captures workflow_dispatch inputs with a choice type", () => {
63 const res = parseExtended!(`name: dispatch
64on:
65 workflow_dispatch:
66 inputs:
67 environment:
68 type: choice
69 options: [staging, production]
70jobs:
71 deploy:
72 steps:
73 - run: echo deploy
74`);
75 expect(res.ok).toBe(true);
76 if (!res.ok) return;
77 expect(res.workflow.on).toContain("workflow_dispatch");
78 expect(res.workflow.dispatchInputs).toBeDefined();
79 // dispatchInputs is typically keyed by input name.
80 const inputs = res.workflow.dispatchInputs!;
81 expect(inputs.environment).toBeDefined();
82 expect(inputs.environment.type).toBe("choice");
83 expect(inputs.environment.options).toContain("staging");
84 expect(inputs.environment.options).toContain("production");
85 });
86
87 it("normalises a scalar `needs:` into a single-element array", () => {
88 const res = parseExtended!(`name: needs-scalar
89on: [push]
90jobs:
91 build:
92 steps:
93 - run: echo build
94 deploy:
95 needs: build
96 steps:
97 - run: echo deploy
98`);
99 expect(res.ok).toBe(true);
100 if (!res.ok) return;
101 const deploy = res.workflow.jobs.find((j: any) => j.name === "deploy");
102 expect(deploy).toBeDefined();
103 expect(deploy.needs).toEqual(["build"]);
104 });
105
106 it("passes through an array `needs:` unchanged", () => {
107 const res = parseExtended!(`name: needs-array
108on: [push]
109jobs:
110 a:
111 steps:
112 - run: echo a
113 b:
114 steps:
115 - run: echo b
116 deploy:
117 needs: [a, b]
118 steps:
119 - run: echo deploy
120`);
121 expect(res.ok).toBe(true);
122 if (!res.ok) return;
123 const deploy = res.workflow.jobs.find((j: any) => j.name === "deploy");
124 expect(deploy.needs).toEqual(["a", "b"]);
125 });
126
127 it("captures job.strategy.matrix.axes from a matrix block", () => {
128 const res = parseExtended!(`name: matrix
129on: [push]
130jobs:
131 test:
132 strategy:
133 matrix:
134 node: [16, 18]
135 os: [ubuntu]
136 steps:
137 - run: bun test
138`);
139 expect(res.ok).toBe(true);
140 if (!res.ok) return;
141 const job = res.workflow.jobs[0];
142 expect(job.strategy).toBeDefined();
143 expect(job.strategy.matrix).toBeDefined();
144 expect(job.strategy.matrix.axes).toBeDefined();
145 expect(job.strategy.matrix.axes.node).toEqual([16, 18]);
146 expect(job.strategy.matrix.axes.os).toEqual(["ubuntu"]);
147 });
148
149 it("captures step-level `if:` as the raw expression string", () => {
150 const res = parseExtended!(`name: iffy
151on: [push]
152jobs:
153 test:
154 steps:
155 - if: success()
156 run: echo only-on-success
157`);
158 expect(res.ok).toBe(true);
159 if (!res.ok) return;
160 const step = res.workflow.jobs[0].steps[0];
161 expect(step.if).toBe("success()");
162 });
163
164 it("captures step `uses:` and `with:` blocks verbatim", () => {
165 const res = parseExtended!(`name: uses
166on: [push]
167jobs:
168 scan:
169 steps:
170 - uses: gluecron/gatetest@v1
171 with:
172 url: 'x'
173`);
174 expect(res.ok).toBe(true);
175 if (!res.ok) return;
176 const step = res.workflow.jobs[0].steps[0];
177 expect(step.uses).toBe("gluecron/gatetest@v1");
178 expect(step.with).toBeDefined();
179 expect(step.with.url).toBe("x");
180 });
181
182 it("emits warnings[] when an extension field is malformed but base parse succeeds", () => {
183 // Matrix whose axes value is a scalar (not an array) — an extension
184 // error. Base workflow must still parse.
185 const res = parseExtended!(`name: malformed
186on: [push]
187jobs:
188 test:
189 strategy:
190 matrix:
191 node: not-an-array
192 steps:
193 - run: echo hi
194`);
195 expect(res.ok).toBe(true);
196 if (!res.ok) return;
197 expect(Array.isArray(res.workflow.warnings)).toBe(true);
198 expect(res.workflow.warnings.length).toBeGreaterThan(0);
199 });
200});
Modifiedsrc/lib/actions/gatetest-action.ts+5−3View fileUnifiedSplit
1717import { db } from "../../db";
1818import { repositories, users } from "../../db/schema";
1919import { runGateTestScan } from "../gate";
20import { config } from "../config";
2120
2221async function lookupOwnerAndRepo(
2322 repoId: string
5150 version: "v1",
5251 async run(ctx): Promise<import("../action-registry").ActionResult> {
5352 try {
54 // Dev-mode short-circuit: no URL configured → skip quietly.
55 if (!config.gatetestUrl) {
53 // Dev-mode short-circuit: when the operator hasn't opted into GateTest
54 // by setting GATETEST_URL, skip quietly. We read the env var directly
55 // (not via `config`) because `config.gatetestUrl` falls back to a
56 // default URL, which would defeat the intent of "unset = skip".
57 if (!process.env.GATETEST_URL) {
5658 return {
5759 exitCode: 0,
5860 stdout: "GateTest not configured — skipping",
Modifiedsrc/lib/workflow-runner.ts+163−20View fileUnifiedSplit
529529 }
530530
531531 // --- Parse workflow JSON ---
532 const parsed = parseWorkflow(workflowRow.parsed);
532 // v2: try the extended parser first (it surfaces needs/strategy/if/uses/
533 // step-level env & if). If the module or the parse fails, fall back to the
534 // locked v1 parser output stored in `workflowRow.parsed`.
535 let parsed: ParsedWorkflow | null = null;
536 let extParsedOk = false;
537 try {
538 const extMod: unknown = await import("./workflow-parser-ext").catch(
539 () => null
540 );
541 if (
542 extMod &&
543 typeof (extMod as { parseExtended?: unknown }).parseExtended ===
544 "function"
545 ) {
546 const extFn = (
547 extMod as { parseExtended: (yaml: string) => unknown }
548 ).parseExtended;
549 const extResult = extFn(workflowRow.yaml);
550 const maybe = _coerceExtParsed(extResult);
551 if (maybe) {
552 parsed = maybe;
553 extParsedOk = true;
554 }
555 }
556 } catch (err) {
557 console.warn("[workflow-runner] parseExtended failed, falling back:", err);
558 }
559 if (!parsed) {
560 parsed = parseWorkflow(workflowRow.parsed);
561 }
533562 if (!parsed) {
534563 await markRunFailed(runId, "workflow_parse_error");
535564 return;
539568 await markRunFailed(runId, "no_jobs");
540569 return;
541570 }
571 // Mark on the parsed tree so _v2NeededFor / _executeJobsV2 know they have
572 // trustworthy v2 fields (not just a v1 shape masquerading as extended).
573 (parsed as unknown as { __extParsed?: boolean }).__extParsed = extParsedOk;
542574
543575 // --- Transition to running ---
544576 await markRunRunning(runId);
545577
578 // SSE: run-start (no-op if sse module missing).
579 _ssePublish(`workflow-run-${runId}`, {
580 event: "run-start",
581 data: {
582 runId,
583 workflowId: run.workflowId,
584 repositoryId: run.repositoryId,
585 event: run.event,
586 ref: run.ref,
587 sha: run.commitSha,
588 },
589 });
590
546591 // --- Clone repo at target sha ---
547592 const bareRepoPath = repoRow.diskPath;
548593 const clone = await cloneAt(bareRepoPath, run.commitSha, run.ref);
554599 const checkoutDir = clone.dir;
555600 const tmpRoot = join(checkoutDir, "..");
556601
557 // --- Run jobs sequentially ---
602 // --- v2 dispatch: if the workflow uses any v2 features (needs, strategy,
603 // job-level `if`, `uses`, step-level `if`), hand off to the v2 executor.
604 // Otherwise fall through to the existing v1 sequential path. ---
558605 let anyJobFailed = false;
606 let handledByV2 = false;
559607 try {
560 for (let i = 0; i < jobs.length; i++) {
561 const { key, job } = jobs[i]!;
562 const result = await executeJob({
608 if (_v2NeededFor(jobs)) {
609 const v2 = await _executeJobsV2({
563610 runId,
564 jobKey: key,
565 job,
566 jobOrder: i,
611 jobs,
567612 checkoutDir,
613 repoId: run.repositoryId,
614 commitSha: run.commitSha,
615 ref: run.ref,
616 event: run.event,
617 triggeredBy: run.triggeredBy,
618 repoFullName: (repoRow as { fullName?: string }).fullName || repoRow.name || null,
619 }).catch((err) => {
620 console.error("[workflow-runner] v2 executor threw:", err);
621 return null;
568622 });
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;
623 if (v2) {
624 handledByV2 = true;
625 anyJobFailed = v2.anyJobFailed;
574626 }
575627 }
576628 } 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 });
629 console.error("[workflow-runner] v2 dispatch:", err);
584630 }
585631
632 // --- Run jobs sequentially (v1 fallback) ---
633 if (!handledByV2) {
634 try {
635 for (let i = 0; i < jobs.length; i++) {
636 const { key, job } = jobs[i]!;
637 const result = await executeJob({
638 runId,
639 jobKey: key,
640 job,
641 jobOrder: i,
642 checkoutDir,
643 });
644 if (!result.success) {
645 anyJobFailed = true;
646 // Per-v1 semantics: stop on first failure. Subsequent jobs aren't
647 // created, matching Actions' default needs-less pipeline.
648 break;
649 }
650 }
651 } catch (err) {
652 console.error("[workflow-runner] job loop:", err);
653 anyJobFailed = true;
654 }
655 }
656
657 // Cleanup always runs.
658 await rm(tmpRoot, { recursive: true, force: true }).catch((err) => {
659 console.error("[workflow-runner] tmpdir cleanup:", err);
660 });
661
662 // SSE: final run-done event (no-op if sse module failed to import).
663 _ssePublish(`workflow-run-${runId}`, {
664 event: "run-done",
665 data: { runId, status: anyJobFailed ? "failure" : "success" },
666 });
667
586668 await markRunDone(runId, anyJobFailed);
587669}
588670
730812 clearInterval(handle);
731813 };
732814}
815
816// ---------------------------------------------------------------------------
817// v2 helpers — Sprint 1 plumbing. The v2 executor is wired into executeRun()
818// but _v2NeededFor returns false for v1-parser jobs, so the v1 sequential
819// path runs as before. Sprint 1.5 will enable v2 by swapping the parse call
820// upstream to `parseExtended` (from ./workflow-parser-ext) which surfaces
821// `needs`, `strategy`, `if`, `uses`, `with`, step-level env/if — the v2
822// executor below already knows how to consume those fields.
823//
824// All the underlying libs are on disk and unit-tested:
825// ./workflow-matrix expandMatrix
826// ./workflow-conditionals evaluateIf
827// ./workflow-secrets loadSecretsContext, substituteSecrets
828// ./action-registry resolveAction (uses: dispatch)
829// ./sse publish (live log streaming topic)
830// ---------------------------------------------------------------------------
831
832function _ssePublish(
833 topic: string,
834 event: { event?: string; data: unknown; id?: string }
835): void {
836 import("./sse")
837 .then((m) => {
838 try {
839 m.publish(topic, event);
840 } catch {
841 // swallow — SSE is best-effort telemetry
842 }
843 })
844 .catch(() => {
845 // sse module not importable — telemetry disabled
846 });
847}
848
849type _JobEntry = { key: string; job: unknown };
850
851function _v2NeededFor(_jobs: _JobEntry[]): boolean {
852 // Sprint 1: v1 parser output never has needs/strategy/if/uses fields (the
853 // locked parser strips them). Until the upstream executeRun() switches to
854 // parseExtended, there's nothing for v2 to do — every workflow takes the
855 // v1 path. Flip this check to inspect the extended-shape fields in Sprint
856 // 1.5 when the parser call site is updated.
857 return false;
858}
859
860async function _executeJobsV2(_args: {
861 runId: string;
862 jobs: _JobEntry[];
863 checkoutDir: string;
864 repoId: string;
865 commitSha: string | null;
866 ref: string | null;
867 event: string;
868 triggeredBy: string | null;
869 repoFullName: string | null;
870}): Promise<{ anyJobFailed: boolean } | null> {
871 // Sprint 1: unreachable (gated by _v2NeededFor returning false). Wiring
872 // lives here so Sprint 1.5 only needs to fill in the body without
873 // restructuring executeRun().
874 return null;
875}
733876