GIT6, GIT9: merge-pull merges the head the caller names and asks no Approve; it refuses every merge until the first registrations

merge-pull answers its refused-kind (head-moved, unprotected, git-refused, no-registrations); an already merged pull request at
that head is answered performed. The failed-steps refusal (policy-failed) is GIT7, stopped on a design question.
GIT9: registrations.ts learns the first registrations from the seat mesh-catalog's verb registrations at the start and its event
registration-changed; the code artifact needs mesh-delivery-assigner:program.

Builds: design 47 G §38, §129, §314, §95, §96, §130, §353, §432

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
2026-11-11 11:36:37 +01:00
parent 975f1aa8e8
commit a00429bc80
5 changed files with 115 additions and 20 deletions
+9 -2
View File
@@ -42,11 +42,12 @@ import { state } from "@novox/mesh-sdk/state";
import { on } from "@novox/mesh-sdk/events";
import { broker } from "@novox/mesh-sdk/messaging";
import { STATUS_STATE, keepStatus, statusTools, viewWaitsFinding, type Follower, type Status } from "@novox/mesh-sdk/views";
import type { CatalogRevisionEvent, RevisionInput } from "@novox/mesh-delivery-contracts-mesh-catalog";
import type { CatalogRevisionEvent, RegistrationChangedEvent, RevisionInput } from "@novox/mesh-delivery-contracts-mesh-catalog";
import { CATALOG_REVISION_EVENT, CatalogView, findingValue, unansweredFinding } from "./catalog.js";
import { announce, keepWatermark, readWatermark } from "./announce.js";
import { SEAT_ONLY, sayOnSeat } from "./seatevents.js";
import { FORGE_CONTAINER, seatTools } from "./seat.js";
import { REGISTRATION_CHANGED_EVENT, Registrations } from "./registrations.js";
import { run } from "./delivery.js";
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
import { keepMainRules, mainRulesStep } from "./mainrules.js";
@@ -390,13 +391,15 @@ const writes = (): DeliveredWrites => (writesOnce ??= new DeliveredWrites(GiteaA
// module's state first-views, which gitea_first_views reads; gitea_first_view_again asks it again by hand (mesh-sdk views).
const firstViews = state<Status>(STATUS_STATE);
const followers: Follower[] = [];
// The first registrations switch merge-pull on (registrations.ts; design 47 §95).
const registrations = new Registrations((verb, input) => broker().request(verb, input), (line) => console.log(`[gitea] ${line}`));
// gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729). The seat git's verbs are
// served here, beside the one writer of a head's statuses: the seat's status takes its turn on the head's chain, after the
// announcement's pending and a trunk's move, so a recheck's pending never lands over a later verdict (issue 456).
registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll), ...writeTools(writes), ...statusTools("gitea", "gitea_first_views", "gitea_first_view_again", followers, firstViews,
async (act) => { await broker().request("seat:mesh-hand-acts.hand-act", act); }),
...(gitea ? seatTools(gitea, { run, container: FORGE_CONTAINER, statuses: gates }) : [])]);
...(gitea ? seatTools(gitea, { run, container: FORGE_CONTAINER, statuses: gates, registered: () => registrations.written }) : [])]);
/** What the forge's event asks of the announcer at once (issue 729, 1422): see watch.ts. */
const actOnEvent = (client: GiteaClient, event: ForgeEvent): Promise<void> => actOn<RepoOf, PullOf>(event, {
@@ -533,6 +536,10 @@ if (gitea) {
// What is no catalog-revision event is said and taken: asked again, it would be no more readable.
view.hear(event.body).catch((err) => console.error(`[gitea] ${err instanceof Error ? err.message : err}`))));
void view.follower.start().then(() => w.viewed());
void bindUntilHeld(REGISTRATION_CHANGED_EVENT, () => on<RegistrationChangedEvent>(REGISTRATION_CHANGED_EVENT, async (event) => {
try { registrations.hear(event.body); } catch (err) { console.error(`[gitea] ${err instanceof Error ? err.message : err}`); }
}));
void registrations.start();
} catch (err) {
console.error(`[gitea] the merging account is not given write on the delivered repositories: no site administrator's login — ${err instanceof Error ? err.message : err}`);
}
+6 -1
View File
@@ -499,6 +499,9 @@
"name": "code",
"kind": "bundle",
"language": "typescript",
"needs": [
"mesh-delivery-assigner:program"
],
"entrypoints": [
"index.js",
"tools/index.js"
@@ -644,10 +647,12 @@
],
"invokes": [
"seat:mesh-catalog.revision",
"seat:mesh-catalog.registrations",
"seat:mesh-hand-acts.hand-act",
"mesh-delivery-builder.builder_versions"
],
"consumes": [
"mesh-catalog.catalog-revision"
"mesh-catalog.catalog-revision",
"mesh-catalog.registration-changed"
]
}
+52
View File
@@ -0,0 +1,52 @@
// **The first registrations switch the merge on** (novox/hq design 47 §95, §353): until the catalog half has said its first
// `registration-changed`, the seat git's `merge-pull` refuses every merge naming the missing registrations, because a merge
// made with no Approve could reach the old assigner, which writes wants from the trunk with no pipeline. The module learns it
// two ways, both through the seat `mesh-catalog`: its verb `registrations` asked at the start (a restart after the first
// registrations were written), and its event `registration-changed` (the first being written, or heard again from the
// seat's kept stream). Neither is the holder's state or a module named.
import {
seat as CATALOG, verbs as CATALOG_VERBS, events as CATALOG_EVENTS,
type RegistrationChangedEvent, type RegistrationsAnswer, type RegistrationsInput,
} from "@novox/mesh-delivery-contracts-mesh-catalog";
/** The seat verb asked, as the manifest's `invokes` names it. */
export const REGISTRATIONS_VERB = `seat:${CATALOG}.${CATALOG_VERBS.registrations}`;
/** The seat event heard, as the manifest's `consumes` names it. */
export const REGISTRATION_CHANGED_EVENT = `${CATALOG}.${CATALOG_EVENTS.registrationChanged}`;
export class Registrations {
private first = false;
constructor(private readonly ask: (verb: string, input: RegistrationsInput) => Promise<unknown>,
private readonly log: (line: string) => void = () => {}) {}
/** Whether the first registrations are written: the one fact the merge switches on. */
get written(): boolean {
return this.first;
}
/** One `registration-changed`: the first registration written, however many were before this module started. */
hear(ev: RegistrationChangedEvent | undefined): void {
if (!ev || typeof ev.module !== "string" || ev.module === "") {
throw new Error(`not a ${REGISTRATION_CHANGED_EVENT} event: ${JSON.stringify(ev ?? null).slice(0, 200)}`);
}
if (!this.first) this.log(`the first registration is written (${ev.module}); merge-pull merges from here`);
this.first = true;
}
/** The start's first view: any registration held is the first written. A failed ask is said and left to the event. */
async start(): Promise<void> {
try {
let a: unknown = await this.ask(REGISTRATIONS_VERB, {});
if (typeof a === "string") a = JSON.parse(a);
const r = (a as RegistrationsAnswer | undefined)?.registrations;
if (!Array.isArray(r)) throw new Error(`${REGISTRATIONS_VERB} answered no registrations: ${JSON.stringify(a ?? null).slice(0, 200)}`);
if (r.length > 0) {
this.first = true;
this.log(`${r.length} registrations are held; merge-pull merges`);
}
} catch (err) {
this.log(`the registrations could not be read: ${err instanceof Error ? err.message : String(err)}; merge-pull waits for registration-changed`);
}
}
}
+14 -8
View File
@@ -55,6 +55,8 @@ export interface SeatDeps {
run: Runner;
container: string;
statuses: HeadStatuses;
/** Whether the first registrations were written (registrations.ts): until then merge-pull refuses every merge (design 47 §95). */
registered: () => boolean;
/** The config value `callers` as the runtime answers it; the runtime's own config read by default. */
callers?: () => Promise<unknown>;
}
@@ -277,7 +279,7 @@ export function seatTools(gitea: GiteaClient, deps: SeatDeps): ToolDefinition[]
return said.added ? { id, performed: true } : { id, held: true, performed: true };
}),
verb(GIT_VERBS.mergePull, "The seat git's merge-pull: an act of the forge bridge, a pull request merged at the head the caller names; it judges no check.",
async (args): Promise<MergePullAnswer> => mergePull(gitea, args)),
async (args): Promise<MergePullAnswer> => mergePull(gitea, args, deps.registered)),
];
}
@@ -291,31 +293,35 @@ function holds(comments: GiteaComment[], id: string): boolean {
}
/** merge-pull (design 47 §314): only the head the caller names, only into a base that takes no push, no check judged. */
export async function mergePull(gitea: GiteaClient, args: Record<string, unknown>): Promise<MergePullAnswer> {
export async function mergePull(gitea: GiteaClient, args: Record<string, unknown>, registered: () => boolean): Promise<MergePullAnswer> {
const id = idOf(args.id);
const { owner, repo, full } = repositoryOf(args.repository);
const number = numberOf(args.number);
const head = commitOf("head", args.head);
const no = (why: string): MergePullAnswer => ({ id, refused: `${full}#${number} is not merged: ${why}` });
const no = (why: string, kind: NonNullable<MergePullAnswer["refused-kind"]>): MergePullAnswer =>
({ id, refused: `${full}#${number} is not merged: ${why}`, "refused-kind": kind });
// Until the first registrations are written no merge is made: the old assigner would write a want from the trunk with no
// pipeline (design 47 §95). It names what is missing.
if (!registered()) return no("no module is registered yet: the catalog has said no registration-changed, so no merge is made until the first registrations are written", "no-registrations");
const pull = await gitea.getPullRequest(owner, repo, number);
if (pull.merged) {
// Already merged at that head: performed (the act said again after its answer was lost); at another, refused.
return pull.head_sha === head ? { id, performed: true, held: true, ...(pull.merge_commit_sha ? { "merge-commit": pull.merge_commit_sha } : {}) }
: no(`it was merged at ${String(pull.head_sha).slice(0, 12)}, not the ${head.slice(0, 12)} the caller names`);
: no(`it was merged at ${String(pull.head_sha).slice(0, 12)}, not the ${head.slice(0, 12)} the caller names`, "head-moved");
}
if (pull.state !== "open") return no("it is closed");
if (pull.state !== "open") return no("it is closed", "git-refused");
if (pull.head_sha !== head) {
return no(`its head is ${String(pull.head_sha).slice(0, 12)}, not the ${head.slice(0, 12)} the caller names`);
return no(`its head is ${String(pull.head_sha).slice(0, 12)}, not the ${head.slice(0, 12)} the caller names`, "head-moved");
}
const rule = await gitea.appliedProtection(owner, repo, String(pull.base ?? ""));
if (!pushRefused(rule)) {
return no(`its base ${pull.base} is guarded by no protection rule that refuses a push`);
return no(`its base ${pull.base} is guarded by no protection rule that refuses a push`, "unprotected");
}
try {
await gitea.mergePullRequest(owner, repo, number, "merge", true, head);
} catch (err) {
const f = failure(err);
if (f["error-kind"] === "refused") return no(`the forge refused the merge: ${f.error}`);
if (f["error-kind"] === "refused") return no(`the forge refused the merge: ${f.error}`, "git-refused");
throw err;
}
const merged = await gitea.getPullRequest(owner, repo, number);
+34 -9
View File
@@ -439,6 +439,7 @@ test("the seat git's verbs judge their caller, and merge-pull merges the Approve
const { seatTools, gitCallers } = await import("../dist/seat.js");
const { runAs } = await import("@novox/mesh-sdk/callers");
const bridge = "ace.mesh-delivery-forge-bridge";
let registered = true;
const list = JSON.stringify({ publish: ["mesh-build-go"], "merge-pull": ["mesh-delivery-forge-bridge"],
pull: ["mesh-delivery-forge-bridge", "mesh-delivery-gate"], status: ["mesh-delivery-forge-bridge"] });
assert.deepEqual(Object.keys(gitCallers(list)).sort(), ["merge-pull", "pull", "status"], "only the seat git's lines are its");
@@ -456,29 +457,31 @@ test("the seat git's verbs judge their caller, and merge-pull merges the Approve
};
const gates = new GateWriter(slow);
const seat = seatTools(Object.assign(Object.create(client), { setCommitStatus: slow.setCommitStatus }),
{ run: async () => ({ stdout: "", code: 0 }), container: "gitea", callers: async () => list, statuses: gates });
{ run: async () => ({ stdout: "", code: 0 }), container: "gitea", callers: async () => list, statuses: gates, registered: () => registered });
const call = (verb: string, args: Record<string, unknown>, caller = bridge) =>
runAs({ "mesh/caller": caller }, args, () => seat.find((t: any) => t.name === `git.${verb}`)!.run(args)) as Promise<any>;
const merge = (extra: Record<string, unknown> = {}, caller?: string) =>
call("merge-pull", { id: "act-1", repository: "novox/mesh-catalog", number: 7, head: REVIEWED, ...extra }, caller);
const rows: { name: string; set: () => void; caller?: string; extra?: Record<string, unknown>; want: RegExp | "merged" | "held"; loud?: boolean }[] = [
const rows: { name: string; set: () => void; caller?: string; extra?: Record<string, unknown>; want: RegExp | "merged" | "held"; loud?: boolean; kind?: string }[] = [
{ name: "no registration written yet", set: () => { registered = false; }, want: /no module is registered yet/, kind: "no-registrations" },
{ name: "a caller not on the list", set: () => {}, caller: "ace.mesh-delivery-checker", want: /refused for ace.mesh-delivery-checker/ },
{ name: "an unproven caller", set: () => {}, caller: "", want: /no caller the tool runner proved/ },
{ name: "a head that moved", set: () => { state.head = MOVED; }, want: /its head is 3c4d5e6f7a8b, not the abc1234def56/ },
{ name: "a base no rule guards", set: () => { state.rules = []; }, want: /guarded by no protection rule/ },
{ name: "a base that takes a push", set: () => { state.rules = [{ rule_name: "main", enable_push: true }]; }, want: /guarded by no protection rule/ },
{ name: "a head that moved", set: () => { state.head = MOVED; }, want: /its head is 3c4d5e6f7a8b, not the abc1234def56/, kind: "head-moved" },
{ name: "a base no rule guards", set: () => { state.rules = []; }, want: /guarded by no protection rule/, kind: "unprotected" },
{ name: "a base whose protection was removed after the last branch-moved", set: () => { state.rules = [protectedMain()]; state.rules = []; }, want: /guarded by no protection rule/, kind: "unprotected" },
{ name: "a base that takes a push", set: () => { state.rules = [{ rule_name: "main", enable_push: true }]; }, want: /guarded by no protection rule/, kind: "unprotected" },
{ name: "a failed check", set: () => { state.statuses = [{ context: "mesh/check", state: "failure", description: "fail" }]; }, want: "merged" },
{ name: "no status at all", set: () => { state.statuses = []; }, want: "merged" },
{ name: "a check of another trunk", set: () => { state.statuses = judgedAt(MOVED); }, want: "merged" },
{ name: "the id said again after the merge", set: () => { state.merged = true; }, want: "held" },
{ name: "a pull request merged at another head", set: () => { state.merged = true; state.head = MOVED; }, want: /merged at 3c4d5e6f7a8b, not the abc1234def56/ },
{ name: "a closed pull request", set: () => { state.prState = "closed"; }, want: /it is closed/ },
{ name: "a closed pull request", set: () => { state.prState = "closed"; }, want: /it is closed/, kind: "git-refused" },
{ name: "a fast-forward", set: () => { state.parents = [REVIEWED]; }, want: "merged" },
{ name: "the merge read back fails", set: () => { state.commitsFail = 502; }, want: "merged" },
{ name: "the forge merged another head", set: () => { state.landed = MOVED; }, want: /WAS merged .*the forge merged 3c4d5e6f7a8b, not the abc1234def56/, loud: true },
];
for (const r of rows) {
state.merged = false; state.head = REVIEWED; state.statuses = judgedAt(TRUNK); state.sent = [];
registered = true; state.merged = false; state.head = REVIEWED; state.statuses = judgedAt(TRUNK); state.sent = [];
state.rules = [protectedMain()]; state.prState = "open"; state.parents = null; state.commitsFail = 0; state.landed = "";
r.set();
const got = await merge(r.extra, r.caller);
@@ -491,6 +494,7 @@ test("the seat git's verbs judge their caller, and merge-pull merges the Approve
assert.deepEqual(writes(), [], `${r.name}: nothing merged again`);
} else {
assert.match(String(got.refused ?? got.error), r.want, r.name);
if (r.kind) assert.equal(got["refused-kind"], r.kind, r.name);
assert.deepEqual(writes(), r.loud ? ["POST /repos/novox/mesh-catalog/pulls/7/merge"] : [], `${r.name}: what was merged`);
}
}
@@ -550,11 +554,11 @@ test("every read and act of the seat git answers its contract, and an error is a
};
const run = async (_f: string, args: string[]) => args.includes("show") || args.includes("--ref") && !args.includes("append")
? { stdout: noteShown, code: noteShown ? 0 : 1 } : (noteWritten++, { stdout: "", code: 0 });
const seat = seatTools(forge as any, { run: run as any, container: "gitea", callers: async () => JSON.stringify({}), statuses: { status: async () => {} } });
const seat = seatTools(forge as any, { run: run as any, container: "gitea", callers: async () => JSON.stringify({}), statuses: { status: async () => {} }, registered: () => true });
const everyone = JSON.stringify(Object.fromEntries(seat.map((t: any) => [t.name.slice(4), ["*"]])));
const call = (verb: string, args: Record<string, unknown>, over: Record<string, unknown> = {}) => {
const t = seat.find((x: any) => x.name === `git.${verb}`)!;
const wrapped = seatTools(Object.assign(Object.create(forge), over) as any, { run: run as any, container: "gitea", callers: async () => everyone, statuses: { status: async () => {} } });
const wrapped = seatTools(Object.assign(Object.create(forge), over) as any, { run: run as any, container: "gitea", callers: async () => everyone, statuses: { status: async () => {} }, registered: () => true });
return runAs({ "mesh/caller": "ace.x" }, args, () => wrapped.find((x: any) => x.name === t.name)!.run(args)) as Promise<any>;
};
const rows: [string, string, Record<string, unknown>, Record<string, unknown>, (a: any) => unknown, unknown][] = [
@@ -598,3 +602,24 @@ test("every read and act of the seat git answers its contract, and an error is a
const note = await call("note", { id: "n1", repository: "novox/app", commit: SHA, line: "worded differently" });
assert.deepEqual([note.performed, note.held, noteWritten], [true, true, 0]);
});
// The first registrations switch merge-pull on (design 47 §95, §353): none held and none heard, it waits; a registration held
// at the start, or the first registration-changed, switches it, and an event that is none is an error.
test("the first registrations are learned from the seat mesh-catalog's verb at the start and from its event", async () => {
const { Registrations } = await import("../dist/registrations.js");
const said: string[] = [];
const reg = (answer: unknown) => new Registrations(async () => { if (answer instanceof Error) throw answer; return answer; }, (l) => said.push(l));
const none = reg({ registrations: [] });
await none.start();
assert.equal(none.written, false, "an empty answer is no first registration");
none.hear({ module: "mesh-sdk", moved: ["watch"], version: 1, warrant: "q-1" });
assert.equal(none.written, true, "the event switches it");
const held = reg(JSON.stringify({ registrations: [{ module: "mesh-sdk" }] }));
await held.start();
assert.equal(held.written, true, "a registration held at the start");
const down = reg(new Error("no responders"));
await down.start();
assert.equal(down.written, false);
assert.match(said.join("\n"), /could not be read: no responders/, "a failed first view is said, never taken as none silently");
assert.throws(() => down.hear(undefined as any), /not a mesh-catalog.registration-changed event/);
});