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
7 changes: 7 additions & 0 deletions packages/loopover-miner/bin/loopover-miner-hosted.ts
Original file line number Diff line number Diff line change
@@ -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));
74 changes: 74 additions & 0 deletions packages/loopover-miner/lib/hosted-entry.ts
Original file line number Diff line number Diff line change
@@ -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<string, (args: string[]) => Promise<number>>;

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<string, string | undefined>): ReadinessProbe {
return {
name: "state_dir",
check: async () => {
try {
await access(resolveMinerStateDir(env));
return true;
} catch {
return false;
}
},
};
}

export type RunHostedEntryOptions = {
env?: Record<string, string | undefined>;
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<number> {
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<void>((resolve) => (server ? server.close(() => resolve()) : resolve()));
}
}
3 changes: 2 additions & 1 deletion packages/loopover-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions scripts/check-miner-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/,
Expand Down
141 changes: 141 additions & 0 deletions test/unit/miner-hosted-entry.test.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> }> };

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");
});
});
1 change: 1 addition & 0 deletions test/unit/miner-package-skeleton.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down