diff --git a/apps/dispatcher/src/failure-summary.test.ts b/apps/dispatcher/src/failure-summary.test.ts index 52a8e0d..58e8794 100644 --- a/apps/dispatcher/src/failure-summary.test.ts +++ b/apps/dispatcher/src/failure-summary.test.ts @@ -24,6 +24,7 @@ import { TRUNCATION_NOTE, admissionTimedOutMd, appendFailureSummary, + failingStepMd, failureSummaryMd, runSkippedReason, } from "./failure-summary"; @@ -273,3 +274,33 @@ describe("appendFailureSummary", () => { expect(combined).not.toContain("summary truncated"); }); }); + +describe("failingStepMd", () => { + it("names the recorded step and the tag it failed with", () => { + const md = failingStepMd(Exit.fail(new ExecFailed({ exitCode: 101, stderrTail: "x" })), { + name: "measure", + errorTag: "ExecFailed", + }); + expect(md).toBe("**Failing step:** `measure` — `ExecFailed`"); + }); + + it("takes the step a StepFailed names when no step row recorded the failure", () => { + const md = failingStepMd( + Exit.fail(new StepFailed({ step: "build-report", cause: "exit 2" })), + undefined, + ); + expect(md).toBe("**Failing step:** `build-report` — `StepFailed`"); + }); + + it("ignores a step row whose tag differs from the run's failure", () => { + const md = failingStepMd(Exit.fail(new SecretsMissing({ keys: ["K"] })), { + name: "checkout", + errorTag: "ExecFailed", + }); + expect(md).toBe("**Failed outside any step** — `SecretsMissing`"); + }); + + it("is undefined for a success Exit", () => { + expect(failingStepMd(Exit.succeed(1), undefined)).toBeUndefined(); + }); +}); diff --git a/apps/dispatcher/src/failure-summary.ts b/apps/dispatcher/src/failure-summary.ts index 1b1355f..ec7b510 100644 --- a/apps/dispatcher/src/failure-summary.ts +++ b/apps/dispatcher/src/failure-summary.ts @@ -157,6 +157,41 @@ export const failureSummaryMd = (exit: Exit.Exit): string | u }), }); +/** The last `steps` row an execution recorded as `failure`. */ +export type FailedStepRow = { readonly name: string; readonly errorTag: string | null }; + +/** + * Name the step a failed run died in and the tag it failed with — the line a + * scheduled run's check-run carries in place of a row only D1 holds. The tag is + * the run's typed failure; the step is the one a `StepFailed` names, else the + * last failed `steps` row when that row recorded the same tag. A failure no + * step recorded reads as outside any step. `undefined` on success and on a + * defect or interrupt, which carry no tag. + */ +export const failingStepMd = ( + exit: Exit.Exit, + failedStep: FailedStepRow | undefined, +): string | undefined => + Exit.match(exit, { + onSuccess: () => undefined, + onFailure: (cause) => + Option.match(Cause.failureOption(cause), { + onNone: () => undefined, + onSome: (failure) => { + // `_tag` read as data for the label, as `describeFailure` in core's step.ts does. + const tag = (failure as { _tag?: unknown })._tag; + if (typeof tag !== "string") return undefined; + const stepName = Match.value(failure).pipe( + Match.tag("StepFailed", (e) => e.step), + Match.orElse(() => (failedStep?.errorTag === tag ? failedStep.name : undefined)), + ); + return stepName === undefined + ? `**Failed outside any step** — \`${tag}\`` + : `**Failing step:** \`${stepName}\` — \`${tag}\``; + }, + }), + }); + /** * Append a run's failure markdown beneath the generic failure line (blank-line * separated, so the markdown renders as its own block), truncating the diff --git a/apps/dispatcher/src/routes/scheduled.test.ts b/apps/dispatcher/src/routes/scheduled.test.ts index 39e466c..665f0cc 100644 --- a/apps/dispatcher/src/routes/scheduled.test.ts +++ b/apps/dispatcher/src/routes/scheduled.test.ts @@ -64,6 +64,63 @@ describe("handleScheduled", () => { warn.mockRestore(); }); + it("pins a branch-targeting run to the head it resolves and carries the installation for its check-run", async () => { + const workflow = makeFakeWorkflow(); + const env = makeEnv(workflow); + const head = "c".repeat(40); + const resolveHead = vi.fn(async () => ({ installationId: 4242, sha: head })); + const info = vi.spyOn(console, "info").mockImplementation(() => {}); + + // `contextful-measures` subscribes to "17 2 * * *" with inputs { repo, ref: refs/heads/main }. + await handleScheduled(env, "17 2 * * *", FIRED_AT, resolveHead); + + expect(resolveHead).toHaveBeenCalledWith("fractalboxdev/contextful", "main"); + const params = workflow.calls[0]!.params as { + github: { repo: string; ref: string; sha: string; installation_id?: number }; + inputs: { sha?: string }; + }; + expect(params.github).toEqual({ + repo: "fractalboxdev/contextful", + ref: "refs/heads/main", + sha: head, + installation_id: 4242, + }); + // The run measures the same commit the check-run lands on. + expect(params.inputs.sha).toBe(head); + info.mockRestore(); + }); + + it("fires without a check-run when the target repo's head does not resolve", async () => { + const workflow = makeFakeWorkflow(); + const env = makeEnv(workflow); + const info = vi.spyOn(console, "info").mockImplementation(() => {}); + + await handleScheduled(env, "17 2 * * *", FIRED_AT, async () => undefined); + + const params = workflow.calls[0]!.params as { + github: { installation_id?: number }; + inputs: { sha?: string }; + }; + expect(params.github.installation_id).toBeUndefined(); + expect(params.inputs.sha).toBeUndefined(); + info.mockRestore(); + }); + + it("does not resolve a head for a run whose inputs name no branch ref", async () => { + const workflow = makeFakeWorkflow(); + const env = makeEnv(workflow); + const resolveHead = vi.fn(async () => ({ installationId: 1, sha: "d".repeat(40) })); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await handleScheduled(env, "0 14 * * *", FIRED_AT, resolveHead); + + expect(resolveHead).not.toHaveBeenCalled(); + expect((workflow.calls[0]!.params as { github: object }).github).not.toHaveProperty( + "installation_id", + ); + warn.mockRestore(); + }); + it("collapses duplicate-id create errors as the dedup path (not a hard failure)", async () => { const workflow = makeFakeWorkflow(); // First call records; second call (with same id) throws as a real diff --git a/apps/dispatcher/src/routes/scheduled.ts b/apps/dispatcher/src/routes/scheduled.ts index a5ac991..fb99e5b 100644 --- a/apps/dispatcher/src/routes/scheduled.ts +++ b/apps/dispatcher/src/routes/scheduled.ts @@ -40,10 +40,48 @@ // own `executions` D1 row is keyed off the *Workflow instance* id, so a // dedup-collapsed second call is a literal no-op end-to-end. +import { + getInstallationToken, + readBranchHead, + resolveRepoInstallationId, +} from "@fractalboxdev/flare-dispatch-github-app"; import { toInstanceId } from "../instance-id"; import { type ScheduleMatch, schedulesByCron } from "../registry"; import type { Env } from "../env"; +/** The installation covering a repo and the head commit of one of its branches. */ +export type ScheduledHead = { readonly installationId: number; readonly sha: string }; + +/** + * Resolve the GitHub App installation for `repo` and the head of `branch`, or + * `undefined` when the deploy has no App credentials, the App is not installed + * on the repo, or GitHub fails the read — the run then fires without a + * check-run, as every scheduled run did before. + */ +export type ResolveScheduledHead = ( + repo: string, + branch: string, +) => Promise; + +/** The live resolver: App JWT → repo installation → installation token → branch ref. */ +export const githubScheduledHead = + (env: Env): ResolveScheduledHead => + async (repo, branch) => { + const appId = env.GITHUB_APP_ID; + const privateKeyPem = env.GITHUB_APP_PRIVATE_KEY; + if (appId === undefined || privateKeyPem === undefined) return undefined; + try { + const installationId = await resolveRepoInstallationId({ appId, privateKeyPem, repo }); + const token = await getInstallationToken({ appId, privateKeyPem, installationId }); + const sha = await readBranchHead({ token, repo, branch }); + return { installationId, sha }; + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + console.warn(`[scheduled] no check-run for ${repo}:${branch} — ${message}`); + return undefined; + } + }; + /** Trim a long cron to fit inline in a log message. */ const fmt = (cron: string): string => (cron.length > 40 ? `${cron.slice(0, 37)}...` : cron); @@ -56,11 +94,13 @@ const fmt = (cron: string): string => (cron.length > 40 ? `${cron.slice(0, 37)}. * @param env binding env (`RUNS_WORKFLOW`). * @param cron `controller.cron` — the expression that fired. * @param scheduledTime `controller.scheduledTime` — epoch ms. + * @param resolveHead installation + branch-head lookup for runs that target a branch. */ export const handleScheduled = async ( env: Env, cron: string, scheduledTime: number, + resolveHead: ResolveScheduledHead = githubScheduledHead(env), ): Promise => { const matches = schedulesByCron(cron); @@ -74,7 +114,7 @@ export const handleScheduled = async ( const ctx = { cron, firedAt: scheduledTime }; - await Promise.all(matches.map(async (match) => fireOne(env, match, ctx))); + await Promise.all(matches.map(async (match) => fireOne(env, match, ctx, resolveHead))); }; /** Fire a single (run, schedule-spec) match, swallowing individual failures. */ @@ -82,6 +122,7 @@ const fireOne = async ( env: Env, match: ScheduleMatch, ctx: { cron: string; firedAt: number }, + resolveHead: ResolveScheduledHead, ): Promise => { const { run, schedule } = match; @@ -92,17 +133,21 @@ const fireOne = async ( } const id = toInstanceId(schedule.idempotencyKey(ctx)); - const inputs = schedule.inputs(ctx); + const declared = schedule.inputs(ctx); // The Workflow `params` shape `RunWorkflow.run` decodes — same as dispatch.ts. // Schedule mode has no GitHub event; the run's own `inputs` carries the - // target repo/ref/sha (e.g. `product-demo` records against a deployed URL). - // `payload.github` here is just executions-row metadata + the optional - // check-run hint — installation_id is omitted, so `Checks` falls through to - // the no-op Layer (the run still records its rows, only the PR check-run is - // skipped). An operator who wants a check-run on a tracking repo sets - // `SCHEDULED_CHECK_RUN_*` env vars — deferred to a follow-up. - const target = scheduleGithubTarget(inputs); + // target repo/ref/sha. A run whose inputs name a repo and a `refs/heads/` + // branch, and no `sha`, is pinned here to that branch's head: the resolved + // installation selects the live `Checks` Layer, so `RunWorkflow` opens the + // check-run on that commit when the run starts and concludes it with the + // run's outcome — the same code push and PR runs take. The head also rides + // in `inputs.sha`, so the run executes against the commit the check names. + // An unresolved head leaves `installation_id` absent and the inputs as + // declared: the run fires, records its rows, and posts no check-run. + const pinned = await pinBranchHead(declared, resolveHead); + const inputs = pinned?.inputs ?? declared; + const target = scheduleGithubTarget(inputs, pinned?.installationId); const params = { executionId: id, run: run.name, @@ -125,6 +170,28 @@ const fireOne = async ( } }; +/** + * Resolve the head a branch-targeting run executes against. `undefined` when + * the inputs name no repo, no `refs/heads/` ref, or already carry a `sha`, and + * when the resolver finds no installation or head. + */ +const pinBranchHead = async ( + inputs: unknown, + resolveHead: ResolveScheduledHead, +): Promise<{ inputs: Record; installationId: number } | undefined> => { + if (typeof inputs !== "object" || inputs === null) return undefined; + const obj = inputs as Record; + if (typeof obj.repo !== "string" || typeof obj.ref !== "string" || obj.sha !== undefined) { + return undefined; + } + const branch = obj.ref.startsWith("refs/heads/") ? obj.ref.slice("refs/heads/".length) : ""; + if (branch === "") return undefined; + const head = await resolveHead(obj.repo, branch); + return head === undefined + ? undefined + : { inputs: { ...obj, sha: head.sha }, installationId: head.installationId }; +}; + /** * Synthesize the `github` block `RunWorkflow` expects. The cron tick carries * no GitHub payload, so we lift `repo` / `ref` / `sha` from the run's own @@ -132,20 +199,20 @@ const fireOne = async ( * uses these fields only for the executions D1 row and the (no-op) check-run * Layer when no `installation_id` is wired. * - * A run that wants a real check-run on its tracking repo should put - * `installation_id` in its `inputs` and the operator should set - * `GITHUB_APP_ID` + `GITHUB_APP_PRIVATE_KEY`; that path is wired but no - * shipped Schedule-mode run uses it yet. + * `installationId` comes from `pinBranchHead`; an `installation_id` in the + * run's own inputs is the fallback. */ const scheduleGithubTarget = ( inputs: unknown, + installationId?: number, ): { repo: string; ref: string; sha: string; installation_id?: number } => { const obj = (inputs ?? {}) as Record; const repo = typeof obj.repo === "string" ? obj.repo : "schedule/unknown"; const ref = typeof obj.ref === "string" ? obj.ref : "refs/heads/main"; const sha = typeof obj.sha === "string" ? obj.sha : typeof obj.ref === "string" ? obj.ref : "main"; - const installation_id = typeof obj.installation_id === "number" ? obj.installation_id : undefined; + const installation_id = + installationId ?? (typeof obj.installation_id === "number" ? obj.installation_id : undefined); // Warn when the run's `schedules[].inputs()` returned a shape without // the fields the `executions` row expects — a Schema mismatch between diff --git a/apps/dispatcher/src/workflow.ts b/apps/dispatcher/src/workflow.ts index ddf407c..5112666 100644 --- a/apps/dispatcher/src/workflow.ts +++ b/apps/dispatcher/src/workflow.ts @@ -28,7 +28,8 @@ // // * The App credentials (`GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`) come from // `env`; the installation id rides in the dispatch payload's -// `github.installation_id` (PR5's dispatch route fills it in). When any of +// `github.installation_id` (PR5's dispatch route fills it in; the +// scheduled path resolves it for a run that targets a branch). When any of // the three is absent — local dev without secrets, or a dispatch with no // installation — `makeCFRuntimeLive` selects the *no-op* `Checks` Layer: // the execution runs to completion and records its D1 rows, only the PR @@ -86,7 +87,13 @@ import { lookupRun } from "./registry"; import { preAssertedApproval, resolveTargets, runGrant, runsOnFacade } from "./grant-catalog"; import { selectSandboxNs } from "./sandbox-routing"; import { queuedSummary, serialQueuedSummary } from "./admission-summary"; -import { appendFailureSummary, failureSummaryMd, runSkippedReason } from "./failure-summary"; +import { + appendFailureSummary, + type FailedStepRow, + failingStepMd, + failureSummaryMd, + runSkippedReason, +} from "./failure-summary"; import { renderResultEmail } from "./notify"; import { workflowDashboardUrl } from "./dashboard-url"; import { checkRunNameFor, checkRunTitleFor } from "./check-name"; @@ -1031,9 +1038,28 @@ export class RunWorkflow extends WorkflowEntrypoint { // carry markdown (`AcceptanceFailed.summaryMd` — e.g. `product-demo`'s // per-chapter table). Extracted ONCE here and reused by both the // check-run failure summary and the notify email below. `undefined` on - // success, on a defect/interrupt, and on a summary-less failure — those - // render the generic line alone, exactly as before. - const failureMd = failureSummaryMd(exit); + // success and on a defect/interrupt — those render the generic line alone. + // + // A failure leads with the step it died in and its error tag, read from + // the last `steps` row recorded `failure` — the diagnosis a scheduled run + // otherwise leaves only in D1. The read is best-effort: a D1 error drops + // the step name, never the verdict. + const failedStep = Exit.isFailure(exit) + ? yield* Effect.tryPromise(() => + db + .prepare( + `SELECT name, error_tag AS errorTag FROM steps + WHERE execution_id = ? AND status = 'failure' + ORDER BY completed_at DESC LIMIT 1`, + ) + .bind(payload.executionId) + .first(), + ).pipe(Effect.orElseSucceed(() => null)) + : null; + const failureMd = + [failingStepMd(exit, failedStep ?? undefined), failureSummaryMd(exit)] + .filter((part): part is string => part !== undefined && part.trim() !== "") + .join("\n\n") || undefined; // Persist the run output as JSON so `io.priorExecution` can recover it // on the next execution in the semantic family. A failed Exit has no diff --git a/runs/README.md b/runs/README.md index 135e37e..15169b9 100644 --- a/runs/README.md +++ b/runs/README.md @@ -24,6 +24,14 @@ flowchart TB class checks ok ``` +A cron tick carries no commit. A scheduled run whose inputs name a `repo` and a +`refs/heads/` ref, and no `sha`, is pinned at the tick to that branch's +head through the repo's GitHub App installation: the run receives the commit as +`inputs.sha`, and its `flare-dispatch/` check-run opens on that commit when +the run starts and concludes with the run's outcome. A failure's summary names +the failing step and its error tag. A repo without the App installed, or a +deploy without App credentials, runs the schedule and posts no check-run. + A run is a recipe: it composes primitives, which ride the capability layer, and calls capabilities directly where no primitive fits. diff --git a/runs/contextful-measures.test.ts b/runs/contextful-measures.test.ts index b8e248c..23a1771 100644 --- a/runs/contextful-measures.test.ts +++ b/runs/contextful-measures.test.ts @@ -45,13 +45,29 @@ describe("contextful-measures", () => { { repo: input.repo, commit: sha, text: report }, ]); expect(handles.artifact.uploads[0]?.name).toBe("measure-report.json"); - const reportExec = handles.sandbox.execs.find((entry) => entry.command.includes("measure-report")); + const reportExec = handles.sandbox.execs.find((entry) => + entry.command.includes("measure-report"), + ); expect(reportExec?.env?.MEASURE_RUN_ID).toBe("contextful_measures_attempt-2"); expect(reportExec?.env?.MEASURE_RUN_ATTEMPT).toBe("2"); expect(reportExec?.env?.MEASURE_EXIT).toBe("0"); }).pipe(Effect.provide(layer)); }); + it.effect("measures the commit the schedule pinned without re-reading the branch head", () => { + const pinned = "b".repeat(40); + const { layer, handles } = makeCFRuntimeTest({ + sandboxProgram: { "measure --tier all": { exitCode: 0 }, "measure-report": { exitCode: 0 } }, + sandboxFiles: { "/workspace/contextful/measure-report.json": report }, + }); + return Effect.gen(function* () { + const out = yield* contextfulMeasures.run({ ...input, sha: pinned }); + expect(out.commit).toBe(pinned); + expect(handles.github.branchHeadCalls).toHaveLength(0); + expect(handles.sandbox.clones).toEqual([{ repo: input.repo, sha: pinned }]); + }).pipe(Effect.provide(layer)); + }); + it.effect("attaches a report even when a measure tier is red", () => { const { layer, handles } = makeCFRuntimeTest({ github: { branchHeads: { "fractalboxdev/contextful:main": sha } }, diff --git a/runs/contextful-measures.ts b/runs/contextful-measures.ts index c20e5ec..1799bfc 100644 --- a/runs/contextful-measures.ts +++ b/runs/contextful-measures.ts @@ -19,6 +19,8 @@ const Input = Schema.Struct({ repo: Schema.String, ref: Schema.String, firedAt: Schema.Number, + /** The head the scheduled tick pinned; its check-run lands on this commit. */ + sha: Schema.optional(Schema.String), timeoutSec: Schema.optional(Schema.Number), }); const Output = Schema.Struct({ @@ -46,7 +48,11 @@ export const contextfulMeasures = defineRun({ cron: "17 2 * * *", idempotencyKey: ({ firedAt }) => `contextful-measures:${new Date(firedAt).toISOString().slice(0, 10)}`, - inputs: ({ firedAt }): typeof Input.Type => ({ repo: REPO, ref: `refs/heads/${BRANCH}`, firedAt }), + inputs: ({ firedAt }): typeof Input.Type => ({ + repo: REPO, + ref: `refs/heads/${BRANCH}`, + firedAt, + }), }, ], serialize: (input) => ({ @@ -66,10 +72,10 @@ export const contextfulMeasures = defineRun({ }), ); } - // Resolve HEAD at execution time; a cron tick has no GitHub event SHA. - const commit = yield* step("resolve-head", () => - github.branchHead({ repo: REPO, branch: BRANCH }), - ); + // The tick's pinned head, else HEAD at execution time. + const commit = + input.sha ?? + (yield* step("resolve-head", () => github.branchHead({ repo: REPO, branch: BRANCH }))); const executionId = yield* io.executionId; const runId = executionId || `contextful-measures:${input.firedAt}`; // The ledger runs 65 entries in sequence and compiles its own test diff --git a/runs/contextful-protocol.test.ts b/runs/contextful-protocol.test.ts index 478243e..4e3f235 100644 --- a/runs/contextful-protocol.test.ts +++ b/runs/contextful-protocol.test.ts @@ -113,6 +113,24 @@ describe("contextful-protocol", () => { }, ); + it.effect("explores the commit the schedule pinned without re-reading the branch head", () => { + const pinned = "b".repeat(40); + const { layer, handles } = makeCFRuntimeTest({ + sandboxProgram: { + "gate --predecessors --stage toolchain": { exitCode: 0 }, + randomBytes: { exitCode: 0, stdout: "7\n" }, + "protocol-differential": { exitCode: 0 }, + "formal check": { exitCode: 0 }, + }, + }); + return Effect.gen(function* () { + const out = yield* contextfulProtocol.run({ ...input, sha: pinned }); + expect(out.commit).toBe(pinned); + expect(handles.github.branchHeadCalls).toHaveLength(0); + expect(handles.sandbox.clones).toEqual([{ repo: input.repo, sha: pinned }]); + }).pipe(Effect.provide(layer)); + }); + it.effect("rejects a different repository before allocating a sandbox", () => { const { layer, handles } = makeCFRuntimeTest({}); return Effect.gen(function* () { diff --git a/runs/contextful-protocol.ts b/runs/contextful-protocol.ts index 26d1980..969ea87 100644 --- a/runs/contextful-protocol.ts +++ b/runs/contextful-protocol.ts @@ -20,7 +20,13 @@ const FORMAL_COMMAND = `${PINNED_ENVIRONMENT}cargo run --locked -q -p contextful const SEED_COMMAND = `node -e 'console.log(require("node:crypto").randomBytes(8).readBigUInt64LE().toString())'`; const REGRESSIONS = "formal/protocol/regressions.jsonl"; const COMMAND_TIMEOUT_SEC = 1800; -const Input = Schema.Struct({ repo: Schema.String, ref: Schema.String, firedAt: Schema.Number }); +const Input = Schema.Struct({ + repo: Schema.String, + ref: Schema.String, + firedAt: Schema.Number, + /** The head the scheduled tick pinned; its check-run lands on this commit. */ + sha: Schema.optional(Schema.String), +}); const Output = Schema.Struct({ commit: Schema.String, seed: Schema.String, logUrl: Schema.String }); /** The gate installs elan; the separate command restores its pinned executable environment. */ @@ -50,9 +56,9 @@ export const contextfulProtocol = defineRun({ }), ); } - const commit = yield* step("resolve-head", () => - github.branchHead({ repo: REPO, branch: "main" }), - ); + const commit = + input.sha ?? + (yield* step("resolve-head", () => github.branchHead({ repo: REPO, branch: "main" }))); const ws = yield* step("checkout", () => workspace({ repo: REPO, sha: commit })); const provisioned = yield* step( "toolchain",