From e9e41ee196d8d0050118f105ef99f862eaecfa5d Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Thu, 22 Oct 2026 16:33:37 +0800 Subject: [PATCH 1/5] feat(dispatch): skip webhook runs a repo never opted in to A run declares optIn: ({ input }) => CONFIG_KV keys. The webhook receiver reads them before it creates a Workflow; with none present it dispatches nothing and lists the run in skipped with reason not-configured. check, offload-test and worker-deploy declare the command keys their bodies read. A dispatch carrying its own command needs no read, and a failed read dispatches as before. Spec: specs/adr/0007-cost-efficient-execution.md (Dispatch only work) --- apps/dispatcher/src/opt-in.ts | 41 ++++++ apps/dispatcher/src/routes/webhook.test.ts | 151 ++++++++++++++++++++- apps/dispatcher/src/routes/webhook.ts | 34 ++++- packages/core/src/define-run.ts | 16 +++ packages/core/src/index.ts | 1 + runs/check.test.ts | 10 ++ runs/check.ts | 6 + runs/offload-test.test.ts | 10 ++ runs/offload-test.ts | 8 ++ runs/worker-deploy.test.ts | 9 ++ runs/worker-deploy.ts | 5 + 11 files changed, 283 insertions(+), 8 deletions(-) create mode 100644 apps/dispatcher/src/opt-in.ts diff --git a/apps/dispatcher/src/opt-in.ts b/apps/dispatcher/src/opt-in.ts new file mode 100644 index 00000000..2fd347d9 --- /dev/null +++ b/apps/dispatcher/src/opt-in.ts @@ -0,0 +1,41 @@ +// FlareDispatch Dispatcher — receiver-side opt-in. +// +// A run may declare `optIn: ({ input }) => keys` (packages/core define-run.ts): +// the CONFIG_KV keys whose presence means the repo opted in. The webhook +// receiver reads them before it creates a Workflow, so a repo that never +// configured the run costs no Workflow, check-run, admission slot or container +// (specs/adr/0007-cost-efficient-execution.md § Dispatch only work). +// +// The read fails open. A KV error, or a deploy without CONFIG_KV, dispatches +// exactly as the receiver did before the check existed: the run body still +// resolves its own command and no-ops when there is none, so a failed read +// costs one wasted run, never a configured repo's verdict. + +/** The receiver's decision for one triggered run. */ +export type OptInVerdict = "dispatch" | "not-configured"; + +/** + * Decide whether to dispatch a run whose `optIn` named `keys`. + * + * `keys` empty (the inputs carry their own command) or no `configKv` → + * `dispatch` with no read. Otherwise any present key → `dispatch`; none → + * `not-configured`. A rejected read logs and answers `dispatch`. + */ +export const resolveOptIn = async (args: { + readonly configKv: KVNamespace | undefined; + readonly runName: string; + readonly keys: readonly string[]; +}): Promise => { + const { configKv, runName, keys } = args; + if (keys.length === 0 || configKv === undefined) return "dispatch"; + try { + const values = await Promise.all(keys.map((key) => configKv.get(key))); + return values.some((value) => value !== null) ? "dispatch" : "not-configured"; + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + console.error( + `[webhook] opt-in read failed run="${runName}" keys=${JSON.stringify(keys)}: ${message} — dispatching`, + ); + return "dispatch"; + } +}; diff --git a/apps/dispatcher/src/routes/webhook.test.ts b/apps/dispatcher/src/routes/webhook.test.ts index 762f2462..6c1f88cd 100644 --- a/apps/dispatcher/src/routes/webhook.test.ts +++ b/apps/dispatcher/src/routes/webhook.test.ts @@ -69,7 +69,12 @@ const webhookRequest = async ( }; const fixture = ( - opts: { withWebhookSecret?: boolean; withKv?: boolean; publicOrigin?: string } = {}, + opts: { + withWebhookSecret?: boolean; + withKv?: boolean; + publicOrigin?: string; + configKv?: KVNamespace; + } = {}, ) => { const workflow = makeFakeWorkflow(); const storage = makeFakeR2(); @@ -81,6 +86,7 @@ const fixture = ( idempotencyKv: idempotencyKv?.binding, githubWebhookSecret: opts.withWebhookSecret === false ? undefined : WEBHOOK_SECRET, ...(opts.publicOrigin !== undefined ? { publicOrigin: opts.publicOrigin } : {}), + ...(opts.configKv !== undefined ? { configKv: opts.configKv } : {}), }); return { workflow, storage, env, idempotencyKv }; }; @@ -566,3 +572,146 @@ describe("POST /v1/webhooks/github — pull request closed", () => { }); }); +describe("POST /v1/webhooks/github — receiver opt-in", () => { + /** A pull_request payload passing the check / offload-test gates. */ + const prPayload = { + action: "synchronize", + pull_request: { + number: 7, + draft: false, + labels: [], + user: { login: "octocat" }, + head: { sha: "aaaa111122223333" }, + base: { sha: "base000000000000" }, + }, + repository: { full_name: "owner/test-repo" }, + installation: { id: 99999 }, + }; + + /** A default-branch check_suite payload passing worker-deploy's gate. */ + const checkSuitePayload = { + action: "requested", + check_suite: { head_branch: "main", head_sha: "dddd111122223333" }, + repository: { full_name: "owner/test-repo", default_branch: "main" }, + installation: { id: 99999 }, + }; + + type Body = { + dispatched: Array<{ run: string }>; + skipped?: Array<{ run: string; reason: string }>; + }; + + const deliver = async ( + env: Parameters[1], + payload: unknown, + event: string, + ): Promise => { + const res = await handleRequest(await webhookRequest(payload, { event }), env); + expect(res.status).toBe(202); + return (await res.json()) as Body; + }; + + const createdRuns = (workflow: ReturnType): string[] => + workflow.calls.map((c) => (c.params as { run: string }).run); + + it("a repo with none of a run's keys creates no Workflow and lists it not-configured", async () => { + const configKv = makeFakeKv(); + const { env, workflow } = fixture({ configKv: configKv.binding }); + + const body = await deliver(env, prPayload, "pull_request"); + + expect(body.skipped).toEqual( + expect.arrayContaining([ + { run: "check", reason: "not-configured" }, + { run: "offload-test", reason: "not-configured" }, + ]), + ); + expect(body.dispatched.map((d) => d.run)).not.toContain("check"); + expect(body.dispatched.map((d) => d.run)).not.toContain("offload-test"); + expect(createdRuns(workflow)).not.toContain("check"); + expect(createdRuns(workflow)).not.toContain("offload-test"); + // A run without `optIn` dispatches as before. + expect(body.dispatched.map((d) => d.run)).toContain("pr-review"); + }); + + it("a present per-repo command key dispatches that run only", async () => { + const configKv = makeFakeKv(); + configKv.store.set("check.command:owner/test-repo", "pnpm lint"); + const { env, workflow } = fixture({ configKv: configKv.binding }); + + const body = await deliver(env, prPayload, "pull_request"); + + expect(body.dispatched.map((d) => d.run)).toContain("check"); + expect(createdRuns(workflow)).toContain("check"); + expect(body.skipped).toEqual([{ run: "offload-test", reason: "not-configured" }]); + }); + + it("offload-test opts in through the dispatcher-wide command or the stages key", async () => { + for (const key of ["offload-test.command", "offload-test.stages:owner/test-repo"]) { + const configKv = makeFakeKv(); + configKv.store.set(key, "unit"); + const { env } = fixture({ configKv: configKv.binding }); + + const body = await deliver(env, prPayload, "pull_request"); + + expect(body.dispatched.map((d) => d.run)).toContain("offload-test"); + expect(body.skipped?.map((s) => s.run)).not.toContain("offload-test"); + } + }); + + it("worker-deploy skips a repo without its command key and dispatches one with it", async () => { + const configKv = makeFakeKv(); + const { env, workflow } = fixture({ configKv: configKv.binding }); + + const skippedBody = await deliver(env, checkSuitePayload, "check_suite"); + expect(skippedBody.skipped).toEqual( + expect.arrayContaining([{ run: "worker-deploy", reason: "not-configured" }]), + ); + expect(createdRuns(workflow)).not.toContain("worker-deploy"); + + configKv.store.set("worker-deploy.command:owner/test-repo", "pnpm deploy"); + const { env: configured, workflow: configuredWorkflow } = fixture({ + configKv: configKv.binding, + }); + const body = await deliver(configured, checkSuitePayload, "check_suite"); + expect(body.dispatched.map((d) => d.run)).toContain("worker-deploy"); + expect(createdRuns(configuredWorkflow)).toContain("worker-deploy"); + }); + + it("a failed opt-in read dispatches as before and logs", async () => { + const configKv = { + get: async () => { + throw new Error("kv unavailable"); + }, + } as unknown as KVNamespace; + const { env, workflow } = fixture({ configKv }); + const errors: string[] = []; + const original = console.error; + console.error = (...args: unknown[]) => { + errors.push(args.map(String).join(" ")); + }; + try { + const body = await deliver(env, prPayload, "pull_request"); + expect(body.dispatched.map((d) => d.run)).toEqual( + expect.arrayContaining(["check", "offload-test"]), + ); + expect(body.skipped).toBeUndefined(); + expect(createdRuns(workflow)).toEqual(expect.arrayContaining(["check", "offload-test"])); + } finally { + console.error = original; + } + expect(errors.some((e) => e.includes("opt-in read failed") && e.includes('run="check"'))).toBe( + true, + ); + }); + + it("a deploy without CONFIG_KV dispatches as before", async () => { + const { env } = fixture(); + const body = await deliver(env, prPayload, "pull_request"); + expect(body.dispatched.map((d) => d.run)).toEqual( + expect.arrayContaining(["check", "offload-test"]), + ); + expect(body.skipped).toBeUndefined(); + }); +}); + diff --git a/apps/dispatcher/src/routes/webhook.ts b/apps/dispatcher/src/routes/webhook.ts index 2849c004..a7204fe0 100644 --- a/apps/dispatcher/src/routes/webhook.ts +++ b/apps/dispatcher/src/routes/webhook.ts @@ -35,6 +35,7 @@ import { checkAndArmCooldown } from "../cooldown"; import { verify } from "../hmac"; import { toInstanceId } from "../instance-id"; +import { resolveOptIn } from "../opt-in"; import { closedGroups, closeInstanceId, type CloseGroupPayload } from "../pr-close"; import { triggersByEvent } from "../registry"; import { resolveReleaseApproval } from "../release-approval"; @@ -268,12 +269,15 @@ export const handleGithubWebhook = async (request: Request, env: Env): Promise = []; - const skipped: Array<{ - run: string; - executionId: string; - reason: "cooldown"; - retryAfterSec: number; - }> = []; + const skipped: Array< + | { + run: string; + executionId: string; + reason: "cooldown"; + retryAfterSec: number; + } + | { run: string; reason: "not-configured" } + > = []; for (const { run, trigger } of matches) { if ( trigger.actions !== undefined && @@ -287,6 +291,21 @@ export const handleGithubWebhook = async (request: Request, env: Env): Promise 0 ? { skipped } : {}), ...(closed.length > 0 ? { closed } : {}), }, diff --git a/packages/core/src/define-run.ts b/packages/core/src/define-run.ts index d422d4a9..dbdbd6e8 100644 --- a/packages/core/src/define-run.ts +++ b/packages/core/src/define-run.ts @@ -54,6 +54,11 @@ export type CooldownSpec = { readonly scope: (input: I) => string; }; +/** What a run's `optIn` reads its keys from: the dispatch's inputs. */ +export type OptInContext = { + readonly input: I; +}; + /** The raw GitHub webhook delivery; a run narrows it per-event itself. */ export type WebhookPayload = Record; @@ -157,6 +162,17 @@ export type RunSpec = { * Schedule mode is exempt (crons self-pace). */ readonly cooldown?: CooldownSpec; + /** + * The CONFIG_KV keys whose presence means the repo opted in to this run — + * exactly the keys the run body reads its command from. The webhook + * receiver reads them before it creates a Workflow: with none present it + * dispatches nothing and reports the run `skipped` as `not-configured`. An + * empty list means the inputs already carry the command (an Action or a + * fan-out parent named it), so no read happens. A failed read dispatches. + * The run body keeps its own opt-in check. See + * specs/adr/0007-cost-efficient-execution.md § Dispatch only work. + */ + readonly optIn?: (ctx: OptInContext) => readonly string[]; /** * Declares that this run PAUSES for a human decision partway through — it * hibernates in `step.waitForEvent` until a person approves or rejects, and diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4512ebec..14105fe6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,6 +17,7 @@ export { type RunSpec, type RunLimits, type CooldownSpec, + type OptInContext, type HumanGateSpec, type SandboxImage, type SerializeSpec, diff --git a/runs/check.test.ts b/runs/check.test.ts index f4b7437c..51db7e11 100644 --- a/runs/check.test.ts +++ b/runs/check.test.ts @@ -654,6 +654,16 @@ describe("check", () => { }), ).toBe(false); }); + + it("optIn — names the command keys resolve-config reads; none when the dispatch carries one", () => { + const input = check.triggers![0]!.inputs({ payload: prPayload() }); + expect(check.optIn?.({ input })).toEqual(["check.command:owner/name"]); + expect(check.optIn?.({ input: { ...input, checkLabel: "codegen" } })).toEqual([ + "check.command:owner/name:codegen", + "check.command:owner/name", + ]); + expect(check.optIn?.({ input: { ...input, command: "pnpm lint" } })).toEqual([]); + }); }); // --- Source guard: no direct Date.now() / crypto.randomUUID() in the run ----- diff --git a/runs/check.ts b/runs/check.ts index bdb35213..346257d5 100644 --- a/runs/check.ts +++ b/runs/check.ts @@ -278,6 +278,12 @@ export const check = defineRun({ }, ], + // The command keys `resolve-config` reads — the receiver skips a repo with + // none of them (specs/adr/0007-cost-efficient-execution.md § Dispatch only + // work). A webhook dispatch is unlabelled, so it reads `check.command:`. + optIn: ({ input }) => + input.command !== undefined ? [] : commandKeys(input.repo, input.checkLabel), + limits: { // Run-body wall-time ceiling, enforced by the dispatcher: one full 2700 s // exec (the per-stage ceiling a fan-out gate passes) plus checkout, upload diff --git a/runs/offload-test.test.ts b/runs/offload-test.test.ts index 796ce114..ce8a0394 100644 --- a/runs/offload-test.test.ts +++ b/runs/offload-test.test.ts @@ -469,6 +469,16 @@ describe("offload-test", () => { ).toBe(false); }); + it("optIn — names the keys resolve-command decides on; none when the dispatch carries a command", () => { + const input = offloadTest.triggers![0]!.inputs({ payload: prPayload() }); + expect(offloadTest.optIn?.({ input })).toEqual([ + "offload-test.command:owner/name", + "offload-test.command", + "offload-test.stages:owner/name", + ]); + expect(offloadTest.optIn?.({ input: { ...input, command: "pnpm test" } })).toEqual([]); + }); + it.effect( "command resolution — a dispatch without `command` resolves it from CONFIG_KV (per-repo wins over the default)", () => { diff --git a/runs/offload-test.ts b/runs/offload-test.ts index b5077783..b2256418 100644 --- a/runs/offload-test.ts +++ b/runs/offload-test.ts @@ -506,6 +506,14 @@ export const offloadTest = defineRun({ inputs: OffloadTestInput, outputs: OffloadTestOutput, + // The keys `resolve-command` decides on: the per-repo command, the + // dispatcher-wide default, and the stages key (staged mode runs with neither + // command). See specs/adr/0007-cost-efficient-execution.md § Dispatch only work. + optIn: ({ input }) => + input.command !== undefined + ? [] + : [repoCommandKey(input.repo), COMMAND_KEY, repoStagesKey(input.repo)], + limits: { // Run-body wall-time ceiling — specs/02-runs.md § 1, enforced by the // dispatcher. One documented 1800 s exec plus checkout, install and diff --git a/runs/worker-deploy.test.ts b/runs/worker-deploy.test.ts index e0a298e4..3d6f2b2e 100644 --- a/runs/worker-deploy.test.ts +++ b/runs/worker-deploy.test.ts @@ -646,6 +646,15 @@ describe("worker-deploy", () => { }), ).toBe(false); }); + + it("optIn — names the command key resolve-config reads; none when the dispatch carries one", () => { + const input = workerDeploy.triggers![0]!.inputs({ payload: checkSuitePayload() }); + expect(workerDeploy.optIn?.({ input })).toEqual(["worker-deploy.command:owner/name"]); + expect(workerDeploy.optIn?.({ input: { ...input, checkLabel: "docs" } })).toEqual([ + "worker-deploy.command:owner/name:docs", + ]); + expect(workerDeploy.optIn?.({ input: { ...input, command: "pnpm deploy" } })).toEqual([]); + }); }); // --- Source guard: no direct Date.now() / crypto.randomUUID() in the run ----- diff --git a/runs/worker-deploy.ts b/runs/worker-deploy.ts index 17bb88a4..e60b28c4 100644 --- a/runs/worker-deploy.ts +++ b/runs/worker-deploy.ts @@ -332,6 +332,11 @@ export const workerDeploy = defineRun({ inputs: WorkerDeployInput, outputs: WorkerDeployOutput, + // The command key `resolve-config` reads — the receiver skips a repo without + // it (specs/adr/0007-cost-efficient-execution.md § Dispatch only work). + optIn: ({ input }) => + input.command !== undefined ? [] : [commandKey(input.repo, input.checkLabel)], + limits: { maxDurationSec: 1800, }, From 76ece772a66a49b61fb857dfa86296fe6c7b4856 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Thu, 22 Oct 2026 16:37:14 +0800 Subject: [PATCH 2/5] fix(dispatch): fail open when a run's optIn throws, and unit-test resolveOptIn resolveOptIn takes the key list as a thunk, so a throwing optIn takes the same log-and-dispatch branch as a rejected KV read instead of failing the whole delivery for the sibling triggers. --- apps/dispatcher/src/opt-in.test.ts | 73 +++++++++++++++++++++++++++ apps/dispatcher/src/opt-in.ts | 21 +++++--- apps/dispatcher/src/routes/webhook.ts | 9 ++-- 3 files changed, 91 insertions(+), 12 deletions(-) create mode 100644 apps/dispatcher/src/opt-in.test.ts diff --git a/apps/dispatcher/src/opt-in.test.ts b/apps/dispatcher/src/opt-in.test.ts new file mode 100644 index 00000000..468755a5 --- /dev/null +++ b/apps/dispatcher/src/opt-in.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveOptIn } from "./opt-in"; +import { makeFakeKv } from "./test-helpers"; + +const KEYS = ["check.command:acme/widgets:docs", "check.command:acme/widgets"] as const; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("resolveOptIn", () => { + it("answers not-configured when no key is present", async () => { + const kv = makeFakeKv(); + expect(await resolveOptIn({ configKv: kv.binding, runName: "check", keys: () => KEYS })).toBe( + "not-configured", + ); + }); + + it("dispatches when any one key is present, the least specific included", async () => { + const kv = makeFakeKv(); + kv.store.set("check.command:acme/widgets", "pnpm lint"); + expect(await resolveOptIn({ configKv: kv.binding, runName: "check", keys: () => KEYS })).toBe( + "dispatch", + ); + }); + + it("treats a present key as opted in whatever its value — the run body judges the value", async () => { + const kv = makeFakeKv(); + kv.store.set("check.command:acme/widgets", ""); + expect(await resolveOptIn({ configKv: kv.binding, runName: "check", keys: () => KEYS })).toBe( + "dispatch", + ); + }); + + it("dispatches without a read when the inputs carry the command", async () => { + const get = vi.fn(); + const configKv = { get } as unknown as KVNamespace; + expect(await resolveOptIn({ configKv, runName: "check", keys: () => [] })).toBe("dispatch"); + expect(get).not.toHaveBeenCalled(); + }); + + it("dispatches without computing keys on a deploy with no CONFIG_KV", async () => { + const keys = vi.fn(() => KEYS); + expect(await resolveOptIn({ configKv: undefined, runName: "check", keys })).toBe("dispatch"); + expect(keys).not.toHaveBeenCalled(); + }); + + it("fails open on a rejected read and logs the run and keys", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const configKv = { + get: async () => { + throw new Error("kv unavailable"); + }, + } as unknown as KVNamespace; + expect(await resolveOptIn({ configKv, runName: "check", keys: () => KEYS })).toBe("dispatch"); + expect(String(error.mock.calls[0]?.[0])).toContain('run="check"'); + expect(String(error.mock.calls[0]?.[0])).toContain("kv unavailable"); + }); + + it("fails open when the run's optIn throws", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const kv = makeFakeKv(); + const verdict = await resolveOptIn({ + configKv: kv.binding, + runName: "check", + keys: () => { + throw new Error("input has no repo"); + }, + }); + expect(verdict).toBe("dispatch"); + expect(String(error.mock.calls[0]?.[0])).toContain("input has no repo"); + }); +}); diff --git a/apps/dispatcher/src/opt-in.ts b/apps/dispatcher/src/opt-in.ts index 2fd347d9..bcd7badc 100644 --- a/apps/dispatcher/src/opt-in.ts +++ b/apps/dispatcher/src/opt-in.ts @@ -6,8 +6,8 @@ // configured the run costs no Workflow, check-run, admission slot or container // (specs/adr/0007-cost-efficient-execution.md § Dispatch only work). // -// The read fails open. A KV error, or a deploy without CONFIG_KV, dispatches -// exactly as the receiver did before the check existed: the run body still +// The read fails open. A KV error, a throwing `optIn`, or a deploy without +// CONFIG_KV dispatches as if the run declared no opt-in: the run body still // resolves its own command and no-ops when there is none, so a failed read // costs one wasted run, never a configured repo's verdict. @@ -15,20 +15,25 @@ export type OptInVerdict = "dispatch" | "not-configured"; /** - * Decide whether to dispatch a run whose `optIn` named `keys`. + * Decide whether to dispatch a run whose `optIn` names `keys`. * - * `keys` empty (the inputs carry their own command) or no `configKv` → + * No `configKv`, or `keys` empty (the inputs carry their own command) → * `dispatch` with no read. Otherwise any present key → `dispatch`; none → - * `not-configured`. A rejected read logs and answers `dispatch`. + * `not-configured`. `keys` is a thunk so a throwing `optIn` lands in the same + * fail-open branch as a rejected read: it logs and answers `dispatch`, and + * never fails the delivery for the sibling triggers. */ export const resolveOptIn = async (args: { readonly configKv: KVNamespace | undefined; readonly runName: string; - readonly keys: readonly string[]; + readonly keys: () => readonly string[]; }): Promise => { - const { configKv, runName, keys } = args; - if (keys.length === 0 || configKv === undefined) return "dispatch"; + const { configKv, runName } = args; + if (configKv === undefined) return "dispatch"; + let keys: readonly string[] = []; try { + keys = args.keys(); + if (keys.length === 0) return "dispatch"; const values = await Promise.all(keys.map((key) => configKv.get(key))); return values.some((value) => value !== null) ? "dispatch" : "not-configured"; } catch (cause) { diff --git a/apps/dispatcher/src/routes/webhook.ts b/apps/dispatcher/src/routes/webhook.ts index a7204fe0..29182e81 100644 --- a/apps/dispatcher/src/routes/webhook.ts +++ b/apps/dispatcher/src/routes/webhook.ts @@ -294,13 +294,14 @@ export const handleGithubWebhook = async (request: Request, env: Env): Promise optIn({ input: inputs }), }); - if (optIn === "not-configured") { + if (verdict === "not-configured") { skipped.push({ run: run.name, reason: "not-configured" }); continue; } From d5c360d02f0576a8ab6658dd57b851721e93db56 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Thu, 22 Oct 2026 17:14:02 +0800 Subject: [PATCH 3/5] feat(supersede): count each supersession and label superseded executions Each cancelled tree logs one supersede.cancelled line naming its group, root and superseding execution, with the children, admitted slots and open check-runs it ended. A cancelled member's run_supersessions row turns 'cancelled' instead of being deleted, so the dashboard execution list reads superseded_by and labels the row with what superseded it. --- apps/dispatcher/src/admission-parent.test.ts | 2 +- apps/dispatcher/src/dashboard.test.ts | 35 ++++++++++++ apps/dispatcher/src/dashboard.ts | 19 ++++++- apps/dispatcher/src/executions-read.ts | 15 +++++- apps/dispatcher/src/http-app.ts | 1 + apps/dispatcher/src/supersede.test.ts | 50 ++++++++++++----- apps/dispatcher/src/supersede.ts | 53 ++++++++++++++----- apps/dispatcher/src/workflow.ts | 6 +-- infra/migrations/0010_run_supersessions.sql | 6 ++- packages/runtime-cf/src/index.ts | 1 + packages/runtime-cf/src/run-supersede-d1.ts | 36 +++++++++---- .../src/run-supersede-d1.workers.test.ts | 24 ++++++++- 12 files changed, 204 insertions(+), 44 deletions(-) diff --git a/apps/dispatcher/src/admission-parent.test.ts b/apps/dispatcher/src/admission-parent.test.ts index d2f77914..a6d2e883 100644 --- a/apps/dispatcher/src/admission-parent.test.ts +++ b/apps/dispatcher/src/admission-parent.test.ts @@ -67,7 +67,7 @@ describe("leaveQueueForEndedParent", () => { { executionId: "part-a", parentExecutionId: "gate", parentStatus: "failure" }, { stepDo: (name, body) => Effect.promise(async () => { events.push(`step:${name}`); return body(); }), - store: { cancel: (ids) => Effect.sync(() => { events.push(`cancel:${ids.join(",")}`); }) }, + store: { cancel: (ids) => Effect.sync(() => { events.push(`cancel:${ids.join(",")}`); return { admittedSlots: 0 }; }) }, concludeCancelled: (summary) => Effect.sync(() => { events.push(`conclude:${summary}`); }), }, ), diff --git a/apps/dispatcher/src/dashboard.test.ts b/apps/dispatcher/src/dashboard.test.ts index 72f2ff18..d7973509 100644 --- a/apps/dispatcher/src/dashboard.test.ts +++ b/apps/dispatcher/src/dashboard.test.ts @@ -27,6 +27,7 @@ const baseRow = (over: Partial = {}): DashboardRow => ({ logsUrl: null, demosUrl: null, selfHealPrUrl: null, + supersededBy: null, ...over, }); @@ -114,6 +115,24 @@ describe("renderDashboard", () => { expect(html).not.toContain("badge selfheal"); }); + it("labels a cancelled execution with the execution that superseded it", () => { + const html = renderDashboard(data([baseRow({ status: "cancelled", supersededBy: "gate-newer" })])); + expect(html).toContain('superseded by gate-newer'); + }); + + it("labels a cancelled execution a pull-request close superseded", () => { + const html = renderDashboard( + data([baseRow({ status: "cancelled", supersededBy: "pr-closed:1000:contextful-gate:o/r#7" })]), + ); + expect(html).toContain("pull request closed"); + expect(html).not.toContain("superseded by"); + }); + + it("labels no execution that is not cancelled", () => { + const html = renderDashboard(data([baseRow({ status: "running", supersededBy: "gate-newer" })])); + expect(html).not.toContain("superseded by"); + }); + it("escapes HTML in execution fields", () => { const html = renderDashboard(data([baseRow({ repo: "