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
58 changes: 41 additions & 17 deletions packages/loopover-miner/lib/ams-health-server.d.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,43 @@
import type { Server } from "node:http";

export type ReadinessProbe = { name: string; check: () => Promise<boolean> };

import { type Server } from "node:http";
export type ReadinessProbe = {
name: string;
check: () => Promise<boolean>;
};
export type Readiness = {
ok: boolean;
checks: Record<string, boolean>;
durationsMs: Record<string, number>;
ok: boolean;
checks: Record<string, boolean>;
durationsMs: Record<string, number>;
};
/** Bare liveness body: the process is up and answering, independent of any backend it depends on. */
export declare function buildHealthBody(): {
status: "ok";
};

export function buildHealthBody(): { status: "ok" };

export function readiness(probes?: ReadinessProbe[]): Promise<Readiness>;

export function createAmsHealthHandler(
probes?: ReadinessProbe[],
): (req: { method?: string; url?: string }, res: { writeHead: (status: number, headers: Record<string, string>) => void; end: (body: string) => void }) => Promise<void>;

export function startAmsHealthServer(options?: { port?: number; host?: string; probes?: ReadinessProbe[] }): Promise<Server>;
/**
* Readiness: run every injected probe and report per-probe pass/fail plus how long each took. `ok` is true only
* when every probe passed -- a container that can't reach a backend it depends on must stop reporting ready so
* the fleet aggregator can route around it. A probe that throws counts as failed (never crashes readiness), and
* its duration is still recorded. Mirrors src/selfhost/health.ts's `readiness`/`timedReadinessCheck` behavior.
*/
export declare function readiness(probes?: ReadinessProbe[]): Promise<Readiness>;
/**
* Build the request handler for the AMS health surface: `GET /health` -> 200 liveness, `GET /ready` -> 200/503
* readiness (503 when any probe fails, so a load balancer stops routing to a degraded container), anything else
* -> 404. Exported separately from {@link startAmsHealthServer} so it can be exercised without binding a socket.
*/
export declare function createAmsHealthHandler(probes?: ReadinessProbe[]): (req: {
method?: string | undefined;
url?: string | undefined;
}, res: {
writeHead: (status: number, headers: Record<string, string>) => void;
end: (body: string) => void;
}) => Promise<void>;
/**
* Start the AMS health HTTP server. Resolves once it is listening. `port: 0` binds an ephemeral port (the caller
* reads `server.address()`), which is what the tests use. The hosted-container entry point owns the lifecycle and
* passes the AMS-specific probes (store reachable, loop cycle alive); the returned server is closed on shutdown.
*/
export declare function startAmsHealthServer(options?: {
port?: number;
host?: string;
probes?: ReadinessProbe[];
}): Promise<Server>;
107 changes: 44 additions & 63 deletions packages/loopover-miner/lib/ams-health-server.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

93 changes: 93 additions & 0 deletions packages/loopover-miner/lib/ams-health-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { createServer, type Server } from "node:http";

// Minimal HTTP health surface for a hosted AMS container (#7177). AMS is otherwise CLI-only (loopover-miner
// status/doctor) and the operator UI reads its SQLite files directly -- but a hosted control-plane polling
// container health across a fleet (#4933/#4934) needs each container to answer over HTTP. This deliberately
// mirrors ORB's src/selfhost/health.ts SHAPE -- `/health` -> `{ status: "ok" }` liveness, `/ready` -> a
// `{ ok, checks, durationsMs }` readiness built from injectable ReadinessProbes -- so the same aggregator can
// poll both products identically. It runs ONLY from the hosted-container entry point; the self-host CLI never
// starts it, so self-host behavior is unchanged. No HTTP framework dependency: node:http is enough for two routes.

export type ReadinessProbe = { name: string; check: () => Promise<boolean> };

export type Readiness = {
ok: boolean;
checks: Record<string, boolean>;
durationsMs: Record<string, number>;
};

/** Bare liveness body: the process is up and answering, independent of any backend it depends on. */
export function buildHealthBody(): { status: "ok" } {
return { status: "ok" };
}

/**
* Readiness: run every injected probe and report per-probe pass/fail plus how long each took. `ok` is true only
* when every probe passed -- a container that can't reach a backend it depends on must stop reporting ready so
* the fleet aggregator can route around it. A probe that throws counts as failed (never crashes readiness), and
* its duration is still recorded. Mirrors src/selfhost/health.ts's `readiness`/`timedReadinessCheck` behavior.
*/
export async function readiness(probes: ReadinessProbe[] = []): Promise<Readiness> {
const checks: Record<string, boolean> = {};
const durationsMs: Record<string, number> = {};
let ok = true;
for (const probe of probes) {
const startedAt = performance.now();
let passed = false;
try {
passed = (await probe.check()) === true;
} catch {
passed = false;
} finally {
durationsMs[probe.name] = Math.max(0, performance.now() - startedAt);
}
checks[probe.name] = passed;
if (!passed) ok = false;
}
return { ok, checks, durationsMs };
}

function sendJson(res: { writeHead: (status: number, headers: Record<string, string>) => void; end: (body: string) => void }, status: number, body: unknown): void {
const payload = JSON.stringify(body);
res.writeHead(status, { "content-type": "application/json" });
res.end(payload);
}

/**
* Build the request handler for the AMS health surface: `GET /health` -> 200 liveness, `GET /ready` -> 200/503
* readiness (503 when any probe fails, so a load balancer stops routing to a degraded container), anything else
* -> 404. Exported separately from {@link startAmsHealthServer} so it can be exercised without binding a socket.
*/
export function createAmsHealthHandler(probes: ReadinessProbe[] = []) {
return async (
req: { method?: string | undefined; url?: string | undefined },
res: { writeHead: (status: number, headers: Record<string, string>) => void; end: (body: string) => void },
): Promise<void> => {
const path = (req.url ?? "").split("?", 1)[0];
if (req.method === "GET" && path === "/health") {
sendJson(res, 200, buildHealthBody());
return;
}
if (req.method === "GET" && path === "/ready") {
const result = await readiness(probes);
sendJson(res, result.ok ? 200 : 503, result);
return;
}
sendJson(res, 404, { error: "not_found" });
};
}

/**
* Start the AMS health HTTP server. Resolves once it is listening. `port: 0` binds an ephemeral port (the caller
* reads `server.address()`), which is what the tests use. The hosted-container entry point owns the lifecycle and
* passes the AMS-specific probes (store reachable, loop cycle alive); the returned server is closed on shutdown.
*/
export function startAmsHealthServer(options: { port?: number; host?: string; probes?: ReadinessProbe[] } = {}): Promise<Server> {
const port = Number.isInteger(options.port) ? (options.port as number) : 0;
const host = typeof options.host === "string" && options.host ? options.host : "0.0.0.0";
const probes = Array.isArray(options.probes) ? options.probes : [];
const server = createServer(createAmsHealthHandler(probes));
return new Promise((resolve) => {
server.listen(port, host, () => resolve(server));
});
}
40 changes: 24 additions & 16 deletions packages/loopover-miner/lib/chat-action-dispatch.d.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
import type { ChatActionRegistry, ChatActionRequest } from "./chat-action-registry.js";

export const CHAT_ACTION_DISPATCH_FLAG: string;
export const CHAT_ACTION_DISPATCH_ENABLE_VALUE: string;

export function isChatActionDispatchEnabled(env?: Record<string, string | undefined>): boolean;

/** Env var an operator sets to turn the chat-action dispatch layer on. */
export declare const CHAT_ACTION_DISPATCH_FLAG = "LOOPOVER_MINER_CHAT_ACTIONS";
/** The one and only value that enables dispatch. Anything else (unset, empty, "true", "1", ...) stays off. */
export declare const CHAT_ACTION_DISPATCH_ENABLE_VALUE = "enabled";
/**
* Fail-closed config-flag gate: enabled only when the flag is set to exactly the enable value (trimmed).
* Unset, empty, or any other value -- including truthy-looking ones like "true"/"1" -- reads as disabled.
*/
export declare function isChatActionDispatchEnabled(env?: Record<string, string | undefined>): boolean;
export type ChatActionDispatchResult = {
ok: boolean;
status: string;
action: string | null;
[key: string]: unknown;
ok: boolean;
status: string;
action: string | null;
[key: string]: unknown;
};

export function dispatchChatAction(
request: ChatActionRequest,
options?: {
/**
* The single entry point every chat-issued action goes through. In order:
* 1. Check the config flag FIRST -- before touching the registry or validating params. When disabled,
* return a clearly-typed `"disabled"` result and look up nothing.
* 2. Reject an unknown (unregistered) action.
* 3. Run the action's own registered params-validator; reject on failure without coercing or dropping
* fields (the caller's `params` is passed through unchanged).
* 4. Invoke the registered (governor-gated) handler and return its result.
*/
export declare function dispatchChatAction(request: ChatActionRequest, options?: {
env?: Record<string, string | undefined>;
registry?: ChatActionRegistry;
},
): Promise<ChatActionDispatchResult>;
}): Promise<ChatActionDispatchResult>;
Loading