Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/gittensory-miner/lib/loop-closure.d.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> };
queue: { total: number; byStatus: Record<string, number> };
runState: string | null;
}

export function buildLoopClosureSummary(sources: LoopClosureSources, options?: LoopClosureOptions): LoopClosureSummary;
64 changes: 64 additions & 0 deletions packages/gittensory-miner/lib/loop-closure.js
Original file line number Diff line number Diff line change
@@ -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<string, number> }, queue: { total: number, byStatus: Record<string, number> }, 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,
};
}
89 changes: 89 additions & 0 deletions test/unit/miner-loop-closure.test.ts
Original file line number Diff line number Diff line change
@@ -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 }));
});
});