From 3876dce2d73a0f811f685126210307821afd853c Mon Sep 17 00:00:00 2001 From: davion-knight <298846663+davion-knight@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:51:05 -0500 Subject: [PATCH] feat(miner-manage): loop-closure summary builder (pre-reentry, read-only) (#4282) Add packages/gittensory-miner/lib/loop-closure.js exporting a pure, read-only buildLoopClosureSummary(sources, options): aggregate what happened in a completed discover->plan->prepare->manage cycle before the miner loop considers re-entering (idle -> discovering again). In the spirit of manage-status.js's collectManageStatus - it never calls GitHub, writes a store, or decides/performs the re-entry itself. The cycle boundary is caller-supplied: options.sinceSeq is the event-ledger seq at the end of the prior cycle, so events with a strictly greater seq are 'this cycle', reusing event-ledger.js's readEvents({ since }) cursor rather than inventing a new persisted boundary marker. The ledger stores an open type vocabulary, so events are tallied generically by type (new phase event types surface automatically). Output: per-type event counts + last seq (the next cycle's boundary), queue state by status at cycle end, and the current run-state. Adds the hand-written loop-closure.d.ts declaration alongside the module, matching the package's per-lib .d.ts convention. Deciding whether to re-enter, and the re-entry itself (setRunState), are explicitly out of scope. Closes #4282 --- .../gittensory-miner/lib/loop-closure.d.ts | 35 ++++++++ packages/gittensory-miner/lib/loop-closure.js | 64 +++++++++++++ test/unit/miner-loop-closure.test.ts | 89 +++++++++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 packages/gittensory-miner/lib/loop-closure.d.ts create mode 100644 packages/gittensory-miner/lib/loop-closure.js create mode 100644 test/unit/miner-loop-closure.test.ts diff --git a/packages/gittensory-miner/lib/loop-closure.d.ts b/packages/gittensory-miner/lib/loop-closure.d.ts new file mode 100644 index 0000000000..5c0adfc2c5 --- /dev/null +++ b/packages/gittensory-miner/lib/loop-closure.d.ts @@ -0,0 +1,35 @@ +export interface LoopClosureEventLedger { + readEvents(filter?: { since?: number; repoFullName?: string }): Array<{ seq?: number; type?: unknown; repoFullName?: string }>; +} + +export interface LoopClosurePortfolioQueue { + listQueue(repoFullName: string | null): Array<{ status?: unknown }>; +} + +export interface LoopClosureRunState { + getRunState(repoFullName: string | null): string | null; +} + +export interface LoopClosureSources { + eventLedger: LoopClosureEventLedger; + portfolioQueue: LoopClosurePortfolioQueue; + runState?: LoopClosureRunState; +} + +export interface LoopClosureOptions { + /** Event-ledger seq at the END of the prior cycle; events with a strictly greater seq are "this cycle". */ + sinceSeq?: number; + /** Scope the summary to a single repo (its events and queue entries) when set. */ + repoFullName?: string; +} + +export interface LoopClosureSummary { + sinceSeq: number | null; + /** Highest event seq observed this cycle (>= sinceSeq); the boundary a caller passes as the next cycle's sinceSeq. */ + lastSeq: number; + events: { total: number; byType: Record }; + queue: { total: number; byStatus: Record }; + runState: string | null; +} + +export function buildLoopClosureSummary(sources: LoopClosureSources, options?: LoopClosureOptions): LoopClosureSummary; diff --git a/packages/gittensory-miner/lib/loop-closure.js b/packages/gittensory-miner/lib/loop-closure.js new file mode 100644 index 0000000000..1c2e01b4c1 --- /dev/null +++ b/packages/gittensory-miner/lib/loop-closure.js @@ -0,0 +1,64 @@ +// Loop-closure summary builder (pure, read-only) — #4282, Wave 2 tracker #2353 (miner-manage phase). +// +// A pure, read-only aggregator in the spirit of manage-status.js's collectManageStatus: read across the local-state +// primitives (event ledger, portfolio queue, run-state) and summarize what happened in a completed +// discover→plan→prepare→manage cycle BEFORE the miner loop considers re-entering (idle → discovering again). It +// never calls GitHub, never writes a local store, and never decides whether to re-enter or performs the re-entry +// itself — it only builds the summary a future caller reads before making that call. +// +// Cycle boundary is CALLER-SUPPLIED (deliberately, per the issue): `options.sinceSeq` is the event-ledger seq at the +// END of the prior cycle, so events with a STRICTLY greater seq are "this cycle" — reusing event-ledger.js's own +// `readEvents({ since })` cursor rather than inventing a new persisted cycle-boundary marker. The ledger stores an +// OPEN type vocabulary (only the phase writers define concrete types), so events are tallied GENERICALLY by `type`; +// new phase event types (plans built, PRs prepared/opened, outcomes recorded — landing via sibling issues) surface +// in the tally automatically without a hardcoded list here. + +/** + * Build a read-only loop-closure summary from local-state sources. Pure: reads `sources` + `options` and returns a + * structured summary, mutating nothing. + * + * @param {{ eventLedger: { readEvents: Function }, portfolioQueue: { listQueue: Function }, runState?: { getRunState: Function } }} sources + * @param {{ sinceSeq?: number, repoFullName?: string }} [options] + * @returns {{ sinceSeq: number|null, lastSeq: number, events: { total: number, byType: Record }, queue: { total: number, byStatus: Record }, runState: string|null }} + */ +export function buildLoopClosureSummary(sources, options = {}) { + const eventLedger = sources?.eventLedger; + const portfolioQueue = sources?.portfolioQueue; + const runState = sources?.runState; + if (!eventLedger || typeof eventLedger.readEvents !== "function") throw new Error("invalid_event_ledger"); + if (!portfolioQueue || typeof portfolioQueue.listQueue !== "function") throw new Error("invalid_portfolio_queue"); + + const repoFullName = typeof options.repoFullName === "string" && options.repoFullName.length > 0 ? options.repoFullName : null; + const sinceSeq = Number.isInteger(options.sinceSeq) && options.sinceSeq >= 0 ? options.sinceSeq : null; + + // Bound "this cycle" to events after the prior cycle's ending seq; event-ledger applies the `since`/repo filter. + const filter = {}; + if (repoFullName !== null) filter.repoFullName = repoFullName; + if (sinceSeq !== null) filter.since = sinceSeq; + const events = eventLedger.readEvents(filter); + + const byType = {}; + let lastSeq = sinceSeq ?? 0; + for (const event of events) { + const type = typeof event?.type === "string" && event.type.length > 0 ? event.type : "unknown"; + byType[type] = (byType[type] ?? 0) + 1; + if (Number.isInteger(event?.seq) && event.seq > lastSeq) lastSeq = event.seq; + } + + const byStatus = {}; + const queueEntries = portfolioQueue.listQueue(repoFullName); + for (const entry of queueEntries) { + const status = typeof entry?.status === "string" && entry.status.length > 0 ? entry.status : "unknown"; + byStatus[status] = (byStatus[status] ?? 0) + 1; + } + + const currentRunState = runState && typeof runState.getRunState === "function" ? runState.getRunState(repoFullName) : null; + + return { + sinceSeq, + lastSeq, + events: { total: events.length, byType }, + queue: { total: queueEntries.length, byStatus }, + runState: currentRunState ?? null, + }; +} diff --git a/test/unit/miner-loop-closure.test.ts b/test/unit/miner-loop-closure.test.ts new file mode 100644 index 0000000000..b9537a3d4a --- /dev/null +++ b/test/unit/miner-loop-closure.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { buildLoopClosureSummary } from "../../packages/gittensory-miner/lib/loop-closure.js"; + +// A mock event ledger that honors the real `readEvents({ since, repoFullName })` cursor contract (strictly-greater +// seq, optional repo filter), so the sinceSeq cycle boundary is exercised through the same shape as the SQLite one. +function mockEventLedger(events: Array<{ seq: number; type?: unknown; repoFullName?: string }>): { readEvents: (filter?: { since?: number; repoFullName?: string }) => typeof events } { + return { + readEvents: (filter = {}) => + events.filter( + (event) => + (filter.since === undefined || event.seq > filter.since) && + (filter.repoFullName === undefined || event.repoFullName === filter.repoFullName), + ), + }; +} +const mockQueue = (entries: Array<{ status?: unknown }>): { listQueue: () => typeof entries } => ({ listQueue: () => entries }); + +describe("buildLoopClosureSummary (#4282 loop-closure summary)", () => { + it("rejects sources missing a usable event ledger or portfolio queue", () => { + expect(() => buildLoopClosureSummary({ portfolioQueue: mockQueue([]) } as never)).toThrow("invalid_event_ledger"); + expect(() => buildLoopClosureSummary({ eventLedger: mockEventLedger([]) } as never)).toThrow("invalid_portfolio_queue"); + }); + + it("summarizes an empty cycle (nothing happened) as zeroed tallies", () => { + const summary = buildLoopClosureSummary({ eventLedger: mockEventLedger([]), portfolioQueue: mockQueue([]) }); + expect(summary).toEqual({ + sinceSeq: null, + lastSeq: 0, + events: { total: 0, byType: {} }, + queue: { total: 0, byStatus: {} }, + runState: null, + }); + }); + + it("tallies a mix of event types generically and reports the cycle's last seq", () => { + const summary = buildLoopClosureSummary( + { + eventLedger: mockEventLedger([ + { seq: 1, type: "discovered_issue", repoFullName: "acme/widgets" }, + { seq: 2, type: "discovered_issue", repoFullName: "acme/widgets" }, + { seq: 3, type: "plan_built", repoFullName: "acme/widgets" }, + { seq: 4, type: "pr_opened", repoFullName: "acme/widgets" }, + ]), + portfolioQueue: mockQueue([{ status: "managing" }, { status: "managing" }, { status: "done" }]), + runState: { getRunState: () => "idle" }, + }, + { repoFullName: "acme/widgets" }, + ); + expect(summary.events).toEqual({ total: 4, byType: { discovered_issue: 2, plan_built: 1, pr_opened: 1 } }); + expect(summary.queue).toEqual({ total: 3, byStatus: { managing: 2, done: 1 } }); + expect(summary.lastSeq).toBe(4); + expect(summary.runState).toBe("idle"); + }); + + it("uses sinceSeq as the cycle boundary — prior-cycle events are excluded", () => { + const ledger = mockEventLedger([ + { seq: 1, type: "discovered_issue" }, // prior cycle + { seq: 2, type: "discovered_issue" }, // prior cycle + { seq: 3, type: "plan_built" }, // this cycle + { seq: 4, type: "pr_prepared" }, // this cycle + ]); + const summary = buildLoopClosureSummary({ eventLedger: ledger, portfolioQueue: mockQueue([]) }, { sinceSeq: 2 }); + expect(summary.sinceSeq).toBe(2); + expect(summary.events).toEqual({ total: 2, byType: { plan_built: 1, pr_prepared: 1 } }); + expect(summary.lastSeq).toBe(4); // boundary for the next cycle + }); + + it("falls back to 'unknown' for events/queue entries with a missing or non-string kind, and ignores a non-integer seq", () => { + const summary = buildLoopClosureSummary({ + eventLedger: mockEventLedger([{ seq: 5, type: "discovered_issue" }, { seq: Number.NaN, type: undefined }]), + portfolioQueue: mockQueue([{ status: "pending" }, { status: undefined }]), + }); + expect(summary.events.byType).toEqual({ discovered_issue: 1, unknown: 1 }); + expect(summary.queue.byStatus).toEqual({ pending: 1, unknown: 1 }); + expect(summary.lastSeq).toBe(5); // the NaN-seq event never advances lastSeq + }); + + it("treats a run-state source that reports no state as null, and omits run-state entirely when not supplied", () => { + const nullState = buildLoopClosureSummary({ eventLedger: mockEventLedger([]), portfolioQueue: mockQueue([]), runState: { getRunState: () => null } }); + expect(nullState.runState).toBeNull(); + const noSource = buildLoopClosureSummary({ eventLedger: mockEventLedger([]), portfolioQueue: mockQueue([]) }); + expect(noSource.runState).toBeNull(); + }); + + it("is deterministic: same sources + options yield identical output", () => { + const sources = { eventLedger: mockEventLedger([{ seq: 1, type: "discovered_issue" }]), portfolioQueue: mockQueue([{ status: "managing" }]) }; + expect(buildLoopClosureSummary(sources, { sinceSeq: 0 })).toEqual(buildLoopClosureSummary(sources, { sinceSeq: 0 })); + }); +});