From f248b6f2577c242e94f06c2499b68334bf2adeb3 Mon Sep 17 00:00:00 2001 From: joaovictor91123 Date: Sun, 19 Jul 2026 20:11:33 +0400 Subject: [PATCH] chore(miner): migrate batch 2.1 utility modules to TypeScript Converts env-file-indirection, deny-check, cli, chat-action-dispatch, ams-health-server, replay-task-bridge, chat-governor-actions, and chat-portfolio-actions from plain .js to real, compiler-verified TypeScript, following the in-place-emit pattern the Phase 1 build pipeline (#7299) already wired up. Each file's hand-maintained .d.ts sibling is now tsc-generated instead. No behavior change; existing tests pass unmodified. Closes #7300 --- .../loopover-miner/lib/ams-health-server.d.ts | 58 +++++-- .../loopover-miner/lib/ams-health-server.js | 107 ++++++------- .../loopover-miner/lib/ams-health-server.ts | 93 +++++++++++ .../lib/chat-action-dispatch.d.ts | 40 +++-- .../lib/chat-action-dispatch.js | 91 +++++------ .../lib/chat-action-dispatch.ts | 98 ++++++++++++ .../lib/chat-governor-actions.d.ts | 28 ++-- .../lib/chat-governor-actions.js | 124 ++++++--------- .../lib/chat-governor-actions.ts | 84 ++++++++++ .../lib/chat-portfolio-actions.d.ts | 38 +++-- .../lib/chat-portfolio-actions.js | 108 ++++++------- .../lib/chat-portfolio-actions.ts | 100 ++++++++++++ packages/loopover-miner/lib/cli.d.ts | 13 +- packages/loopover-miner/lib/cli.js | 144 ++++++++--------- packages/loopover-miner/lib/cli.ts | 81 ++++++++++ packages/loopover-miner/lib/deny-check.d.ts | 20 ++- packages/loopover-miner/lib/deny-check.js | 133 ++++++++-------- packages/loopover-miner/lib/deny-check.ts | 87 ++++++++++ .../lib/env-file-indirection.d.ts | 16 +- .../lib/env-file-indirection.js | 31 ++-- .../lib/env-file-indirection.ts | 45 ++++++ .../lib/replay-task-bridge.d.ts | 82 +++------- .../loopover-miner/lib/replay-task-bridge.js | 86 +++++----- .../loopover-miner/lib/replay-task-bridge.ts | 149 ++++++++++++++++++ 24 files changed, 1266 insertions(+), 590 deletions(-) create mode 100644 packages/loopover-miner/lib/ams-health-server.ts create mode 100644 packages/loopover-miner/lib/chat-action-dispatch.ts create mode 100644 packages/loopover-miner/lib/chat-governor-actions.ts create mode 100644 packages/loopover-miner/lib/chat-portfolio-actions.ts create mode 100644 packages/loopover-miner/lib/cli.ts create mode 100644 packages/loopover-miner/lib/deny-check.ts create mode 100644 packages/loopover-miner/lib/env-file-indirection.ts create mode 100644 packages/loopover-miner/lib/replay-task-bridge.ts diff --git a/packages/loopover-miner/lib/ams-health-server.d.ts b/packages/loopover-miner/lib/ams-health-server.d.ts index 2e56b3a0b1..6173e344cf 100644 --- a/packages/loopover-miner/lib/ams-health-server.d.ts +++ b/packages/loopover-miner/lib/ams-health-server.d.ts @@ -1,19 +1,43 @@ -import type { Server } from "node:http"; - -export type ReadinessProbe = { name: string; check: () => Promise }; - +import { type Server } from "node:http"; +export type ReadinessProbe = { + name: string; + check: () => Promise; +}; export type Readiness = { - ok: boolean; - checks: Record; - durationsMs: Record; + ok: boolean; + checks: Record; + durationsMs: Record; +}; +/** 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; - -export function createAmsHealthHandler( - probes?: ReadinessProbe[], -): (req: { method?: string; url?: string }, res: { writeHead: (status: number, headers: Record) => void; end: (body: string) => void }) => Promise; - -export function startAmsHealthServer(options?: { port?: number; host?: string; probes?: ReadinessProbe[] }): Promise; +/** + * 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; +/** + * 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) => void; + end: (body: string) => void; +}) => Promise; +/** + * 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; diff --git a/packages/loopover-miner/lib/ams-health-server.js b/packages/loopover-miner/lib/ams-health-server.js index 1949f10cfb..fe35310f40 100644 --- a/packages/loopover-miner/lib/ams-health-server.js +++ b/packages/loopover-miner/lib/ams-health-server.js @@ -1,92 +1,73 @@ import { createServer } 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. - -/** @typedef {{ name: string, check: () => Promise }} ReadinessProbe */ - /** Bare liveness body: the process is up and answering, independent of any backend it depends on. */ export function buildHealthBody() { - return { 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. - * - * @param {ReadinessProbe[]} [probes] - * @returns {Promise<{ ok: boolean, checks: Record, durationsMs: Record }>} */ export async function readiness(probes = []) { - const checks = {}; - const durationsMs = {}; - 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); + const checks = {}; + const durationsMs = {}; + 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; } - checks[probe.name] = passed; - if (!passed) ok = false; - } - return { ok, checks, durationsMs }; + return { ok, checks, durationsMs }; } - function sendJson(res, status, body) { - const payload = JSON.stringify(body); - res.writeHead(status, { "content-type": "application/json" }); - res.end(payload); + 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. - * - * @param {ReadinessProbe[]} [probes] */ export function createAmsHealthHandler(probes = []) { - return async (req, res) => { - 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" }); - }; + return async (req, res) => { + 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. - * - * @param {{ port?: number, host?: string, probes?: ReadinessProbe[] }} [options] - * @returns {Promise} */ export function startAmsHealthServer(options = {}) { - const port = Number.isInteger(options.port) ? options.port : 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)); - }); + const port = Number.isInteger(options.port) ? options.port : 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)); + }); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYW1zLWhlYWx0aC1zZXJ2ZXIuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJhbXMtaGVhbHRoLXNlcnZlci50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEVBQUUsWUFBWSxFQUFlLE1BQU0sV0FBVyxDQUFDO0FBa0J0RCxxR0FBcUc7QUFDckcsTUFBTSxVQUFVLGVBQWU7SUFDN0IsT0FBTyxFQUFFLE1BQU0sRUFBRSxJQUFJLEVBQUUsQ0FBQztBQUMxQixDQUFDO0FBRUQ7Ozs7O0dBS0c7QUFDSCxNQUFNLENBQUMsS0FBSyxVQUFVLFNBQVMsQ0FBQyxTQUEyQixFQUFFO0lBQzNELE1BQU0sTUFBTSxHQUE0QixFQUFFLENBQUM7SUFDM0MsTUFBTSxXQUFXLEdBQTJCLEVBQUUsQ0FBQztJQUMvQyxJQUFJLEVBQUUsR0FBRyxJQUFJLENBQUM7SUFDZCxLQUFLLE1BQU0sS0FBSyxJQUFJLE1BQU0sRUFBRSxDQUFDO1FBQzNCLE1BQU0sU0FBUyxHQUFHLFdBQVcsQ0FBQyxHQUFHLEVBQUUsQ0FBQztRQUNwQyxJQUFJLE1BQU0sR0FBRyxLQUFLLENBQUM7UUFDbkIsSUFBSSxDQUFDO1lBQ0gsTUFBTSxHQUFHLENBQUMsTUFBTSxLQUFLLENBQUMsS0FBSyxFQUFFLENBQUMsS0FBSyxJQUFJLENBQUM7UUFDMUMsQ0FBQztRQUFDLE1BQU0sQ0FBQztZQUNQLE1BQU0sR0FBRyxLQUFLLENBQUM7UUFDakIsQ0FBQztnQkFBUyxDQUFDO1lBQ1QsV0FBVyxDQUFDLEtBQUssQ0FBQyxJQUFJLENBQUMsR0FBRyxJQUFJLENBQUMsR0FBRyxDQUFDLENBQUMsRUFBRSxXQUFXLENBQUMsR0FBRyxFQUFFLEdBQUcsU0FBUyxDQUFDLENBQUM7UUFDdkUsQ0FBQztRQUNELE1BQU0sQ0FBQyxLQUFLLENBQUMsSUFBSSxDQUFDLEdBQUcsTUFBTSxDQUFDO1FBQzVCLElBQUksQ0FBQyxNQUFNO1lBQUUsRUFBRSxHQUFHLEtBQUssQ0FBQztJQUMxQixDQUFDO0lB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/ams-health-server.ts b/packages/loopover-miner/lib/ams-health-server.ts new file mode 100644 index 0000000000..9bb73bf4e8 --- /dev/null +++ b/packages/loopover-miner/lib/ams-health-server.ts @@ -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 }; + +export type Readiness = { + ok: boolean; + checks: Record; + durationsMs: Record; +}; + +/** 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 { + const checks: Record = {}; + const durationsMs: Record = {}; + 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) => 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) => void; end: (body: string) => void }, + ): Promise => { + 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 { + 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)); + }); +} diff --git a/packages/loopover-miner/lib/chat-action-dispatch.d.ts b/packages/loopover-miner/lib/chat-action-dispatch.d.ts index d7e6dd96ca..b1e4ba3d6e 100644 --- a/packages/loopover-miner/lib/chat-action-dispatch.d.ts +++ b/packages/loopover-miner/lib/chat-action-dispatch.d.ts @@ -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): 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): 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; registry?: ChatActionRegistry; - }, -): Promise; +}): Promise; diff --git a/packages/loopover-miner/lib/chat-action-dispatch.js b/packages/loopover-miner/lib/chat-action-dispatch.js index 346cf991c6..7605c5e481 100644 --- a/packages/loopover-miner/lib/chat-action-dispatch.js +++ b/packages/loopover-miner/lib/chat-action-dispatch.js @@ -12,26 +12,19 @@ // Disabled by default: the flag fails closed (off unless explicitly enabled), and the shared registry // (chat-action-registry.js) ships empty, so no action can execute until a child issue registers a handler // AND an operator flips the flag on. - import { chatActionRegistry } from "./chat-action-registry.js"; - /** Env var an operator sets to turn the chat-action dispatch layer on. */ export 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 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. - * - * @param {Record} [env] - * @returns {boolean} */ export function isChatActionDispatchEnabled(env = process.env) { - const raw = env?.[CHAT_ACTION_DISPATCH_FLAG]; - return typeof raw === "string" && raw.trim() === CHAT_ACTION_DISPATCH_ENABLE_VALUE; + const raw = env?.[CHAT_ACTION_DISPATCH_FLAG]; + return typeof raw === "string" && raw.trim() === CHAT_ACTION_DISPATCH_ENABLE_VALUE; } - /** * 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, @@ -40,52 +33,46 @@ export function isChatActionDispatchEnabled(env = process.env) { * 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. - * - * @param {{ action?: string, params?: unknown, governorInput?: unknown }} request - * @param {{ env?: Record, registry?: typeof chatActionRegistry }} [options] - * @returns {Promise<{ ok: boolean, status: string, action: string | null, [k: string]: unknown }>} */ export async function dispatchChatAction(request, options = {}) { - const env = options.env ?? process.env; - - // Flag first -- before touching the registry or validating params. Fail closed. - if (!isChatActionDispatchEnabled(env)) { - return { ok: false, status: "disabled", action: readAction(request) }; - } - - const registry = options.registry ?? chatActionRegistry; - const action = readAction(request); - if (action === null || !registry.has(action)) { - return { ok: false, status: "unknown_action", action }; - } - - const registered = registry.get(action); - let valid; - try { - valid = registered.paramsValidator(request?.params) === true; - } catch (error) { - // A validator that throws is treated as a rejection (fail closed), not as a dispatch error. - return { ok: false, status: "invalid_params", action, error: error instanceof Error ? error.message : String(error) }; - } - if (!valid) { - return { ok: false, status: "invalid_params", action }; - } - - let result; - try { - result = await registered.handler(request); - } catch { - // A handler that throws fails closed with the module's typed result shape (#6989), consistent with the - // paramsValidator catch above. The thrown value is deliberately NOT echoed back: a handler wraps - // arbitrary action work (e.g. a network call), so its error could carry external detail -- the sibling - // fail-closed paths (sentry.js, pretooluse-hook.js) likewise swallow rather than surface it. A distinct - // "handler_error" status still lets a caller tell an execution failure from a params-validation failure. - return { ok: false, status: "handler_error", action }; - } - return { ok: true, status: "dispatched", action, result }; + const env = options.env ?? process.env; + // Flag first -- before touching the registry or validating params. Fail closed. + if (!isChatActionDispatchEnabled(env)) { + return { ok: false, status: "disabled", action: readAction(request) }; + } + const registry = options.registry ?? chatActionRegistry; + const action = readAction(request); + if (action === null || !registry.has(action)) { + return { ok: false, status: "unknown_action", action }; + } + const registered = registry.get(action); + let valid; + try { + valid = registered.paramsValidator(request?.params) === true; + } + catch (error) { + // A validator that throws is treated as a rejection (fail closed), not as a dispatch error. + return { ok: false, status: "invalid_params", action, error: error instanceof Error ? error.message : String(error) }; + } + if (!valid) { + return { ok: false, status: "invalid_params", action }; + } + let result; + try { + result = await registered.handler(request); + } + catch { + // A handler that throws fails closed with the module's typed result shape (#6989), consistent with the + // paramsValidator catch above. The thrown value is deliberately NOT echoed back: a handler wraps + // arbitrary action work (e.g. a network call), so its error could carry external detail -- the sibling + // fail-closed paths (sentry.js, pretooluse-hook.js) likewise swallow rather than surface it. A distinct + // "handler_error" status still lets a caller tell an execution failure from a params-validation failure. + return { ok: false, status: "handler_error", action }; + } + return { ok: true, status: "dispatched", action, result }; } - /** The requested action name, or null when the request omits a string action. */ function readAction(request) { - return request && typeof request.action === "string" ? request.action : null; + return request && typeof request.action === "string" ? request.action : null; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2hhdC1hY3Rpb24tZGlzcGF0Y2guanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJjaGF0LWFjdGlvbi1kaXNwYXRjaC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSwyQ0FBMkM7QUFDM0MsRUFBRTtBQUNGLDZGQUE2RjtBQUM3Riw0R0FBNEc7QUFDNUcsdUdBQXVHO0FBQ3ZHLG1HQUFtRztBQUNuRyx1R0FBdUc7QUFDdkcsNkdBQTZHO0FBQzdHLDZHQUE2RztBQUM3RywyR0FBMkc7QUFDM0csRUFBRTtBQUNGLHNHQUFzRztBQUN0RywwR0FBMEc7QUFDMUcscUNBQXFDO0FBRXJDLE9BQU8sRUFBRSxrQkFBa0IsRUFBRSxNQUFNLDJCQUEyQixDQUFDO0FBRy9ELDBFQUEwRTtBQUMxRSxNQUFNLENBQUMsTUFBTSx5QkFBeUIsR0FBRyw2QkFBNkIsQ0FBQztBQUN2RSw4R0FBOEc7QUFDOUcsTUFBTSxDQUFDLE1BQU0saUNBQWlDLEdBQUcsU0FBUyxDQUFDO0FBRTNEOzs7R0FHRztBQUNILE1BQU0sVUFBVSwyQkFBMkIsQ0FBQyxNQUEwQyxPQUFPLENBQUMsR0FBRztJQUMvRixNQUFNLEdBQUcsR0FBRyxHQUFHLEVBQUUsQ0FBQyx5QkFBeUIsQ0FBQyxDQUFDO0lBQzdDLE9BQU8sT0FBTyxHQUFHLEtBQUssUUFBUSxJQUFJLEdBQUcsQ0FBQyxJQUFJLEVBQUUsS0FBSyxpQ0FBaUMsQ0FBQztBQUNyRixDQUFDO0FBU0Q7Ozs7Ozs7O0dBUUc7QUFDSCxNQUFNLENBQUMsS0FBSyxVQUFVLGtCQUFrQixDQUN0QyxPQUEwQixFQUMxQixVQUdJLEVBQUU7SUFFTixNQUFNLEdBQUcsR0FBRyxPQUFPLENBQUMsR0FBRyxJQUFJLE9BQU8sQ0FBQyxHQUFHLENBQUM7SUFFdkMsZ0ZBQWdGO0lBQ2hGLElBQUksQ0FBQywyQkFBMkIsQ0FBQyxHQUFHLENBQUMsRUFBRSxDQUFDO1FBQ3RDLE9BQU8sRUFBRSxFQUFFLEVBQUUsS0FBSyxFQUFFLE1BQU0sRUFBRSxVQUFVLEVBQUUsTUFBTSxFQUFFLFVBQVUsQ0FBQyxPQUFPLENBQUMsRUFBRSxDQUFDO0lBQ3hFLENBQUM7SUFFRCxNQUFNLFFBQVEsR0FBRyxPQUFPLENBQUMsUUFBUSxJQUFJLGtCQUFrQixDQUFDO0lBQ3hELE1BQU0sTUFBTSxHQUFHLFVBQVUsQ0FBQyxPQUFPLENBQUMsQ0FBQztJQUNuQyxJQUFJLE1BQU0sS0FBSyxJQUFJLElBQUksQ0FBQyxRQUFRLENBQUMsR0FBRyxDQUFDLE1BQU0sQ0FBQyxFQUFFLENBQUM7UUFDN0MsT0FBTyxFQUFFLEVBQUUsRUFBRSxLQUFLLEVBQUUsTUFBTSxFQUFFLGdCQUFnQixFQUFFLE1BQU0sRUFBRSxDQUFDO0lBQ3pELENBQUM7SUFFRCxNQUFNLFVBQVUsR0FBRyxRQUFRLENBQUMsR0FBRyxDQUFDLE1BQU0sQ0FBRSxDQUFDO0lBQ3pDLElBQUksS0FBSyxDQUFDO0lBQ1YsSUFBSSxDQUFDO1FBQ0gsS0FBSyxHQUFHLFVBQVUsQ0FBQyxlQUFlLENBQUMsT0FBTyxFQUFFLE1BQU0sQ0FBQyxLQUFLLElBQUksQ0FBQztJQUMvRCxDQUFDO0lBQUMsT0FBTyxLQUFLLEVBQUUsQ0FBQztRQUNmLDRGQUE0RjtRQUM1RixPQUFPLEVBQUUsRUFBRSxFQUFFLEtBQUssRUFBRSxNQUFNLEVBQUUsZ0JBQWdCLEVBQUUsTUFBTSxFQUFFLEtBQUssRUFBRSxLQUFLLFlBQVksS0FBSyxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUMsT0FBTyxDQUFDLENBQUMsQ0FBQyxNQUFNLENBQUMsS0FBSyxDQUFDLEVBQUUsQ0FBQztJQUN4SCxDQUFDO0lBQ0QsSUFBSSxDQUFDLEtBQUssRUFBRSxDQUFDO1FBQ1gsT0FBTyxFQUFFLEVBQUUsRUFBRSxLQUFLLEVBQUUsTUFBTSxFQUFFLGdCQUFnQixFQUFFLE1BQU0sRUFBRSxDQUFDO0lBQ3pELENBQUM7SUFFRCxJQUFJLE1BQU0sQ0FBQztJQUNYLElBQUksQ0FBQztRQUNILE1BQU0sR0FBRyxNQUFNLFVBQVUsQ0FBQyxPQUFPLENBQUMsT0FBTyxDQUFDLENBQUM7SUFDN0MsQ0FBQztJQUFDLE1BQU0sQ0FBQztRQUNQLHVHQUF1RztRQUN2RyxpR0FBaUc7UUFDakcsdUdBQXVHO1FBQ3ZHLHdHQUF3RztRQUN4Ryx5R0FBeUc7UUFDekcsT0FBTyxFQUFFLEVBQUUsRUFBRSxLQUFLLEVBQUUsTUFBTSxFQUFFLGVBQWUsRUFBRSxNQUFNLEVBQUUsQ0FBQztJQUN4RCxDQUFDO0lBQ0QsT0FBTyxFQUFFLEVBQUUsRUFBRSxJQUFJLEVBQUUsTUFBTSxFQUFFLFlBQVksRUFBRSxNQUFNLEVBQUUsTUFBTSxFQUFFLENBQUM7QUFDNUQsQ0FBQztBQUVELGlGQUFpRjtBQUNqRixTQUFTLFVBQVUsQ0FBQyxPQUEwQjtJQUM1QyxPQUFPLE9BQU8sSUFBSSxPQUFPLE9BQU8sQ0FBQyxNQUFNLEtBQUssUUFBUSxDQUFDLENBQUMsQ0FBQyxPQUFPLENBQUMsTUFBTSxDQUFDLENBQUMsQ0FBQyxJQUFJLENBQUM7QUFDL0UsQ0FBQyJ9 \ No newline at end of file diff --git a/packages/loopover-miner/lib/chat-action-dispatch.ts b/packages/loopover-miner/lib/chat-action-dispatch.ts new file mode 100644 index 0000000000..cc630919dc --- /dev/null +++ b/packages/loopover-miner/lib/chat-action-dispatch.ts @@ -0,0 +1,98 @@ +// Chat action-dispatch chokepoint (#6519). +// +// SINGLE ENTRY POINT, NEVER BYPASS: every action a miner-chat message issues MUST go through +// `dispatchChatAction` here -- never a parallel or direct call into a registered handler, an HTTP endpoint, +// or a local-write tool. This function is the one place the config flag is checked and the one place a +// registered handler is looked up and invoked. It adds NO second safety check of its own: the real +// fail-closed enforcement lives in packages/loopover-engine/src/governor/chokepoint.ts (the precedence +// ladder) reached through the packages/loopover-miner/lib/governor-chokepoint.js stateful wrapper, which the +// registry's `governorGatedHandler` contract forces every registered handler through. Dispatch only gates on +// the flag, rejects unknown actions, and runs the registered params-validator before invoking the handler. +// +// Disabled by default: the flag fails closed (off unless explicitly enabled), and the shared registry +// (chat-action-registry.js) ships empty, so no action can execute until a child issue registers a handler +// AND an operator flips the flag on. + +import { chatActionRegistry } from "./chat-action-registry.js"; +import type { ChatActionRegistry, ChatActionRequest } from "./chat-action-registry.js"; + +/** Env var an operator sets to turn the chat-action dispatch layer on. */ +export 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 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 function isChatActionDispatchEnabled(env: Record = process.env): boolean { + const raw = env?.[CHAT_ACTION_DISPATCH_FLAG]; + return typeof raw === "string" && raw.trim() === CHAT_ACTION_DISPATCH_ENABLE_VALUE; +} + +export type ChatActionDispatchResult = { + ok: boolean; + status: string; + action: string | null; + [key: string]: unknown; +}; + +/** + * 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 async function dispatchChatAction( + request: ChatActionRequest, + options: { + env?: Record; + registry?: ChatActionRegistry; + } = {}, +): Promise { + const env = options.env ?? process.env; + + // Flag first -- before touching the registry or validating params. Fail closed. + if (!isChatActionDispatchEnabled(env)) { + return { ok: false, status: "disabled", action: readAction(request) }; + } + + const registry = options.registry ?? chatActionRegistry; + const action = readAction(request); + if (action === null || !registry.has(action)) { + return { ok: false, status: "unknown_action", action }; + } + + const registered = registry.get(action)!; + let valid; + try { + valid = registered.paramsValidator(request?.params) === true; + } catch (error) { + // A validator that throws is treated as a rejection (fail closed), not as a dispatch error. + return { ok: false, status: "invalid_params", action, error: error instanceof Error ? error.message : String(error) }; + } + if (!valid) { + return { ok: false, status: "invalid_params", action }; + } + + let result; + try { + result = await registered.handler(request); + } catch { + // A handler that throws fails closed with the module's typed result shape (#6989), consistent with the + // paramsValidator catch above. The thrown value is deliberately NOT echoed back: a handler wraps + // arbitrary action work (e.g. a network call), so its error could carry external detail -- the sibling + // fail-closed paths (sentry.js, pretooluse-hook.js) likewise swallow rather than surface it. A distinct + // "handler_error" status still lets a caller tell an execution failure from a params-validation failure. + return { ok: false, status: "handler_error", action }; + } + return { ok: true, status: "dispatched", action, result }; +} + +/** The requested action name, or null when the request omits a string action. */ +function readAction(request: ChatActionRequest): string | null { + return request && typeof request.action === "string" ? request.action : null; +} diff --git a/packages/loopover-miner/lib/chat-governor-actions.d.ts b/packages/loopover-miner/lib/chat-governor-actions.d.ts index e19ca729e6..aef540a029 100644 --- a/packages/loopover-miner/lib/chat-governor-actions.d.ts +++ b/packages/loopover-miner/lib/chat-governor-actions.d.ts @@ -1,14 +1,18 @@ import type { ChatActionRegistry } from "./chat-action-registry.js"; - -export const GOVERNOR_PAUSE_CHAT_ACTION: "governor_pause"; -export const GOVERNOR_RESUME_CHAT_ACTION: "governor_resume"; - -export function isGovernorPauseChatParams(params: unknown): boolean; -export function isGovernorResumeChatParams(params: unknown): boolean; - -export function registerGovernorChatActions(options: { - pauseGovernor: (reason?: string) => Promise; - resumeGovernor: () => Promise; - registry?: ChatActionRegistry; - evaluateGate?: () => { decision: { stage: string } }; +export declare const GOVERNOR_PAUSE_CHAT_ACTION = "governor_pause"; +export declare const GOVERNOR_RESUME_CHAT_ACTION = "governor_resume"; +/** Optional `{ reason?: string }` — absent/empty params are valid; a non-string reason is rejected. */ +export declare function isGovernorPauseChatParams(params: unknown): boolean; +/** Resume takes no arguments — only nullish or an empty object is valid. */ +export declare function isGovernorResumeChatParams(params: unknown): boolean; +/** Idempotently register `governor_pause` / `governor_resume`. */ +export declare function registerGovernorChatActions(options: { + pauseGovernor: (reason?: string) => Promise; + resumeGovernor: () => Promise; + registry?: ChatActionRegistry; + evaluateGate?: () => { + decision: { + stage: string; + }; + }; }): void; diff --git a/packages/loopover-miner/lib/chat-governor-actions.js b/packages/loopover-miner/lib/chat-governor-actions.js index 68f3a90077..733f92524c 100644 --- a/packages/loopover-miner/lib/chat-governor-actions.js +++ b/packages/loopover-miner/lib/chat-governor-actions.js @@ -10,90 +10,66 @@ // a `governorGatedHandler` brand, so we supply an allow-stage evaluateGate rather than routing through // governor-chokepoint.js. Execution stays behind the shared LOOPOVER_MINER_CHAT_ACTIONS flag via // `dispatchChatAction`. - import { governorGatedHandler, chatActionRegistry } from "./chat-action-registry.js"; - export const GOVERNOR_PAUSE_CHAT_ACTION = "governor_pause"; export const GOVERNOR_RESUME_CHAT_ACTION = "governor_resume"; - /** Administrative pause/resume is not a chokepoint content-write (#6521); satisfy the registry brand only. */ const allowAdministrativeGate = () => ({ decision: { stage: "allow" } }); - -/** - * Optional `{ reason?: string }` — absent/empty params are valid; a non-string reason is rejected. - * @param {unknown} params - * @returns {boolean} - */ +/** Optional `{ reason?: string }` — absent/empty params are valid; a non-string reason is rejected. */ export function isGovernorPauseChatParams(params) { - if (params == null) return true; - if (typeof params !== "object" || Array.isArray(params)) return false; - const keys = Object.keys(/** @type {object} */ (params)); - if (keys.length === 0) return true; - if (keys.length === 1 && keys[0] === "reason") { - const reason = /** @type {{ reason?: unknown }} */ (params).reason; - return reason === undefined || typeof reason === "string"; - } - return false; + if (params == null) + return true; + if (typeof params !== "object" || Array.isArray(params)) + return false; + const keys = Object.keys(params); + if (keys.length === 0) + return true; + if (keys.length === 1 && keys[0] === "reason") { + const reason = params.reason; + return reason === undefined || typeof reason === "string"; + } + return false; } - -/** - * Resume takes no arguments — only nullish or an empty object is valid. - * @param {unknown} params - * @returns {boolean} - */ +/** Resume takes no arguments — only nullish or an empty object is valid. */ export function isGovernorResumeChatParams(params) { - if (params == null) return true; - if (typeof params !== "object" || Array.isArray(params)) return false; - return Object.keys(/** @type {object} */ (params)).length === 0; + if (params == null) + return true; + if (typeof params !== "object" || Array.isArray(params)) + return false; + return Object.keys(params).length === 0; } - -/** - * @param {unknown} params - * @returns {string | undefined} - */ function readOptionalPauseReason(params) { - if (params == null || typeof params !== "object" || Array.isArray(params)) return undefined; - const reason = /** @type {{ reason?: unknown }} */ (params).reason; - // Mirror LedgersPage: empty string → undefined so pauseGovernor omits the body field. - return typeof reason === "string" && reason ? reason : undefined; + if (params == null || typeof params !== "object" || Array.isArray(params)) + return undefined; + const reason = params.reason; + // Mirror LedgersPage: empty string → undefined so pauseGovernor omits the body field. + return typeof reason === "string" && reason ? reason : undefined; } - -/** - * Idempotently register `governor_pause` / `governor_resume`. - * - * @param {{ - * pauseGovernor: (reason?: string) => Promise, - * resumeGovernor: () => Promise, - * registry?: import("./chat-action-registry.js").ChatActionRegistry, - * evaluateGate?: () => { decision: { stage: string } }, - * }} options - */ +/** Idempotently register `governor_pause` / `governor_resume`. */ export function registerGovernorChatActions(options) { - const pauseGovernor = options?.pauseGovernor; - const resumeGovernor = options?.resumeGovernor; - if (typeof pauseGovernor !== "function") { - throw new TypeError("registerGovernorChatActions: pauseGovernor must be a function"); - } - if (typeof resumeGovernor !== "function") { - throw new TypeError("registerGovernorChatActions: resumeGovernor must be a function"); - } - - const registry = options.registry ?? chatActionRegistry; - const evaluateGate = options.evaluateGate ?? allowAdministrativeGate; - - if (!registry.has(GOVERNOR_PAUSE_CHAT_ACTION)) { - registry.register(GOVERNOR_PAUSE_CHAT_ACTION, { - paramsValidator: isGovernorPauseChatParams, - handler: governorGatedHandler(async (request) => pauseGovernor(readOptionalPauseReason(request?.params)), { - evaluateGate, - }), - }); - } - - if (!registry.has(GOVERNOR_RESUME_CHAT_ACTION)) { - registry.register(GOVERNOR_RESUME_CHAT_ACTION, { - paramsValidator: isGovernorResumeChatParams, - handler: governorGatedHandler(async () => resumeGovernor(), { evaluateGate }), - }); - } + const pauseGovernor = options?.pauseGovernor; + const resumeGovernor = options?.resumeGovernor; + if (typeof pauseGovernor !== "function") { + throw new TypeError("registerGovernorChatActions: pauseGovernor must be a function"); + } + if (typeof resumeGovernor !== "function") { + throw new TypeError("registerGovernorChatActions: resumeGovernor must be a function"); + } + const registry = options.registry ?? chatActionRegistry; + const evaluateGate = options.evaluateGate ?? allowAdministrativeGate; + if (!registry.has(GOVERNOR_PAUSE_CHAT_ACTION)) { + registry.register(GOVERNOR_PAUSE_CHAT_ACTION, { + paramsValidator: isGovernorPauseChatParams, + handler: governorGatedHandler(async (request) => pauseGovernor(readOptionalPauseReason(request?.params)), { + evaluateGate, + }), + }); + } + if (!registry.has(GOVERNOR_RESUME_CHAT_ACTION)) { + registry.register(GOVERNOR_RESUME_CHAT_ACTION, { + paramsValidator: isGovernorResumeChatParams, + handler: governorGatedHandler(async () => resumeGovernor(), { evaluateGate }), + }); + } } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2hhdC1nb3Zlcm5vci1hY3Rpb25zLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiY2hhdC1nb3Zlcm5vci1hY3Rpb25zLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLDJEQUEyRDtBQUMzRCxFQUFFO0FBQ0YsNEZBQTRGO0FBQzVGLGdHQUFnRztBQUNoRyw2RkFBNkY7QUFDN0YscUdBQXFHO0FBQ3JHLDJFQUEyRTtBQUMzRSxFQUFFO0FBQ0Ysc0dBQXNHO0FBQ3RHLHVHQUF1RztBQUN2RyxpR0FBaUc7QUFDakcsd0JBQXdCO0FBRXhCLE9BQU8sRUFBRSxvQkFBb0IsRUFBRSxrQkFBa0IsRUFBRSxNQUFNLDJCQUEyQixDQUFDO0FBR3JGLE1BQU0sQ0FBQyxNQUFNLDBCQUEwQixHQUFHLGdCQUFnQixDQUFDO0FBQzNELE1BQU0sQ0FBQyxNQUFNLDJCQUEyQixHQUFHLGlCQUFpQixDQUFDO0FBRTdELDhHQUE4RztBQUM5RyxNQUFNLHVCQUF1QixHQUFHLEdBQUcsRUFBRSxDQUFDLENBQUMsRUFBRSxRQUFRLEVBQUUsRUFBRSxLQUFLLEVBQUUsT0FBTyxFQUFFLEVBQUUsQ0FBQyxDQUFDO0FBRXpFLHVHQUF1RztBQUN2RyxNQUFNLFVBQVUseUJBQXlCLENBQUMsTUFBZTtJQUN2RCxJQUFJLE1BQU0sSUFBSSxJQUFJO1FBQUUsT0FBTyxJQUFJLENBQUM7SUFDaEMsSUFBSSxPQUFPLE1BQU0sS0FBSyxRQUFRLElBQUksS0FBSyxDQUFDLE9BQU8sQ0FBQyxNQUFNLENBQUM7UUFBRSxPQUFPLEtBQUssQ0FBQztJQUN0RSxNQUFNLElBQUksR0FBRyxNQUFNLENBQUMsSUFBSSxDQUFDLE1BQWdCLENBQUMsQ0FBQztJQUMzQyxJQUFJLElBQUksQ0FBQyxNQUFNLEtBQUssQ0FBQztRQUFFLE9BQU8sSUFBSSxDQUFDO0lBQ25DLElBQUksSUFBSSxDQUFDLE1BQU0sS0FBSyxDQUFDLElBQUksSUFBSSxDQUFDLENBQUMsQ0FBQyxLQUFLLFFBQVEsRUFBRSxD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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/chat-governor-actions.ts b/packages/loopover-miner/lib/chat-governor-actions.ts new file mode 100644 index 0000000000..90a755108a --- /dev/null +++ b/packages/loopover-miner/lib/chat-governor-actions.ts @@ -0,0 +1,84 @@ +// Governor pause/resume chat-action registrations (#6521). +// +// Child issue of the chat action-dispatch scaffolding (#6519). Registers `governor_pause` / +// `governor_resume` into a chat-action registry. Handlers MUST be wired to the miner-ui clients +// `pauseGovernor` / `resumeGovernor` (apps/loopover-miner-ui/src/lib/governor.ts) — never to +// governor-state.js and never via a hand-rolled fetch. The miner-ui wire module passes those clients +// in; this module only owns the registration contract + params validators. +// +// Pause/resume is administrative control, not a chokepoint content-write. The registry still requires +// a `governorGatedHandler` brand, so we supply an allow-stage evaluateGate rather than routing through +// governor-chokepoint.js. Execution stays behind the shared LOOPOVER_MINER_CHAT_ACTIONS flag via +// `dispatchChatAction`. + +import { governorGatedHandler, chatActionRegistry } from "./chat-action-registry.js"; +import type { ChatActionRegistry } from "./chat-action-registry.js"; + +export const GOVERNOR_PAUSE_CHAT_ACTION = "governor_pause"; +export const GOVERNOR_RESUME_CHAT_ACTION = "governor_resume"; + +/** Administrative pause/resume is not a chokepoint content-write (#6521); satisfy the registry brand only. */ +const allowAdministrativeGate = () => ({ decision: { stage: "allow" } }); + +/** Optional `{ reason?: string }` — absent/empty params are valid; a non-string reason is rejected. */ +export function isGovernorPauseChatParams(params: unknown): boolean { + if (params == null) return true; + if (typeof params !== "object" || Array.isArray(params)) return false; + const keys = Object.keys(params as object); + if (keys.length === 0) return true; + if (keys.length === 1 && keys[0] === "reason") { + const reason = (params as { reason?: unknown }).reason; + return reason === undefined || typeof reason === "string"; + } + return false; +} + +/** Resume takes no arguments — only nullish or an empty object is valid. */ +export function isGovernorResumeChatParams(params: unknown): boolean { + if (params == null) return true; + if (typeof params !== "object" || Array.isArray(params)) return false; + return Object.keys(params as object).length === 0; +} + +function readOptionalPauseReason(params: unknown): string | undefined { + if (params == null || typeof params !== "object" || Array.isArray(params)) return undefined; + const reason = (params as { reason?: unknown }).reason; + // Mirror LedgersPage: empty string → undefined so pauseGovernor omits the body field. + return typeof reason === "string" && reason ? reason : undefined; +} + +/** Idempotently register `governor_pause` / `governor_resume`. */ +export function registerGovernorChatActions(options: { + pauseGovernor: (reason?: string) => Promise; + resumeGovernor: () => Promise; + registry?: ChatActionRegistry; + evaluateGate?: () => { decision: { stage: string } }; +}): void { + const pauseGovernor = options?.pauseGovernor; + const resumeGovernor = options?.resumeGovernor; + if (typeof pauseGovernor !== "function") { + throw new TypeError("registerGovernorChatActions: pauseGovernor must be a function"); + } + if (typeof resumeGovernor !== "function") { + throw new TypeError("registerGovernorChatActions: resumeGovernor must be a function"); + } + + const registry = options.registry ?? chatActionRegistry; + const evaluateGate = options.evaluateGate ?? allowAdministrativeGate; + + if (!registry.has(GOVERNOR_PAUSE_CHAT_ACTION)) { + registry.register(GOVERNOR_PAUSE_CHAT_ACTION, { + paramsValidator: isGovernorPauseChatParams, + handler: governorGatedHandler(async (request) => pauseGovernor(readOptionalPauseReason(request?.params)), { + evaluateGate, + }), + }); + } + + if (!registry.has(GOVERNOR_RESUME_CHAT_ACTION)) { + registry.register(GOVERNOR_RESUME_CHAT_ACTION, { + paramsValidator: isGovernorResumeChatParams, + handler: governorGatedHandler(async () => resumeGovernor(), { evaluateGate }), + }); + } +} diff --git a/packages/loopover-miner/lib/chat-portfolio-actions.d.ts b/packages/loopover-miner/lib/chat-portfolio-actions.d.ts index cbb7b75401..6da12f0583 100644 --- a/packages/loopover-miner/lib/chat-portfolio-actions.d.ts +++ b/packages/loopover-miner/lib/chat-portfolio-actions.d.ts @@ -1,19 +1,27 @@ import type { ChatActionRegistry } from "./chat-action-registry.js"; - -export const PORTFOLIO_RELEASE_CHAT_ACTION: "portfolio_release"; -export const PORTFOLIO_REQUEUE_CHAT_ACTION: "portfolio_requeue"; - +export declare const PORTFOLIO_RELEASE_CHAT_ACTION = "portfolio_release"; +export declare const PORTFOLIO_REQUEUE_CHAT_ACTION = "portfolio_requeue"; export type PortfolioChatActionItem = { - repoFullName: string; - identifier: string; - apiBaseUrl?: string; + repoFullName: string; + identifier: string; + apiBaseUrl?: string; }; - -export function isPortfolioItemChatParams(params: unknown): boolean; - -export function registerPortfolioChatActions(options: { - releaseItem: (item: PortfolioChatActionItem) => Promise; - requeueItem: (item: PortfolioChatActionItem) => Promise; - registry?: ChatActionRegistry; - evaluateGate?: () => { decision: { stage: string } }; +/** + * Params for both actions: the queue item to act on. `repoFullName` + `identifier` are required non-empty + * strings; `apiBaseUrl` is optional (the route defaults it, mirroring the client's own + * `Pick` shape, where the buttons + * always pass one but the CLI path does not). Unknown keys are rejected rather than ignored: a typo'd param + * from a model-authored call must fail loudly, not silently act on the wrong item. + */ +export declare function isPortfolioItemChatParams(params: unknown): boolean; +/** Idempotently register `portfolio_release` / `portfolio_requeue`. */ +export declare function registerPortfolioChatActions(options: { + releaseItem: (item: PortfolioChatActionItem) => Promise; + requeueItem: (item: PortfolioChatActionItem) => Promise; + registry?: ChatActionRegistry; + evaluateGate?: () => { + decision: { + stage: string; + }; + }; }): void; diff --git a/packages/loopover-miner/lib/chat-portfolio-actions.js b/packages/loopover-miner/lib/chat-portfolio-actions.js index 5abb8d8fac..867b160e40 100644 --- a/packages/loopover-miner/lib/chat-portfolio-actions.js +++ b/packages/loopover-miner/lib/chat-portfolio-actions.js @@ -16,88 +16,70 @@ // chat-governor-actions.js's administrative pause/resume, we satisfy the registry's `governorGatedHandler` // brand with an allow-stage evaluateGate rather than routing through governor-chokepoint.js. Execution still // stays behind the shared LOOPOVER_MINER_CHAT_ACTIONS flag via `dispatchChatAction`. - import { governorGatedHandler, chatActionRegistry } from "./chat-action-registry.js"; - export const PORTFOLIO_RELEASE_CHAT_ACTION = "portfolio_release"; export const PORTFOLIO_REQUEUE_CHAT_ACTION = "portfolio_requeue"; - /** Local queue administration is not a chokepoint content-write (#6838); satisfy the registry brand only. */ const allowAdministrativeGate = () => ({ decision: { stage: "allow" } }); - /** * Params for both actions: the queue item to act on. `repoFullName` + `identifier` are required non-empty * strings; `apiBaseUrl` is optional (the route defaults it, mirroring the client's own * `Pick` shape, where the buttons * always pass one but the CLI path does not). Unknown keys are rejected rather than ignored: a typo'd param * from a model-authored call must fail loudly, not silently act on the wrong item. - * - * @param {unknown} params - * @returns {boolean} */ export function isPortfolioItemChatParams(params) { - if (params == null || typeof params !== "object" || Array.isArray(params)) return false; - const record = /** @type {Record} */ (params); - for (const key of Object.keys(record)) { - if (key !== "repoFullName" && key !== "identifier" && key !== "apiBaseUrl") return false; - } - if (typeof record.repoFullName !== "string" || record.repoFullName.trim() === "") return false; - if (typeof record.identifier !== "string" || record.identifier.trim() === "") return false; - if (record.apiBaseUrl !== undefined && typeof record.apiBaseUrl !== "string") return false; - return true; + if (params == null || typeof params !== "object" || Array.isArray(params)) + return false; + const record = params; + for (const key of Object.keys(record)) { + if (key !== "repoFullName" && key !== "identifier" && key !== "apiBaseUrl") + return false; + } + if (typeof record.repoFullName !== "string" || record.repoFullName.trim() === "") + return false; + if (typeof record.identifier !== "string" || record.identifier.trim() === "") + return false; + if (record.apiBaseUrl !== undefined && typeof record.apiBaseUrl !== "string") + return false; + return true; } - /** * Narrow validated params to the client's item shape. `apiBaseUrl` is only forwarded when present, so an * omitted one stays omitted rather than becoming an explicit `undefined` in the POST body. - * - * @param {unknown} params - * @returns {{ repoFullName: string, identifier: string, apiBaseUrl?: string }} */ function readPortfolioItem(params) { - const record = /** @type {{ repoFullName: string, identifier: string, apiBaseUrl?: unknown }} */ (params); - const item = { repoFullName: record.repoFullName, identifier: record.identifier }; - return typeof record.apiBaseUrl === "string" ? { ...item, apiBaseUrl: record.apiBaseUrl } : item; + const record = params; + const item = { repoFullName: record.repoFullName, identifier: record.identifier }; + return typeof record.apiBaseUrl === "string" ? { ...item, apiBaseUrl: record.apiBaseUrl } : item; } - -/** - * Idempotently register `portfolio_release` / `portfolio_requeue`. - * - * @param {{ - * releaseItem: (item: { repoFullName: string, identifier: string, apiBaseUrl?: string }) => Promise, - * requeueItem: (item: { repoFullName: string, identifier: string, apiBaseUrl?: string }) => Promise, - * registry?: import("./chat-action-registry.js").ChatActionRegistry, - * evaluateGate?: () => { decision: { stage: string } }, - * }} options - */ +/** Idempotently register `portfolio_release` / `portfolio_requeue`. */ export function registerPortfolioChatActions(options) { - const releaseItem = options?.releaseItem; - const requeueItem = options?.requeueItem; - if (typeof releaseItem !== "function") { - throw new TypeError("registerPortfolioChatActions: releaseItem must be a function"); - } - if (typeof requeueItem !== "function") { - throw new TypeError("registerPortfolioChatActions: requeueItem must be a function"); - } - - const registry = options.registry ?? chatActionRegistry; - const evaluateGate = options.evaluateGate ?? allowAdministrativeGate; - - if (!registry.has(PORTFOLIO_RELEASE_CHAT_ACTION)) { - registry.register(PORTFOLIO_RELEASE_CHAT_ACTION, { - paramsValidator: isPortfolioItemChatParams, - handler: governorGatedHandler(async (request) => releaseItem(readPortfolioItem(request?.params)), { - evaluateGate, - }), - }); - } - - if (!registry.has(PORTFOLIO_REQUEUE_CHAT_ACTION)) { - registry.register(PORTFOLIO_REQUEUE_CHAT_ACTION, { - paramsValidator: isPortfolioItemChatParams, - handler: governorGatedHandler(async (request) => requeueItem(readPortfolioItem(request?.params)), { - evaluateGate, - }), - }); - } + const releaseItem = options?.releaseItem; + const requeueItem = options?.requeueItem; + if (typeof releaseItem !== "function") { + throw new TypeError("registerPortfolioChatActions: releaseItem must be a function"); + } + if (typeof requeueItem !== "function") { + throw new TypeError("registerPortfolioChatActions: requeueItem must be a function"); + } + const registry = options.registry ?? chatActionRegistry; + const evaluateGate = options.evaluateGate ?? allowAdministrativeGate; + if (!registry.has(PORTFOLIO_RELEASE_CHAT_ACTION)) { + registry.register(PORTFOLIO_RELEASE_CHAT_ACTION, { + paramsValidator: isPortfolioItemChatParams, + handler: governorGatedHandler(async (request) => releaseItem(readPortfolioItem(request?.params)), { + evaluateGate, + }), + }); + } + if (!registry.has(PORTFOLIO_REQUEUE_CHAT_ACTION)) { + registry.register(PORTFOLIO_REQUEUE_CHAT_ACTION, { + paramsValidator: isPortfolioItemChatParams, + handler: governorGatedHandler(async (request) => requeueItem(readPortfolioItem(request?.params)), { + evaluateGate, + }), + }); + } } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2hhdC1wb3J0Zm9saW8tYWN0aW9ucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbImNoYXQtcG9ydGZvbGlvLWFjdGlvbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsK0RBQStEO0FBQy9ELEVBQUU7QUFDRiwrRkFBK0Y7QUFDL0Ysa0dBQWtHO0FBQ2xHLDZGQUE2RjtBQUM3RiwwR0FBMEc7QUFDMUcseUdBQXlHO0FBQ3pHLDJHQUEyRztBQUMzRyxxR0FBcUc7QUFDckcsRUFBRTtBQUNGLDRHQUE0RztBQUM1RyxnR0FBZ0c7QUFDaEcsOEdBQThHO0FBQzlHLDJHQUEyRztBQUMzRyxnR0FBZ0c7QUFDaEcsMkdBQTJHO0FBQzNHLDZHQUE2RztBQUM3RyxxRkFBcUY7QUFFckYsT0FBTyxFQUFFLG9CQUFvQixFQUFFLGtCQUFrQixFQUFFLE1BQU0sMkJBQTJCLENBQUM7QUFHckYsTUFBTSxDQUFDLE1BQU0sNkJBQTZCLEdBQUcsbUJBQW1CLENBQUM7QUFDakUsTUFBTSxDQUFDLE1BQU0sNkJBQTZCLEdBQUcsbUJBQW1CLENBQUM7QUFRakUsNkdBQTZHO0FBQzdHLE1BQU0sdUJBQXVCLEdBQUcsR0FBRyxFQUFFLENBQUMsQ0FBQyxFQUFFLFFBQVEsRUFBRSxFQUFFLEtBQUssRUFBRSxPQUFPLEVBQUUsRUFBRSxDQUFDLENBQUM7QUFFekU7Ozs7OztHQU1HO0FBQ0gsTUFBTSxVQUFVLHlCQUF5QixDQUFDLE1BQWU7SUFDdkQsSUFBSSxNQUFNLElBQUksSUFBSSxJQUFJLE9BQU8sTUFBTSxLQUFLLFFBQVEsSUFBSSxLQUFLLENBQUMsT0FBTyxDQUFDLE1BQU0sQ0FBQztRQUFFLE9BQU8sS0FBSyxDQUFDO0lBQ3hGLE1BQU0sTUFBTSxHQUFHLE1BQWlDLENBQUM7SUFDakQsS0FBSyxNQUFNLEdBQUcsSUFBSSxNQUFNLENBQUMsSUFBSSxDQUFDLE1BQU0sQ0FBQyxFQUFFLENBQUM7UUFDdEMsSUFBSSxHQUFHLEtBQUssY0FBYyxJQUFJLEdBQUcs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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/chat-portfolio-actions.ts b/packages/loopover-miner/lib/chat-portfolio-actions.ts new file mode 100644 index 0000000000..c225b60a10 --- /dev/null +++ b/packages/loopover-miner/lib/chat-portfolio-actions.ts @@ -0,0 +1,100 @@ +// Portfolio release/requeue chat-action registrations (#6838). +// +// Child issue of the chat action-dispatch scaffolding (#6519). Registers `portfolio_release` / +// `portfolio_requeue` into a chat-action registry. Handlers MUST be wired to the miner-ui clients +// `releasePortfolioQueueItem` / `requeuePortfolioQueueItem` (apps/loopover-miner-ui/src/lib/ +// portfolio-queue-actions.ts), so chat POSTs the SAME `/api/portfolio-queue/{release,requeue}` routes the +// dashboard's existing buttons already call — never portfolio-queue.js directly, and never a hand-rolled +// fetch. The miner-ui wire module passes those clients in; this module only owns the registration contract +// + params validators. That is what keeps chat from becoming a parallel write path (#6504's design). +// +// Release/requeue is local queue administration, not a chokepoint content-write: the route it lands on is a +// thin bridge to the same store methods the CLI's `queue release` / `queue requeue` already use +// (vite-portfolio-queue-actions-api.ts → reclaimStuckItem / requeueItem), and it invokes no chokepoint of its +// own. Requiring one only for the chat path would gate chat MORE strictly than the button beside it, which +// #6838 forbids ("No changes to the existing route or button-triggered flow"). So, exactly like +// chat-governor-actions.js's administrative pause/resume, we satisfy the registry's `governorGatedHandler` +// brand with an allow-stage evaluateGate rather than routing through governor-chokepoint.js. Execution still +// stays behind the shared LOOPOVER_MINER_CHAT_ACTIONS flag via `dispatchChatAction`. + +import { governorGatedHandler, chatActionRegistry } from "./chat-action-registry.js"; +import type { ChatActionRegistry } from "./chat-action-registry.js"; + +export const PORTFOLIO_RELEASE_CHAT_ACTION = "portfolio_release"; +export const PORTFOLIO_REQUEUE_CHAT_ACTION = "portfolio_requeue"; + +export type PortfolioChatActionItem = { + repoFullName: string; + identifier: string; + apiBaseUrl?: string; +}; + +/** Local queue administration is not a chokepoint content-write (#6838); satisfy the registry brand only. */ +const allowAdministrativeGate = () => ({ decision: { stage: "allow" } }); + +/** + * Params for both actions: the queue item to act on. `repoFullName` + `identifier` are required non-empty + * strings; `apiBaseUrl` is optional (the route defaults it, mirroring the client's own + * `Pick` shape, where the buttons + * always pass one but the CLI path does not). Unknown keys are rejected rather than ignored: a typo'd param + * from a model-authored call must fail loudly, not silently act on the wrong item. + */ +export function isPortfolioItemChatParams(params: unknown): boolean { + if (params == null || typeof params !== "object" || Array.isArray(params)) return false; + const record = params as Record; + for (const key of Object.keys(record)) { + if (key !== "repoFullName" && key !== "identifier" && key !== "apiBaseUrl") return false; + } + if (typeof record.repoFullName !== "string" || record.repoFullName.trim() === "") return false; + if (typeof record.identifier !== "string" || record.identifier.trim() === "") return false; + if (record.apiBaseUrl !== undefined && typeof record.apiBaseUrl !== "string") return false; + return true; +} + +/** + * Narrow validated params to the client's item shape. `apiBaseUrl` is only forwarded when present, so an + * omitted one stays omitted rather than becoming an explicit `undefined` in the POST body. + */ +function readPortfolioItem(params: unknown): PortfolioChatActionItem { + const record = params as { repoFullName: string; identifier: string; apiBaseUrl?: unknown }; + const item = { repoFullName: record.repoFullName, identifier: record.identifier }; + return typeof record.apiBaseUrl === "string" ? { ...item, apiBaseUrl: record.apiBaseUrl } : item; +} + +/** Idempotently register `portfolio_release` / `portfolio_requeue`. */ +export function registerPortfolioChatActions(options: { + releaseItem: (item: PortfolioChatActionItem) => Promise; + requeueItem: (item: PortfolioChatActionItem) => Promise; + registry?: ChatActionRegistry; + evaluateGate?: () => { decision: { stage: string } }; +}): void { + const releaseItem = options?.releaseItem; + const requeueItem = options?.requeueItem; + if (typeof releaseItem !== "function") { + throw new TypeError("registerPortfolioChatActions: releaseItem must be a function"); + } + if (typeof requeueItem !== "function") { + throw new TypeError("registerPortfolioChatActions: requeueItem must be a function"); + } + + const registry = options.registry ?? chatActionRegistry; + const evaluateGate = options.evaluateGate ?? allowAdministrativeGate; + + if (!registry.has(PORTFOLIO_RELEASE_CHAT_ACTION)) { + registry.register(PORTFOLIO_RELEASE_CHAT_ACTION, { + paramsValidator: isPortfolioItemChatParams, + handler: governorGatedHandler(async (request) => releaseItem(readPortfolioItem(request?.params)), { + evaluateGate, + }), + }); + } + + if (!registry.has(PORTFOLIO_REQUEUE_CHAT_ACTION)) { + registry.register(PORTFOLIO_REQUEUE_CHAT_ACTION, { + paramsValidator: isPortfolioItemChatParams, + handler: governorGatedHandler(async (request) => requeueItem(readPortfolioItem(request?.params)), { + evaluateGate, + }), + }); + } +} diff --git a/packages/loopover-miner/lib/cli.d.ts b/packages/loopover-miner/lib/cli.d.ts index 30a181d860..fdfab37f46 100644 --- a/packages/loopover-miner/lib/cli.d.ts +++ b/packages/loopover-miner/lib/cli.d.ts @@ -1,3 +1,10 @@ -export function printVersion(input: { packageName: string; packageVersion: string }): void; -export function printHelp(input: { packageName: string }): void; -export function runCli(cliArgs: string[], input: { packageName: string }): number; +export declare function printVersion(input: { + packageName: string; + packageVersion: string; +}): void; +export declare function printHelp(input: { + packageName: string; +}): void; +export declare function runCli(cliArgs: string[], input: { + packageName: string; +}): number; diff --git a/packages/loopover-miner/lib/cli.js b/packages/loopover-miner/lib/cli.js index 012f669474..e597f77858 100644 --- a/packages/loopover-miner/lib/cli.js +++ b/packages/loopover-miner/lib/cli.js @@ -1,81 +1,77 @@ import { argsWantJson, reportCliFailure } from "./cli-error.js"; - export function printVersion(input) { - console.log(`${input.packageName}/${input.packageVersion} (node ${process.version})`); + console.log(`${input.packageName}/${input.packageVersion} (node ${process.version})`); } - export function printHelp(input) { - console.log( - [ - input.packageName, - "", - "Foundation CLI for the local LoopOver miner runtime.", - "", - "Usage:", - " loopover-miner --help", - " loopover-miner --version", - " loopover-miner help", - " loopover-miner version", - " loopover-miner init [--json] [--verify-token] Bootstrap laptop-mode local SQLite state", - " loopover-miner init --interactive Guided first-run wizard: prompts for GITHUB_TOKEN + provider, writes a starter .env, then runs doctor", - " loopover-miner status [--json] Show installed versions + local state paths", - " loopover-miner doctor [--json] Check this laptop is set up correctly", - " loopover-miner migrate [--json] Apply pending schema migrations to existing local stores", - " loopover-miner metrics Print prediction-calibration counters in Prometheus text format", - " loopover-miner manage status [--json] Show managed PR rows from local portfolio + ledger", - " loopover-miner manage poll [--branch ] [--dry-run] [--json]", - " loopover-miner discover [...] [--dry-run] [--json]", - " loopover-miner discover --search [--dry-run] [--json] Fan out, rank, and enqueue candidates", - " loopover-miner attempt --miner-login [--base ] [--live] [--dry-run] [--json]", - " loopover-miner loop [...] --miner-login [--base ] [--live] [--dry-run]", - " loopover-miner loop --search --miner-login [--max-cycles ] [--cycle-delay-ms ] [--dry-run] [--json]", - " Autonomous discover->claim->attempt->reenter loop", - " loopover-miner queue list [--repo ] [--json] List portfolio backlog rows", - " loopover-miner queue next [--global-wip ] [--per-repo-wip ] [--dry-run] [--json]", - " Claim the highest-priority queued item, optionally WIP-cap-aware", - " loopover-miner queue claim-batch [--global-wip ] [--per-repo-wip ] [--dry-run] [--json]", - " loopover-miner queue metrics Print portfolio-queue counters in Prometheus text format", - " loopover-miner queue dashboard [--json] Print portfolio-queue backlog status counts + oldest-queued age", - " loopover-miner queue done [--dry-run] [--json]", - " loopover-miner queue release [--dry-run] [--json] Return a claimed item to the queue", - " loopover-miner queue requeue [--dry-run] [--json] Put a completed item back on the queue", - " loopover-miner claim claim [--note ] [--dry-run] [--json]", - " loopover-miner claim release [--dry-run] [--json]", - " loopover-miner claim list [--repo ] [--status active|released|expired] [--json]", - " loopover-miner ledger list [--repo ] [--since ] [--type ] [--json]", - " loopover-miner ledger metrics Print event-ledger counters in Prometheus text format", - " loopover-miner plan list [--status pending|running|completed|failed] [--json]", - " loopover-miner plan show [--json]", - " loopover-miner governor list [--repo ] [--type allowed|denied|throttled|kill_switch] [--json]", - " loopover-miner governor pause [--reason ] [--dry-run] [--json] Stop the loop before its next cycle", - " loopover-miner governor resume [--dry-run] [--json] Let a paused loop continue", - " loopover-miner governor status [--json] Show whether the governor is paused", - " loopover-miner governor metrics Print governor rate-limit/cap-usage counters in Prometheus text format", - " loopover-miner calibration [--json] Report predicted-vs-realized gate accuracy", - " loopover-miner feasibility [--not-found] [--json]", - " loopover-miner idea-feasibility [--not-resolvable] [--hint ]... [--json]", - " Pre-compute feasibility gate for a freeform Rent-a-Loop idea (#5671)", - " loopover-miner hooks check --tool --input [--json]", - " loopover-miner state get [--json]", - " loopover-miner state set [--dry-run] [--json]", - " loopover-miner orb export [--enable] [--send] [--dry-run] [--json] Build (and optionally send) the opt-in anonymized telemetry batch", - " loopover-miner tenant create [--product ] [--json] Provision a hosted tenant via the control-plane API", - " loopover-miner tenant list [--json] List hosted tenants", - " loopover-miner tenant destroy [--json] Tear down a hosted tenant", - " loopover-miner purge --repo [--dry-run] [--json]", - " Right-to-be-forgotten: delete a repo's rows from every local store", - "", - "Options:", - " --no-update-check Skip the npm registry version nudge (also LOOPOVER_MINER_NO_UPDATE_CHECK=1)", - " --quiet Log only warnings and errors (also LOOPOVER_MINER_LOG_LEVEL=error)", - " --verbose Log debug-level diagnostics (also LOOPOVER_MINER_LOG_LEVEL=debug)", - " --log-level Set the log level explicitly: silent|error|warn|info|debug", - ].join("\n"), - ); + console.log([ + input.packageName, + "", + "Foundation CLI for the local LoopOver miner runtime.", + "", + "Usage:", + " loopover-miner --help", + " loopover-miner --version", + " loopover-miner help", + " loopover-miner version", + " loopover-miner init [--json] [--verify-token] Bootstrap laptop-mode local SQLite state", + " loopover-miner init --interactive Guided first-run wizard: prompts for GITHUB_TOKEN + provider, writes a starter .env, then runs doctor", + " loopover-miner status [--json] Show installed versions + local state paths", + " loopover-miner doctor [--json] Check this laptop is set up correctly", + " loopover-miner migrate [--json] Apply pending schema migrations to existing local stores", + " loopover-miner metrics Print prediction-calibration counters in Prometheus text format", + " loopover-miner manage status [--json] Show managed PR rows from local portfolio + ledger", + " loopover-miner manage poll [--branch ] [--dry-run] [--json]", + " loopover-miner discover [...] [--dry-run] [--json]", + " loopover-miner discover --search [--dry-run] [--json] Fan out, rank, and enqueue candidates", + " loopover-miner attempt --miner-login [--base ] [--live] [--dry-run] [--json]", + " loopover-miner loop [...] --miner-login [--base ] [--live] [--dry-run]", + " loopover-miner loop --search --miner-login [--max-cycles ] [--cycle-delay-ms ] [--dry-run] [--json]", + " Autonomous discover->claim->attempt->reenter loop", + " loopover-miner queue list [--repo ] [--json] List portfolio backlog rows", + " loopover-miner queue next [--global-wip ] [--per-repo-wip ] [--dry-run] [--json]", + " Claim the highest-priority queued item, optionally WIP-cap-aware", + " loopover-miner queue claim-batch [--global-wip ] [--per-repo-wip ] [--dry-run] [--json]", + " loopover-miner queue metrics Print portfolio-queue counters in Prometheus text format", + " loopover-miner queue dashboard [--json] Print portfolio-queue backlog status counts + oldest-queued age", + " loopover-miner queue done [--dry-run] [--json]", + " loopover-miner queue release [--dry-run] [--json] Return a claimed item to the queue", + " loopover-miner queue requeue [--dry-run] [--json] Put a completed item back on the queue", + " loopover-miner claim claim [--note ] [--dry-run] [--json]", + " loopover-miner claim release [--dry-run] [--json]", + " loopover-miner claim list [--repo ] [--status active|released|expired] [--json]", + " loopover-miner ledger list [--repo ] [--since ] [--type ] [--json]", + " loopover-miner ledger metrics Print event-ledger counters in Prometheus text format", + " loopover-miner plan list [--status pending|running|completed|failed] [--json]", + " loopover-miner plan show [--json]", + " loopover-miner governor list [--repo ] [--type allowed|denied|throttled|kill_switch] [--json]", + " loopover-miner governor pause [--reason ] [--dry-run] [--json] Stop the loop before its next cycle", + " loopover-miner governor resume [--dry-run] [--json] Let a paused loop continue", + " loopover-miner governor status [--json] Show whether the governor is paused", + " loopover-miner governor metrics Print governor rate-limit/cap-usage counters in Prometheus text format", + " loopover-miner calibration [--json] Report predicted-vs-realized gate accuracy", + " loopover-miner feasibility [--not-found] [--json]", + " loopover-miner idea-feasibility [--not-resolvable] [--hint ]... [--json]", + " Pre-compute feasibility gate for a freeform Rent-a-Loop idea (#5671)", + " loopover-miner hooks check --tool --input [--json]", + " loopover-miner state get [--json]", + " loopover-miner state set [--dry-run] [--json]", + " loopover-miner orb export [--enable] [--send] [--dry-run] [--json] Build (and optionally send) the opt-in anonymized telemetry batch", + " loopover-miner tenant create [--product ] [--json] Provision a hosted tenant via the control-plane API", + " loopover-miner tenant list [--json] List hosted tenants", + " loopover-miner tenant destroy [--json] Tear down a hosted tenant", + " loopover-miner purge --repo [--dry-run] [--json]", + " Right-to-be-forgotten: delete a repo's rows from every local store", + "", + "Options:", + " --no-update-check Skip the npm registry version nudge (also LOOPOVER_MINER_NO_UPDATE_CHECK=1)", + " --quiet Log only warnings and errors (also LOOPOVER_MINER_LOG_LEVEL=error)", + " --verbose Log debug-level diagnostics (also LOOPOVER_MINER_LOG_LEVEL=debug)", + " --log-level Set the log level explicitly: silent|error|warn|info|debug", + ].join("\n")); } - export function runCli(cliArgs, input) { - const command = cliArgs[0] ?? ""; - const message = `Unknown command: ${command}. Run ${input.packageName} --help.`; - return reportCliFailure(argsWantJson(cliArgs), message, 1); + const command = cliArgs[0] ?? ""; + const message = `Unknown command: ${command}. Run ${input.packageName} --help.`; + return reportCliFailure(argsWantJson(cliArgs), message, 1); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xpLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiY2xpLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sRUFBRSxZQUFZLEVBQUUsZ0JBQWdCLEVBQUUsTUFBTSxnQkFBZ0IsQ0FBQztBQUVoRSxNQUFNLFVBQVUsWUFBWSxDQUFDLEtBQXNEO0lBQ2pGLE9BQU8sQ0FBQyxHQUFHLENBQUMsR0FBRyxLQUFLLENBQUMsV0FBVyxJQUFJLEtBQUssQ0FBQyxjQUFjLFVBQVUsT0FBTyxDQUFDLE9BQU8sR0FBRyxDQUFDLENBQUM7QUFDeEYsQ0FBQztBQUVELE1BQU0sVUFBVSxTQUFTLENBQUMsS0FBOEI7SUFDdEQsT0FBTyxDQUFDLEdBQUcsQ0FDVDtRQUNFLEtBQUssQ0FBQyxXQUFXO1FBQ2pCLEVBQUU7UUFDRixzREFBc0Q7UUFDdEQsRUFBRTtRQUNGLFFBQVE7UUFDUix5QkFBeUI7UUFDekIsNEJBQTRCO1FBQzVCLHVCQUF1QjtRQUN2QiwwQkFBMEI7UUFDMUIsdUdBQXVHO1FBQ3ZHLHFLQUFxSztRQUNySywyR0FBMkc7UUFDM0cscUdBQXFHO1FBQ3JHLHdIQUF3SDtRQUN4SCwrSEFBK0g7UUFDL0gsa0hBQWtIO1FBQ2xILHdGQUF3RjtRQUN4RiwrRUFBK0U7UUFDL0Usd0dBQXdHO1FBQ3hHLHNIQUFzSDtRQUN0SCxtSEFBbUg7UUFDbkgsOEhBQThIO1FBQzlILG9IQUFvSDtRQUNwSCwyRkFBMkY7UUFDM0YsMEZBQTBGO1FBQzFGLG1JQUFtSTtRQUNuSSxpR0FBaUc7UUFDakcsd0hBQXdIO1FBQ3hILCtIQUErSDtRQUMvSCw0RUFBNEU7UUFDNUUsbUhBQW1IO1FBQ25ILHVIQUF1SDtRQUN2SCx5RkFBeUY7UUFDekYsMkVBQTJFO1FBQzNFLCtGQUErRjtRQUMvRixrR0FBa0c7UUFDbEcscUhBQXFIO1FBQ3JILGlGQUFpRjtRQUNqRiw4Q0FBOEM7UUFDOUMsNkdBQTZHO1FBQzdHLDZHQUE2RztRQUM3RywwRkFBMEY7UUFDMUYsbUdBQW1HO1FBQ25HLHVJQUF1STtRQUN2SSwwR0FBMEc7UUFDMUcsd0dBQXdHO1FBQ3hHLHVIQUF1SDtRQUN2SCx1SUFBdUk7UUFDdkksb0VBQW9FO1FBQ3BFLGtEQUFrRDtRQUNsRCxvR0FBb0c7UUFDcEcsMElBQTBJO1FBQzFJLDJIQUEySDtRQUMzSCxtRkFBbUY7UUFDbkYseUZBQXlGO1FBQ3pGLGlFQUFpRTtRQUNqRSxxSUFBcUk7UUFDckksRUFBRTtRQUNGLFVBQVU7UUFDVixrR0FBa0c7UUFDbEcseUZBQXlGO1FBQ3pGLHdGQUF3RjtRQUN4RixpRkFBaUY7S0FDbEYsQ0FBQyxJQUFJLENBQUMsSUFBSSxDQUFDLENBQ2IsQ0FBQztBQUNKLENBQUM7QUFFRCxNQUFNLFVBQVUsTUFBTSxDQUFDLE9BQWlCLEVBQUUsS0FBOEI7SUFDdEUsTUFBTSxPQUFPLEdBQUcsT0FBTyxDQUFDLENBQUMsQ0FBQyxJQUFJLEVBQUUsQ0FBQztJQUNqQyxNQUFNLE9BQU8sR0FBRyxvQkFBb0IsT0FBTyxTQUFTLEtBQUssQ0FBQyxXQUFXLFVBQVUsQ0FBQztJQUNoRixPQUFPLGdCQUFnQixDQUFDLFlBQVksQ0FBQyxPQUFPLENBQUMsRUFBRSxPQUFPLEVBQUUsQ0FBQyxDQUFDLENBQUM7QUFDN0QsQ0FBQyJ9 \ No newline at end of file diff --git a/packages/loopover-miner/lib/cli.ts b/packages/loopover-miner/lib/cli.ts new file mode 100644 index 0000000000..d551903ccb --- /dev/null +++ b/packages/loopover-miner/lib/cli.ts @@ -0,0 +1,81 @@ +import { argsWantJson, reportCliFailure } from "./cli-error.js"; + +export function printVersion(input: { packageName: string; packageVersion: string }): void { + console.log(`${input.packageName}/${input.packageVersion} (node ${process.version})`); +} + +export function printHelp(input: { packageName: string }): void { + console.log( + [ + input.packageName, + "", + "Foundation CLI for the local LoopOver miner runtime.", + "", + "Usage:", + " loopover-miner --help", + " loopover-miner --version", + " loopover-miner help", + " loopover-miner version", + " loopover-miner init [--json] [--verify-token] Bootstrap laptop-mode local SQLite state", + " loopover-miner init --interactive Guided first-run wizard: prompts for GITHUB_TOKEN + provider, writes a starter .env, then runs doctor", + " loopover-miner status [--json] Show installed versions + local state paths", + " loopover-miner doctor [--json] Check this laptop is set up correctly", + " loopover-miner migrate [--json] Apply pending schema migrations to existing local stores", + " loopover-miner metrics Print prediction-calibration counters in Prometheus text format", + " loopover-miner manage status [--json] Show managed PR rows from local portfolio + ledger", + " loopover-miner manage poll [--branch ] [--dry-run] [--json]", + " loopover-miner discover [...] [--dry-run] [--json]", + " loopover-miner discover --search [--dry-run] [--json] Fan out, rank, and enqueue candidates", + " loopover-miner attempt --miner-login [--base ] [--live] [--dry-run] [--json]", + " loopover-miner loop [...] --miner-login [--base ] [--live] [--dry-run]", + " loopover-miner loop --search --miner-login [--max-cycles ] [--cycle-delay-ms ] [--dry-run] [--json]", + " Autonomous discover->claim->attempt->reenter loop", + " loopover-miner queue list [--repo ] [--json] List portfolio backlog rows", + " loopover-miner queue next [--global-wip ] [--per-repo-wip ] [--dry-run] [--json]", + " Claim the highest-priority queued item, optionally WIP-cap-aware", + " loopover-miner queue claim-batch [--global-wip ] [--per-repo-wip ] [--dry-run] [--json]", + " loopover-miner queue metrics Print portfolio-queue counters in Prometheus text format", + " loopover-miner queue dashboard [--json] Print portfolio-queue backlog status counts + oldest-queued age", + " loopover-miner queue done [--dry-run] [--json]", + " loopover-miner queue release [--dry-run] [--json] Return a claimed item to the queue", + " loopover-miner queue requeue [--dry-run] [--json] Put a completed item back on the queue", + " loopover-miner claim claim [--note ] [--dry-run] [--json]", + " loopover-miner claim release [--dry-run] [--json]", + " loopover-miner claim list [--repo ] [--status active|released|expired] [--json]", + " loopover-miner ledger list [--repo ] [--since ] [--type ] [--json]", + " loopover-miner ledger metrics Print event-ledger counters in Prometheus text format", + " loopover-miner plan list [--status pending|running|completed|failed] [--json]", + " loopover-miner plan show [--json]", + " loopover-miner governor list [--repo ] [--type allowed|denied|throttled|kill_switch] [--json]", + " loopover-miner governor pause [--reason ] [--dry-run] [--json] Stop the loop before its next cycle", + " loopover-miner governor resume [--dry-run] [--json] Let a paused loop continue", + " loopover-miner governor status [--json] Show whether the governor is paused", + " loopover-miner governor metrics Print governor rate-limit/cap-usage counters in Prometheus text format", + " loopover-miner calibration [--json] Report predicted-vs-realized gate accuracy", + " loopover-miner feasibility [--not-found] [--json]", + " loopover-miner idea-feasibility [--not-resolvable] [--hint ]... [--json]", + " Pre-compute feasibility gate for a freeform Rent-a-Loop idea (#5671)", + " loopover-miner hooks check --tool --input [--json]", + " loopover-miner state get [--json]", + " loopover-miner state set [--dry-run] [--json]", + " loopover-miner orb export [--enable] [--send] [--dry-run] [--json] Build (and optionally send) the opt-in anonymized telemetry batch", + " loopover-miner tenant create [--product ] [--json] Provision a hosted tenant via the control-plane API", + " loopover-miner tenant list [--json] List hosted tenants", + " loopover-miner tenant destroy [--json] Tear down a hosted tenant", + " loopover-miner purge --repo [--dry-run] [--json]", + " Right-to-be-forgotten: delete a repo's rows from every local store", + "", + "Options:", + " --no-update-check Skip the npm registry version nudge (also LOOPOVER_MINER_NO_UPDATE_CHECK=1)", + " --quiet Log only warnings and errors (also LOOPOVER_MINER_LOG_LEVEL=error)", + " --verbose Log debug-level diagnostics (also LOOPOVER_MINER_LOG_LEVEL=debug)", + " --log-level Set the log level explicitly: silent|error|warn|info|debug", + ].join("\n"), + ); +} + +export function runCli(cliArgs: string[], input: { packageName: string }): number { + const command = cliArgs[0] ?? ""; + const message = `Unknown command: ${command}. Run ${input.packageName} --help.`; + return reportCliFailure(argsWantJson(cliArgs), message, 1); +} diff --git a/packages/loopover-miner/lib/deny-check.d.ts b/packages/loopover-miner/lib/deny-check.d.ts index e1b108d834..e67ffed277 100644 --- a/packages/loopover-miner/lib/deny-check.d.ts +++ b/packages/loopover-miner/lib/deny-check.d.ts @@ -1,11 +1,9 @@ -export type ParsedDenyCheckArgs = - | { - tool: string; - input: Record; - json: boolean; - } - | { error: string }; - -export function parseDenyCheckArgs(args: string[]): ParsedDenyCheckArgs; - -export function runDenyCheck(args: string[]): number; +export type ParsedDenyCheckArgs = { + tool: string; + input: Record; + json: boolean; +} | { + error: string; +}; +export declare function parseDenyCheckArgs(args: string[]): ParsedDenyCheckArgs; +export declare function runDenyCheck(args: string[]): number; diff --git a/packages/loopover-miner/lib/deny-check.js b/packages/loopover-miner/lib/deny-check.js index d8bd347d10..02cca5b5b0 100644 --- a/packages/loopover-miner/lib/deny-check.js +++ b/packages/loopover-miner/lib/deny-check.js @@ -1,78 +1,77 @@ import { evaluateDenyHooks } from "./deny-hooks.js"; import { argsWantJson, reportCliFailure } from "./cli-error.js"; - -const DENY_CHECK_USAGE = - "Usage: loopover-miner hooks check --tool --input [--json]"; - +const DENY_CHECK_USAGE = "Usage: loopover-miner hooks check --tool --input [--json]"; function parseToolInput(raw) { - if (raw === undefined) { - return { error: "Missing value for --input." }; - } - try { - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return { error: "Tool input must be a JSON object." }; + if (raw === undefined) { + return { error: "Missing value for --input." }; } - return { value: parsed }; - } catch { - return { error: "Tool input must be valid JSON." }; - } -} - -export function parseDenyCheckArgs(args) { - const options = { - json: false, - tool: undefined, - input: undefined, - }; - - for (let index = 0; index < args.length; index += 1) { - const token = args[index]; - if (token === "--json") { - options.json = true; - continue; + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { error: "Tool input must be a JSON object." }; + } + return { value: parsed }; } - if (token === "--tool" || token === "--name") { - const tool = args[++index]; - if (!tool || tool.startsWith("-")) return { error: "Missing value for --tool." }; - options.tool = tool; - continue; + catch { + return { error: "Tool input must be valid JSON." }; } - if (token === "--input") { - const raw = args[++index]; - if (!raw || raw.startsWith("-")) return { error: "Missing value for --input." }; - const parsed = parseToolInput(raw); - if ("error" in parsed) return { error: parsed.error }; - options.input = parsed.value; - continue; +} +export function parseDenyCheckArgs(args) { + const options = { + json: false, + tool: undefined, + input: undefined, + }; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === undefined) + continue; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--tool" || token === "--name") { + const tool = args[++index]; + if (!tool || tool.startsWith("-")) + return { error: "Missing value for --tool." }; + options.tool = tool; + continue; + } + if (token === "--input") { + const raw = args[++index]; + if (!raw || raw.startsWith("-")) + return { error: "Missing value for --input." }; + const parsed = parseToolInput(raw); + if ("error" in parsed) + return { error: parsed.error }; + options.input = parsed.value; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + return { error: DENY_CHECK_USAGE }; } - if (token.startsWith("-")) { - return { error: `Unknown option: ${token}` }; + if (!options.tool || !options.input) { + return { error: DENY_CHECK_USAGE }; } - return { error: DENY_CHECK_USAGE }; - } - - if (!options.tool || !options.input) { - return { error: DENY_CHECK_USAGE }; - } - - return options; + return { tool: options.tool, input: options.input, json: options.json }; } - export function runDenyCheck(args) { - const parsed = parseDenyCheckArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - const verdict = evaluateDenyHooks({ name: parsed.tool, input: parsed.input }); - if (parsed.json) { - console.log(JSON.stringify(verdict)); - } else if (!verdict.allowed) { - console.error(verdict.blockedBy?.reason ?? "Blocked by deny hook."); - } else { - console.log("allowed"); - } - - return verdict.allowed ? 0 : 1; + const parsed = parseDenyCheckArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + const verdict = evaluateDenyHooks({ name: parsed.tool, input: parsed.input }); + if (parsed.json) { + console.log(JSON.stringify(verdict)); + } + else if (!verdict.allowed) { + console.error(verdict.blockedBy?.reason ?? "Blocked by deny hook."); + } + else { + console.log("allowed"); + } + return verdict.allowed ? 0 : 1; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZGVueS1jaGVjay5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbImRlbnktY2hlY2sudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUFFLGlCQUFpQixFQUFFLE1BQU0saUJBQWlCLENBQUM7QUFDcEQsT0FBTyxFQUFFLFlBQVksRUFBRSxnQkFBZ0IsRUFBRSxNQUFNLGdCQUFnQixDQUFDO0FBRWhFLE1BQU0sZ0JBQWdCLEdBQ3BCLHlFQUF5RSxDQUFDO0FBVTVFLFNBQVMsY0FBYyxDQUFDLEdBQXVCO0lBQzdDLElBQUksR0FBRyxLQUFLLFNBQVMsRUFBRSxDQUFDO1FBQ3RCLE9BQU8sRUFBRSxLQUFLLEVBQUUsNEJBQTRCLEVBQUUsQ0FBQztJQUNqRCxDQUFDO0lBQ0QsSUFBSSxDQUFDO1FBQ0gsTUFBTSxNQUFNLEdBQVksSUFBSSxDQUFDLEtBQUssQ0FBQyxHQUFHLENBQUMsQ0FBQztRQUN4QyxJQUFJLENBQUMsTUFBTSxJQUFJLE9BQU8sTUFBTSxLQUFLLFFBQVEsSUFBSSxLQUFLLENBQUMsT0FBTyxDQUFDLE1BQU0sQ0FBQyxFQUFFLENBQUM7WUFDbkUsT0FBTyxFQUFFLEtBQUssRUFBRSxtQ0FBbUMsRUFBRSxDQUFDO1FBQ3hELENBQUM7UUFDRCxPQUFPLEVBQUUsS0FBSyxFQUFFLE1BQWlDLEVBQUUsQ0FBQztJQUN0RCxDQUFDO0lBQUMsTUFBTSxDQUFDO1FBQ1AsT0FBTyxFQUFFLEtBQUssRUFBRSxnQ0FBZ0MsRUFBRSxDQUFDO0lBQ3JELENBQUM7QUFDSCxDQUFDO0FBRUQsTUFBTSxVQUFVLGtCQUFrQixDQUFDLElBQWM7SUFDL0MsTUFBTSxPQUFPLEdBQTRGO1FBQ3ZHLElBQUksRUFBRSxLQUFLO1FBQ1gsSUFBSSxFQUFFLFNBQVM7UUFDZixLQUFLLEVBQUUsU0FBUztLQUNqQixDQUFDO0lBRUYsS0FBSyxJQUFJLEtBQUssR0FBRyxDQUFDLEVBQUUsS0FBSyxHQUFHLElBQUksQ0FBQyxNQUFNLEVBQUUsS0FBSyxJQUFJ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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/deny-check.ts b/packages/loopover-miner/lib/deny-check.ts new file mode 100644 index 0000000000..ee0af3a7df --- /dev/null +++ b/packages/loopover-miner/lib/deny-check.ts @@ -0,0 +1,87 @@ +import { evaluateDenyHooks } from "./deny-hooks.js"; +import { argsWantJson, reportCliFailure } from "./cli-error.js"; + +const DENY_CHECK_USAGE = + "Usage: loopover-miner hooks check --tool --input [--json]"; + +export type ParsedDenyCheckArgs = + | { + tool: string; + input: Record; + json: boolean; + } + | { error: string }; + +function parseToolInput(raw: string | undefined): { value: Record } | { error: string } { + if (raw === undefined) { + return { error: "Missing value for --input." }; + } + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { error: "Tool input must be a JSON object." }; + } + return { value: parsed as Record }; + } catch { + return { error: "Tool input must be valid JSON." }; + } +} + +export function parseDenyCheckArgs(args: string[]): ParsedDenyCheckArgs { + const options: { json: boolean; tool: string | undefined; input: Record | undefined } = { + json: false, + tool: undefined, + input: undefined, + }; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === undefined) continue; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--tool" || token === "--name") { + const tool = args[++index]; + if (!tool || tool.startsWith("-")) return { error: "Missing value for --tool." }; + options.tool = tool; + continue; + } + if (token === "--input") { + const raw = args[++index]; + if (!raw || raw.startsWith("-")) return { error: "Missing value for --input." }; + const parsed = parseToolInput(raw); + if ("error" in parsed) return { error: parsed.error }; + options.input = parsed.value; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + return { error: DENY_CHECK_USAGE }; + } + + if (!options.tool || !options.input) { + return { error: DENY_CHECK_USAGE }; + } + + return { tool: options.tool, input: options.input, json: options.json }; +} + +export function runDenyCheck(args: string[]): number { + const parsed = parseDenyCheckArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + const verdict = evaluateDenyHooks({ name: parsed.tool, input: parsed.input }); + if (parsed.json) { + console.log(JSON.stringify(verdict)); + } else if (!verdict.allowed) { + console.error(verdict.blockedBy?.reason ?? "Blocked by deny hook."); + } else { + console.log("allowed"); + } + + return verdict.allowed ? 0 : 1; +} diff --git a/packages/loopover-miner/lib/env-file-indirection.d.ts b/packages/loopover-miner/lib/env-file-indirection.d.ts index ad731369df..c970aefbde 100644 --- a/packages/loopover-miner/lib/env-file-indirection.d.ts +++ b/packages/loopover-miner/lib/env-file-indirection.d.ts @@ -1,4 +1,12 @@ -export function loadMinerFileSecrets( - env?: Record, - readFile?: (path: string) => string, -): void; +/** + * Scan `env` for `_FILE` vars and resolve each into `` in place, reading the referenced file's + * contents (trimmed). An explicit `` value always wins over `_FILE` (mirrors the ORB analogue's + * precedence rule exactly) -- a `_FILE` var is only consulted when its plain counterpart is unset. Throws a + * clear, actionable error identifying the offending `_FILE` var and its file path when the file is + * missing or unreadable -- this never silently leaves a credential empty/undefined. Never logs or returns any + * resolved secret value itself; only the (non-secret) var name and file path ever appear in a thrown message. + * + * `env` and `readFile` are injectable purely for testability -- every real caller uses the defaults + * (`process.env`, `node:fs`'s `readFileSync`), so this is byte-identical to a hardcoded version at runtime. + */ +export declare function loadMinerFileSecrets(env?: Record, readFile?: (path: string) => string): void; diff --git a/packages/loopover-miner/lib/env-file-indirection.js b/packages/loopover-miner/lib/env-file-indirection.js index 9e381c493b..375fc887e6 100644 --- a/packages/loopover-miner/lib/env-file-indirection.js +++ b/packages/loopover-miner/lib/env-file-indirection.js @@ -6,13 +6,11 @@ // container fast and loud (never silently proceeds with an unset/empty credential the next real GitHub call // would then fail on anyway, with a far less specific error). import { readFileSync } from "node:fs"; - // Docker Compose's OWN reserved `_FILE`-suffixed environment variables -- never loopover's secret-file // convention, so they must never be dereferenced below (mirrors src/selfhost/load-file-secrets.ts's own // exclusion and rationale: `COMPOSE_FILE` is a colon-delimited list of compose file paths, never a single // readable file itself, and `COMPOSE_ENV_FILE` points at an operator's own .env file, not a secret). const COMPOSE_RESERVED_FILE_VARS = new Set(["COMPOSE_FILE", "COMPOSE_ENV_FILE"]); - /** * Scan `env` for `_FILE` vars and resolve each into `` in place, reading the referenced file's * contents (trimmed). An explicit `` value always wins over `_FILE` (mirrors the ORB analogue's @@ -23,23 +21,20 @@ const COMPOSE_RESERVED_FILE_VARS = new Set(["COMPOSE_FILE", "COMPOSE_ENV_FILE"]) * * `env` and `readFile` are injectable purely for testability -- every real caller uses the defaults * (`process.env`, `node:fs`'s `readFileSync`), so this is byte-identical to a hardcoded version at runtime. - * - * @param {Record} [env] - * @param {(path: string) => string} [readFile] */ export function loadMinerFileSecrets(env = process.env, readFile = (path) => readFileSync(path, "utf8")) { - for (const key of Object.keys(env)) { - if (!key.endsWith("_FILE") || !env[key] || COMPOSE_RESERVED_FILE_VARS.has(key)) continue; - const target = key.slice(0, -"_FILE".length); - if (env[target]) continue; // an explicit value always wins over _FILE - try { - env[target] = readFile(env[key]).trim(); - } catch (error) { - throw new Error( - `Failed to read secret file for ${key} (${env[key]}): ${ - error instanceof Error ? error.message : String(error) - }`, - ); + for (const key of Object.keys(env)) { + if (!key.endsWith("_FILE") || !env[key] || COMPOSE_RESERVED_FILE_VARS.has(key)) + continue; + const target = key.slice(0, -"_FILE".length); + if (env[target]) + continue; // an explicit value always wins over _FILE + try { + env[target] = readFile(env[key]).trim(); + } + catch (error) { + throw new Error(`Failed to read secret file for ${key} (${env[key]}): ${error instanceof Error ? error.message : String(error)}`); + } } - } } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZW52LWZpbGUtaW5kaXJlY3Rpb24uanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJlbnYtZmlsZS1pbmRpcmVjdGlvbi50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSwwR0FBMEc7QUFDMUcsc0dBQXNHO0FBQ3RHLDRHQUE0RztBQUM1RyxzR0FBc0c7QUFDdEcsdUdBQXVHO0FBQ3ZHLDRHQUE0RztBQUM1Ryw4REFBOEQ7QUFDOUQsT0FBTyxFQUFFLFlBQVksRUFBRSxNQUFNLFNBQVMsQ0FBQztBQUV2Qyx1R0FBdUc7QUFDdkcsd0dBQXdHO0FBQ3hHLDBHQUEwRztBQUMxRyxxR0FBcUc7QUFDckcsTUFBTSwwQkFBMEIsR0FBRyxJQUFJLEdBQUcsQ0FBQyxDQUFDLGNBQWMsRUFBRSxrQkFBa0IsQ0FBQyxDQUFDLENBQUM7QUFFakY7Ozs7Ozs7Ozs7R0FVRztBQUNILE1BQU0sVUFBVSxvQkFBb0IsQ0FDbEMsTUFBMEMsT0FBTyxDQUFDLEdBQUcsRUFDckQsV0FBcUMsQ0FBQyxJQUFJLEVBQUUsRUFBRSxDQUFDLFlBQVksQ0FBQyxJQUFJLEVBQUUsTUFBTSxDQUFDO0lBRXpFLEtBQUssTUFBTSxHQUFHLElBQUksTUFBTSxDQUFDLElBQUksQ0FBQyxHQUFHLENBQUMsRUFBRSxDQUFDO1FBQ25DLElBQUksQ0FBQyxHQUFHLENBQUMsUUFBUSxDQUFDLE9BQU8sQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFDLEdBQUcsQ0FBQyxJQUFJLDBCQUEwQixDQUFDLEdBQUcsQ0FBQyxHQUFHLENBQUM7WUFBRSxTQUFTO1FBQ3pGLE1BQU0sTUFBTSxHQUFHLEdBQUcsQ0FBQyxLQUFLLENBQUMsQ0FBQyxFQUFFLENBQUMsT0FBTyxDQUFDLE1BQU0sQ0FBQyxDQUFDO1FBQzdDLElBQUksR0FBRyxDQUFDLE1BQU0sQ0FBQztZQUFFLFNBQVMsQ0FBQyx3REFBd0Q7UUFDbkYsSUFBSSxDQUFDO1lBQ0gsR0FBRyxDQUFDLE1BQU0sQ0FBQyxHQUFHLFFBQVEsQ0FBQyxHQUFHLENBQUMsR0FBRyxDQUFDLENBQUMsQ0FBQyxJQUFJLEVBQUUsQ0FBQztRQUMxQyxDQUFDO1FBQUMsT0FBTyxLQUFLLEVBQUUsQ0FBQztZQUNmLE1BQU0sSUFBSSxLQUFLLENBQ2Isa0NBQWtDLEdBQUcsS0FBSyxHQUFHLENBQUMsR0FBRyxDQUFDLE1BQ2hELEtBQUssWUFBWSxLQUFLLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxPQUFPLENBQUMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxLQUFLLENBQ3ZELEVBQUUsQ0FDSCxDQUFDO1FBQ0osQ0FBQztJQUNILENBQUM7QUFDSCxDQUFDIn0= \ No newline at end of file diff --git a/packages/loopover-miner/lib/env-file-indirection.ts b/packages/loopover-miner/lib/env-file-indirection.ts new file mode 100644 index 0000000000..38db21bd65 --- /dev/null +++ b/packages/loopover-miner/lib/env-file-indirection.ts @@ -0,0 +1,45 @@ +// Resolve `_FILE` env vars (Docker/Swarm/K8s secret mounts) into `` at miner startup (#5178). +// Ports src/selfhost/load-file-secrets.ts's pattern into the miner package -- the miner is a separate +// deployable (its own process/container per DEPLOYMENT.md's fleet mode), so it never runs through ORB's own +// server-startup resolver. Deliberately diverges from that analogue in one way: an unreadable/missing +// `_FILE` here THROWS rather than logging and continuing, so a broken secret mount fails a miner +// container fast and loud (never silently proceeds with an unset/empty credential the next real GitHub call +// would then fail on anyway, with a far less specific error). +import { readFileSync } from "node:fs"; + +// Docker Compose's OWN reserved `_FILE`-suffixed environment variables -- never loopover's secret-file +// convention, so they must never be dereferenced below (mirrors src/selfhost/load-file-secrets.ts's own +// exclusion and rationale: `COMPOSE_FILE` is a colon-delimited list of compose file paths, never a single +// readable file itself, and `COMPOSE_ENV_FILE` points at an operator's own .env file, not a secret). +const COMPOSE_RESERVED_FILE_VARS = new Set(["COMPOSE_FILE", "COMPOSE_ENV_FILE"]); + +/** + * Scan `env` for `_FILE` vars and resolve each into `` in place, reading the referenced file's + * contents (trimmed). An explicit `` value always wins over `_FILE` (mirrors the ORB analogue's + * precedence rule exactly) -- a `_FILE` var is only consulted when its plain counterpart is unset. Throws a + * clear, actionable error identifying the offending `_FILE` var and its file path when the file is + * missing or unreadable -- this never silently leaves a credential empty/undefined. Never logs or returns any + * resolved secret value itself; only the (non-secret) var name and file path ever appear in a thrown message. + * + * `env` and `readFile` are injectable purely for testability -- every real caller uses the defaults + * (`process.env`, `node:fs`'s `readFileSync`), so this is byte-identical to a hardcoded version at runtime. + */ +export function loadMinerFileSecrets( + env: Record = process.env, + readFile: (path: string) => string = (path) => readFileSync(path, "utf8"), +): void { + for (const key of Object.keys(env)) { + if (!key.endsWith("_FILE") || !env[key] || COMPOSE_RESERVED_FILE_VARS.has(key)) continue; + const target = key.slice(0, -"_FILE".length); + if (env[target]) continue; // an explicit value always wins over _FILE + try { + env[target] = readFile(env[key]).trim(); + } catch (error) { + throw new Error( + `Failed to read secret file for ${key} (${env[key]}): ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } +} diff --git a/packages/loopover-miner/lib/replay-task-bridge.d.ts b/packages/loopover-miner/lib/replay-task-bridge.d.ts index 7563b67c3b..abca7ddf5a 100644 --- a/packages/loopover-miner/lib/replay-task-bridge.d.ts +++ b/packages/loopover-miner/lib/replay-task-bridge.d.ts @@ -1,66 +1,36 @@ -import type { - ForwardRefContext, - FreezePointCandidate, - ReplayScoringKey, - ReplayScoringKeyRejected, - ReplayTask, - ReplayTaskOptions, - ReplayTaskRejected, -} from "./replay-task-generation.js"; - +import type { ForwardRefContext, FreezePointCandidate, ReplayScoringKey, ReplayScoringKeyRejected, ReplayTask, ReplayTaskOptions, ReplayTaskRejected } from "./replay-task-generation.js"; export type ReplaySnapshotCommit = { - sha?: string; - date?: string; - subject?: string; + sha?: string; + date?: string; + subject?: string; }; - export type ReplaySnapshotTag = { - name?: string; - date?: string; - targetSha?: string; + name?: string; + date?: string; + targetSha?: string; }; - export type ReplaySnapshot = { - repoFullName?: string; - commitSha?: string; - targetDate?: string; - commits?: ReplaySnapshotCommit[]; - tags?: ReplaySnapshotTag[]; - readme?: { filename?: string; content?: string } | null; - [key: string]: unknown; + repoFullName?: string; + commitSha?: string; + targetDate?: string; + commits?: ReplaySnapshotCommit[]; + tags?: ReplaySnapshotTag[]; + readme?: { + filename?: string; + content?: string; + } | null; + [key: string]: unknown; }; - export type RevealedReplaySide = { - revealedCommitCount?: number; - revealedGroundTruth?: unknown; + revealedCommitCount?: number; + revealedGroundTruth?: unknown; }; - export type SnapshotIssueContext = { - knownIssueMax?: number; - revealedIssueNumbers?: number[]; + knownIssueMax?: number; + revealedIssueNumbers?: number[]; }; - -export function collectFrozenContextTexts(snapshot: ReplaySnapshot): string[]; - -export function buildLeakageContextFromSnapshot( - snapshot: ReplaySnapshot, - issueContext?: SnapshotIssueContext, -): ForwardRefContext; - -export function buildReplayCandidateFromSnapshot( - snapshot: ReplaySnapshot, - revealed?: RevealedReplaySide, -): FreezePointCandidate; - -export function generateLeakageSafeReplayTask( - snapshot: ReplaySnapshot, - revealed?: RevealedReplaySide, - issueContext?: SnapshotIssueContext, - options?: ReplayTaskOptions, -): ReplayTask | ReplayTaskRejected; - -export function generateLeakageSafeScoringKey( - snapshot: ReplaySnapshot, - revealed?: RevealedReplaySide, - options?: ReplayTaskOptions, -): ReplayScoringKey | ReplayScoringKeyRejected; +export declare function collectFrozenContextTexts(snapshot: ReplaySnapshot): string[]; +export declare function buildLeakageContextFromSnapshot(snapshot: ReplaySnapshot, issueContext?: SnapshotIssueContext): ForwardRefContext; +export declare function buildReplayCandidateFromSnapshot(snapshot: ReplaySnapshot, revealed?: RevealedReplaySide): FreezePointCandidate; +export declare function generateLeakageSafeReplayTask(snapshot: ReplaySnapshot, revealed?: RevealedReplaySide, issueContext?: SnapshotIssueContext, options?: ReplayTaskOptions): ReplayTask | ReplayTaskRejected; +export declare function generateLeakageSafeScoringKey(snapshot: ReplaySnapshot, revealed?: RevealedReplaySide, options?: ReplayTaskOptions): ReplayScoringKey | ReplayScoringKeyRejected; diff --git a/packages/loopover-miner/lib/replay-task-bridge.js b/packages/loopover-miner/lib/replay-task-bridge.js index f8b2bad014..9e61e59943 100644 --- a/packages/loopover-miner/lib/replay-task-bridge.js +++ b/packages/loopover-miner/lib/replay-task-bridge.js @@ -16,78 +16,74 @@ // results calibration-run.js scores is still unbuilt (nothing calls exportReplaySnapshot yet either). This bridge // wires the two halves that DO exist -- snapshot -> leakage-safe task -- and leaves that executor as the next // connective step. Every function here is pure and deterministic (no clock, no randomness, no IO). - import { generateReplayScoringKey, generateReplayTask } from "./replay-task-generation.js"; - function assertSnapshot(snapshot) { - if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { - throw new Error("invalid_replay_snapshot"); - } - return snapshot; + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { + throw new Error("invalid_replay_snapshot"); + } + return snapshot; } - function snapshotCommits(snapshot) { - return Array.isArray(snapshot.commits) ? snapshot.commits : []; + return Array.isArray(snapshot.commits) ? snapshot.commits : []; } - // The snapshot's free-text context, in a fixed order: README-at-T, then each commit subject, then each reachable // tag name. Unlike the structurally pre-T-validated SHAs/dates, these are author-controlled prose where a forward // reference can hide, so they are exactly what must be scrubbed/linted before a task is frozen. Empty and // non-string fields are skipped so they never dilute the frozen context. export function collectFrozenContextTexts(snapshot) { - assertSnapshot(snapshot); - const texts = []; - const readmeContent = snapshot.readme?.content; - if (typeof readmeContent === "string" && readmeContent.length > 0) texts.push(readmeContent); - for (const commit of snapshotCommits(snapshot)) { - if (typeof commit?.subject === "string" && commit.subject.length > 0) texts.push(commit.subject); - } - const tags = Array.isArray(snapshot.tags) ? snapshot.tags : []; - for (const tag of tags) { - if (typeof tag?.name === "string" && tag.name.length > 0) texts.push(tag.name); - } - return texts; + assertSnapshot(snapshot); + const texts = []; + const readmeContent = snapshot.readme?.content; + if (typeof readmeContent === "string" && readmeContent.length > 0) + texts.push(readmeContent); + for (const commit of snapshotCommits(snapshot)) { + if (typeof commit?.subject === "string" && commit.subject.length > 0) + texts.push(commit.subject); + } + const tags = Array.isArray(snapshot.tags) ? snapshot.tags : []; + for (const tag of tags) { + if (typeof tag?.name === "string" && tag.name.length > 0) + texts.push(tag.name); + } + return texts; } - // The leakage context #3011's scrubber needs. The pre-T commit SHAs are DERIVED from the snapshot itself (it // carries the full ancestry up to T), so a snapshot's own commits are never mistaken for forward references; // the issue-number knowledge the git snapshot cannot hold is passed through from the caller. export function buildLeakageContextFromSnapshot(snapshot, issueContext = {}) { - assertSnapshot(snapshot); - return { - knownIssueMax: issueContext.knownIssueMax, - knownCommitShas: snapshotCommits(snapshot).map((commit) => commit?.sha), - revealedIssueNumbers: issueContext.revealedIssueNumbers, - }; + assertSnapshot(snapshot); + return { + knownIssueMax: issueContext.knownIssueMax, + knownCommitShas: snapshotCommits(snapshot).map((commit) => commit?.sha), + revealedIssueNumbers: issueContext.revealedIssueNumbers, + }; } - // The freeze-point candidate #3011's selector/generator expect, mapped from the snapshot plus the revealed post-T // side (commit count + ground truth) the snapshot deliberately does not hold. export function buildReplayCandidateFromSnapshot(snapshot, revealed = {}) { - assertSnapshot(snapshot); - return { - repo: typeof snapshot.repoFullName === "string" ? snapshot.repoFullName : null, - commitT: typeof snapshot.commitSha === "string" ? snapshot.commitSha : null, - lastActivityAt: typeof snapshot.targetDate === "string" ? snapshot.targetDate : null, - priorCommitCount: snapshotCommits(snapshot).length, - revealedCommitCount: revealed.revealedCommitCount, - revealedGroundTruth: revealed.revealedGroundTruth, - frozenContextTexts: collectFrozenContextTexts(snapshot), - }; + assertSnapshot(snapshot); + return { + repo: typeof snapshot.repoFullName === "string" ? snapshot.repoFullName : null, + commitT: typeof snapshot.commitSha === "string" ? snapshot.commitSha : null, + lastActivityAt: typeof snapshot.targetDate === "string" ? snapshot.targetDate : null, + priorCommitCount: snapshotCommits(snapshot).length, + revealedCommitCount: revealed.revealedCommitCount, + revealedGroundTruth: revealed.revealedGroundTruth, + frozenContextTexts: collectFrozenContextTexts(snapshot), + }; } - // The wiring. Builds the leakage context + candidate from the snapshot, then runs generateReplayTask, which LINTS // the frozen context (rejecting on any unscrubbable forward reference) and SCRUBS the surviving text before // returning the frozen task -- so a replay task is never generated from leaky historical context. export function generateLeakageSafeReplayTask(snapshot, revealed = {}, issueContext = {}, options = {}) { - const context = buildLeakageContextFromSnapshot(snapshot, issueContext); - const candidate = buildReplayCandidateFromSnapshot(snapshot, revealed); - return generateReplayTask(candidate, context, options); + const context = buildLeakageContextFromSnapshot(snapshot, issueContext); + const candidate = buildReplayCandidateFromSnapshot(snapshot, revealed); + return generateReplayTask(candidate, context, options); } - // Scoring-only sibling: the isolated post-execution scorer key for the same snapshot. It shares only selection // eligibility with the task above and never carries frozen context (mirroring #3011's own generate/scoring split), // so a caller must check generateLeakageSafeReplayTask's own result before treating the two as a matched pair. export function generateLeakageSafeScoringKey(snapshot, revealed = {}, options = {}) { - return generateReplayScoringKey(buildReplayCandidateFromSnapshot(snapshot, revealed), options); + return generateReplayScoringKey(buildReplayCandidateFromSnapshot(snapshot, revealed), options); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicmVwbGF5LXRhc2stYnJpZGdlLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsicmVwbGF5LXRhc2stYnJpZGdlLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLGlIQUFpSDtBQUNqSCwyR0FBMkc7QUFDM0csK0dBQStHO0FBQy9HLEVBQUU7QUFDRixnSEFBZ0g7QUFDaEgsbUhBQW1IO0FBQ25ILGtIQUFrSDtBQUNsSCw4R0FBOEc7QUFDOUcsNEdBQTRHO0FBQzVHLGdIQUFnSDtBQUNoSCxrREFBa0Q7QUFDbEQsRUFBRTtBQUNGLDRHQUE0RztBQUM1RyxvSEFBb0g7QUFDcEgsa0hBQWtIO0FBQ2xILGtIQUFrSDtBQUNsSCw4R0FBOEc7QUFDOUcsbUdBQW1HO0FBRW5HLE9BQU8sRUFBRSx3QkFBd0IsRUFBRSxrQkFBa0IsRUFBRSxNQUFNLDZCQUE2QixDQUFDO0FBMkMzRixTQUFTLGNBQWMsQ0FBQyxRQUF3QjtJQUM5QyxJQUFJLENBQUMsUUFBUSxJQUFJLE9BQU8sUUFBUSxLQUFLLFFBQVEsSUFBSSxLQUFLLENBQUMsT0FBTyxDQUFDLFFBQVEsQ0FBQyxFQUFFLENBQUM7UUFDekUsTUFBTSxJQUFJLEtBQUssQ0FBQyx5QkFBeUIsQ0FBQyxDQUFDO0lBQzdDLENBQUM7SUFDRCxPQUFPLFFBQVEsQ0FBQztBQUNsQixDQUFDO0FBRUQsU0FBUyxlQUFlLENBQUMsUUFBd0I7SUFDL0MsT0FBTyxLQUFLLENBQUMsT0FBTyxDQUFDLFFBQVEsQ0FBQyxPQUFPLENBQUMsQ0FBQyxDQUFDLENBQUMsUUFBUSxDQUFDLE9BQU8sQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDO0FBQ2pFLENBQUM7QUFFRCxpSEFBaUg7QUFDakgsa0hBQWtIO0FBQ2xILDBHQUEwRztBQUMxRyx5RUFBeUU7QUFDekUsTUFBTSxVQUFVLHlCQUF5QixDQUFDLFFBQXdCO0lBQ2hFLGNBQWMsQ0FBQyxRQUFRLENBQUMsQ0FBQztJQUN6QixNQUFNLEtBQUssR0FBYSxFQUFFLENBQUM7SUFD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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/replay-task-bridge.ts b/packages/loopover-miner/lib/replay-task-bridge.ts new file mode 100644 index 0000000000..7aa6bc08db --- /dev/null +++ b/packages/loopover-miner/lib/replay-task-bridge.ts @@ -0,0 +1,149 @@ +// Bridge (#6160): turns a frozen replay snapshot (replay-snapshot.js, #3010) into a leakage-safe replay task via +// the leakage-safe generator (replay-task-generation.js, #3011), so the historical-replay calibration flow +// (calibration-run.js, #4248) never scores a task built from historical context that still leaks post-T state. +// +// #3011 shipped the leakage-safe generator (scrub/lint/select/classify + generateReplayTask) with ZERO callers: +// nothing turned a #3010 snapshot into a task, so scrubForwardReferences/lintFrozenContext were never actually run +// on real historical-replay data -- the safety harness sat unused. This module is that missing seam. It reads the +// snapshot's own free-text context (README-at-T, commit subjects, reachable tag names -- the fields a forward +// reference can hide in), derives the leakage context the snapshot already knows (every pre-T commit SHA it +// carries), and hands both to generateReplayTask, which LINTS then SCRUBS the frozen context BEFORE returning a +// task -- exactly #3011's original design intent. +// +// REMAINING GAP (noted honestly, per the issue): the snapshot is git-only, so it cannot know issue numbers. +// `knownIssueMax` / `revealedIssueNumbers` -- the calibration harness's issue-history knowledge -- must be supplied +// by the caller. And the replay EXECUTOR that turns these frozen tasks into the `{ replayPlan, revealedHistory }` +// results calibration-run.js scores is still unbuilt (nothing calls exportReplaySnapshot yet either). This bridge +// wires the two halves that DO exist -- snapshot -> leakage-safe task -- and leaves that executor as the next +// connective step. Every function here is pure and deterministic (no clock, no randomness, no IO). + +import { generateReplayScoringKey, generateReplayTask } from "./replay-task-generation.js"; +import type { + ForwardRefContext, + FreezePointCandidate, + ReplayScoringKey, + ReplayScoringKeyRejected, + ReplayTask, + ReplayTaskOptions, + ReplayTaskRejected, +} from "./replay-task-generation.js"; + +export type ReplaySnapshotCommit = { + sha?: string; + date?: string; + subject?: string; +}; + +export type ReplaySnapshotTag = { + name?: string; + date?: string; + targetSha?: string; +}; + +export type ReplaySnapshot = { + repoFullName?: string; + commitSha?: string; + targetDate?: string; + commits?: ReplaySnapshotCommit[]; + tags?: ReplaySnapshotTag[]; + readme?: { filename?: string; content?: string } | null; + [key: string]: unknown; +}; + +export type RevealedReplaySide = { + revealedCommitCount?: number; + revealedGroundTruth?: unknown; +}; + +export type SnapshotIssueContext = { + knownIssueMax?: number; + revealedIssueNumbers?: number[]; +}; + +function assertSnapshot(snapshot: ReplaySnapshot): ReplaySnapshot { + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { + throw new Error("invalid_replay_snapshot"); + } + return snapshot; +} + +function snapshotCommits(snapshot: ReplaySnapshot): ReplaySnapshotCommit[] { + return Array.isArray(snapshot.commits) ? snapshot.commits : []; +} + +// The snapshot's free-text context, in a fixed order: README-at-T, then each commit subject, then each reachable +// tag name. Unlike the structurally pre-T-validated SHAs/dates, these are author-controlled prose where a forward +// reference can hide, so they are exactly what must be scrubbed/linted before a task is frozen. Empty and +// non-string fields are skipped so they never dilute the frozen context. +export function collectFrozenContextTexts(snapshot: ReplaySnapshot): string[] { + assertSnapshot(snapshot); + const texts: string[] = []; + const readmeContent = snapshot.readme?.content; + if (typeof readmeContent === "string" && readmeContent.length > 0) texts.push(readmeContent); + for (const commit of snapshotCommits(snapshot)) { + if (typeof commit?.subject === "string" && commit.subject.length > 0) texts.push(commit.subject); + } + const tags = Array.isArray(snapshot.tags) ? snapshot.tags : []; + for (const tag of tags) { + if (typeof tag?.name === "string" && tag.name.length > 0) texts.push(tag.name); + } + return texts; +} + +// The leakage context #3011's scrubber needs. The pre-T commit SHAs are DERIVED from the snapshot itself (it +// carries the full ancestry up to T), so a snapshot's own commits are never mistaken for forward references; +// the issue-number knowledge the git snapshot cannot hold is passed through from the caller. +export function buildLeakageContextFromSnapshot( + snapshot: ReplaySnapshot, + issueContext: SnapshotIssueContext = {}, +): ForwardRefContext { + assertSnapshot(snapshot); + return { + knownIssueMax: issueContext.knownIssueMax, + knownCommitShas: snapshotCommits(snapshot).map((commit) => commit?.sha), + revealedIssueNumbers: issueContext.revealedIssueNumbers, + } as ForwardRefContext; +} + +// The freeze-point candidate #3011's selector/generator expect, mapped from the snapshot plus the revealed post-T +// side (commit count + ground truth) the snapshot deliberately does not hold. +export function buildReplayCandidateFromSnapshot( + snapshot: ReplaySnapshot, + revealed: RevealedReplaySide = {}, +): FreezePointCandidate { + assertSnapshot(snapshot); + return { + repo: typeof snapshot.repoFullName === "string" ? snapshot.repoFullName : null, + commitT: typeof snapshot.commitSha === "string" ? snapshot.commitSha : null, + lastActivityAt: typeof snapshot.targetDate === "string" ? snapshot.targetDate : null, + priorCommitCount: snapshotCommits(snapshot).length, + revealedCommitCount: revealed.revealedCommitCount, + revealedGroundTruth: revealed.revealedGroundTruth, + frozenContextTexts: collectFrozenContextTexts(snapshot), + } as FreezePointCandidate; +} + +// The wiring. Builds the leakage context + candidate from the snapshot, then runs generateReplayTask, which LINTS +// the frozen context (rejecting on any unscrubbable forward reference) and SCRUBS the surviving text before +// returning the frozen task -- so a replay task is never generated from leaky historical context. +export function generateLeakageSafeReplayTask( + snapshot: ReplaySnapshot, + revealed: RevealedReplaySide = {}, + issueContext: SnapshotIssueContext = {}, + options: ReplayTaskOptions = {}, +): ReplayTask | ReplayTaskRejected { + const context = buildLeakageContextFromSnapshot(snapshot, issueContext); + const candidate = buildReplayCandidateFromSnapshot(snapshot, revealed); + return generateReplayTask(candidate, context, options); +} + +// Scoring-only sibling: the isolated post-execution scorer key for the same snapshot. It shares only selection +// eligibility with the task above and never carries frozen context (mirroring #3011's own generate/scoring split), +// so a caller must check generateLeakageSafeReplayTask's own result before treating the two as a matched pair. +export function generateLeakageSafeScoringKey( + snapshot: ReplaySnapshot, + revealed: RevealedReplaySide = {}, + options: ReplayTaskOptions = {}, +): ReplayScoringKey | ReplayScoringKeyRejected { + return generateReplayScoringKey(buildReplayCandidateFromSnapshot(snapshot, revealed), options); +}