diff --git a/tools/bg/missed-substrate-detector.test.ts b/tools/bg/missed-substrate-detector.test.ts new file mode 100644 index 000000000..6eca95e28 --- /dev/null +++ b/tools/bg/missed-substrate-detector.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; +import { + DEFAULT_CONFIG, + parseArgs, + parsePositiveMinutes, + pollOnce, + runOnce, +} from "./missed-substrate-detector"; + +describe("missed-substrate-detector slice 1", () => { + test("default config has sensible poll interval", () => { + expect(DEFAULT_CONFIG.pollIntervalMin).toBe(5); + expect(DEFAULT_CONFIG.once).toBe(false); + }); + + test("pollOnce returns a result with no-op cascade scan", () => { + const result = pollOnce(DEFAULT_CONFIG); + expect(result.cascadesDetected).toBe(0); + expect(result.note).toContain("slice-1 skeleton"); + expect(result.pollAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + test("runOnce returns a single result without entering daemon mode", () => { + const result = runOnce(DEFAULT_CONFIG); + expect(result.cascadesDetected).toBe(0); + expect(result.pollAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + describe("parsePositiveMinutes", () => { + test("accepts positive finite numbers", () => { + expect(parsePositiveMinutes("5", "--poll-min")).toBe(5); + expect(parsePositiveMinutes("0.5", "--poll-min")).toBe(0.5); + }); + + test("rejects undefined", () => { + expect(() => parsePositiveMinutes(undefined, "--poll-min")).toThrow(/requires a value/); + }); + + test("rejects non-numeric strings", () => { + expect(() => parsePositiveMinutes("abc", "--poll-min")).toThrow(/positive finite/); + }); + + test("rejects zero and negatives", () => { + expect(() => parsePositiveMinutes("0", "--poll-min")).toThrow(/positive finite/); + expect(() => parsePositiveMinutes("-3", "--poll-min")).toThrow(/positive finite/); + }); + + test("rejects Infinity / NaN", () => { + expect(() => parsePositiveMinutes("Infinity", "--poll-min")).toThrow(/positive finite/); + expect(() => parsePositiveMinutes("NaN", "--poll-min")).toThrow(/positive finite/); + }); + }); + + describe("parseArgs", () => { + test("default config when no args", () => { + expect(parseArgs([])).toEqual(DEFAULT_CONFIG); + }); + + test("--once flag sets once: true", () => { + expect(parseArgs(["--once"]).once).toBe(true); + }); + + test("--poll-min sets the interval", () => { + expect(parseArgs(["--poll-min", "10"]).pollIntervalMin).toBe(10); + }); + + test("rejects unknown flags fail-fast", () => { + expect(() => parseArgs(["--unknown"])).toThrow(/unknown flag/); + expect(() => parseArgs(["--pollmin", "5"])).toThrow(/unknown flag/); + }); + + test("rejects invalid --poll-min value", () => { + expect(() => parseArgs(["--poll-min", "abc"])).toThrow(/positive finite/); + }); + }); +}); diff --git a/tools/bg/missed-substrate-detector.ts b/tools/bg/missed-substrate-detector.ts new file mode 100644 index 000000000..f025e3eb0 --- /dev/null +++ b/tools/bg/missed-substrate-detector.ts @@ -0,0 +1,105 @@ +// missed-substrate-detector.ts — B-0442 slice 1: skeleton + no-op poll loop +// +// Background service that detects branch-vs-merged-PR drift, e.g., commits +// landing on a feature branch AFTER its parent PR squash-merged. The +// canonical operational example: the substrate-recovery cascade from earlier +// today (recovered via a follow-up PR). This service mechanizes detection +// so the cascade is caught BEFORE branch deletion erases substrate. +// +// This slice ships ONLY the skeleton. Future slices add merged-PR state +// fetch, branch-vs-squash comparison, cascade-detection bus publish, and +// optional auto-recovery-PR opening. +// +// Run: bun tools/bg/missed-substrate-detector.ts [--once] [--poll-min N] +// Compose with: B-0442 + B-0400 (bus) + B-0440 / B-0441 (companion services). + +export type DetectorConfig = { + /** How often to poll, in minutes */ + pollIntervalMin: number; + /** When true, run a single poll and exit */ + once: boolean; +}; + +export const DEFAULT_CONFIG: DetectorConfig = { + pollIntervalMin: 5, + once: false, +}; + +export type PollResult = { + pollAt: string; // ISO-8601 + cascadesDetected: number; + note: string; +}; + +/** + * Single poll iteration. Slice 1 returns a no-op result. Future slices + * fetch recent merged PRs and compare branch HEAD against squash content. + */ +export function pollOnce(_config: DetectorConfig): PollResult { + return { + pollAt: new Date().toISOString(), + cascadesDetected: 0, + note: "slice-1 skeleton — no detection yet; future slices add merged-PR scan + branch-vs-squash compare + bus publish", + }; +} + +/** + * Run a single poll iteration and return its result. + */ +export function runOnce(config: DetectorConfig = DEFAULT_CONFIG): PollResult { + const result = pollOnce(config); + console.log(JSON.stringify(result)); + return result; +} + +/** + * Run the detector as a daemon. Sleeps for pollIntervalMin between + * iterations and never returns; results are NOT accumulated (no memory + * growth). Caller is responsible for process termination (SIGTERM, etc.). + */ +export async function runDaemon(config: DetectorConfig = DEFAULT_CONFIG): Promise { + while (true) { + runOnce(config); + await new Promise(resolve => setTimeout(resolve, config.pollIntervalMin * 60 * 1000)); + } +} + +export function parsePositiveMinutes(raw: string | undefined, name: string): number { + if (raw === undefined) throw new Error(`${name} requires a value`); + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) { + throw new Error(`${name} must be a positive finite number; got "${raw}"`); + } + return n; +} + +const KNOWN_FLAGS = new Set(["--once", "--poll-min"]); + +export function parseArgs(argv: string[]): DetectorConfig { + const config: DetectorConfig = { ...DEFAULT_CONFIG }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--once") { + config.once = true; + } else if (arg === "--poll-min") { + config.pollIntervalMin = parsePositiveMinutes(argv[++i], "--poll-min"); + } else if (KNOWN_FLAGS.has(arg)) { + // Defensive: should be unreachable given the explicit checks above. + throw new Error(`internal: known flag ${arg} not handled`); + } else { + throw new Error(`unknown flag: ${arg}; known flags: ${[...KNOWN_FLAGS].join(", ")}`); + } + } + + return config; +} + +if (import.meta.main) { + const config = parseArgs(process.argv.slice(2)); + if (config.once) { + runOnce(config); + } else { + await runDaemon(config); + } +}