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": ["."]
+}