diff --git a/AGENTS.md b/AGENTS.md index b4820297..67864871 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,7 @@ Check these for a stock route or primitive before touching the hub, sidecars or - Tests beside the code are fast and offline; anything that runs real services lives in `e2e/`. - The one exception is `tests/chain/`: it starts the real hub and local sidecars but reaches no outside service (a fake GitHub and a stub decision model), and CI runs it as its own job, `bun run test:chain`. - `tests/policy-corpus/` holds one policy file per case with its expected outcome, run through every reader of the policy; a new policy key needs a fixture there. +- `tests/model/` checks R1, R3 and R5 of [docs/INVARIANTS.md](docs/INVARIANTS.md) across event orders, running the real GitHub listener and manager in one process against the hub's own routes and a fake GitHub under a deterministic policy; what it stands in for is listed there. A change to the listener, the manager, the workflows or the contracts names the rules it touches and keeps it green. - Never read `process.env` in tests. - A failed chain shard is read, never rerun. - A change to how a workflow judges or reviews states its package's eval result before and after. diff --git a/bun.lock b/bun.lock index 9df96c51..d6164cd1 100644 --- a/bun.lock +++ b/bun.lock @@ -412,6 +412,30 @@ "drizzle-orm": "catalog:", }, }, + "tests/model": { + "name": "@corbits/model-test", + "version": "0.1.0", + "dependencies": { + "@corbits/github-reads": "workspace:*", + "@corbits/triage-contracts": "workspace:*", + "@corbits/triage-manager": "workspace:*", + "@intx/agent": "workspace:*", + "@intx/authz": "workspace:*", + "@intx/crypto": "0.5.0", + "@intx/db": "workspace:*", + "@intx/harness": "workspace:*", + "@intx/hub-api": "0.5.0", + "@intx/hub-sessions": "0.5.0", + "@intx/inference": "workspace:*", + "@intx/storage-isogit": "workspace:*", + "@intx/types": "workspace:*", + "@intx/workflow": "workspace:*", + "@intx/workflow-deploy": "0.5.0", + "@intx/workflow-host": "workspace:*", + "arktype": "catalog:", + "drizzle-orm": "catalog:", + }, + }, "tests/policy-corpus": { "name": "@corbits/policy-corpus-test", "version": "0.1.0", @@ -989,6 +1013,8 @@ "@corbits/hub": ["@corbits/hub@workspace:apps/hub"], + "@corbits/model-test": ["@corbits/model-test@workspace:tests/model"], + "@corbits/openai-null-delta": ["@corbits/openai-null-delta@workspace:packages/openai-null-delta"], "@corbits/policy-check-action": ["@corbits/policy-check-action@workspace:packages/policy-check-action"], diff --git a/docs/DEV.md b/docs/DEV.md index a5e0a3e6..342b9722 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -91,6 +91,8 @@ Hub tests connect to `postgres://postgres:postgres@localhost:5432/interchange` a The chain starts its hub with its waits short (`TIMINGS` and `MAIL_HOLD_MS` in `tests/chain/stack.ts`, set through the `TRIAGE_*_MS` variables in [SELF_HOST.md](SELF_HOST.md#triage-tuning) and `HUB_MAIL_HOLD_TTL_MS`). A scenario polls every 100 ms for the state it names and never sleeps for a fixed time, which `bun run lint` enforces under `tests/chain/`. The real waits left are stock's one-minute ping timeout in the scenarios that freeze a listener's sidecar, the one-minute mail hold `unstarted` waits out, and the manager's 30-second poll in `woken`. A failed shard is handled as the [Tests rules](../AGENTS.md#tests) say. +`tests/model/` checks the rules in [INVARIANTS.md](INVARIANTS.md) across event orders. It runs the real GitHub listener and manager definitions in the test's own process through Interchange's `runLocal`, against the hub's own intake and manager routes on a database of its own and the chain's fake GitHub, with steps authorized against the grants a deployed run holds. Stock's deploy and stop-run routes are stood in for, mail between runs waits in a queue the test delivers, and webhooks enter at the hub's intake; [INVARIANTS.md](INVARIANTS.md#how-the-model-check-works) lists every stand-in and what it leaves out. It is a Postgres test, so `bun run test:postgres` runs it. + In CI the chain runs in five shards, each with its own list of files in `.github/workflows/ci.yml`; a new chain file must be added to one. A pull request that changes none of `apps/hub`, `apps/sidecar`, `apps/web/scripts`, `apps/web/src/lib`, `tooling/`, `packages/`, `tests/chain/`, `vendor/`, the lockfile, the root `package.json` or the CI workflow and setup actions skips the chain, so a change limited to the portal's pages and components, docs, e2e or research does not run it; every push to `main` runs it. Scripts are listed in [IMPLEMENTATION.md](IMPLEMENTATION.md#scripts). diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 8839d315..544bad16 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -111,7 +111,7 @@ A WXT browser extension that checks the open GitHub pull request locally; see | `bun run dev` | Database migrations, then hub and portal with prefixed logs (`tooling/dev.ts`). | | `bun run db:migrate` | Apply every database migration the hub needs to `DATABASE_URL`; run before the hub starts. | | `bun run hub` | Hub only; does not migrate. | -| `bun run check` | Typecheck tooling, portal, extension, hub, packages, `e2e/` and `tests/chain/`, and bundle the hub. | +| `bun run check` | Typecheck tooling, portal, extension, hub, packages, `e2e/`, `tests/chain/` and `tests/model/`, and bundle the hub. | | `bun run test` | Unit and integration tests; hub tests need Postgres. | | `bun run test:unit`, `test:postgres`, `test:dom` | The three parts of `bun run test`: tests that need no Postgres, the `*.postgres.test.ts` tests, and the portal's DOM tests. CI runs unit and Postgres tests in three shards each (`--shard=1/3`). | | `bun run test:chain` | The live triage chain from webhook to pull request on one machine, against a fake GitHub and a stub decision model; needs Postgres and reaches the npm registry for native modules. | diff --git a/docs/INVARIANTS.md b/docs/INVARIANTS.md new file mode 100644 index 00000000..429df3c1 --- /dev/null +++ b/docs/INVARIANTS.md @@ -0,0 +1,60 @@ +# Invariants + +The rules Triage keeps on every pull request, whatever order its events arrive in. Breaking one is a defect. A pull +request that changes how Triage keeps a rule names the rule. + +## Rules + +- **R1** One final verdict per pull request head, for the current head. Checked by `tests/model/rules.postgres.test.ts`: once the pull request settles it has exactly one current Triage comment, which is a verdict naming the head, and no waiting label. +- **R2** Triage approves or merges only on Pass with a complete code review that meets the policy's findings condition, all for the current head. No automated check yet. +- **R3** Every host write happens once under its effect id. Checked by `tests/model/rules.postgres.test.ts` over every write the pull request received, whichever pass made it: no Triage comment is created while one is current, unless that one was superseded, and no label is added while it is on. +- **R4** Unreadable input never passes or approves. No automated check yet. +- **R5** Nothing is left running after the final post. Checked by `tests/model/rules.postgres.test.ts`: once the pull request settles, none of its managers is live, no live manager was deployed for no single pull request, and no mail for it was lost to a run that had ended. Settling delivers every queued mail, so no mail is left waiting by construction. +- **R6** An older run never overwrites a newer head's comment, label, status or approval. No automated check yet. +- **R7** A pull request waiting on CI or a reviewer always gets its verdict. No automated check yet; R1's check covers a head waiting on CI. +- **R8** GitHub and Bitbucket Data Center behave the same except where documented. No automated check yet. +- **R9** Every reader of the policy agrees on what it says. No automated check yet. +- **R10** The portal shows only what the hub says. No automated check yet. + +A label may come off and go back on while a pass runs; R1 judges the labels a pull request ends with, and R3 only an +add while the label is on. + +## How the model check works + +`tests/model/` runs the real `triage-listener-github` and `triage-manager-github` definitions in the test's process +through Interchange's `runLocal` and the stock step invoker. They run against the hub's own intake and manager routes on +a database of their own and the chain's fake GitHub. Steps authorize through Interchange's credentials-backed authorize +against the grants a deployed run holds: those its definition's walk yields, its credential bindings, and the grants +recorded for it at deploy. + +Events played: open, push, CI pending, success, failure or cancelled on the head, CI finishing on an older head, an +edit, conversion to draft and back to ready, a mail delivered twice, and which queued mail is delivered next. Every +sequence of none to two of them after the open is played and checked, 121 in all, as no sequence with an event that +changes nothing is extended; then 300 seeded random walks of ten. A pull request settles once it is ready for +review, CI on its head has finished and every mail has been delivered. A failure names its +sequence, and a walk's seed, so it replays; a run that ends other than completed, or a sequence that throws, fails too. + +Stand-ins, and what they leave out: + +- Stock's deploy route records the run, its definition's grant requirements as creator grants, and its run tokens. It + checks the caller holds the deploy and the asset's read and refuses a body that names no published release, but it + does not resolve the requirements against the creator's own grants. It ties each manager it deploys to the pull + request named by the mail the listener was running, which R5 reads. +- Stock's stop-run route checks the caller's `manage` grant and ends the run at once, so the window while a cancelled + run still runs is not played. +- Mail waits in the test's queue. Stock's `mailAccept` check and its hold on mail to a just-deployed run are bypassed; + mail to a run that has ended is dropped or refused, as stock does. +- Webhooks enter at the hub's intake as the coalescer flushes them. The bridge and coalescer do not run; the bridge's + rule that a draft's webhooks are dropped is played by the test. +- The intake's hooks are fixed: the listener is always routable, no pull request is paused, the App's login is fixed, + and code review and backlog reads are not served. +- Runs use the real clock, so CI's start grace, the manager's poll and its lifetime are not played. +- The policy is deterministic only, with no judge, no code review and no model. + +## Not covered by the model check + +- Interleaving inside a pass: a push or a second manager while a pass runs, and contention for the claim. +- The cancellation window, `mailAccept` and the listener's `onTrigger` admission, and the real coalescer. +- A fake clock for CI and lifetime timers. +- Judge and code review stand-ins, and so R2. +- Every sequence of three events and more, which takes longer than CI allows on one shard; the seeded walks reach some. diff --git a/package.json b/package.json index a86fc8c9..4f9f60a9 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "e2e", "tests/chain", "tests/policy-corpus", + "tests/model", "vendor/interchange/packages/*", "vendor/interchange/apps/*", "vendor/interchange/tests/admin-ui-e2e" @@ -33,7 +34,7 @@ "test:unit": "bun --conditions=intx-src test --path-ignore-patterns='**/*.dom.test.tsx' --path-ignore-patterns='**/*.postgres.test.ts' ./apps/ ./packages/ ./tooling/ ./tests/policy-corpus/", "test:postgres": "bun --conditions=intx-src test .postgres.test.ts", "test:dom": "bun --conditions=intx-src test --isolate --preload ./apps/web/src/lib/dom-preload.ts .dom.test", - "check": "bun tooling/require-bun.ts && bun tooling/exact-deps-check.ts && bun check --no-script -p tooling && bun check --no-script -p apps/web && bun check --no-script -p apps/extension && bun check --no-script -p apps/extension/tsconfig.background.json && bun check --no-script -p apps/extension/e2e && bun check --no-script -p apps/hub && bun check --no-script -p packages/triage-workflows && bun check --no-script -p packages/triage-manager && bun check --no-script -p packages/triage-judge && bun check --no-script -p packages/pr-code-review && bun check --no-script -p packages/pr-code-review-anthropic && bun check --no-script -p packages/provisioner-test && bun check --no-script -p packages/provisioner-template && bun check --no-script -p packages/provisioner-docker && bun check --no-script -p packages/provisioner-e2b && bun check --no-script -p packages/provisioner-railway && bun check --no-script -p packages/policy-check-action && bun check --no-script -p e2e && bun check --no-script -p tests/chain && bun check --no-script -p tests/policy-corpus && bun build --conditions=intx-src --external @ast-grep/napi apps/hub/src/server.ts --target bun --outfile /dev/null", + "check": "bun tooling/require-bun.ts && bun tooling/exact-deps-check.ts && bun check --no-script -p tooling && bun check --no-script -p apps/web && bun check --no-script -p apps/extension && bun check --no-script -p apps/extension/tsconfig.background.json && bun check --no-script -p apps/extension/e2e && bun check --no-script -p apps/hub && bun check --no-script -p packages/triage-workflows && bun check --no-script -p packages/triage-manager && bun check --no-script -p packages/triage-judge && bun check --no-script -p packages/pr-code-review && bun check --no-script -p packages/pr-code-review-anthropic && bun check --no-script -p packages/provisioner-test && bun check --no-script -p packages/provisioner-template && bun check --no-script -p packages/provisioner-docker && bun check --no-script -p packages/provisioner-e2b && bun check --no-script -p packages/provisioner-railway && bun check --no-script -p packages/policy-check-action && bun check --no-script -p e2e && bun check --no-script -p tests/chain && bun check --no-script -p tests/policy-corpus && bun check --no-script -p tests/model && bun build --conditions=intx-src --external @ast-grep/napi apps/hub/src/server.ts --target bun --outfile /dev/null", "test:chain": "bun --conditions=intx-src test --parallel=2 tests/chain", "lint": "oxlint", "eval:review": "bun --conditions=intx-src packages/pr-code-review-anthropic/evals/review.eval.ts", diff --git a/tests/chain/github.ts b/tests/chain/github.ts index 6170b2d5..3de0f017 100644 --- a/tests/chain/github.ts +++ b/tests/chain/github.ts @@ -10,7 +10,7 @@ const TOKEN = "installation-token"; const READ_TOKEN = "read-token"; export type File = { filename: string; status: string; additions: number; deletions: number; patch: string }; -export type CheckRun = { name: string; status: "queued" | "in_progress" | "completed"; conclusion: "success" | "failure" | null }; +export type CheckRun = { name: string; status: "queued" | "in_progress" | "completed"; conclusion: "success" | "failure" | "cancelled" | null }; export type Pull = { number: number; title: string; @@ -21,6 +21,7 @@ export type Pull = { commits: string[]; labels: string[]; comments: { id: number; user: typeof BOT; body: string }[]; + draft?: boolean; }; export type Write = { method: string; path: string; body: unknown }; @@ -57,7 +58,7 @@ export function fakeGithub() { function pullJson(repo: Repo, pull: Pull) { return { - number: pull.number, title: pull.title, body: pull.body, body_html: `

${pull.body}

`, state: "open", draft: false, merged: false, mergeable: true, mergeable_state: "clean", + number: pull.number, title: pull.title, body: pull.body, body_html: `

${pull.body}

`, state: "open", draft: pull.draft ?? false, merged: false, mergeable: true, mergeable_state: "clean", user: { login: pull.author, type: "User" }, author_association: "CONTRIBUTOR", head: { sha: pull.head, ref: `pr-${pull.number}`, repo: { id: 1, full_name: repo.name } }, base: { ref: "main", sha: "0".repeat(40), repo: { id: 1, full_name: repo.name } }, additions: pull.files.reduce((sum, file) => sum + file.additions, 0), deletions: pull.files.reduce((sum, file) => sum + file.deletions, 0), changed_files: pull.files.length, labels: pull.labels.map((name) => ({ name })), requested_reviewers: [], requested_teams: [], assignees: [], @@ -97,6 +98,7 @@ export function fakeGithub() { if (commit[2] === "status") return Response.json({ state: "success", statuses: [], total_count: 0 }); return Response.json({ total_count: 0, check_suites: [] }); } + if (rest.startsWith("/compare/")) return Response.json({ status: "ahead", ahead_by: 1, behind_by: 0, commits: [] }); if (rest.startsWith("/git/trees/")) return Response.json({ sha: "tree", tree: [], truncated: false }); if (rest.startsWith("/collaborators/")) return Response.json({ permission: "read", role_name: "read" }); if (rest === "/labels" || rest.startsWith("/labels?")) return Response.json([]); @@ -201,6 +203,9 @@ export function fakeGithub() { setChecks(repo: string, head: string, runs: CheckRun[]): void { repos.get(repo)!.checks.set(head, runs); }, + checksOf(repo: string, head: string): CheckRun[] { + return repos.get(repo)!.checks.get(head) ?? []; + }, writesTo(repo: string, number: number): Write[] { return writes.filter((write) => write.path.startsWith(`/repos/${repo}/issues/${number}/`) || write.path.startsWith(`/repos/${repo}/pulls/${number}/`)); }, diff --git a/tests/model/events.ts b/tests/model/events.ts new file mode 100644 index 00000000..f44f698f --- /dev/null +++ b/tests/model/events.ts @@ -0,0 +1,124 @@ +// The events a pull request's sequence is made of, each played on the stack as GitHub and the hub would see it, and the +// seeded random walks the rules are checked over past the sequences played in full. +import { pullOf, sha, type CheckRun } from "../chain/github.ts"; +import type { modelStack } from "./stack.ts"; + +type Stack = Awaited>; + +export const LETTERS = ["push", "ci-pending", "ci-success", "ci-failure", "ci-cancelled", "ci-stale", "edit", "draft", "ready", "duplicate", "run-first", "run-second"] as const; +export type Letter = (typeof LETTERS)[number]; + +/** One pull request a sequence plays on: its number, every head it had, newest last, and whether it is a draft. */ +export type Played = { number: number; heads: string[]; draft: boolean }; + +const BUILD = "build"; + +function current(pull: Played): string { + return pull.heads.at(-1)!; +} + +/** + * The bridge takes in no webhook of a draft but its conversion to one (apps/hub/src/github/bridge.ts), so a draft's + * events reach the hub as nothing. + */ +async function webhook(stack: Stack, pull: Played, event: Parameters[1], head = current(pull)): Promise { + if (pull.draft && event !== "drafted") return; + await stack.webhook(pull.number, event, head); +} + +/** CI on the head settles; GitHub sends a webhook only for a completed check, never for one still running. */ +async function checks(stack: Stack, pull: Played, run: CheckRun, head = current(pull)): Promise { + stack.github.setChecks(stack.repo, head, [run]); + if (run.status === "completed") await webhook(stack, pull, "checks", head); +} + +/** Plays `letter`; false when it changes nothing in this state, so the sequence equals a shorter one. */ +export async function play(stack: Stack, pull: Played, letter: Letter): Promise { + switch (letter) { + case "push": { + const head = sha(`${pull.number}-${pull.heads.length}`); + pull.heads.push(head); + stack.github.pull(stack.repo, pull.number).head = head; + stack.github.setChecks(stack.repo, head, []); + await webhook(stack, pull, "updated"); + return true; + } + case "ci-pending": + await checks(stack, pull, { name: BUILD, status: "in_progress", conclusion: null }); + return true; + case "ci-success": + await checks(stack, pull, { name: BUILD, status: "completed", conclusion: "success" }); + return true; + case "ci-failure": + await checks(stack, pull, { name: BUILD, status: "completed", conclusion: "failure" }); + return true; + case "ci-cancelled": + await checks(stack, pull, { name: BUILD, status: "completed", conclusion: "cancelled" }); + return true; + case "ci-stale": { + const old = pull.heads.at(-2); + if (old === undefined) return false; + await checks(stack, pull, { name: BUILD, status: "completed", conclusion: "success" }, old); + return true; + } + case "edit": + await webhook(stack, pull, "updated"); + return true; + case "draft": + if (pull.draft) return false; + pull.draft = true; + stack.github.pull(stack.repo, pull.number).draft = true; + await webhook(stack, pull, "drafted"); + return true; + case "ready": + if (!pull.draft) return false; + pull.draft = false; + stack.github.pull(stack.repo, pull.number).draft = false; + await webhook(stack, pull, "ready"); + return true; + case "duplicate": + if (stack.queue.length === 0) return false; + stack.duplicate(0); + return true; + case "run-first": + if (stack.queue.length === 0) return false; + await stack.run(0); + return true; + case "run-second": + if (stack.queue.length < 2) return false; + await stack.run(1); + return true; + } +} + +/** Opens pull request `number` at its first head. */ +export async function open(stack: Stack, number: number): Promise { + const pull = { number, heads: [sha(`${number}-0`)], draft: false }; + await stack.open(pullOf(number, current(pull))); + return pull; +} + +/** + * Lets the pull request settle as it does once people stop acting on it: a draft is marked ready, CI on its head + * finishes, and every mail is run. + */ +export async function settle(stack: Stack, pull: Played): Promise { + if (pull.draft) await play(stack, pull, "ready"); + await stack.drain(); + const [run] = stack.github.checksOf(stack.repo, current(pull)); + if (run?.status !== "completed") await play(stack, pull, "ci-success"); + await stack.drain(); +} + +/** A random walk of `length` letters from `seed`, the same on every machine. */ +export function walk(seed: number, length: number): Letter[] { + let state = seed >>> 0; + function next(): number { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296; + } + return Array.from({ length }, () => LETTERS[Math.floor(next() * LETTERS.length)]!); +} diff --git a/tests/model/package.json b/tests/model/package.json new file mode 100644 index 00000000..f9b5b203 --- /dev/null +++ b/tests/model/package.json @@ -0,0 +1,28 @@ +{ + "name": "@corbits/model-test", + "version": "0.1.0", + "private": true, + "license": "SEE LICENSE IN LICENSE.md", + "type": "module", + "dependencies": { + "@corbits/github-reads": "workspace:*", + "@corbits/triage-contracts": "workspace:*", + "@corbits/triage-manager": "workspace:*", + "@intx/agent": "workspace:*", + "@intx/authz": "workspace:*", + "@intx/crypto": "0.5.0", + "@intx/db": "workspace:*", + "@intx/harness": "workspace:*", + "@intx/hub-api": "0.5.0", + "@intx/hub-sessions": "0.5.0", + "@intx/inference": "workspace:*", + "@intx/storage-isogit": "workspace:*", + "arktype": "catalog:", + "@intx/types": "workspace:*", + "@intx/workflow": "workspace:*", + "@intx/workflow-deploy": "0.5.0", + "@intx/workflow-host": "workspace:*", + "drizzle-orm": "catalog:" + }, + "description": "Triage's real listener and manager run in one process through Interchange's runLocal, against the hub's own state and a recording fake GitHub" +} diff --git a/tests/model/rules.postgres.test.ts b/tests/model/rules.postgres.test.ts new file mode 100644 index 00000000..ec42a584 --- /dev/null +++ b/tests/model/rules.postgres.test.ts @@ -0,0 +1,72 @@ +// Requires PostgreSQL at localhost:5432 (docs/DEV.md). Checks the rules in docs/INVARIANTS.md after every sequence of +// events on a pull request, each on a pull request of its own, so no reset is needed between them. +import { afterAll, expect, mock, setDefaultTimeout, test } from "bun:test"; +import { emptyPack } from "@corbits/triage-contracts"; +import { OWNER } from "../chain/github.ts"; +import { LETTERS, open, play, settle, walk, type Letter } from "./events.ts"; +import { broken } from "./rules.ts"; +import { MANAGER_RELEASE, modelStack } from "./stack.ts"; + +/** Every sequence of up to this many events after the open is played and checked; one with an event that changes nothing is not extended. */ +const LENGTH = 2; +const WALKS = 300; +const WALK_LENGTH = 10; + +mock.module(new URL("../../packages/triage-manager/src/release.json", import.meta.url).pathname, function published() { + return { default: { github: { manager: MANAGER_RELEASE } } }; +}); + +const REPO = `${OWNER}/model`; +// Under `ask`, the pr-triage role holds only posting: its labels and its comment. +const stack = await modelStack(REPO, { pack: { ...emptyPack(REPO), checks: { "max-files-changed": { enabled: true }, "ci-green": { enabled: true } } }, automatic: ["labels", "comment"] }); +setDefaultTimeout(5 * 60_000); +afterAll(() => stack.stop()); + +let pulls = 0; + +type Checked = { played: Letter[]; breaks: string[] }; + +/** Plays `letters` after an open on a pull request of its own, each that changes nothing left out, and settles it. */ +async function check(letters: readonly Letter[]): Promise { + const missed = stack.github.misses.length; + const played: Letter[] = []; + try { + const pull = await open(stack, ++pulls); + for (const letter of letters) if (await play(stack, pull, letter)) played.push(letter); + await settle(stack, pull); + return { played, breaks: await broken(stack, pull, missed) }; + } catch (err) { + // The queue may hold the failed sequence's mail; the next sequence starts from none. + stack.queue.length = 0; + return { played: [...letters], breaks: [`threw after ${played.length} events: ${err instanceof Error ? err.message : String(err)}`] }; + } +} + +function replayOf(letters: readonly Letter[]): string { + return ["open", ...letters].join(" "); +} + +test(`every sequence up to ${LENGTH} events after an open keeps the rules`, async () => { + const found: string[] = []; + let level: Letter[][] = [[]]; + for (let size = 0; size <= LENGTH; size++) { + const next: Letter[][] = []; + for (const letters of level) { + const checked = await check(letters); + found.push(...checked.breaks.map((rule) => `${replayOf(letters)}: ${rule}`)); + // A sequence with an event that changed nothing is a shorter one already played, and so is every sequence after it. + if (size < LENGTH && checked.played.length === letters.length) next.push(...LETTERS.map((letter) => [...letters, letter])); + } + level = next; + } + expect(found).toEqual([]); +}); + +test(`${WALKS} seeded walks of ${WALK_LENGTH} events keep the rules`, async () => { + const found: string[] = []; + for (let seed = 0; seed < WALKS; seed++) { + const { played, breaks } = await check(walk(seed, WALK_LENGTH)); + found.push(...breaks.map((rule) => `seed ${seed}, ${replayOf(played)}: ${rule}`)); + } + expect(found).toEqual([]); +}); diff --git a/tests/model/rules.ts b/tests/model/rules.ts new file mode 100644 index 00000000..e6d7a5ff --- /dev/null +++ b/tests/model/rules.ts @@ -0,0 +1,69 @@ +// The rules in docs/INVARIANTS.md that a settled pull request is checked against: what GitHub ends up holding, the +// writes it received, and what is left of its runs and mail. +import { isTriageComment } from "@corbits/github-reads"; +import { HTML_MARKERS, WAITING_LABEL } from "@corbits/triage-contracts"; +import type { Write } from "../chain/github.ts"; +import type { Played } from "./events.ts"; +import type { modelStack } from "./stack.ts"; + +type Stack = Awaited>; + +/** The title a verdict comment opens with under its marker, by the verdict its header names. */ +const VERDICT_TITLES: Record = { pass: "**Triage: Pass**", fail: "**Triage: Fail**", unsure: "**Triage: Unsure**" }; + +/** R1: exactly one current Triage comment, a verdict on the head, and no waiting label left. */ +function oneVerdict(stack: Stack, pull: Played): string[] { + const { comments, labels } = stack.github.pull(stack.repo, pull.number); + const head = pull.heads.at(-1)!; + const current = comments.filter((comment) => isTriageComment(comment.body) && !HTML_MARKERS.isSuperseded(comment.body)); + const breaks: string[] = []; + if (current.length !== 1) breaks.push(`R1: ${current.length} current Triage comments`); + for (const { body } of current) { + const header = HTML_MARKERS.headerOf(body); + if (header?.head !== head) breaks.push(`R1: the Triage comment names ${header?.head}, not the head ${head}`); + const title = header && VERDICT_TITLES[header.verdict]; + if (!title || body.split("\n")[1] !== title) breaks.push(`R1: the Triage comment is no verdict: ${body.split("\n")[1]}`); + } + if (labels.includes(WAITING_LABEL)) breaks.push(`R1: the pull request still has ${WAITING_LABEL}`); + return breaks; +} + +/** + * R3, over every write the pull request received, whichever pass made it: a Triage comment is created only while none + * is current, so never twice unless the one before was superseded, and no label is added while it is on. + */ +function onceEach(writes: readonly Write[]): string[] { + const breaks: string[] = []; + let current = 0; + const on = new Set(); + for (const { method, path, body } of writes) { + const text = (body as { body?: unknown } | null)?.body; + if (method === "POST" && path.endsWith("/comments") && typeof text === "string" && isTriageComment(text)) { + if (current > 0) breaks.push("R3: a Triage comment was created while one was current"); + current += 1; + } else if (method === "PATCH" && path.includes("/issues/comments/") && typeof text === "string" && HTML_MARKERS.isSuperseded(text)) { + current -= 1; + } else if (method === "POST" && path.endsWith("/labels")) { + for (const label of (body as { labels: string[] }).labels) { + if (on.has(label)) breaks.push(`R3: ${label} was added while it was on`); + on.add(label); + } + } else if (method === "DELETE" && path.includes("/labels/")) { + on.delete(decodeURIComponent(path.slice(path.lastIndexOf("/") + 1))); + } + } + return breaks; +} + +/** Each rule the settled pull request breaks, by its number, saying how; `missed` is how many misses the fake GitHub had before it. */ +export async function broken(stack: Stack, pull: Played, missed: number): Promise { + const { live, unattributed, lost, failed } = await stack.leftover(pull.number); + const breaks = [...oneVerdict(stack, pull), ...onceEach(stack.writes(pull.number))]; + if (live.length > 0) breaks.push(`R5: managers still live: ${live.join(", ")}`); + if (unattributed.length > 0) breaks.push(`R5: live managers deployed for no single pull request: ${unattributed.join(", ")}`); + if (lost.length > 0) breaks.push(`R5: mail no run took: ${lost.map((mail) => mail.to).join(", ")}`); + if (failed.length > 0) breaks.push(`runs that did not complete: ${failed.join(", ")}`); + const misses = stack.github.misses.slice(missed); + if (misses.length > 0) breaks.push(`the fake GitHub was asked what it does not serve: ${misses.join(", ")}`); + return breaks; +} diff --git a/tests/model/stack.ts b/tests/model/stack.ts new file mode 100644 index 00000000..b0400ded --- /dev/null +++ b/tests/model/stack.ts @@ -0,0 +1,511 @@ +// Triage's real listener and manager definitions, each run through Interchange's runLocal in this process, against the +// hub's own intake and manager routes on a database of their own and the chain's fake GitHub, which records every +// write. Steps authorize against the grants a deployed run holds, built by Interchange's local credentials snapshot +// from the definition and the grants recorded for the run. Mail between runs waits in one queue the test drains, so +// the order runs see events in is the test's to choose. +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomBytes, randomUUID } from "node:crypto"; +import { and, eq, sql } from "drizzle-orm"; +import { type } from "arktype"; +import { createWorkflowDirectorRegistry } from "@intx/agent"; +import { actionConsumer, authorize, toolConsumer } from "@intx/authz"; +import { createNoopCredentialCipher } from "@intx/crypto"; +import { createDB, createGrantStore, createRunTokenStore, runMigrations, schema } from "@intx/db"; +import { buildCredentialCapabilities, createActionCredentialsFactory, createCredentialProviderRegistry, createHttpCredentialProvider, createInferenceCredentialResolver } from "@intx/harness"; +import { createApp, createRequireGrant, HUB_CONDITION_REGISTRY } from "@intx/hub-api"; +import { createEventCollectorRegistry, createSidecarRouter, type SessionService } from "@intx/hub-sessions"; +import { createDependencies } from "@intx/inference"; +import { loadAdapterRegistry } from "@intx/inference/providers"; +import { createIsogitStore } from "@intx/storage-isogit/node"; +import { SELF_RUN_RESOURCE } from "@intx/types"; +import { HUB_CREDENTIAL_HANDLE, hubCredentialId, type CredentialDelivery } from "@intx/types/credential-delivery"; +import { createRuntimeCapabilities } from "@intx/types/runtime-capabilities"; +import { WorkflowProjectionDefinition } from "@intx/types/sidecar"; +import { projectLiveToInert, runLocal, type ActionHandler, type LoopFn, type OnTriggerPrimitive, type StepInvokeRequest, type WorkflowDefinition } from "@intx/workflow"; +import { collectHubConsumers } from "@intx/workflow-deploy"; +import { createCredentialsBackedAuthorize, createGrantEvaluator, createLocalCredentialsSnapshot, createWorkflowStepInvoker, snapshotStepGrants } from "@intx/workflow-host/local"; +import { DEFAULT_REPO_POLICY, TRIAGE_LISTENERS, TRIAGE_MAIL_SENDER, TRIAGE_MANAGERS, type ActionKind, type CheckPack, type TriageEvent } from "@corbits/triage-contracts"; +import * as actions from "@corbits/triage-manager/actions/index"; +import { mailDirector } from "@corbits/triage-manager/directors"; +import * as loops from "@corbits/triage-manager/loops"; +import type { Release } from "@corbits/triage-manager/release"; +import { triageListener } from "@corbits/triage-manager/triage-listener"; +import { triageManager } from "@corbits/triage-manager/triage-manager"; +import { mailPayload } from "../../apps/hub/src/github/bridge.ts"; +import { RunUnavailableError } from "../../apps/hub/src/github/deployment.ts"; +import { createManagerIntake, createManagerWake, createSupersede } from "../../apps/hub/src/manager-intake.ts"; +import { MANAGER_RUN_TOKEN_ROUTES } from "../../apps/hub/src/manager-routes.ts"; +import type { PullRequestRef } from "../../apps/hub/src/manager-state.ts"; +import { mountTenantRoutes } from "../../apps/hub/src/tenant-routes.ts"; +import { migrateTriageTables } from "../../apps/hub/src/triage-db.ts"; +import { fakeGithub, type Pull, type Write } from "../chain/github.ts"; + +const ADMIN_DB = { host: "localhost", port: 5432, user: "postgres", password: "postgres", database: "interchange" }; +const TENANT = "tnt_model"; +const DOMAIN = "model.test"; +const HUB_ORIGIN = "https://hub.test"; +const GITHUB_ORIGIN = "https://api.github.com"; +const APP_LOGIN = "corbits[bot]"; +const OWNER_PRINCIPAL = "prn_owner"; +const LISTENER_RUN = "run_listener"; + +/** The manager the release publishes beside the listener, as the hub writes it into `release.json`. */ +export const MANAGER_RELEASE: Release = { + workflow: { assetId: `ast_${TRIAGE_MANAGERS.github}`, commitSha: "c0ffee", packageName: "@corbits/triage-manager", entry: "./triage-manager-github.mjs" }, + offerings: { ids: ["off_model"], defaultId: "off_model" }, +}; + +/** The definitions as the published `triage-listener-github` and `triage-manager-github` entries build them from that release. */ +const listenerDefinition = triageListener({ id: TRIAGE_LISTENERS.github, manager: MANAGER_RELEASE }); +const managerDefinition = triageManager({ id: TRIAGE_MANAGERS.github, sources: ["github"], codeReview: undefined }); + +/** Each published release by its asset, and the definition it deploys. */ +const RELEASED = new Map([[MANAGER_RELEASE.workflow.assetId, { release: MANAGER_RELEASE, definition: managerDefinition }]]); + +const DeployBody = type({ + source: { kind: "'asset'", assetId: "string", package: { format: "'source'", commitSha: "string", packageName: "string" } }, + entry: "string", + sourceOfferingIds: "string[]", + defaultSourceOfferingId: "string", +}); +const MailText = type({ "prNumber?": "number", "items?": type({ prNumber: "number" }).array() }); + +type Db = ReturnType["db"]; +type Run = { definition: WorkflowDefinition; principalId: string; tokens: ReadonlyMap }; + +const sessionService: SessionService = { + stageWorkflowStep() { + throw new Error("no session runs here"); + }, + endSession() { + throw new Error("no session runs here"); + }, +}; + +/** A mail as stock hands it to the run it is addressed to. */ +function stockMail(from: string, to: string, text: string) { + return { headers: { from, to: [to] }, rawHeaders: {}, parts: [{ contentType: "text/plain", ref: "part-0", text }] }; +} + +/** A mail waiting for its run; a copy the test delivers a second time keeps its `id`. */ +export type Mail = { id: string; to: string; payload: ReturnType }; + +/** The consumers of the hub credential, each of which gets a run token, as deploy reads them off the definition's projection. */ +function hubConsumers(definition: WorkflowDefinition): readonly string[] { + return collectHubConsumers(WorkflowProjectionDefinition.assert(projectLiveToInert(definition))); +} + +function tokenOf(tokens: ReadonlyMap, consumer: string): string { + const token = tokens.get(consumer); + if (token === undefined) throw new Error(`no run token was minted for ${consumer}`); + return token; +} + +/** The pull requests a mail names: a manager's mail names one, the hub's mail to the listener one or more. */ +function pullsOfMail(mail: Mail): number[] { + const named = MailText(JSON.parse(mail.payload.parts[0]!.text)); + if (named instanceof type.errors) throw new Error(`mail to ${mail.to}: ${named.summary}`); + return named.items?.map((item) => item.prNumber) ?? (named.prNumber === undefined ? [] : [named.prNumber]); +} + +/** A database of the hub's own schema with one tenant whose `repo` is connected, and the two triage definitions. */ +async function createDatabase(repo: string) { + const name = `corbits_model_${randomBytes(6).toString("hex")}`; + const admin = createDB(ADMIN_DB); + try { + await admin.db.execute(sql.raw(`create database ${name}`)); + } finally { + await admin.close(); + } + const config = { ...ADMIN_DB, database: name }; + await runMigrations(config, { schema: "public" }); + const handle = createDB(config); + await migrateTriageTables(handle.db, "public"); + await handle.db.insert(schema.tenant).values({ id: TENANT, name: "Model", slug: "model", domain: DOMAIN, config: { corbitsTriage: { repos: [{ name: repo, connected: true, enabled: true }] } } }); + await handle.db.insert(schema.principal).values({ id: OWNER_PRINCIPAL, tenantId: TENANT, kind: "user", refId: "usr_owner", status: "active" }); + for (const workflow of [TRIAGE_LISTENERS.github, TRIAGE_MANAGERS.github]) { + await handle.db.insert(schema.asset).values({ id: `ast_${workflow}`, tenantId: TENANT, kind: "workflow", name: workflow, creatorPrincipalId: OWNER_PRINCIPAL }); + await handle.db.insert(schema.workflowDefinition).values({ id: `wfd_${workflow}`, tenantId: TENANT, name: workflow, assetId: `ast_${workflow}` }); + } + async function drop(): Promise { + await handle.close(); + const again = createDB(ADMIN_DB); + try { + await again.db.execute(sql.raw(`drop database if exists ${name} with (force)`)); + } finally { + await again.close(); + } + } + return { db: handle.db, drop }; +} + +async function noSession() { + return null; +} + +function noAuthRoute(): Response { + return new Response(null, { status: 404 }); +} + +/** The hub's stock app with its run-token routes and the hub's manager routes mounted, as the hub serves them. */ +function hubApp(db: Db, wake: ReturnType, supersede: (tenantId: string, pr: PullRequestRef) => Promise, log: (entry: Record) => void) { + const app = createApp({ + getSession: noSession, + authHandler: noAuthRoute, + db, + sidecarRouter: createSidecarRouter({ + async withExecutableWorkflowRun(_target, send) { + return send(); + }, + async authenticateSidecar({ sidecarId }) { + return { sidecarId }; + }, + async resolveSidecarBindings() { + return []; + }, + async validateSidecarIdentity() { + return true; + }, + }), + sessionService, + eventCollectors: createEventCollectorRegistry({ db }), + assetService: null, + repoStore: null, + maxTarballBytes: 10_000_000, + credentialCipher: createNoopCredentialCipher(), + extraRunTokenRoutes: MANAGER_RUN_TOKEN_ROUTES, + }); + mountTenantRoutes(app, { db, requireGrant: createRequireGrant({ grantStore: createGrantStore(db), conditionRegistry: HUB_CONDITION_REGISTRY }), log, wake, supersede, taken: noteTaken }); + return app; +} + +function noteTaken(): void {} + +/** The hub's log lines; the rules read what GitHub and the hub hold, never what was logged. */ +function log(): void {} + +/** + * A repository with `pack` as its policy, `automatic` the kinds the pr-triage role holds on it, and everything a run of + * it reaches; call `stop` after. + */ +export async function modelStack(repo: string, { pack, automatic }: { pack: CheckPack; automatic: ActionKind[] }) { + const github = fakeGithub(); + github.addRepo(repo, null); + const { db, drop } = await createDatabase(repo); + const grantStore = createGrantStore(db); + const queue: Mail[] = []; + const runs = new Map(); + /** Each manager run's pull request, read off the mail its listener was deploying it for; none when that mail named several. */ + const managed = new Map(); + /** The pull request being played when each manager run was deployed: a manager attributed to none is reported for that one. */ + const deployedWhile = new Map(); + let playing = 0; + /** Mail dropped at a run that had ended, or refused when sent to one; one whose copy a run took is not lost. */ + const dropped: Mail[] = []; + const delivered = new Set(); + /** Each run whose body ended other than completed, or a step that threw. */ + const failed: { mail: Mail; status: string }[] = []; + const directors = createWorkflowDirectorRegistry([mailDirector.factory]); + const adapters = await loadAdapterRegistry([], { import: async function noAdapters() { return {}; } }); + const runTokens = createRunTokenStore({ db, cipher: createNoopCredentialCipher() }); + + /** + * Stock's deploy: a run of `definition` with its own principal, the grants its definition requires of its creator, + * the self-run requirement bound to the run, and a run token for each consumer of the hub credential. + */ + async function deployRun(runId: string, definition: WorkflowDefinition): Promise { + const principalId = `prn_${runId}`; + await db.insert(schema.principal).values({ id: principalId, tenantId: TENANT, kind: "workflow", refId: runId, status: "active" }); + await db.insert(schema.workflowRun).values({ id: runId, anchorRunId: runId, definitionId: `wfd_${definition.id}`, tenantId: TENANT, principalId, status: "deployed" }); + for (const requirement of definition.grantRequirements ?? []) { + const resource = requirement.resource === SELF_RUN_RESOURCE ? `workflow-run:${runId}` : requirement.resource; + await db.insert(schema.grant).values({ id: randomUUID(), tenantId: TENANT, principalId, resource, action: requirement.action, effect: "allow", origin: "creator" }); + } + await runTokens.mintIfAbsent({ principalId, consumers: [...hubConsumers(definition)] }, db); + const delivery = await runTokens.loadDelivery(principalId); + runs.set(runId, { definition, principalId, tokens: new Map(delivery.map((row) => [row.consumer, row.token])) }); + } + + async function live(runId: string): Promise { + const [run] = await db.select({ status: schema.workflowRun.status }).from(schema.workflowRun).where(eq(schema.workflowRun.id, runId)); + return run !== undefined && schema.isLiveWorkflowRunStatus(run.status); + } + + async function endRun(runId: string): Promise { + await db.update(schema.workflowRun).set({ status: "cancelled" }).where(eq(schema.workflowRun.id, runId)); + } + + async function granted(runId: string, resource: string, action: string): Promise { + const run = runs.get(runId); + if (!run) return false; + return (await authorize(grantStore, run.principalId, TENANT, resource, action, { conditions: HUB_CONDITION_REGISTRY })).effect === "allow"; + } + + async function cancelForHub(_tenantId: string, runId: string): Promise { + await endRun(runId); + } + + const { supersede } = createSupersede({ db, cancel: cancelForHub, log }); + + /** The hub's mail to a run, as `deliverToListener` sends it: a run that is not live takes none. */ + async function deliver(_tenantId: string, address: string, payload: unknown): Promise { + if (!(await live(address.slice(0, address.indexOf("@"))))) throw new RunUnavailableError(`${address} is not a live deployment`); + queue.push({ id: randomUUID(), to: address, payload: stockMail(`${TRIAGE_MAIL_SENDER}@${DOMAIN}`, address, JSON.stringify(payload)) }); + } + + async function listener(): Promise { + return `${LISTENER_RUN}@${DOMAIN}`; + } + + async function routable(): Promise { + return true; + } + + async function withAppLogin(_tenantId: string, payload: Record): Promise> { + return { ...payload, appLogin: APP_LOGIN }; + } + + async function ungated(_tenantId: string, _record: unknown, _number: number, request: Record): Promise> { + return request; + } + + const wake = createManagerWake({ db, listener, deliver }); + const app = hubApp(db, wake, supersede, log); + const toListener = createManagerIntake({ + db, + listener, + routable, + readPull: function readPull() { + throw new Error("no code review in this stack"); + }, + openHeads: function openHeads() { + throw new Error("no backlog in this stack"); + }, + withAppLogin, + gate: ungated, + deliver, + supersede, + log, + }); + + /** Stock's deploy route: the caller must hold the deploy and the asset's read, and the body must name a published release exactly. */ + async function deployRoute(runId: string, serving: readonly number[], init?: RequestInit): Promise { + const body = DeployBody(JSON.parse(String(init?.body))); + if (body instanceof type.errors) return Response.json({ error: { code: "invalid_request", message: body.summary } }, { status: 400 }); + const { assetId, package: pkg } = body.source; + if (!(await granted(runId, "workflow:*", "create")) || !(await granted(runId, `asset:${assetId}`, "read"))) return Response.json({ error: { code: "forbidden" } }, { status: 403 }); + const released = RELEASED.get(assetId); + const { workflow } = released?.release ?? {}; + if (!released || workflow?.packageName !== pkg.packageName || workflow.commitSha !== pkg.commitSha || workflow.entry !== body.entry) { + return Response.json({ error: { code: "not_found", message: `${assetId} publishes no ${pkg.packageName}@${pkg.commitSha} ${body.entry}` } }, { status: 404 }); + } + const id = `run_${randomBytes(6).toString("hex")}`; + await deployRun(id, released.definition); + managed.set(id, serving.length === 1 ? serving[0] : undefined); + deployedWhile.set(id, playing); + return Response.json({ id }, { status: 201 }); + } + + /** Stock's stop-run route: the caller must hold `manage` on the run; stock then ends it, here at once. */ + async function stopRoute(runId: string, target: string): Promise { + if (!(await granted(runId, `workflow-run:${target}`, "manage"))) return Response.json({ error: { code: "forbidden" } }, { status: 403 }); + if (!(await live(target))) return Response.json({ error: { code: "workflow_run_terminal" } }, { status: 409 }); + await endRun(target); + return new Response(null, { status: 202 }); + } + + /** + * The hub as `runId` reaches it while it runs mail naming `serving`: stock's deploy and stop-run routes stand in here, + * the rest is the hub's own app. + */ + async function hubFetch(runId: string, serving: readonly number[], url: URL, init?: RequestInit): Promise { + const method = init?.method ?? "GET"; + const runPath = /^\/api\/tenants\/[^/]+\/workflows\/runs\/([^/]+)$/.exec(url.pathname); + if (method === "POST" && url.pathname === `/api/tenants/${TENANT}/workflows/deployments`) return deployRoute(runId, serving, init); + if (method === "DELETE" && runPath) return stopRoute(runId, decodeURIComponent(runPath[1]!)); + if (runPath) throw new Error(`${runId} read run ${runPath[1]}, which this stack does not serve`); + return app.request(url, init); + } + + /** What the hub delivers `runId`: GitHub for its definition's bindings, and each consumer of the hub credential its own run token. */ + function deliveryOf({ definition, tokens }: Run): CredentialDelivery { + const bindings = (definition.credentialBindings ?? []) as readonly ({ handle: string; package: string } | { handle: string; action: string })[]; + const hub = hubConsumers(definition); + return { + bindings: [ + ...bindings.map((binding) => ({ handle: binding.handle, credentialId: "github", consumer: "package" in binding ? toolConsumer(binding.package) : actionConsumer(binding.action) })), + ...hub.map((consumer) => ({ handle: HUB_CREDENTIAL_HANDLE, credentialId: hubCredentialId(consumer), consumer })), + ], + materials: [ + { credentialId: "github", providerKey: "http", origin: GITHUB_ORIGIN, secret: "installation-token" }, + ...hub.map((consumer) => ({ credentialId: hubCredentialId(consumer), providerKey: "http", origin: HUB_ORIGIN, secret: tokenOf(tokens, consumer) })), + ], + }; + } + + /** Runs one mail's body of the run it is addressed to to its end, as the run's sidecar would; stock drops mail to a run that ended. */ + async function runMail(mail: Mail): Promise { + const runId = mail.to.slice(0, mail.to.indexOf("@")); + const run = runs.get(runId); + if (!run) throw new Error(`mail to ${mail.to}, which is no run`); + if (!(await live(runId))) { + dropped.push(mail); + return; + } + delivered.add(mail.id); + await db.update(schema.workflowRun).set({ status: "running" }).where(and(eq(schema.workflowRun.id, runId), eq(schema.workflowRun.status, "deployed"))); + const serving = pullsOfMail(mail); + const cell = { current: deliveryOf(run) }; + const requirements = await grantStore.collectGrants(run.principalId, TENANT); + const snapshot = { current: createLocalCredentialsSnapshot({ definition: run.definition, directors, delivery: cell.current, grants: requirements }) }; + const workflowAuthorize = createCredentialsBackedAuthorize(snapshot, createGrantEvaluator({ floorFor: noFloor })); + async function routed(input: string | URL | Request, init?: RequestInit): Promise { + const url = new URL(input instanceof Request ? input.url : input); + if (url.origin === HUB_ORIGIN) return hubFetch(runId, serving, url, init); + return fetch(`${github.origin}${url.pathname}${url.search}`, init); + } + const providers = createCredentialProviderRegistry([createHttpCredentialProvider({ fetch: routed })]); + const address = `${runId}@${DOMAIN}`; + const root = mkdtempSync(join(tmpdir(), "corbits-model-")); + + /** Stock's transport: mail to a run that is not live is refused, and lost to the run that sent it. */ + async function send(message: { to: string; content: string }) { + const sent = { id: randomUUID(), to: message.to, payload: stockMail(address, message.to, message.content) }; + if (!(await live(message.to.slice(0, message.to.indexOf("@"))))) { + dropped.push(sent); + throw new Error(`${message.to} is not a live run`); + } + queue.push(sent); + return { messageId: randomUUID(), status: "queued" }; + } + + /** What the sidecar gives the mail agent's step: its run's address, the mail transport and the credentials its grants allow. */ + async function buildEnv(req: StepInvokeRequest) { + const workdir = mkdtempSync(join(root, "step-")); + const storage = await createIsogitStore(workdir); + const toolCwd = join(workdir, "workspace"); + mkdirSync(toolCwd); + const factories = req.agent.toolFactories.map((factory) => ({ packageName: factory.id, declaredCredentials: [], factory })); + const stepGrants = snapshotStepGrants(snapshot, req.authzContext.stepId!); + const [credentials] = buildCredentialCapabilities(factories, { materialCell: cell, resolveGrants: function stepGrantsOf() { return stepGrants; }, providers }).values(); + const source = { id: "unused", provider: "unused", baseURL: "http://127.0.0.1:9", credentialId: "unused", model: "unused" }; + return { + sources: [source], + defaultSource: source.id, + storage, + audit: storage, + workdir, + toolCwd, + directors, + deps: createDependencies(adapters), + readCurrentMaterial: createInferenceCredentialResolver(cell), + capabilities: createRuntimeCapabilities(credentials ? { credentials } : {}), + address, + transport: { send }, + }; + } + + function resolveAction(ref: string): ActionHandler { + return (actions as Record)[ref]!; + } + + function resolveLoop(ref: string): LoopFn { + return (loops as Record)[ref]!; + } + + function actionGrants({ stepId }: { stepId: string }) { + return snapshotStepGrants(snapshot, stepId); + } + + const body = (run.definition.steps["events"] as OnTriggerPrimitive).body; + if (!("inline" in body)) throw new Error(`${run.definition.id}'s body is not inline`); + try { + const { terminalStatus } = await runLocal(body.inline, { + authorize: workflowAuthorize, + directors, + hasUpstreamSignalResolver: true, + invokeStep: createWorkflowStepInvoker({ workflowAuthorize, buildEnv }), + actionResolver: resolveAction, + actionCredentials: createActionCredentialsFactory({ materialCell: cell, resolveStepGrants: actionGrants, providers }), + loopFns: resolveLoop, + triggerPayload: mail.payload, + }).complete; + if (terminalStatus !== "completed") failed.push({ mail, status: terminalStatus }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + } + + await deployRun(LISTENER_RUN, listenerDefinition); + await db.update(schema.workflowRun).set({ status: "running" }).where(eq(schema.workflowRun.id, LISTENER_RUN)); + + /** The hub takes in a webhook on pull request `number` as the bridge mails it once its window flushes: `event` on `head`. */ + async function webhook(number: number, event: TriageEvent, head: string): Promise { + const request = mailPayload({ name: repo }, { ...DEFAULT_REPO_POLICY, enabled: true }, { pack, automatic, source: { file: { path: ".corbits-triage.yml", sha: "model-1" } } }, { tenantId: TENANT, prNumber: number, headSha: head, deliveryId: randomUUID(), events: [event] }); + if (!(await toListener(TENANT, request))) throw new Error(`the hub took no mail for ${repo}#${number}`); + } + + function ofPull(number: number) { + return function named(mail: Mail): boolean { + return pullsOfMail(mail).includes(number); + }; + } + + return { + github, + repo, + queue, + webhook, + /** GitHub shows `pull` with no checks yet, and the hub takes in its opened webhook. */ + async open(pull: Pull): Promise { + github.open(repo, pull); + playing = pull.number; + await webhook(pull.number, "opened", pull.head); + }, + /** Runs the queued mail at `index` to its end, as the run it is addressed to. */ + async run(index: number): Promise { + const [mail] = queue.splice(index, 1); + if (!mail) throw new Error(`no mail is queued at ${index}`); + await runMail(mail); + }, + /** Delivers the queued mail at `index` a second time. */ + duplicate(index: number): void { + const mail = queue[index]; + if (!mail) throw new Error(`no mail is queued at ${index}`); + queue.push(mail); + }, + /** Runs each queued mail in turn, oldest first, until none is left. */ + async drain(): Promise { + for (let mail = queue.shift(); mail; mail = queue.shift()) await runMail(mail); + }, + /** + * What is left of pull request `number`'s runs and mail: its managers still live, live managers deployed while it was + * played that no pull request can be told for, mail no run took, and runs that did not complete. + */ + async leftover(number: number) { + const rows = await db.select({ id: schema.workflowRun.id, status: schema.workflowRun.status }).from(schema.workflowRun).where(eq(schema.workflowRun.definitionId, `wfd_${TRIAGE_MANAGERS.github}`)); + const running = rows.filter((row) => schema.isLiveWorkflowRunStatus(row.status)).map((row) => row.id); + const named = ofPull(number); + const failures = failed.filter((failure) => named(failure.mail)).map((failure) => `${failure.mail.to} ${failure.status}`); + const lost = dropped.filter((mail) => named(mail) && !delivered.has(mail.id)); + const unattributed = running.filter((id) => managed.get(id) === undefined && deployedWhile.get(id) === number); + return { live: running.filter((id) => managed.get(id) === number), unattributed, lost, failed: failures }; + }, + /** The writes to pull request `number` and to its comments, in the order GitHub received them. */ + writes(number: number): Write[] { + const own = new Set(github.writesTo(repo, number)); + const comments = new Set(github.pull(repo, number).comments.map((comment) => `/repos/${repo}/issues/comments/${comment.id}`)); + return github.writes.filter((write) => own.has(write) || comments.has(write.path)); + }, + async stop(): Promise { + github.stop(); + await drop(); + }, + }; +} + +function noFloor() { + return []; +} diff --git a/tests/model/tsconfig.json b/tests/model/tsconfig.json new file mode 100644 index 00000000..303fb208 --- /dev/null +++ b/tests/model/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "customConditions": ["intx-src"] + }, + "include": ["."] +}