diff --git a/packages/loopover-miner/bin/loopover-miner-hosted.ts b/packages/loopover-miner/bin/loopover-miner-hosted.ts new file mode 100644 index 0000000000..30216809a3 --- /dev/null +++ b/packages/loopover-miner/bin/loopover-miner-hosted.ts @@ -0,0 +1,7 @@ +#!/usr/bin/env node +// Thin dispatcher for the hosted-container entry point (#7182) -- all real logic lives in +// lib/hosted-entry.ts (importable/testable in-process); this file only wires argv/exit code, mirroring +// bin/loopover-miner.ts's own top-level shape. +import { runHostedEntry } from "../lib/hosted-entry.js"; + +process.exitCode = await runHostedEntry(process.argv.slice(2)); diff --git a/packages/loopover-miner/lib/hosted-entry.ts b/packages/loopover-miner/lib/hosted-entry.ts new file mode 100644 index 0000000000..c7339ea04a --- /dev/null +++ b/packages/loopover-miner/lib/hosted-entry.ts @@ -0,0 +1,74 @@ +// Hosted-container entry point for AMS (#7182, part of the #7173 ORB+AMS hosting control-plane). Self-host +// stays exactly as it is today (the plain `loopover-miner` CLI, unmodified) -- this is an ADDITIONAL entry +// point the hosted Cloudflare Container invokes instead, wired up by bin/loopover-miner-hosted.ts. Runs +// #7177's already-built health server (ams-health-server.ts) for the brief window a cron-woken container is +// up, then dispatches to exactly ONE existing unattended-cycle command +// (docs/unattended-scheduling.md's `discover`/`manage poll`, plus `attempt`) reused in-process -- these +// functions already return the miner's own 0=success/2=failure exit-code contract unmodified; this file adds +// no new exit-code vocabulary, it only wraps the health server's lifecycle around one of them. +import type { Server } from "node:http"; +import { access } from "node:fs/promises"; +import { runAttempt } from "./attempt-cli.js"; +import { runDiscover } from "./discover-cli.js"; +import { runManagePoll } from "./manage-poll.js"; +import { resolveMinerStateDir } from "./status.js"; +import { startAmsHealthServer, type ReadinessProbe } from "./ams-health-server.js"; + +/** The one-shot cycle commands a hosted tenant can be woken to run -- deliberately NOT `loop` (the + * self-scheduling continuous mode, semantically incompatible with "wake, run one cycle, sleep") and NOT + * any strictly-local command (`status`/`doctor`/etc, which never make sense as a hosted wake reason). */ +export const HOSTED_CYCLE_COMMANDS = { + discover: runDiscover, + "manage-poll": runManagePoll, + attempt: runAttempt, +} satisfies Record Promise>; + +export type HostedCycleCommand = keyof typeof HOSTED_CYCLE_COMMANDS; + +export function isHostedCycleCommand(value: string): value is HostedCycleCommand { + return Object.hasOwn(HOSTED_CYCLE_COMMANDS, value); +} + +/** Reachability probe for the health server's `/ready`: the miner's local state directory (SQLite ledgers/ + * queue) must exist and be accessible, or this tenant's container can't do real work regardless of what + * cycle it's asked to run. */ +function stateDirProbe(env: Record): ReadinessProbe { + return { + name: "state_dir", + check: async () => { + try { + await access(resolveMinerStateDir(env)); + return true; + } catch { + return false; + } + }, + }; +} + +export type RunHostedEntryOptions = { + env?: Record; + port?: number; +}; + +/** Starts the health server, runs exactly one cycle command to completion, stops the health server, and + * returns the cycle's own exit code unmodified. `cycleName` not matching a known command is itself a + * failure (returns 2 -- a misconfigured wake is exactly the kind of thing #7182's alerting contract must + * surface, not swallow). */ +export async function runHostedEntry(cliArgs: string[], options: RunHostedEntryOptions = {}): Promise { + const env = options.env ?? process.env; + const [cycleName, ...cycleArgs] = cliArgs; + + if (!cycleName || !isHostedCycleCommand(cycleName)) { + console.error(JSON.stringify({ event: "ams_hosted_entry_unknown_cycle", cycleName: cycleName ?? null, known: Object.keys(HOSTED_CYCLE_COMMANDS) })); + return 2; + } + + let server: Server | undefined; + try { + server = await startAmsHealthServer({ port: options.port ?? 8080, probes: [stateDirProbe(env)] }); + return await HOSTED_CYCLE_COMMANDS[cycleName](cycleArgs); + } finally { + await new Promise((resolve) => (server ? server.close(() => resolve()) : resolve())); + } +} diff --git a/packages/loopover-miner/package.json b/packages/loopover-miner/package.json index ac0a738aca..702abf78dd 100644 --- a/packages/loopover-miner/package.json +++ b/packages/loopover-miner/package.json @@ -24,7 +24,8 @@ }, "bin": { "loopover-miner": "bin/loopover-miner.js", - "loopover-miner-mcp": "bin/loopover-miner-mcp.js" + "loopover-miner-mcp": "bin/loopover-miner-mcp.js", + "loopover-miner-hosted": "bin/loopover-miner-hosted.js" }, "files": [ "bin", diff --git a/scripts/check-miner-package.ts b/scripts/check-miner-package.ts index db41830402..5d8b6dbbf3 100644 --- a/scripts/check-miner-package.ts +++ b/scripts/check-miner-package.ts @@ -8,6 +8,9 @@ import { FORBIDDEN_CONTENT } from "./forbidden-content.js"; const ALLOWED = [ /^bin\/loopover-miner\.(js|d\.ts)$/, /^bin\/loopover-miner-mcp\.(js|d\.ts)$/, + // Hosted-container entry point (#7182) -- not REQUIRED like bin/loopover-miner.js, matching + // loopover-miner-mcp's own treatment above (an additional bin, not the package's primary CLI). + /^bin\/loopover-miner-hosted\.(js|d\.ts)$/, /^lib\/[a-z0-9-]+\.(js|d\.ts)$/, /^package\.json$/, /^README\.md$/, diff --git a/test/unit/miner-hosted-entry.test.ts b/test/unit/miner-hosted-entry.test.ts new file mode 100644 index 0000000000..ce54e7d4b3 --- /dev/null +++ b/test/unit/miner-hosted-entry.test.ts @@ -0,0 +1,141 @@ +// Tests for the AMS hosted-container entry point (#7182). runDiscover/runManagePoll/runAttempt and the +// health server are all mocked -- no real GitHub calls, no real HTTP listener bound. +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type HealthServerOptions = { port: number; probes: Array<{ name: string; check: () => Promise }> }; + +const runDiscover = vi.fn(async (_args: string[]) => 0); +const runManagePoll = vi.fn(async (_args: string[]) => 0); +const runAttempt = vi.fn(async (_args: string[]) => 0); +const startAmsHealthServer = vi.fn(async (_options: HealthServerOptions) => ({ close: (cb: () => void) => cb() })); + +vi.mock("../../packages/loopover-miner/lib/discover-cli.js", () => ({ runDiscover })); +vi.mock("../../packages/loopover-miner/lib/manage-poll.js", () => ({ runManagePoll })); +vi.mock("../../packages/loopover-miner/lib/attempt-cli.js", () => ({ runAttempt })); +vi.mock("../../packages/loopover-miner/lib/ams-health-server.js", () => ({ startAmsHealthServer })); + +const { isHostedCycleCommand, runHostedEntry } = await import("../../packages/loopover-miner/lib/hosted-entry.js"); + +let stateDir: string; + +beforeEach(() => { + vi.clearAllMocks(); + stateDir = mkdtempSync(join(tmpdir(), "loopover-miner-hosted-entry-")); +}); + +afterEach(() => { + rmSync(stateDir, { recursive: true, force: true }); +}); + +describe("isHostedCycleCommand (#7182)", () => { + it("recognizes exactly the three one-shot cycle commands", () => { + expect(isHostedCycleCommand("discover")).toBe(true); + expect(isHostedCycleCommand("manage-poll")).toBe(true); + expect(isHostedCycleCommand("attempt")).toBe(true); + }); + + it("rejects the continuous self-scheduling `loop` command and anything unknown", () => { + expect(isHostedCycleCommand("loop")).toBe(false); + expect(isHostedCycleCommand("status")).toBe(false); + expect(isHostedCycleCommand("")).toBe(false); + }); +}); + +describe("runHostedEntry (#7182)", () => { + it("returns 2 and never starts the health server when no cycle name is given", async () => { + const exitCode = await runHostedEntry([], { env: { LOOPOVER_MINER_CONFIG_DIR: stateDir } }); + + expect(exitCode).toBe(2); + expect(startAmsHealthServer).not.toHaveBeenCalled(); + }); + + it("returns 2 and never starts the health server for an unknown cycle name", async () => { + const exitCode = await runHostedEntry(["loop"], { env: { LOOPOVER_MINER_CONFIG_DIR: stateDir } }); + + expect(exitCode).toBe(2); + expect(startAmsHealthServer).not.toHaveBeenCalled(); + }); + + it("dispatches 'discover' to runDiscover, forwarding the remaining args, and returns its exit code", async () => { + runDiscover.mockResolvedValueOnce(0); + + const exitCode = await runHostedEntry(["discover", "--search", "label:good-first-issue"], { env: { LOOPOVER_MINER_CONFIG_DIR: stateDir } }); + + expect(exitCode).toBe(0); + expect(runDiscover).toHaveBeenCalledWith(["--search", "label:good-first-issue"]); + }); + + it("dispatches 'manage-poll' to runManagePoll, forwarding the remaining args", async () => { + await runHostedEntry(["manage-poll", "acme/widgets", "42", "--json"], { env: { LOOPOVER_MINER_CONFIG_DIR: stateDir } }); + + expect(runManagePoll).toHaveBeenCalledWith(["acme/widgets", "42", "--json"]); + }); + + it("dispatches 'attempt' to runAttempt, forwarding the remaining args", async () => { + await runHostedEntry(["attempt", "some-item-id"], { env: { LOOPOVER_MINER_CONFIG_DIR: stateDir } }); + + expect(runAttempt).toHaveBeenCalledWith(["some-item-id"]); + }); + + it("propagates the underlying cycle command's real failure exit code (2)", async () => { + runDiscover.mockResolvedValueOnce(2); + + const exitCode = await runHostedEntry(["discover"], { env: { LOOPOVER_MINER_CONFIG_DIR: stateDir } }); + + expect(exitCode).toBe(2); + }); + + it("starts the health server on the given port with a state-dir readiness probe", async () => { + await runHostedEntry(["discover"], { env: { LOOPOVER_MINER_CONFIG_DIR: stateDir }, port: 9090 }); + + expect(startAmsHealthServer).toHaveBeenCalledTimes(1); + const call = startAmsHealthServer.mock.calls[0]![0]; + expect(call.port).toBe(9090); + expect(call.probes).toHaveLength(1); + expect(call.probes[0]?.name).toBe("state_dir"); + }); + + it("defaults the health server port to 8080 when not given", async () => { + await runHostedEntry(["discover"], { env: { LOOPOVER_MINER_CONFIG_DIR: stateDir } }); + + expect(startAmsHealthServer.mock.calls[0]![0].port).toBe(8080); + }); + + it("the state_dir probe passes when the resolved state directory exists", async () => { + await runHostedEntry(["discover"], { env: { LOOPOVER_MINER_CONFIG_DIR: stateDir } }); + + const call = startAmsHealthServer.mock.calls[0]![0]; + await expect(call.probes[0]!.check()).resolves.toBe(true); + }); + + it("the state_dir probe fails when the resolved state directory does not exist", async () => { + await runHostedEntry(["discover"], { env: { LOOPOVER_MINER_CONFIG_DIR: join(stateDir, "does-not-exist") } }); + + const call = startAmsHealthServer.mock.calls[0]![0]; + await expect(call.probes[0]!.check()).resolves.toBe(false); + }); + + it("closes the health server even when the cycle command throws, and still propagates the error", async () => { + const close = vi.fn((cb: () => void) => cb()); + startAmsHealthServer.mockResolvedValueOnce({ close } as never); + runDiscover.mockRejectedValueOnce(new Error("boom")); + + await expect(runHostedEntry(["discover"], { env: { LOOPOVER_MINER_CONFIG_DIR: stateDir } })).rejects.toThrow("boom"); + expect(close).toHaveBeenCalledTimes(1); + }); + + it("propagates a health-server startup failure without crashing on the never-assigned server", async () => { + startAmsHealthServer.mockRejectedValueOnce(new Error("port already in use")); + + await expect(runHostedEntry(["discover"], { env: { LOOPOVER_MINER_CONFIG_DIR: stateDir } })).rejects.toThrow("port already in use"); + expect(runDiscover).not.toHaveBeenCalled(); + }); + + it("defaults env to process.env when no override is passed", async () => { + const exitCode = await runHostedEntry(["discover"]); + expect(typeof exitCode).toBe("number"); + }); +}); diff --git a/test/unit/miner-package-skeleton.test.ts b/test/unit/miner-package-skeleton.test.ts index 7ac6b2a4e6..8181dd4989 100644 --- a/test/unit/miner-package-skeleton.test.ts +++ b/test/unit/miner-package-skeleton.test.ts @@ -36,6 +36,7 @@ describe("loopover-miner package skeleton (#2287)", () => { expect(miner.bin).toEqual({ "loopover-miner": "bin/loopover-miner.js", "loopover-miner-mcp": "bin/loopover-miner-mcp.js", + "loopover-miner-hosted": "bin/loopover-miner-hosted.js", }); expect(miner.publishConfig).toEqual(mcp.publishConfig); expect(miner.dependencies["@loopover/engine"]).toBeDefined();