Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions apps/dispatcher/src/failure-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
TRUNCATION_NOTE,
admissionTimedOutMd,
appendFailureSummary,
failingStepMd,
failureSummaryMd,
runSkippedReason,
} from "./failure-summary";
Expand Down Expand Up @@ -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();
});
});
35 changes: 35 additions & 0 deletions apps/dispatcher/src/failure-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,41 @@ export const failureSummaryMd = (exit: Exit.Exit<unknown, RunError>): 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<unknown, RunError>,
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
Expand Down
57 changes: 57 additions & 0 deletions apps/dispatcher/src/routes/scheduled.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 81 additions & 14 deletions apps/dispatcher/src/routes/scheduled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ScheduledHead | undefined>;

/** 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);

Expand All @@ -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<void> => {
const matches = schedulesByCron(cron);

Expand All @@ -74,14 +114,15 @@ 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. */
const fireOne = async (
env: Env,
match: ScheduleMatch,
ctx: { cron: string; firedAt: number },
resolveHead: ResolveScheduledHead,
): Promise<void> => {
const { run, schedule } = match;

Expand All @@ -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,
Expand All @@ -125,27 +170,49 @@ 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<string, unknown>; installationId: number } | undefined> => {
if (typeof inputs !== "object" || inputs === null) return undefined;
const obj = inputs as Record<string, unknown>;
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
* inputs when they are present. The fallback is a placeholder — the Workflow
* 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<string, unknown>;
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
Expand Down
36 changes: 31 additions & 5 deletions apps/dispatcher/src/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -1031,9 +1038,28 @@ export class RunWorkflow extends WorkflowEntrypoint<Env> {
// 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<FailedStepRow>(),
).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
Expand Down
8 changes: 8 additions & 0 deletions runs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<branch>` 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/<run>` 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.

Expand Down
Loading
Loading