diff --git a/packages/gittensory-miner/lib/harness-submission-trigger.d.ts b/packages/gittensory-miner/lib/harness-submission-trigger.d.ts index 1a223030a..c18dc01ad 100644 --- a/packages/gittensory-miner/lib/harness-submission-trigger.d.ts +++ b/packages/gittensory-miner/lib/harness-submission-trigger.d.ts @@ -44,3 +44,26 @@ export type HarnessSubmissionResult = { export function countConsecutiveGateBlocks(eventLedger: HarnessSubmissionEventLedger, sinceMs: number): number; export function evaluateAndRecordHarnessSubmissionTrigger(candidate: HarnessSubmissionCandidateInput, deps: HarnessSubmissionDeps): HarnessSubmissionResult; + +/** The exact input shape buildOpenPrSpec (root src/mcp/local-write-tools.ts) expects. */ +export type OpenPrInput = { + repoFullName: string; + base: string; + head: string; + title: string; + body: string; + draft: boolean; +}; + +export type PrepareOpenPrSubmissionCandidate = HarnessSubmissionCandidateInput & { + base: string; + title: string; + body?: string; + draft?: boolean; +}; + +export type PrepareOpenPrSubmissionResult = + | { ready: true; decision: HarnessSubmissionDecision; event: HarnessSubmissionResult["event"]; openPrInput: OpenPrInput } + | { ready: false; decision: HarnessSubmissionDecision; event: HarnessSubmissionResult["event"] }; + +export function prepareOpenPrSubmission(candidate: PrepareOpenPrSubmissionCandidate, deps: HarnessSubmissionDeps): PrepareOpenPrSubmissionResult; diff --git a/packages/gittensory-miner/lib/harness-submission-trigger.js b/packages/gittensory-miner/lib/harness-submission-trigger.js index 9eb580aa6..c6039052a 100644 --- a/packages/gittensory-miner/lib/harness-submission-trigger.js +++ b/packages/gittensory-miner/lib/harness-submission-trigger.js @@ -7,11 +7,16 @@ import { evaluateHarnessSubmissionTrigger } from "@jsonbored/gittensory-engine"; // event -- regardless of outcome, so a paused-pending-human-review session leaves a full trail of why. // // NOT WIRED INTO ANY AUTOMATIC SCHEDULE: per this issue's own "manual owner sign-off on the wiring before this -// ships to any default-on profile" deliverable. A real call site (root-side server/CLI integration) invokes -// this function with a real `HandoffPacket`; on `allow: true` it may then build the `open_pr` local-write spec -// itself -- this module does not, and cannot, do that (the spec builder lives in the private root `src/` tree, -// unreachable from this package -- same cross-package-boundary reason self-review-adapter.ts's slop injection -// exists). +// ships to any default-on profile" deliverable. `prepareOpenPrSubmission` below is the call site up to the +// cross-package boundary: on `allow: true` it shapes the exact input `buildOpenPrSpec` (root +// `src/mcp/local-write-tools.ts`) needs -- but does not, and cannot, call that function itself, since the spec +// builder lives in the private root `src/` tree, unreachable from this package (same cross-package-boundary +// reason self-review-adapter.ts's slop injection exists). A real root-side/MCP call site (e.g. the existing +// `gittensory_open_pr` tool, src/mcp/server.ts) takes `openPrInput` from a `ready: true` result and passes it +// to `buildOpenPrSpec` (or the equivalent tool call) to actually produce the runnable local-write spec. The +// CLI/driver entrypoint that instantiates a real `CodingAgentDriver` and calls `runIterateLoop` end to end with +// live credentials does not exist yet in this package -- that is separate, larger scope from this decision-to- +// payload bridge. // // SESSION-SCOPED, NOT PER-REPO: the circuit breaker's own "pauses the run entirely" wording means the tally is // counted across EVERY repo's decisions this session, not scoped to one repo -- distinct from #2338's loop- @@ -82,3 +87,52 @@ export function evaluateAndRecordHarnessSubmissionTrigger(candidate, deps) { return { decision, event }; } + +/** + * Bridge one completed handoff through the submission gate to a submission-READY payload -- the exact input + * shape `buildOpenPrSpec` expects (repoFullName/base/head/title/body/draft). On `allow: true` returns + * `{ ready: true, decision, event, openPrInput }`; otherwise `{ ready: false, decision, event }` -- the block + * reasons are on `decision.reasons` and already on the ledger via the wrapped call either way. Does NOT call + * `buildOpenPrSpec` itself (see this module's own doc comment for why it cannot) -- a real root-side/MCP call + * site takes `openPrInput` from a `ready: true` result and passes it to that function or the equivalent + * `gittensory_open_pr` MCP tool. + * + * Fails closed (throws) on a malformed candidate, mirroring evaluateAndRecordHarnessSubmissionTrigger's own + * validation -- a missing PR title/base is a caller bug that must never silently degrade into a garbage spec. + * The one field evaluateAndRecordHarnessSubmissionTrigger does NOT itself require -- handoffPacket.branchRef, + * optional there because iterate-loop.ts deliberately does not manage worktrees/branches -- IS required here, + * but only once the decision is known to be `allow: true`: a PR cannot be opened without a source branch, but a + * blocked candidate needs no branch at all, and must not throw for a reason unrelated to why it was blocked. + * + * @param {{ killSwitchScope: "global"|"repo"|"none", repoFullName: string, handoffPacket: { branchRef?: string, [key: string]: unknown }, slopThreshold: "clean"|"low"|"elevated"|"high", mode: "observe"|"enforce", maxConsecutiveGateBlocks?: number, base: string, title: string, body?: string, draft?: boolean }} candidate + * @param {{ eventLedger: object, sessionStartMs?: number }} deps + */ +export function prepareOpenPrSubmission(candidate, deps) { + if (!candidate || typeof candidate !== "object") throw new Error("invalid_harness_submission_candidate"); + const base = typeof candidate.base === "string" ? candidate.base.trim() : ""; + if (!base) throw new Error("invalid_pr_base"); + const title = typeof candidate.title === "string" ? candidate.title.trim() : ""; + if (!title) throw new Error("invalid_pr_title"); + + const { decision, event } = evaluateAndRecordHarnessSubmissionTrigger(candidate, deps); + if (!decision.allow) return { ready: false, decision, event }; + + // Only reached once evaluateAndRecordHarnessSubmissionTrigger has already validated handoffPacket is a + // well-formed object -- safe to read .branchRef directly. + const head = typeof candidate.handoffPacket.branchRef === "string" ? candidate.handoffPacket.branchRef.trim() : ""; + if (!head) throw new Error("invalid_pr_head_branch"); + + return { + ready: true, + decision, + event, + openPrInput: { + repoFullName: candidate.repoFullName.trim(), + base, + head, + title, + body: typeof candidate.body === "string" ? candidate.body : "", + draft: candidate.draft === true, + }, + }; +} diff --git a/test/unit/miner-harness-submission-trigger.test.ts b/test/unit/miner-harness-submission-trigger.test.ts index 77df563f2..78adabc3a 100644 --- a/test/unit/miner-harness-submission-trigger.test.ts +++ b/test/unit/miner-harness-submission-trigger.test.ts @@ -7,7 +7,12 @@ vi.mock("@jsonbored/gittensory-engine", async () => { return import("../../packages/gittensory-engine/src/index"); }); -import { evaluateAndRecordHarnessSubmissionTrigger, countConsecutiveGateBlocks, HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT } from "../../packages/gittensory-miner/lib/harness-submission-trigger.js"; +import { + evaluateAndRecordHarnessSubmissionTrigger, + countConsecutiveGateBlocks, + prepareOpenPrSubmission, + HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT, +} from "../../packages/gittensory-miner/lib/harness-submission-trigger.js"; import { initEventLedger } from "../../packages/gittensory-miner/lib/event-ledger.js"; const roots: string[] = []; @@ -285,3 +290,148 @@ describe("evaluateAndRecordHarnessSubmissionTrigger (#2337)", () => { ).toThrow("invalid_event_ledger"); }); }); + +describe("prepareOpenPrSubmission (#2337 open-pr call site)", () => { + it("shapes a ready:true openPrInput exactly matching buildOpenPrSpec's expected fields when the gate allows", () => { + const eventLedger = tempEventLedger(); + + const result = prepareOpenPrSubmission( + { + killSwitchScope: "none", + repoFullName: "acme/widgets", + handoffPacket: handoffPacket({ branchRef: "attempt/1" }), + slopThreshold: "low", + mode: "enforce", + base: "main", + title: "fix: add retry logic", + body: "Closes #1.", + draft: true, + }, + { eventLedger }, + ); + + expect(result.ready).toBe(true); + if (!result.ready) throw new Error("expected ready:true"); + expect(result.decision.allow).toBe(true); + expect(result.openPrInput).toEqual({ + repoFullName: "acme/widgets", + base: "main", + head: "attempt/1", + title: "fix: add retry logic", + body: "Closes #1.", + draft: true, + }); + }); + + it("returns ready:false (no openPrInput) when the gate blocks, without requiring a branch to open a PR from at all", () => { + const eventLedger = tempEventLedger(); + + // Kill-switch active AND no branchRef on the handoff -- proves the head-branch check never runs on the + // blocked path (a candidate that will never open a PR must not throw for an unrelated missing-field reason). + const result = prepareOpenPrSubmission( + { + killSwitchScope: "global", + repoFullName: "acme/widgets", + handoffPacket: handoffPacket(), + slopThreshold: "low", + mode: "enforce", + base: "main", + title: "fix: add retry logic", + }, + { eventLedger }, + ); + + expect(result.ready).toBe(false); + expect(result.decision).toEqual({ allow: false, reasons: ["global_kill_switch_active"], circuitBreakerTripped: false }); + expect((result as { openPrInput?: unknown }).openPrInput).toBeUndefined(); + }); + + it("throws invalid_pr_base on a missing/blank base, before any gate evaluation or ledger write", () => { + const eventLedger = tempEventLedger(); + expect(() => + prepareOpenPrSubmission( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: handoffPacket({ branchRef: "attempt/1" }), slopThreshold: "low", mode: "enforce", title: "t" } as never, + { eventLedger }, + ), + ).toThrow("invalid_pr_base"); + expect(() => + prepareOpenPrSubmission( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: handoffPacket({ branchRef: "attempt/1" }), slopThreshold: "low", mode: "enforce", base: " ", title: "t" }, + { eventLedger }, + ), + ).toThrow("invalid_pr_base"); + expect(eventLedger.readEvents({})).toHaveLength(0); // failed before the wrapped gate call ever wrote an event + }); + + it("throws invalid_pr_title on a missing/blank title, before any gate evaluation or ledger write", () => { + const eventLedger = tempEventLedger(); + expect(() => + prepareOpenPrSubmission( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: handoffPacket({ branchRef: "attempt/1" }), slopThreshold: "low", mode: "enforce", base: "main" } as never, + { eventLedger }, + ), + ).toThrow("invalid_pr_title"); + expect(eventLedger.readEvents({})).toHaveLength(0); + }); + + it("throws invalid_pr_head_branch when the gate allows but the handoff packet has no branch to open a PR from -- the allow decision is still recorded to the ledger", () => { + const eventLedger = tempEventLedger(); + + expect(() => + prepareOpenPrSubmission( + { + killSwitchScope: "none", + repoFullName: "acme/widgets", + handoffPacket: handoffPacket(), // no branchRef + slopThreshold: "low", + mode: "enforce", + base: "main", + title: "t", + }, + { eventLedger }, + ), + ).toThrow("invalid_pr_head_branch"); + + // The gate's own allow:true decision is still on the audit trail even though the spec-build step failed. + const events = eventLedger.readEvents({ repoFullName: "acme/widgets" }); + expect(events).toHaveLength(1); + expect(events[0]?.payload).toMatchObject({ allow: true }); + }); + + it("defaults draft to false and body to an empty string when omitted", () => { + const eventLedger = tempEventLedger(); + const result = prepareOpenPrSubmission( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: handoffPacket({ branchRef: "attempt/1" }), slopThreshold: "low", mode: "enforce", base: "main", title: "t" }, + { eventLedger }, + ); + expect(result.ready).toBe(true); + if (!result.ready) throw new Error("expected ready:true"); + expect(result.openPrInput.draft).toBe(false); + expect(result.openPrInput.body).toBe(""); + }); + + it("trims repoFullName/base/title/head", () => { + const eventLedger = tempEventLedger(); + const result = prepareOpenPrSubmission( + { + killSwitchScope: "none", + repoFullName: " acme/widgets ", + handoffPacket: handoffPacket({ branchRef: " attempt/1 " }), + slopThreshold: "low", + mode: "enforce", + base: " main ", + title: " t ", + }, + { eventLedger }, + ); + expect(result.ready).toBe(true); + if (!result.ready) throw new Error("expected ready:true"); + expect(result.openPrInput).toMatchObject({ repoFullName: "acme/widgets", base: "main", head: "attempt/1", title: "t" }); + }); + + it("fails closed on a malformed candidate (null, or a non-object primitive) rather than silently allowing", () => { + const eventLedger = tempEventLedger(); + expect(() => prepareOpenPrSubmission(null as never, { eventLedger })).toThrow("invalid_harness_submission_candidate"); + expect(() => prepareOpenPrSubmission("nope" as never, { eventLedger })).toThrow("invalid_harness_submission_candidate"); + }); +});