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
76 changes: 76 additions & 0 deletions tools/bg/missed-substrate-detector.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Comment thread
AceHack marked this conversation as resolved.
});
105 changes: 105 additions & 0 deletions tools/bg/missed-substrate-detector.ts
Original file line number Diff line number Diff line change
@@ -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<never> {
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);
}
}
Loading