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,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2hhdC1wb3J0Zm9saW8tYWN0aW9ucy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbImNoYXQtcG9ydGZvbGlvLWFjdGlvbnMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsK0RBQStEO0FBQy9ELEVBQUU7QUFDRiwrRkFBK0Y7QUFDL0Ysa0dBQWtHO0FBQ2xHLDZGQUE2RjtBQUM3RiwwR0FBMEc7QUFDMUcseUdBQXlHO0FBQ3pHLDJHQUEyRztBQUMzRyxxR0FBcUc7QUFDckcsRUFBRTtBQUNGLDRHQUE0RztBQUM1RyxnR0FBZ0c7QUFDaEcsOEdBQThHO0FBQzlHLDJHQUEyRztBQUMzRyxnR0FBZ0c7QUFDaEcsMkdBQTJHO0FBQzNHLDZHQUE2RztBQUM3RyxxRkFBcUY7QUFFckYsT0FBTyxFQUFFLG9CQUFvQixFQUFFLGtCQUFrQixFQUFFLE1BQU0sMkJBQTJCLENBQUM7QUFHckYsTUFBTSxDQUFDLE1BQU0sNkJBQTZCLEdBQUcsbUJBQW1CLENBQUM7QUFDakUsTUFBTSxDQUFDLE1BQU0sNkJBQTZCLEdBQUcsbUJBQW1CLENBQUM7QUFRakUsNkdBQTZHO0FBQzdHLE1BQU0sdUJBQXVCLEdBQUcsR0FBRyxFQUFFLENBQUMsQ0FBQyxFQUFFLFFBQVEsRUFBRSxFQUFFLEtBQUssRUFBRSxPQUFPLEVBQUUsRUFBRSxDQUFDLENBQUM7QUFFekU7Ozs7OztHQU1HO0FBQ0gsTUFBTSxVQUFVLHlCQUF5QixDQUFDLE1BQWU7SUFDdkQsSUFBSSxNQUFNLElBQUksSUFBSSxJQUFJLE9BQU8sTUFBTSxLQUFLLFFBQVEsSUFBSSxLQUFLLENBQUMsT0FBTyxDQUFDLE1BQU0sQ0FBQztRQUFFLE9BQU8sS0FBSyxDQUFDO0lBQ3hGLE1BQU0sTUFBTSxHQUFHLE1BQWlDLENBQUM7SUFDakQsS0FBSyxNQUFNLEdBQUcsSUFBSSxNQUFNLENBQUMsSUFBSSxDQUFDLE1BQU0sQ0FBQyxFQUFFLENBQUM7UUFDdEMsSUFBSSxHQUFHLEtBQUssY0FBYyxJQUFJLEdBQUcsS0FBSyxZQUFZLElBQUksR0FBRyxLQUFLLFlBQVk7WUFBRSxPQUFPLEtBQUssQ0FBQztJQUMzRixDQUFDO0lBQ0QsSUFBSSxPQUFPLE1BQU0sQ0FBQyxZQUFZLEtBQUssUUFBUSxJQUFJLE1BQU0sQ0FBQyxZQUFZLENBQUMsSUFBSSxFQUFFLEtBQUssRUFBRTtRQUFFLE9BQU8sS0FBSyxDQUFDO0lBQy9GLElBQUksT0FBTyxNQUFNLENBQUMsVUFBVSxLQUFLLFFBQVEsSUFBSSxNQUFNLENBQUMsVUFBVSxDQUFDLElBQUksRUFBRSxLQUFLLEVBQUU7UUFBRSxPQUFPLEtBQUssQ0FBQztJQUMzRixJQUFJLE1BQU0sQ0FBQyxVQUFVLEtBQUssU0FBUyxJQUFJLE9BQU8sTUFBTSxDQUFDLFVBQVUsS0FBSyxRQUFRO1FBQUUsT0FBTyxLQUFLLENBQUM7SUFDM0YsT0FBTyxJQUFJLENBQUM7QUFDZCxDQUFDO0FBRUQ7OztHQUdHO0FBQ0gsU0FBUyxpQkFBaUIsQ0FBQyxNQUFlO0lBQ3hDLE1BQU0sTUFBTSxHQUFHLE1BQTRFLENBQUM7SUFDNUYsTUFBTSxJQUFJLEdBQUcsRUFBRSxZQUFZLEVBQUUsTUFBTSxDQUFDLFlBQVksRUFBRSxVQUFVLEVBQUUsTUFBTSxDQUFDLFVBQVUsRUFBRSxDQUFDO0lBQ2xGLE9BQU8sT0FBTyxNQUFNLENBQUMsVUFBVSxLQUFLLFFBQVEsQ0FBQyxDQUFDLENBQUMsRUFBRSxHQUFHLElBQUksRUFBRSxVQUFVLEVBQUUsTUFBTSxDQUFDLFVBQVUsRUFBRSxDQUFDLENBQUMsQ0FBQyxJQUFJLENBQUM7QUFDbkcsQ0FBQztBQUVELHVFQUF1RTtBQUN2RSxNQUFNLFVBQVUsNEJBQTRCLENBQUMsT0FLNUM7SUFDQyxNQUFNLFdBQVcsR0FBRyxPQUFPLEVBQUUsV0FBVyxDQUFDO0lBQ3pDLE1BQU0sV0FBVyxHQUFHLE9BQU8sRUFBRSxXQUFXLENBQUM7SUFDekMsSUFBSSxPQUFPLFdBQVcsS0FBSyxVQUFVLEVBQUUsQ0FBQztRQUN0QyxNQUFNLElBQUksU0FBUyxDQUFDLDhEQUE4RCxDQUFDLENBQUM7SUFDdEYsQ0FBQztJQUNELElBQUksT0FBTyxXQUFXLEtBQUssVUFBVSxFQUFFLENBQUM7UUFDdEMsTUFBTSxJQUFJLFNBQVMsQ0FBQyw4REFBOEQsQ0FBQyxDQUFDO0lBQ3RGLENBQUM7SUFFRCxNQUFNLFFBQVEsR0FBRyxPQUFPLENBQUMsUUFBUSxJQUFJLGtCQUFrQixDQUFDO0lBQ3hELE1BQU0sWUFBWSxHQUFHLE9BQU8sQ0FBQyxZQUFZLElBQUksdUJBQXVCLENBQUM7SUFFckUsSUFBSSxDQUFDLFFBQVEsQ0FBQyxHQUFHLENBQUMsNkJBQTZCLENBQUMsRUFBRSxDQUFDO1FBQ2pELFFBQVEsQ0FBQyxRQUFRLENBQUMsNkJBQTZCLEVBQUU7WUFDL0MsZUFBZSxFQUFFLHlCQUF5QjtZQUMxQyxPQUFPLEVBQUUsb0JBQW9CLENBQUMsS0FBSyxFQUFFLE9BQU8sRUFBRSxFQUFFLENBQUMsV0FBVyxDQUFDLGlCQUFpQixDQUFDLE9BQU8sRUFBRSxNQUFNLENBQUMsQ0FBQyxFQUFFO2dCQUNoRyxZQUFZO2FBQ2IsQ0FBQztTQUNILENBQUMsQ0FBQztJQUNMLENBQUM7SUFFRCxJQUFJLENBQUMsUUFBUSxDQUFDLEdBQUcsQ0FBQyw2QkFBNkIsQ0FBQyxFQUFFLENBQUM7UUFDakQsUUFBUSxDQUFDLFFBQVEsQ0FBQyw2QkFBNkIsRUFBRTtZQUMvQyxlQUFlLEVBQUUseUJBQXlCO1lBQzFDLE9BQU8sRUFBRSxvQkFBb0IsQ0FBQyxLQUFLLEVBQUUsT0FBTyxFQUFFLEVBQUUsQ0FBQyxXQUFXLENBQUMsaUJBQWlCLENBQUMsT0FBTyxFQUFFLE1BQU0sQ0FBQyxDQUFDLEVBQUU7Z0JBQ2hHLFlBQVk7YUFDYixDQUFDO1NBQ0gsQ0FBQyxDQUFDO0lBQ0wsQ0FBQztBQUNILENBQUMifQ== \ 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/claim-ledger-expiry.d.ts b/packages/loopover-miner/lib/claim-ledger-expiry.d.ts index e35a98242e..6c78c3ba71 100644 --- a/packages/loopover-miner/lib/claim-ledger-expiry.d.ts +++ b/packages/loopover-miner/lib/claim-ledger-expiry.d.ts @@ -1,20 +1,15 @@ +/** PURE — no IO, no Date, no random (#2316). */ import type { ClaimEntry } from "./claim-ledger.js"; - export declare const DEFAULT_MAX_CLAIM_AGE_MS: number; - export type ClaimLedgerExpiryStore = { - listClaims(filter?: { status?: "active" }): ClaimEntry[]; - expireClaim(repoFullName: string, issueNumber: number): ClaimEntry | null; + listClaims(filter?: { + status?: "active"; + }): ClaimEntry[]; + expireClaim(repoFullName: string, issueNumber: number, apiBaseUrl?: string): ClaimEntry | null; }; - -export function findExpiredClaims( - claims: ClaimEntry[], - nowMs: number, - maxAgeMs: number, -): ClaimEntry[]; - -export function sweepExpiredClaims( - store: ClaimLedgerExpiryStore, - nowMs: number, - maxAgeMs?: number, -): ClaimEntry[]; +/** + * Return active claims whose age is strictly greater than `maxAgeMs`. A claim whose age equals `maxAgeMs` exactly + * is still considered within the window (not expired). + */ +export declare function findExpiredClaims(claims: ClaimEntry[], nowMs: number, maxAgeMs: number): ClaimEntry[]; +export declare function sweepExpiredClaims(store: ClaimLedgerExpiryStore, nowMs: number, maxAgeMs?: number): ClaimEntry[]; diff --git a/packages/loopover-miner/lib/claim-ledger-expiry.js b/packages/loopover-miner/lib/claim-ledger-expiry.js index 7455340a20..4e4ee4c6df 100644 --- a/packages/loopover-miner/lib/claim-ledger-expiry.js +++ b/packages/loopover-miner/lib/claim-ledger-expiry.js @@ -1,41 +1,45 @@ /** PURE — no IO, no Date, no random (#2316). */ - export const DEFAULT_MAX_CLAIM_AGE_MS = 14 * 24 * 60 * 60 * 1000; - function claimAgeMs(claim, nowMs) { - const claimedAtMs = Date.parse(claim.claimedAt); - if (!Number.isFinite(claimedAtMs)) return null; - return nowMs - claimedAtMs; + const claimedAtMs = Date.parse(claim.claimedAt); + if (!Number.isFinite(claimedAtMs)) + return null; + return nowMs - claimedAtMs; } - /** * Return active claims whose age is strictly greater than `maxAgeMs`. A claim whose age equals `maxAgeMs` exactly * is still considered within the window (not expired). */ export function findExpiredClaims(claims, nowMs, maxAgeMs) { - if (!Number.isFinite(nowMs) || nowMs < 0) throw new Error("invalid_now_ms"); - if (!Number.isFinite(maxAgeMs) || maxAgeMs < 0) throw new Error("invalid_max_age_ms"); - if (!Array.isArray(claims)) throw new Error("invalid_claims"); - - const expired = []; - for (const claim of claims) { - if (claim?.status !== "active") continue; - const ageMs = claimAgeMs(claim, nowMs); - if (ageMs === null) continue; - if (ageMs > maxAgeMs) expired.push(claim); - } - return expired; + if (!Number.isFinite(nowMs) || nowMs < 0) + throw new Error("invalid_now_ms"); + if (!Number.isFinite(maxAgeMs) || maxAgeMs < 0) + throw new Error("invalid_max_age_ms"); + if (!Array.isArray(claims)) + throw new Error("invalid_claims"); + const expired = []; + for (const claim of claims) { + if (claim?.status !== "active") + continue; + const ageMs = claimAgeMs(claim, nowMs); + if (ageMs === null) + continue; + if (ageMs > maxAgeMs) + expired.push(claim); + } + return expired; } - export function sweepExpiredClaims(store, nowMs, maxAgeMs = DEFAULT_MAX_CLAIM_AGE_MS) { - const activeClaims = store.listClaims({ status: "active" }); - const expired = findExpiredClaims(activeClaims, nowMs, maxAgeMs); - const transitioned = []; - for (const claim of expired) { - // Echo the row's OWN apiBaseUrl back (#5563) rather than defaulting: two forge hosts can each have an - // active claim on the same owner/repo#issue, and defaulting here would expire the wrong host's row. - const updated = store.expireClaim(claim.repoFullName, claim.issueNumber, claim.apiBaseUrl); - if (updated) transitioned.push(updated); - } - return transitioned; + const activeClaims = store.listClaims({ status: "active" }); + const expired = findExpiredClaims(activeClaims, nowMs, maxAgeMs); + const transitioned = []; + for (const claim of expired) { + // Echo the row's OWN apiBaseUrl back (#5563) rather than defaulting: two forge hosts can each have an + // active claim on the same owner/repo#issue, and defaulting here would expire the wrong host's row. + const updated = store.expireClaim(claim.repoFullName, claim.issueNumber, claim.apiBaseUrl); + if (updated) + transitioned.push(updated); + } + return transitioned; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhaW0tbGVkZ2VyLWV4cGlyeS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbImNsYWltLWxlZGdlci1leHBpcnkudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsZ0RBQWdEO0FBSWhELE1BQU0sQ0FBQyxNQUFNLHdCQUF3QixHQUFHLEVBQUUsR0FBRyxFQUFFLEdBQUcsRUFBRSxHQUFHLEVBQUUsR0FBRyxJQUFJLENBQUM7QUFPakUsU0FBUyxVQUFVLENBQUMsS0FBaUIsRUFBRSxLQUFhO0lBQ2xELE1BQU0sV0FBVyxHQUFHLElBQUksQ0FBQyxLQUFLLENBQUMsS0FBSyxDQUFDLFNBQVMsQ0FBQyxDQUFDO0lBQ2hELElBQUksQ0FBQyxNQUFNLENBQUMsUUFBUSxDQUFDLFdBQVcsQ0FBQztRQUFFLE9BQU8sSUFBSSxDQUFDO0lBQy9DLE9BQU8sS0FBSyxHQUFHLFdBQVcsQ0FBQztBQUM3QixDQUFDO0FBRUQ7OztHQUdHO0FBQ0gsTUFBTSxVQUFVLGlCQUFpQixDQUFDLE1BQW9CLEVBQUUsS0FBYSxFQUFFLFFBQWdCO0lBQ3JGLElBQUksQ0FBQyxNQUFNLENBQUMsUUFBUSxDQUFDLEtBQUssQ0FBQyxJQUFJLEtBQUssR0FBRyxDQUFDO1FBQUUsTUFBTSxJQUFJLEtBQUssQ0FBQyxnQkFBZ0IsQ0FBQyxDQUFDO0lBQzVFLElBQUksQ0FBQyxNQUFNLENBQUMsUUFBUSxDQUFDLFFBQVEsQ0FBQyxJQUFJLFFBQVEsR0FBRyxDQUFDO1FBQUUsTUFBTSxJQUFJLEtBQUssQ0FBQyxvQkFBb0IsQ0FBQyxDQUFDO0lBQ3RGLElBQUksQ0FBQyxLQUFLLENBQUMsT0FBTyxDQUFDLE1BQU0sQ0FBQztRQUFFLE1BQU0sSUFBSSxLQUFLLENBQUMsZ0JBQWdCLENBQUMsQ0FBQztJQUU5RCxNQUFNLE9BQU8sR0FBaUIsRUFBRSxDQUFDO0lBQ2pDLEtBQUssTUFBTSxLQUFLLElBQUksTUFBTSxFQUFFLENBQUM7UUFDM0IsSUFBSSxLQUFLLEVBQUUsTUFBTSxLQUFLLFFBQVE7WUFBRSxTQUFTO1FBQ3pDLE1BQU0sS0FBSyxHQUFHLFVBQVUsQ0FBQyxLQUFLLEVBQUUsS0FBSyxDQUFDLENBQUM7UUFDdkMsSUFBSSxLQUFLLEtBQUssSUFBSTtZQUFFLFNBQVM7UUFDN0IsSUFBSSxLQUFLLEdBQUcsUUFBUTtZQUFFLE9BQU8sQ0FBQyxJQUFJLENBQUMsS0FBSyxDQUFDLENBQUM7SUFDNUMsQ0FBQztJQUNELE9BQU8sT0FBTyxDQUFDO0FBQ2pCLENBQUM7QUFFRCxNQUFNLFVBQVUsa0JBQWtCLENBQ2hDLEtBQTZCLEVBQzdCLEtBQWEsRUFDYixXQUFtQix3QkFBd0I7SUFFM0MsTUFBTSxZQUFZLEdBQUcsS0FBSyxDQUFDLFVBQVUsQ0FBQyxFQUFFLE1BQU0sRUFBRSxRQUFRLEVBQUUsQ0FBQyxDQUFDO0lBQzVELE1BQU0sT0FBTyxHQUFHLGlCQUFpQixDQUFDLFlBQVksRUFBRSxLQUFLLEVBQUUsUUFBUSxDQUFDLENBQUM7SUFDakUsTUFBTSxZQUFZLEdBQWlCLEVBQUUsQ0FBQztJQUN0QyxLQUFLLE1BQU0sS0FBSyxJQUFJLE9BQU8sRUFBRSxDQUFDO1FBQzVCLHNHQUFzRztRQUN0RyxvR0FBb0c7UUFDcEcsTUFBTSxPQUFPLEdBQUcsS0FBSyxDQUFDLFdBQVcsQ0FBQyxLQUFLLENBQUMsWUFBWSxFQUFFLEtBQUssQ0FBQyxXQUFXLEVBQUUsS0FBSyxDQUFDLFVBQVUsQ0FBQyxDQUFDO1FBQzNGLElBQUksT0FBTztZQUFFLFlBQVksQ0FBQyxJQUFJLENBQUMsT0FBTyxDQUFDLENBQUM7SUFDMUMsQ0FBQztJQUNELE9BQU8sWUFBWSxDQUFDO0FBQ3RCLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-miner/lib/claim-ledger-expiry.ts b/packages/loopover-miner/lib/claim-ledger-expiry.ts new file mode 100644 index 0000000000..4a2a65699f --- /dev/null +++ b/packages/loopover-miner/lib/claim-ledger-expiry.ts @@ -0,0 +1,52 @@ +/** PURE — no IO, no Date, no random (#2316). */ + +import type { ClaimEntry } from "./claim-ledger.js"; + +export const DEFAULT_MAX_CLAIM_AGE_MS = 14 * 24 * 60 * 60 * 1000; + +export type ClaimLedgerExpiryStore = { + listClaims(filter?: { status?: "active" }): ClaimEntry[]; + expireClaim(repoFullName: string, issueNumber: number, apiBaseUrl?: string): ClaimEntry | null; +}; + +function claimAgeMs(claim: ClaimEntry, nowMs: number): number | null { + const claimedAtMs = Date.parse(claim.claimedAt); + if (!Number.isFinite(claimedAtMs)) return null; + return nowMs - claimedAtMs; +} + +/** + * Return active claims whose age is strictly greater than `maxAgeMs`. A claim whose age equals `maxAgeMs` exactly + * is still considered within the window (not expired). + */ +export function findExpiredClaims(claims: ClaimEntry[], nowMs: number, maxAgeMs: number): ClaimEntry[] { + if (!Number.isFinite(nowMs) || nowMs < 0) throw new Error("invalid_now_ms"); + if (!Number.isFinite(maxAgeMs) || maxAgeMs < 0) throw new Error("invalid_max_age_ms"); + if (!Array.isArray(claims)) throw new Error("invalid_claims"); + + const expired: ClaimEntry[] = []; + for (const claim of claims) { + if (claim?.status !== "active") continue; + const ageMs = claimAgeMs(claim, nowMs); + if (ageMs === null) continue; + if (ageMs > maxAgeMs) expired.push(claim); + } + return expired; +} + +export function sweepExpiredClaims( + store: ClaimLedgerExpiryStore, + nowMs: number, + maxAgeMs: number = DEFAULT_MAX_CLAIM_AGE_MS, +): ClaimEntry[] { + const activeClaims = store.listClaims({ status: "active" }); + const expired = findExpiredClaims(activeClaims, nowMs, maxAgeMs); + const transitioned: ClaimEntry[] = []; + for (const claim of expired) { + // Echo the row's OWN apiBaseUrl back (#5563) rather than defaulting: two forge hosts can each have an + // active claim on the same owner/repo#issue, and defaulting here would expire the wrong host's row. + const updated = store.expireClaim(claim.repoFullName, claim.issueNumber, claim.apiBaseUrl); + if (updated) transitioned.push(updated); + } + return transitioned; +} 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/cross-repo-evaluation.d.ts b/packages/loopover-miner/lib/cross-repo-evaluation.d.ts index 2e7121f735..7a3d900a29 100644 --- a/packages/loopover-miner/lib/cross-repo-evaluation.d.ts +++ b/packages/loopover-miner/lib/cross-repo-evaluation.d.ts @@ -1,90 +1,102 @@ import type { RepoStackResult } from "./stack-detection.js"; - -export const CROSS_REPO_FAILURE_CATEGORY: Readonly<{ - STACK_DETECTION: "stack_detection_gap"; - EXECUTION: "execution_gap"; - GITTENSOR_ASSUMPTION: "loopover_assumption"; - CLONE_SETUP: "clone_setup"; - OTHER: "other"; +/** Failure taxonomy surfaced in per-repo reports (#4788). */ +export declare const CROSS_REPO_FAILURE_CATEGORY: Readonly<{ + readonly STACK_DETECTION: "stack_detection_gap"; + readonly EXECUTION: "execution_gap"; + readonly GITTENSOR_ASSUMPTION: "loopover_assumption"; + readonly CLONE_SETUP: "clone_setup"; + readonly OTHER: "other"; }>; - -export const GITTENSOR_POSITIVE_ASSUMPTION_CHECKS: ReadonlyArray<{ - id: string; - pattern: RegExp; +/** Instruction substrings that indicate a POSITIVE loopover/LoopOver CI assumption leaked into the agent prompt. + * Lines that explicitly tell the agent *not* to assume these are filtered out before scanning. */ +export declare const GITTENSOR_POSITIVE_ASSUMPTION_CHECKS: ReadonlyArray<{ + id: string; + pattern: RegExp; }>; - -export const DEFAULT_CROSS_REPO_MANIFEST_RELATIVE_PATH: string; -export const MAX_CROSS_REPO_MANIFEST_BYTES: number; -export const MAX_CROSS_REPO_MANIFEST_REPOS: number; - +export declare const DEFAULT_CROSS_REPO_MANIFEST_RELATIVE_PATH = "benchmarks/cross-repo/manifest.json"; +export declare const MAX_CROSS_REPO_MANIFEST_BYTES = 65536; +export declare const MAX_CROSS_REPO_MANIFEST_REPOS = 100; export type CrossRepoEvaluationManifestRepo = { - repoFullName: string; - stackHint?: string; - requireTestCommand?: boolean; - fixturePath?: string; + repoFullName: string; + stackHint?: string; + requireTestCommand?: boolean; + fixturePath?: string; }; - export type ParsedCrossRepoEvaluationManifest = { - present: boolean; - manifest: { repos: CrossRepoEvaluationManifestRepo[] }; - warnings: string[]; + present: boolean; + manifest: { + repos: CrossRepoEvaluationManifestRepo[]; + }; + warnings: string[]; }; - export type CrossRepoEvaluationResult = { - repoFullName: string; - passed: boolean; - failureCategory: string | null; - reason: string | null; - stackDetected: boolean; - usedDefaultGoalSpec: boolean | null; - assumptionFindings: Array<{ id: string; line: string }>; - stack?: RepoStackResult; + repoFullName: string; + passed: boolean; + failureCategory: string | null; + reason: string | null; + stackDetected: boolean; + usedDefaultGoalSpec: boolean | null; + assumptionFindings: Array<{ + id: string; + line: string; + }>; + stack?: RepoStackResult; }; - export type CrossRepoEvaluationSummary = { - total: number; - passed: number; - failed: number; - majorityPassed: boolean; - withoutLoopoverConfig: number; - failuresByCategory: Record; + total: number; + passed: number; + failed: number; + majorityPassed: boolean; + withoutLoopoverConfig: number; + failuresByCategory: Record; }; - -export function normalizeCrossRepoFullName(value: unknown): string | null; - -export function parseCrossRepoEvaluationManifest( - content: string | null | undefined, -): ParsedCrossRepoEvaluationManifest; - -export function scanPositiveLoopoverAssumptions(text: string): Array<{ id: string; line: string }>; - -export function evaluateRepoReadiness( - entry: CrossRepoEvaluationManifestRepo, - options?: { +export type EvaluateRepoReadinessOptions = { repoPath?: string; - resolveRepoPath?: (entry: { repoFullName: string }) => string; + resolveRepoPath?: (entry: { + repoFullName: string; + }) => string; env?: NodeJS.ProcessEnv; existsSync?: (path: string) => boolean; detectRepoStack?: (repoPath: string) => RepoStackResult; - resolveMinerGoalSpec?: (repoPath: string) => { present: boolean }; + resolveMinerGoalSpec?: (repoPath: string) => { + present: boolean; + }; buildCodingTaskSpec?: (input: Record) => { - ready: boolean; - verdict?: string; - instructions?: string; + ready: boolean; + verdict?: string; + instructions?: string; }; - }, -): CrossRepoEvaluationResult; - -export function runCrossRepoEvaluation( - parsed: ParsedCrossRepoEvaluationManifest, - options?: { +}; +/** Canonical `owner/repo` with exactly one slash and safe segments; anything else → null. */ +export declare function normalizeCrossRepoFullName(value: unknown): string | null; +/** + * Tolerant JSON manifest parser (#4788). Malformed input degrades to an empty repo list with warnings rather than + * throwing, mirroring the fleet-run-manifest / miner-goal-spec convention. + */ +export declare function parseCrossRepoEvaluationManifest(content: string | null | undefined): ParsedCrossRepoEvaluationManifest; +/** + * Scan agent instructions for positive loopover/LoopOver assumptions (#4788). Lines that already tell the agent + * *not* to assume LoopOver conventions (the negative guidance from buildValidationGuidance) are skipped. + */ +export declare function scanPositiveLoopoverAssumptions(text: string): Array<{ + id: string; + line: string; +}>; +/** + * Evaluate one benchmark repo's miner readiness without running a live coding agent (#4788). + */ +export declare function evaluateRepoReadiness(entry: CrossRepoEvaluationManifestRepo, options?: EvaluateRepoReadinessOptions): CrossRepoEvaluationResult; +/** + * Run the harness across every repo in a parsed manifest (#4788). + */ +export declare function runCrossRepoEvaluation(parsed: ParsedCrossRepoEvaluationManifest, options?: { repoFilter?: string; - } & Parameters[1], -): CrossRepoEvaluationResult[]; - -export function summarizeCrossRepoEvaluation(results: CrossRepoEvaluationResult[]): CrossRepoEvaluationSummary; - -export function formatCrossRepoEvaluationReport( - results: CrossRepoEvaluationResult[], - summary?: CrossRepoEvaluationSummary, -): string; +} & EvaluateRepoReadinessOptions): CrossRepoEvaluationResult[]; +/** + * Reduce per-repo results to pass/fail counts and whether a strict majority passed (#4788). + */ +export declare function summarizeCrossRepoEvaluation(results: CrossRepoEvaluationResult[]): CrossRepoEvaluationSummary; +/** + * Human-readable pass/fail report for one evaluation run (#4788). + */ +export declare function formatCrossRepoEvaluationReport(results: CrossRepoEvaluationResult[], summary?: CrossRepoEvaluationSummary): string; diff --git a/packages/loopover-miner/lib/cross-repo-evaluation.js b/packages/loopover-miner/lib/cross-repo-evaluation.js index 9c8f620df2..3772b97297 100644 --- a/packages/loopover-miner/lib/cross-repo-evaluation.js +++ b/packages/loopover-miner/lib/cross-repo-evaluation.js @@ -3,405 +3,358 @@ // evaluated through the same stack-detection + coding-task-spec path a real attempt uses (detectRepoStack, // resolveMinerGoalSpec, buildCodingTaskSpec) and failures are categorized as stack-detection gaps, execution // readiness gaps, leaked loopover assumptions in agent instructions, clone/setup problems, or other. - import { existsSync } from "node:fs"; -import { join } from "node:path"; import { buildCodingTaskSpec } from "./coding-task-spec.js"; import { resolveMinerGoalSpec } from "./miner-goal-spec.js"; import { isValidRepoSegment, resolveRepoCloneDir } from "./repo-clone.js"; import { detectRepoStack } from "./stack-detection.js"; - /** Failure taxonomy surfaced in per-repo reports (#4788). */ export const CROSS_REPO_FAILURE_CATEGORY = Object.freeze({ - STACK_DETECTION: "stack_detection_gap", - EXECUTION: "execution_gap", - GITTENSOR_ASSUMPTION: "loopover_assumption", - CLONE_SETUP: "clone_setup", - OTHER: "other", + STACK_DETECTION: "stack_detection_gap", + EXECUTION: "execution_gap", + GITTENSOR_ASSUMPTION: "loopover_assumption", + CLONE_SETUP: "clone_setup", + OTHER: "other", }); - /** Instruction substrings that indicate a POSITIVE loopover/LoopOver CI assumption leaked into the agent prompt. * Lines that explicitly tell the agent *not* to assume these are filtered out before scanning. */ export const GITTENSOR_POSITIVE_ASSUMPTION_CHECKS = Object.freeze([ - { id: "test_ci_script", pattern: /npm run test:ci/i }, - { id: "codecov_patch", pattern: /codecov\/patch/i }, - { id: "gittensor_label", pattern: /gittensor:(?:bug|feature|priority)/i }, - { id: "loopover_gate", pattern: /loopover gate/i }, + { id: "test_ci_script", pattern: /npm run test:ci/i }, + { id: "codecov_patch", pattern: /codecov\/patch/i }, + { id: "gittensor_label", pattern: /gittensor:(?:bug|feature|priority)/i }, + { id: "loopover_gate", pattern: /loopover gate/i }, ]); - export const DEFAULT_CROSS_REPO_MANIFEST_RELATIVE_PATH = "benchmarks/cross-repo/manifest.json"; export const MAX_CROSS_REPO_MANIFEST_BYTES = 65_536; export const MAX_CROSS_REPO_MANIFEST_REPOS = 100; - // True UTF-8 byte count for the size guard (#7223): JS string `.length` is UTF-16 code units, which under-counts // any multi-byte character (up to 4x for astral-plane code points), so `MAX_CROSS_REPO_MANIFEST_BYTES` -- named // and warned about in BYTES -- was actually being compared against a code-unit count. Mirrors the identical helper // in the three siblings this parser's own comment claims to follow: fleet-run-manifest.ts, miner-goal-spec.ts, // and ams-policy-spec.ts. function utf8ByteLength(value) { - let bytes = 0; - for (const char of value) { - const codePoint = char.codePointAt(0); - if (codePoint <= 0x7f) bytes += 1; - else if (codePoint <= 0x7ff) bytes += 2; - else if (codePoint <= 0xffff) bytes += 3; - else bytes += 4; - } - return bytes; + let bytes = 0; + for (const char of value) { + const codePoint = char.codePointAt(0); + if (codePoint <= 0x7f) + bytes += 1; + else if (codePoint <= 0x7ff) + bytes += 2; + else if (codePoint <= 0xffff) + bytes += 3; + else + bytes += 4; + } + return bytes; } - function cloneEmptyManifest(warnings = []) { - return { present: false, manifest: { repos: [] }, warnings }; + return { present: false, manifest: { repos: [] }, warnings }; } - /** Canonical `owner/repo` with exactly one slash and safe segments; anything else → null. */ export function normalizeCrossRepoFullName(value) { - if (typeof value !== "string") return null; - const [owner, repo, extra] = value.trim().split("/"); - if (!owner || !repo || extra !== undefined) return null; - if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) return null; - return `${owner}/${repo}`; + if (typeof value !== "string") + return null; + const [owner, repo, extra] = value.trim().split("/"); + if (!owner || !repo || extra !== undefined) + return null; + if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) + return null; + return `${owner}/${repo}`; } - function normalizeBoolean(value, field, fallback, warnings) { - if (value === undefined || value === null) return fallback; - if (typeof value === "boolean") return value; - warnings.push(`CrossRepoEvaluationManifest field "${field}" must be a boolean; falling back to ${fallback}.`); - return fallback; + if (value === undefined || value === null) + return fallback; + if (typeof value === "boolean") + return value; + warnings.push(`CrossRepoEvaluationManifest field "${field}" must be a boolean; falling back to ${fallback}.`); + return fallback; } - function normalizeOptionalString(value, field, warnings) { - if (value === undefined || value === null) return null; - if (typeof value !== "string") { - warnings.push(`CrossRepoEvaluationManifest field "${field}" must be a string; ignoring the value.`); - return null; - } - const trimmed = value.trim(); - return trimmed || null; + if (value === undefined || value === null) + return null; + if (typeof value !== "string") { + warnings.push(`CrossRepoEvaluationManifest field "${field}" must be a string; ignoring the value.`); + return null; + } + const trimmed = value.trim(); + return trimmed || null; } - function normalizeRepoList(value, warnings) { - if (value === undefined || value === null) return []; - if (!Array.isArray(value)) { - warnings.push(`CrossRepoEvaluationManifest field "repos" must be a list; ignoring a ${typeof value} value.`); - return []; - } - const result = []; - const seen = new Set(); - for (const [index, entry] of value.entries()) { - if (index >= MAX_CROSS_REPO_MANIFEST_REPOS) { - warnings.push( - `CrossRepoEvaluationManifest field "repos" exceeded ${MAX_CROSS_REPO_MANIFEST_REPOS} entries; extra entries ignored.`, - ); - break; - } - let repoFullName = null; - let stackHint = null; - let requireTestCommand = false; - let fixturePath = null; - if (typeof entry === "string") { - repoFullName = normalizeCrossRepoFullName(entry); - } else if (entry && typeof entry === "object" && !Array.isArray(entry)) { - const record = entry; - repoFullName = normalizeCrossRepoFullName(record.repoFullName); - stackHint = normalizeOptionalString(record.stackHint, "stackHint", warnings); - requireTestCommand = normalizeBoolean(record.requireTestCommand, "requireTestCommand", false, warnings); - fixturePath = normalizeOptionalString(record.fixturePath, "fixturePath", warnings); - } else { - warnings.push(`CrossRepoEvaluationManifest "repos" skipped a non-string, non-mapping entry.`); - continue; + if (value === undefined || value === null) + return []; + if (!Array.isArray(value)) { + warnings.push(`CrossRepoEvaluationManifest field "repos" must be a list; ignoring a ${typeof value} value.`); + return []; } - if (repoFullName === null) { - warnings.push(`CrossRepoEvaluationManifest "repos" skipped an entry with an invalid "owner/repo" name.`); - continue; + const result = []; + const seen = new Set(); + for (const [index, entry] of value.entries()) { + if (index >= MAX_CROSS_REPO_MANIFEST_REPOS) { + warnings.push(`CrossRepoEvaluationManifest field "repos" exceeded ${MAX_CROSS_REPO_MANIFEST_REPOS} entries; extra entries ignored.`); + break; + } + let repoFullName = null; + let stackHint = null; + let requireTestCommand = false; + let fixturePath = null; + if (typeof entry === "string") { + repoFullName = normalizeCrossRepoFullName(entry); + } + else if (entry && typeof entry === "object" && !Array.isArray(entry)) { + const record = entry; + repoFullName = normalizeCrossRepoFullName(record.repoFullName); + stackHint = normalizeOptionalString(record.stackHint, "stackHint", warnings); + requireTestCommand = normalizeBoolean(record.requireTestCommand, "requireTestCommand", false, warnings); + fixturePath = normalizeOptionalString(record.fixturePath, "fixturePath", warnings); + } + else { + warnings.push(`CrossRepoEvaluationManifest "repos" skipped a non-string, non-mapping entry.`); + continue; + } + if (repoFullName === null) { + warnings.push(`CrossRepoEvaluationManifest "repos" skipped an entry with an invalid "owner/repo" name.`); + continue; + } + if (seen.has(repoFullName)) { + warnings.push(`CrossRepoEvaluationManifest "repos" skipped a duplicate entry for ${repoFullName}.`); + continue; + } + seen.add(repoFullName); + const normalized = { repoFullName, requireTestCommand }; + if (stackHint) + normalized.stackHint = stackHint; + if (fixturePath) + normalized.fixturePath = fixturePath; + result.push(normalized); } - if (seen.has(repoFullName)) { - warnings.push(`CrossRepoEvaluationManifest "repos" skipped a duplicate entry for ${repoFullName}.`); - continue; - } - seen.add(repoFullName); - const normalized = { repoFullName, requireTestCommand }; - if (stackHint) normalized.stackHint = stackHint; - if (fixturePath) normalized.fixturePath = fixturePath; - result.push(normalized); - } - return result; + return result; } - /** * Tolerant JSON manifest parser (#4788). Malformed input degrades to an empty repo list with warnings rather than * throwing, mirroring the fleet-run-manifest / miner-goal-spec convention. - * - * @param {string | null | undefined} content - * @returns {{ present: boolean, manifest: { repos: Array<{ repoFullName: string, stackHint?: string, requireTestCommand?: boolean, fixturePath?: string }> }, warnings: string[] }} */ export function parseCrossRepoEvaluationManifest(content) { - if (content === undefined || content === null) return cloneEmptyManifest(); - if (typeof content !== "string") { - return cloneEmptyManifest([`CrossRepoEvaluationManifest content must be a string; got ${typeof content}.`]); - } - const trimmed = content.trim(); - if (!trimmed) return cloneEmptyManifest(); - if (utf8ByteLength(trimmed) > MAX_CROSS_REPO_MANIFEST_BYTES) { - return cloneEmptyManifest([ - `CrossRepoEvaluationManifest exceeded ${MAX_CROSS_REPO_MANIFEST_BYTES} bytes; ignoring the file.`, - ]); - } - let raw; - try { - raw = JSON.parse(trimmed); - } catch { - return cloneEmptyManifest(["CrossRepoEvaluationManifest is not valid JSON."]); - } - if (!raw || typeof raw !== "object" || Array.isArray(raw)) { - return cloneEmptyManifest(["CrossRepoEvaluationManifest root must be a JSON object."]); - } - const warnings = []; - const repos = normalizeRepoList(raw.repos, warnings); - return { present: true, manifest: { repos }, warnings }; + if (content === undefined || content === null) + return cloneEmptyManifest(); + if (typeof content !== "string") { + return cloneEmptyManifest([`CrossRepoEvaluationManifest content must be a string; got ${typeof content}.`]); + } + const trimmed = content.trim(); + if (!trimmed) + return cloneEmptyManifest(); + if (utf8ByteLength(trimmed) > MAX_CROSS_REPO_MANIFEST_BYTES) { + return cloneEmptyManifest([ + `CrossRepoEvaluationManifest exceeded ${MAX_CROSS_REPO_MANIFEST_BYTES} bytes; ignoring the file.`, + ]); + } + let raw; + try { + raw = JSON.parse(trimmed); + } + catch { + return cloneEmptyManifest(["CrossRepoEvaluationManifest is not valid JSON."]); + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return cloneEmptyManifest(["CrossRepoEvaluationManifest root must be a JSON object."]); + } + const warnings = []; + const repos = normalizeRepoList(raw.repos, warnings); + return { present: true, manifest: { repos }, warnings }; } - /** * Scan agent instructions for positive loopover/LoopOver assumptions (#4788). Lines that already tell the agent * *not* to assume LoopOver conventions (the negative guidance from buildValidationGuidance) are skipped. - * - * @param {string} text - * @returns {Array<{ id: string, line: string }>} */ export function scanPositiveLoopoverAssumptions(text) { - if (typeof text !== "string") return []; - const findings = []; - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || /do not assume/i.test(trimmed)) continue; - for (const check of GITTENSOR_POSITIVE_ASSUMPTION_CHECKS) { - if (check.pattern.test(line)) findings.push({ id: check.id, line: trimmed }); + if (typeof text !== "string") + return []; + const findings = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || /do not assume/i.test(trimmed)) + continue; + for (const check of GITTENSOR_POSITIVE_ASSUMPTION_CHECKS) { + if (check.pattern.test(line)) + findings.push({ id: check.id, line: trimmed }); + } } - } - return findings; + return findings; } - function buildFailure(repoFullName, category, reason, extra = {}) { - return { - repoFullName, - passed: false, - failureCategory: category, - reason, - stackDetected: false, - usedDefaultGoalSpec: null, - assumptionFindings: [], - ...extra, - }; + return { + repoFullName, + passed: false, + failureCategory: category, + reason, + stackDetected: false, + usedDefaultGoalSpec: null, + assumptionFindings: [], + ...extra, + }; } - function buildPass(repoFullName, extra = {}) { - return { - repoFullName, - passed: true, - failureCategory: null, - reason: null, - stackDetected: true, - usedDefaultGoalSpec: true, - assumptionFindings: [], - ...extra, - }; + return { + repoFullName, + passed: true, + failureCategory: null, + reason: null, + stackDetected: true, + usedDefaultGoalSpec: true, + assumptionFindings: [], + ...extra, + }; } - function resolveEvaluationRepoPath(entry, options = {}) { - if (entry.fixturePath && typeof entry.fixturePath === "string") return entry.fixturePath; - if (typeof options.repoPath === "string" && options.repoPath.trim()) return options.repoPath.trim(); - if (typeof options.resolveRepoPath === "function") return options.resolveRepoPath(entry); - return resolveRepoCloneDir(entry.repoFullName, options.env ?? process.env); + if (entry.fixturePath && typeof entry.fixturePath === "string") + return entry.fixturePath; + if (typeof options.repoPath === "string" && options.repoPath.trim()) + return options.repoPath.trim(); + if (typeof options.resolveRepoPath === "function") + return options.resolveRepoPath(entry); + return resolveRepoCloneDir(entry.repoFullName, options.env ?? process.env); } - -function defaultClaimLedger(repoFullName) { - return { listClaims: () => [] }; +function defaultClaimLedger(_repoFullName) { + return { listClaims: () => [] }; } - /** * Evaluate one benchmark repo's miner readiness without running a live coding agent (#4788). - * - * @param {{ repoFullName: string, stackHint?: string, requireTestCommand?: boolean, fixturePath?: string }} entry - * @param {{ - * repoPath?: string, - * resolveRepoPath?: (entry: { repoFullName: string }) => string, - * env?: NodeJS.ProcessEnv, - * existsSync?: (path: string) => boolean, - * detectRepoStack?: typeof detectRepoStack, - * resolveMinerGoalSpec?: typeof resolveMinerGoalSpec, - * buildCodingTaskSpec?: typeof buildCodingTaskSpec, - * }} [options] */ export function evaluateRepoReadiness(entry, options = {}) { - const repoFullName = entry?.repoFullName; - if (typeof repoFullName !== "string" || !normalizeCrossRepoFullName(repoFullName)) { - return buildFailure( - typeof repoFullName === "string" ? repoFullName : "(invalid)", - CROSS_REPO_FAILURE_CATEGORY.OTHER, - "Benchmark entry is missing a valid owner/repo name.", - ); - } - - const existsImpl = options.existsSync ?? existsSync; - const detectImpl = options.detectRepoStack ?? detectRepoStack; - const goalSpecImpl = options.resolveMinerGoalSpec ?? resolveMinerGoalSpec; - const buildSpecImpl = options.buildCodingTaskSpec ?? buildCodingTaskSpec; - const repoPath = resolveEvaluationRepoPath(entry, options); - - if (!existsImpl(repoPath)) { - return buildFailure( - repoFullName, - CROSS_REPO_FAILURE_CATEGORY.CLONE_SETUP, - `Repository path does not exist: ${repoPath}. Clone the repo or set LOOPOVER_MINER_REPO_CLONE_DIR.`, - ); - } - - const goalSpec = goalSpecImpl(repoPath); - const usedDefaultGoalSpec = goalSpec?.present !== true; - - const stack = detectImpl(repoPath); - if (stack?.detected !== true) { - return buildFailure( - repoFullName, - CROSS_REPO_FAILURE_CATEGORY.STACK_DETECTION, - stack?.reason ?? "Stack auto-detection did not recognize this repository.", - { stackDetected: false, usedDefaultGoalSpec }, - ); - } - - if (entry.requireTestCommand === true && !stack.testCommand) { - return buildFailure( - repoFullName, - CROSS_REPO_FAILURE_CATEGORY.EXECUTION, - "Stack detection succeeded but no test command was inferred while requireTestCommand is set.", - { stackDetected: true, usedDefaultGoalSpec, stack }, - ); - } - - let specResult; - try { - specResult = buildSpecImpl({ - repoFullName, - issue: { - number: 1, - title: "Cross-repo evaluation harness smoke issue", - body: "Synthetic issue used only by the cross-repo evaluation harness.", - labels: ["bug"], - }, - context: { issues: [{ number: 1 }], pullRequests: [] }, - claimLedger: defaultClaimLedger(repoFullName), - workingDirectory: repoPath, - detectRepoStack: detectImpl, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return buildFailure(repoFullName, CROSS_REPO_FAILURE_CATEGORY.OTHER, message, { - stackDetected: true, - usedDefaultGoalSpec, - stack, - }); - } - - if (specResult?.ready !== true) { - return buildFailure( - repoFullName, - CROSS_REPO_FAILURE_CATEGORY.EXECUTION, - `Coding task spec is not ready (verdict: ${specResult?.verdict ?? "unknown"}).`, - { stackDetected: true, usedDefaultGoalSpec, stack }, - ); - } - - const assumptionFindings = scanPositiveLoopoverAssumptions(specResult.instructions ?? ""); - if (assumptionFindings.length > 0) { - return buildFailure( - repoFullName, - CROSS_REPO_FAILURE_CATEGORY.GITTENSOR_ASSUMPTION, - `Agent instructions leak loopover-specific assumptions (${assumptionFindings.map((f) => f.id).join(", ")}).`, - { stackDetected: true, usedDefaultGoalSpec, stack, assumptionFindings }, - ); - } - - return buildPass(repoFullName, { usedDefaultGoalSpec, stack }); + const repoFullName = entry?.repoFullName; + if (typeof repoFullName !== "string" || !normalizeCrossRepoFullName(repoFullName)) { + return buildFailure(typeof repoFullName === "string" ? repoFullName : "(invalid)", CROSS_REPO_FAILURE_CATEGORY.OTHER, "Benchmark entry is missing a valid owner/repo name."); + } + const existsImpl = options.existsSync ?? existsSync; + const detectImpl = options.detectRepoStack ?? detectRepoStack; + const goalSpecImpl = options.resolveMinerGoalSpec ?? resolveMinerGoalSpec; + const buildSpecImpl = options.buildCodingTaskSpec ?? buildCodingTaskSpec; + const repoPath = resolveEvaluationRepoPath(entry, options); + if (!existsImpl(repoPath)) { + return buildFailure(repoFullName, CROSS_REPO_FAILURE_CATEGORY.CLONE_SETUP, `Repository path does not exist: ${repoPath}. Clone the repo or set LOOPOVER_MINER_REPO_CLONE_DIR.`); + } + const goalSpec = goalSpecImpl(repoPath); + const usedDefaultGoalSpec = goalSpec?.present !== true; + const stack = detectImpl(repoPath); + if (stack?.detected !== true) { + return buildFailure(repoFullName, CROSS_REPO_FAILURE_CATEGORY.STACK_DETECTION, stack?.reason ?? "Stack auto-detection did not recognize this repository.", { stackDetected: false, usedDefaultGoalSpec }); + } + if (entry.requireTestCommand === true && !stack.testCommand) { + return buildFailure(repoFullName, CROSS_REPO_FAILURE_CATEGORY.EXECUTION, "Stack detection succeeded but no test command was inferred while requireTestCommand is set.", { stackDetected: true, usedDefaultGoalSpec, stack }); + } + const syntheticIssueTitle = "Cross-repo evaluation harness smoke issue"; + const syntheticIssueBody = "Synthetic issue used only by the cross-repo evaluation harness."; + const syntheticIssueLabels = ["bug"]; + let specResult; + try { + specResult = buildSpecImpl({ + repoFullName, + issue: { + number: 1, + title: syntheticIssueTitle, + body: syntheticIssueBody, + labels: syntheticIssueLabels, + }, + context: { + issues: [ + { + repoFullName, + number: 1, + title: syntheticIssueTitle, + state: "open", + labels: syntheticIssueLabels, + linkedPrs: [], + }, + ], + pullRequests: [], + }, + claimLedger: defaultClaimLedger(repoFullName), + workingDirectory: repoPath, + detectRepoStack: detectImpl, + }); + } + catch (error) { + const message = error instanceof Error ? error.message : String(error); + return buildFailure(repoFullName, CROSS_REPO_FAILURE_CATEGORY.OTHER, message, { + stackDetected: true, + usedDefaultGoalSpec, + stack, + }); + } + if (specResult?.ready !== true) { + return buildFailure(repoFullName, CROSS_REPO_FAILURE_CATEGORY.EXECUTION, `Coding task spec is not ready (verdict: ${specResult?.verdict ?? "unknown"}).`, { stackDetected: true, usedDefaultGoalSpec, stack }); + } + const assumptionFindings = scanPositiveLoopoverAssumptions(specResult.instructions ?? ""); + if (assumptionFindings.length > 0) { + return buildFailure(repoFullName, CROSS_REPO_FAILURE_CATEGORY.GITTENSOR_ASSUMPTION, `Agent instructions leak loopover-specific assumptions (${assumptionFindings.map((f) => f.id).join(", ")}).`, { stackDetected: true, usedDefaultGoalSpec, stack, assumptionFindings }); + } + return buildPass(repoFullName, { usedDefaultGoalSpec, stack }); } - /** * Run the harness across every repo in a parsed manifest (#4788). - * - * @param {ReturnType} parsed - * @param {{ repoFilter?: string } & Parameters[1]} [options] - * @returns {ReturnType[]} */ export function runCrossRepoEvaluation(parsed, options = {}) { - const repos = parsed?.manifest?.repos ?? []; - const results = []; - for (const entry of repos) { - if (options.repoFilter && entry.repoFullName !== options.repoFilter) continue; - results.push(evaluateRepoReadiness(entry, options)); - } - return results; + const repos = parsed?.manifest?.repos ?? []; + const results = []; + for (const entry of repos) { + if (options.repoFilter && entry.repoFullName !== options.repoFilter) + continue; + results.push(evaluateRepoReadiness(entry, options)); + } + return results; } - /** * Reduce per-repo results to pass/fail counts and whether a strict majority passed (#4788). - * - * @param {ReturnType[]} results */ export function summarizeCrossRepoEvaluation(results) { - const list = Array.isArray(results) ? results : []; - let passed = 0; - let failed = 0; - const failuresByCategory = {}; - for (const result of list) { - if (result?.passed === true) { - passed += 1; - continue; + const list = Array.isArray(results) ? results : []; + let passed = 0; + let failed = 0; + const failuresByCategory = {}; + for (const result of list) { + if (result?.passed === true) { + passed += 1; + continue; + } + failed += 1; + const category = result?.failureCategory ?? CROSS_REPO_FAILURE_CATEGORY.OTHER; + failuresByCategory[category] = (failuresByCategory[category] ?? 0) + 1; } - failed += 1; - const category = result?.failureCategory ?? CROSS_REPO_FAILURE_CATEGORY.OTHER; - failuresByCategory[category] = (failuresByCategory[category] ?? 0) + 1; - } - const total = passed + failed; - const majorityPassed = total > 0 ? passed > failed : false; - const withoutLoopoverConfig = list.filter((r) => r?.usedDefaultGoalSpec !== false).length; - return { - total, - passed, - failed, - majorityPassed, - withoutLoopoverConfig, - failuresByCategory, - }; + const total = passed + failed; + const majorityPassed = total > 0 ? passed > failed : false; + const withoutLoopoverConfig = list.filter((r) => r?.usedDefaultGoalSpec !== false).length; + return { + total, + passed, + failed, + majorityPassed, + withoutLoopoverConfig, + failuresByCategory, + }; } - /** * Human-readable pass/fail report for one evaluation run (#4788). - * - * @param {ReturnType[]} results - * @param {ReturnType} [summary] */ export function formatCrossRepoEvaluationReport(results, summary = summarizeCrossRepoEvaluation(results)) { - const lines = ["loopover-miner cross-repo evaluation", ""]; - for (const result of results) { - if (result.passed) { - lines.push(`PASS ${result.repoFullName}`); - continue; + const lines = ["loopover-miner cross-repo evaluation", ""]; + for (const result of results) { + if (result.passed) { + lines.push(`PASS ${result.repoFullName}`); + continue; + } + lines.push(`FAIL ${result.repoFullName} [${result.failureCategory}] ${result.reason}`); + } + lines.push("", `summary: ${summary.passed}/${summary.total} passed` + + (summary.majorityPassed ? " (majority passed)" : " (majority failed)")); + if (summary.total > 0) { + lines.push(`without loopover-specific target config: ${summary.withoutLoopoverConfig}/${summary.total}`); } - lines.push(`FAIL ${result.repoFullName} [${result.failureCategory}] ${result.reason}`); - } - lines.push( - "", - `summary: ${summary.passed}/${summary.total} passed` + - (summary.majorityPassed ? " (majority passed)" : " (majority failed)"), - ); - if (summary.total > 0) { - lines.push(`without loopover-specific target config: ${summary.withoutLoopoverConfig}/${summary.total}`); - } - const categories = Object.entries(summary.failuresByCategory).sort(([a], [b]) => a.localeCompare(b)); - if (categories.length > 0) { - lines.push("", "failures by category:"); - for (const [category, count] of categories) { - lines.push(`- ${category}: ${count}`); + const categories = Object.entries(summary.failuresByCategory).sort(([a], [b]) => a.localeCompare(b)); + if (categories.length > 0) { + lines.push("", "failures by category:"); + for (const [category, count] of categories) { + lines.push(`- ${category}: ${count}`); + } } - } - return lines.join("\n"); + return lines.join("\n"); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY3Jvc3MtcmVwby1ldmFsdWF0aW9uLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiY3Jvc3MtcmVwby1ldmFsdWF0aW9uLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLGlIQUFpSDtBQUNqSCw4R0FBOEc7QUFDOUcsMkdBQTJHO0FBQzNHLDZHQUE2RztBQUM3RyxxR0FBcUc7QUFFckcsT0FBTyxFQUFFLFVBQVUsRUFBRSxNQUFNLFNBQVMsQ0FBQztBQUNyQyxPQUFPLEVBQUUsbUJBQW1CLEVBQUUsTUFBTSx1QkFBdUIsQ0FBQztBQUM1RCxPQUFPLEVBQUUsb0JBQW9CLEVBQUUsTUFBTSxzQkFBc0IsQ0FBQztBQUM1RCxPQUFPLEVBQUUsa0JBQWtCLEVBQUUsbUJBQW1CLEVBQUUsTUFBTSxpQkFBaUIsQ0FBQztBQUMxRSxPQUFPLEVBQUUsZUFBZSxFQUFFLE1BQU0sc0JBQXNCLENBQUM7QUFHdkQsNkRBQTZEO0FBQzdELE1BQU0sQ0FBQyxNQUFNLDJCQUEyQixHQUFHLE1BQU0sQ0FBQyxNQUFNLENBQUM7SUFDdkQsZUFBZSxFQUFFLHFCQUFxQjtJQUN0QyxTQUFTLEVBQUUsZUFBZTtJQUMxQixvQkFBb0IsRUFBRSxxQkFBcUI7SUFDM0MsV0FBVyxFQUFFLGFBQWE7SUFDMUIsS0FBSyxFQUFFLE9BQU87Q0FDTixDQUFDLENBQUM7QUFFWjttR0FDbUc7QUFDbkcsTUFBTSxDQUFDLE1BQU0sb0NBQW9DLEdBQW1ELE1BQU0sQ0FBQyxNQUFNLENBQUM7SUFDaEgsRUFBRSxFQUFFLEVBQUUsZ0JBQWdCLEVBQUUsT0FBTyxFQUFFLGtCQUFrQixFQUFFO0lBQ3JELEVBQUUsRUFBRSxFQUFFLGVBQWUsRUFBRSxPQUFPLEVBQUUsaUJBQWlCLEVBQUU7SUFDbkQsRUFBRSxFQUFFLEVBQUUsaUJBQWlCLEVBQUUsT0FBTyxFQUFFLHFDQUFxQyxFQUFFO0lBQ3pFLEVBQUUsRUFBRSxFQUFFLGVBQWUsRUFBRSxPQUFPLEVBQUUsZ0JBQWdCLEVBQUU7Q0FDbkQs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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/cross-repo-evaluation.ts b/packages/loopover-miner/lib/cross-repo-evaluation.ts new file mode 100644 index 0000000000..ce1e4a5c4a --- /dev/null +++ b/packages/loopover-miner/lib/cross-repo-evaluation.ts @@ -0,0 +1,458 @@ +// Cross-repo evaluation harness (#4788): a repeatable, offline-first readiness check that asks whether the miner +// can approach a diverse benchmark repo set without loopover-specific target-repo configuration. Each repo is +// evaluated through the same stack-detection + coding-task-spec path a real attempt uses (detectRepoStack, +// resolveMinerGoalSpec, buildCodingTaskSpec) and failures are categorized as stack-detection gaps, execution +// readiness gaps, leaked loopover assumptions in agent instructions, clone/setup problems, or other. + +import { existsSync } from "node:fs"; +import { buildCodingTaskSpec } from "./coding-task-spec.js"; +import { resolveMinerGoalSpec } from "./miner-goal-spec.js"; +import { isValidRepoSegment, resolveRepoCloneDir } from "./repo-clone.js"; +import { detectRepoStack } from "./stack-detection.js"; +import type { RepoStackResult } from "./stack-detection.js"; + +/** Failure taxonomy surfaced in per-repo reports (#4788). */ +export const CROSS_REPO_FAILURE_CATEGORY = Object.freeze({ + STACK_DETECTION: "stack_detection_gap", + EXECUTION: "execution_gap", + GITTENSOR_ASSUMPTION: "loopover_assumption", + CLONE_SETUP: "clone_setup", + OTHER: "other", +} as const); + +/** Instruction substrings that indicate a POSITIVE loopover/LoopOver CI assumption leaked into the agent prompt. + * Lines that explicitly tell the agent *not* to assume these are filtered out before scanning. */ +export const GITTENSOR_POSITIVE_ASSUMPTION_CHECKS: ReadonlyArray<{ id: string; pattern: RegExp }> = Object.freeze([ + { id: "test_ci_script", pattern: /npm run test:ci/i }, + { id: "codecov_patch", pattern: /codecov\/patch/i }, + { id: "gittensor_label", pattern: /gittensor:(?:bug|feature|priority)/i }, + { id: "loopover_gate", pattern: /loopover gate/i }, +]); + +export const DEFAULT_CROSS_REPO_MANIFEST_RELATIVE_PATH = "benchmarks/cross-repo/manifest.json"; +export const MAX_CROSS_REPO_MANIFEST_BYTES = 65_536; +export const MAX_CROSS_REPO_MANIFEST_REPOS = 100; + +export type CrossRepoEvaluationManifestRepo = { + repoFullName: string; + stackHint?: string; + requireTestCommand?: boolean; + fixturePath?: string; +}; + +export type ParsedCrossRepoEvaluationManifest = { + present: boolean; + manifest: { repos: CrossRepoEvaluationManifestRepo[] }; + warnings: string[]; +}; + +export type CrossRepoEvaluationResult = { + repoFullName: string; + passed: boolean; + failureCategory: string | null; + reason: string | null; + stackDetected: boolean; + usedDefaultGoalSpec: boolean | null; + assumptionFindings: Array<{ id: string; line: string }>; + stack?: RepoStackResult; +}; + +export type CrossRepoEvaluationSummary = { + total: number; + passed: number; + failed: number; + majorityPassed: boolean; + withoutLoopoverConfig: number; + failuresByCategory: Record; +}; + +export type EvaluateRepoReadinessOptions = { + repoPath?: string; + resolveRepoPath?: (entry: { repoFullName: string }) => string; + env?: NodeJS.ProcessEnv; + existsSync?: (path: string) => boolean; + detectRepoStack?: (repoPath: string) => RepoStackResult; + resolveMinerGoalSpec?: (repoPath: string) => { present: boolean }; + buildCodingTaskSpec?: (input: Record) => { + ready: boolean; + verdict?: string; + instructions?: string; + }; +}; + +// True UTF-8 byte count for the size guard (#7223): JS string `.length` is UTF-16 code units, which under-counts +// any multi-byte character (up to 4x for astral-plane code points), so `MAX_CROSS_REPO_MANIFEST_BYTES` -- named +// and warned about in BYTES -- was actually being compared against a code-unit count. Mirrors the identical helper +// in the three siblings this parser's own comment claims to follow: fleet-run-manifest.ts, miner-goal-spec.ts, +// and ams-policy-spec.ts. +function utf8ByteLength(value: string): number { + let bytes = 0; + for (const char of value) { + const codePoint = char.codePointAt(0) as number; + if (codePoint <= 0x7f) bytes += 1; + else if (codePoint <= 0x7ff) bytes += 2; + else if (codePoint <= 0xffff) bytes += 3; + else bytes += 4; + } + return bytes; +} + +function cloneEmptyManifest(warnings: string[] = []): ParsedCrossRepoEvaluationManifest { + return { present: false, manifest: { repos: [] }, warnings }; +} + +/** Canonical `owner/repo` with exactly one slash and safe segments; anything else → null. */ +export function normalizeCrossRepoFullName(value: unknown): string | null { + if (typeof value !== "string") return null; + const [owner, repo, extra] = value.trim().split("/"); + if (!owner || !repo || extra !== undefined) return null; + if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) return null; + return `${owner}/${repo}`; +} + +function normalizeBoolean(value: unknown, field: string, fallback: boolean, warnings: string[]): boolean { + if (value === undefined || value === null) return fallback; + if (typeof value === "boolean") return value; + warnings.push(`CrossRepoEvaluationManifest field "${field}" must be a boolean; falling back to ${fallback}.`); + return fallback; +} + +function normalizeOptionalString(value: unknown, field: string, warnings: string[]): string | null { + if (value === undefined || value === null) return null; + if (typeof value !== "string") { + warnings.push(`CrossRepoEvaluationManifest field "${field}" must be a string; ignoring the value.`); + return null; + } + const trimmed = value.trim(); + return trimmed || null; +} + +function normalizeRepoList(value: unknown, warnings: string[]): CrossRepoEvaluationManifestRepo[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) { + warnings.push(`CrossRepoEvaluationManifest field "repos" must be a list; ignoring a ${typeof value} value.`); + return []; + } + const result: CrossRepoEvaluationManifestRepo[] = []; + const seen = new Set(); + for (const [index, entry] of value.entries()) { + if (index >= MAX_CROSS_REPO_MANIFEST_REPOS) { + warnings.push( + `CrossRepoEvaluationManifest field "repos" exceeded ${MAX_CROSS_REPO_MANIFEST_REPOS} entries; extra entries ignored.`, + ); + break; + } + let repoFullName: string | null = null; + let stackHint: string | null = null; + let requireTestCommand = false; + let fixturePath: string | null = null; + if (typeof entry === "string") { + repoFullName = normalizeCrossRepoFullName(entry); + } else if (entry && typeof entry === "object" && !Array.isArray(entry)) { + const record = entry as Record; + repoFullName = normalizeCrossRepoFullName(record.repoFullName); + stackHint = normalizeOptionalString(record.stackHint, "stackHint", warnings); + requireTestCommand = normalizeBoolean(record.requireTestCommand, "requireTestCommand", false, warnings); + fixturePath = normalizeOptionalString(record.fixturePath, "fixturePath", warnings); + } else { + warnings.push(`CrossRepoEvaluationManifest "repos" skipped a non-string, non-mapping entry.`); + continue; + } + if (repoFullName === null) { + warnings.push(`CrossRepoEvaluationManifest "repos" skipped an entry with an invalid "owner/repo" name.`); + continue; + } + if (seen.has(repoFullName)) { + warnings.push(`CrossRepoEvaluationManifest "repos" skipped a duplicate entry for ${repoFullName}.`); + continue; + } + seen.add(repoFullName); + const normalized: CrossRepoEvaluationManifestRepo = { repoFullName, requireTestCommand }; + if (stackHint) normalized.stackHint = stackHint; + if (fixturePath) normalized.fixturePath = fixturePath; + result.push(normalized); + } + return result; +} + +/** + * Tolerant JSON manifest parser (#4788). Malformed input degrades to an empty repo list with warnings rather than + * throwing, mirroring the fleet-run-manifest / miner-goal-spec convention. + */ +export function parseCrossRepoEvaluationManifest(content: string | null | undefined): ParsedCrossRepoEvaluationManifest { + if (content === undefined || content === null) return cloneEmptyManifest(); + if (typeof content !== "string") { + return cloneEmptyManifest([`CrossRepoEvaluationManifest content must be a string; got ${typeof content}.`]); + } + const trimmed = content.trim(); + if (!trimmed) return cloneEmptyManifest(); + if (utf8ByteLength(trimmed) > MAX_CROSS_REPO_MANIFEST_BYTES) { + return cloneEmptyManifest([ + `CrossRepoEvaluationManifest exceeded ${MAX_CROSS_REPO_MANIFEST_BYTES} bytes; ignoring the file.`, + ]); + } + let raw: unknown; + try { + raw = JSON.parse(trimmed); + } catch { + return cloneEmptyManifest(["CrossRepoEvaluationManifest is not valid JSON."]); + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return cloneEmptyManifest(["CrossRepoEvaluationManifest root must be a JSON object."]); + } + const warnings: string[] = []; + const repos = normalizeRepoList((raw as Record).repos, warnings); + return { present: true, manifest: { repos }, warnings }; +} + +/** + * Scan agent instructions for positive loopover/LoopOver assumptions (#4788). Lines that already tell the agent + * *not* to assume LoopOver conventions (the negative guidance from buildValidationGuidance) are skipped. + */ +export function scanPositiveLoopoverAssumptions(text: string): Array<{ id: string; line: string }> { + if (typeof text !== "string") return []; + const findings: Array<{ id: string; line: string }> = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || /do not assume/i.test(trimmed)) continue; + for (const check of GITTENSOR_POSITIVE_ASSUMPTION_CHECKS) { + if (check.pattern.test(line)) findings.push({ id: check.id, line: trimmed }); + } + } + return findings; +} + +function buildFailure( + repoFullName: string, + category: string, + reason: string, + extra: Partial = {}, +): CrossRepoEvaluationResult { + return { + repoFullName, + passed: false, + failureCategory: category, + reason, + stackDetected: false, + usedDefaultGoalSpec: null, + assumptionFindings: [], + ...extra, + }; +} + +function buildPass(repoFullName: string, extra: Partial = {}): CrossRepoEvaluationResult { + return { + repoFullName, + passed: true, + failureCategory: null, + reason: null, + stackDetected: true, + usedDefaultGoalSpec: true, + assumptionFindings: [], + ...extra, + }; +} + +function resolveEvaluationRepoPath(entry: CrossRepoEvaluationManifestRepo, options: EvaluateRepoReadinessOptions = {}): string { + if (entry.fixturePath && typeof entry.fixturePath === "string") return entry.fixturePath; + if (typeof options.repoPath === "string" && options.repoPath.trim()) return options.repoPath.trim(); + if (typeof options.resolveRepoPath === "function") return options.resolveRepoPath(entry); + return resolveRepoCloneDir(entry.repoFullName, options.env ?? process.env); +} + +function defaultClaimLedger(_repoFullName: string): { listClaims: () => never[] } { + return { listClaims: () => [] }; +} + +/** + * Evaluate one benchmark repo's miner readiness without running a live coding agent (#4788). + */ +export function evaluateRepoReadiness( + entry: CrossRepoEvaluationManifestRepo, + options: EvaluateRepoReadinessOptions = {}, +): CrossRepoEvaluationResult { + const repoFullName = entry?.repoFullName; + if (typeof repoFullName !== "string" || !normalizeCrossRepoFullName(repoFullName)) { + return buildFailure( + typeof repoFullName === "string" ? repoFullName : "(invalid)", + CROSS_REPO_FAILURE_CATEGORY.OTHER, + "Benchmark entry is missing a valid owner/repo name.", + ); + } + + const existsImpl = options.existsSync ?? existsSync; + const detectImpl = options.detectRepoStack ?? detectRepoStack; + const goalSpecImpl = options.resolveMinerGoalSpec ?? resolveMinerGoalSpec; + const buildSpecImpl = options.buildCodingTaskSpec ?? buildCodingTaskSpec; + const repoPath = resolveEvaluationRepoPath(entry, options); + + if (!existsImpl(repoPath)) { + return buildFailure( + repoFullName, + CROSS_REPO_FAILURE_CATEGORY.CLONE_SETUP, + `Repository path does not exist: ${repoPath}. Clone the repo or set LOOPOVER_MINER_REPO_CLONE_DIR.`, + ); + } + + const goalSpec = goalSpecImpl(repoPath); + const usedDefaultGoalSpec = goalSpec?.present !== true; + + const stack = detectImpl(repoPath); + if (stack?.detected !== true) { + return buildFailure( + repoFullName, + CROSS_REPO_FAILURE_CATEGORY.STACK_DETECTION, + stack?.reason ?? "Stack auto-detection did not recognize this repository.", + { stackDetected: false, usedDefaultGoalSpec }, + ); + } + + if (entry.requireTestCommand === true && !stack.testCommand) { + return buildFailure( + repoFullName, + CROSS_REPO_FAILURE_CATEGORY.EXECUTION, + "Stack detection succeeded but no test command was inferred while requireTestCommand is set.", + { stackDetected: true, usedDefaultGoalSpec, stack }, + ); + } + + const syntheticIssueTitle = "Cross-repo evaluation harness smoke issue"; + const syntheticIssueBody = "Synthetic issue used only by the cross-repo evaluation harness."; + const syntheticIssueLabels = ["bug"]; + + let specResult; + try { + specResult = buildSpecImpl({ + repoFullName, + issue: { + number: 1, + title: syntheticIssueTitle, + body: syntheticIssueBody, + labels: syntheticIssueLabels, + }, + context: { + issues: [ + { + repoFullName, + number: 1, + title: syntheticIssueTitle, + state: "open", + labels: syntheticIssueLabels, + linkedPrs: [], + }, + ], + pullRequests: [], + }, + claimLedger: defaultClaimLedger(repoFullName), + workingDirectory: repoPath, + detectRepoStack: detectImpl, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return buildFailure(repoFullName, CROSS_REPO_FAILURE_CATEGORY.OTHER, message, { + stackDetected: true, + usedDefaultGoalSpec, + stack, + }); + } + + if (specResult?.ready !== true) { + return buildFailure( + repoFullName, + CROSS_REPO_FAILURE_CATEGORY.EXECUTION, + `Coding task spec is not ready (verdict: ${specResult?.verdict ?? "unknown"}).`, + { stackDetected: true, usedDefaultGoalSpec, stack }, + ); + } + + const assumptionFindings = scanPositiveLoopoverAssumptions(specResult.instructions ?? ""); + if (assumptionFindings.length > 0) { + return buildFailure( + repoFullName, + CROSS_REPO_FAILURE_CATEGORY.GITTENSOR_ASSUMPTION, + `Agent instructions leak loopover-specific assumptions (${assumptionFindings.map((f) => f.id).join(", ")}).`, + { stackDetected: true, usedDefaultGoalSpec, stack, assumptionFindings }, + ); + } + + return buildPass(repoFullName, { usedDefaultGoalSpec, stack }); +} + +/** + * Run the harness across every repo in a parsed manifest (#4788). + */ +export function runCrossRepoEvaluation( + parsed: ParsedCrossRepoEvaluationManifest, + options: { repoFilter?: string } & EvaluateRepoReadinessOptions = {}, +): CrossRepoEvaluationResult[] { + const repos = parsed?.manifest?.repos ?? []; + const results: CrossRepoEvaluationResult[] = []; + for (const entry of repos) { + if (options.repoFilter && entry.repoFullName !== options.repoFilter) continue; + results.push(evaluateRepoReadiness(entry, options)); + } + return results; +} + +/** + * Reduce per-repo results to pass/fail counts and whether a strict majority passed (#4788). + */ +export function summarizeCrossRepoEvaluation(results: CrossRepoEvaluationResult[]): CrossRepoEvaluationSummary { + const list = Array.isArray(results) ? results : []; + let passed = 0; + let failed = 0; + const failuresByCategory: Record = {}; + for (const result of list) { + if (result?.passed === true) { + passed += 1; + continue; + } + failed += 1; + const category = result?.failureCategory ?? CROSS_REPO_FAILURE_CATEGORY.OTHER; + failuresByCategory[category] = (failuresByCategory[category] ?? 0) + 1; + } + const total = passed + failed; + const majorityPassed = total > 0 ? passed > failed : false; + const withoutLoopoverConfig = list.filter((r) => r?.usedDefaultGoalSpec !== false).length; + return { + total, + passed, + failed, + majorityPassed, + withoutLoopoverConfig, + failuresByCategory, + }; +} + +/** + * Human-readable pass/fail report for one evaluation run (#4788). + */ +export function formatCrossRepoEvaluationReport( + results: CrossRepoEvaluationResult[], + summary: CrossRepoEvaluationSummary = summarizeCrossRepoEvaluation(results), +): string { + const lines = ["loopover-miner cross-repo evaluation", ""]; + for (const result of results) { + if (result.passed) { + lines.push(`PASS ${result.repoFullName}`); + continue; + } + lines.push(`FAIL ${result.repoFullName} [${result.failureCategory}] ${result.reason}`); + } + lines.push( + "", + `summary: ${summary.passed}/${summary.total} passed` + + (summary.majorityPassed ? " (majority passed)" : " (majority failed)"), + ); + if (summary.total > 0) { + lines.push(`without loopover-specific target config: ${summary.withoutLoopoverConfig}/${summary.total}`); + } + const categories = Object.entries(summary.failuresByCategory).sort(([a], [b]) => a.localeCompare(b)); + if (categories.length > 0) { + lines.push("", "failures by category:"); + for (const [category, count] of categories) { + lines.push(`- ${category}: ${count}`); + } + } + return lines.join("\n"); +} 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,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZGVueS1jaGVjay5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbImRlbnktY2hlY2sudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUFFLGlCQUFpQixFQUFFLE1BQU0saUJBQWlCLENBQUM7QUFDcEQsT0FBTyxFQUFFLFlBQVksRUFBRSxnQkFBZ0IsRUFBRSxNQUFNLGdCQUFnQixDQUFDO0FBRWhFLE1BQU0sZ0JBQWdCLEdBQ3BCLHlFQUF5RSxDQUFDO0FBVTVFLFNBQVMsY0FBYyxDQUFDLEdBQXVCO0lBQzdDLElBQUksR0FBRyxLQUFLLFNBQVMsRUFBRSxDQUFDO1FBQ3RCLE9BQU8sRUFBRSxLQUFLLEVBQUUsNEJBQTRCLEVBQUUsQ0FBQztJQUNqRCxDQUFDO0lBQ0QsSUFBSSxDQUFDO1FBQ0gsTUFBTSxNQUFNLEdBQVksSUFBSSxDQUFDLEtBQUssQ0FBQyxHQUFHLENBQUMsQ0FBQztRQUN4QyxJQUFJLENBQUMsTUFBTSxJQUFJLE9BQU8sTUFBTSxLQUFLLFFBQVEsSUFBSSxLQUFLLENBQUMsT0FBTyxDQUFDLE1BQU0sQ0FBQyxFQUFFLENBQUM7WUFDbkUsT0FBTyxFQUFFLEtBQUssRUFBRSxtQ0FBbUMsRUFBRSxDQUFDO1FBQ3hELENBQUM7UUFDRCxPQUFPLEVBQUUsS0FBSyxFQUFFLE1BQWlDLEVBQUUsQ0FBQztJQUN0RCxDQUFDO0lBQUMsTUFBTSxDQUFDO1FBQ1AsT0FBTyxFQUFFLEtBQUssRUFBRSxnQ0FBZ0MsRUFBRSxDQUFDO0lBQ3JELENBQUM7QUFDSCxDQUFDO0FBRUQsTUFBTSxVQUFVLGtCQUFrQixDQUFDLElBQWM7SUFDL0MsTUFBTSxPQUFPLEdBQTRGO1FBQ3ZHLElBQUksRUFBRSxLQUFLO1FBQ1gsSUFBSSxFQUFFLFNBQVM7UUFDZixLQUFLLEVBQUUsU0FBUztLQUNqQixDQUFDO0lBRUYsS0FBSyxJQUFJLEtBQUssR0FBRyxDQUFDLEVBQUUsS0FBSyxHQUFHLElBQUksQ0FBQyxNQUFNLEVBQUUsS0FBSyxJQUFJLENBQUMsRUFBRSxDQUFDO1FBQ3BELE1BQU0sS0FBSyxHQUFHLElBQUksQ0FBQyxLQUFLLENBQUMsQ0FBQztRQUMxQixJQUFJLEtBQUssS0FBSyxTQUFTO1lBQUUsU0FBUztRQUNsQyxJQUFJLEtBQUssS0FBSyxRQUFRLEVBQUUsQ0FBQztZQUN2QixPQUFPLENBQUMsSUFBSSxHQUFHLElBQUksQ0FBQztZQUNwQixTQUFTO1FBQ1gsQ0FBQztRQUNELElBQUksS0FBSyxLQUFLLFFBQVEsSUFBSSxLQUFLLEtBQUssUUFBUSxFQUFFLENBQUM7WUFDN0MsTUFBTSxJQUFJLEdBQUcsSUFBSSxDQUFDLEVBQUUsS0FBSyxDQUFDLENBQUM7WUFDM0IsSUFBSSxDQUFDLElBQUksSUFBSSxJQUFJLENBQUMsVUFBVSxDQUFDLEdBQUcsQ0FBQztnQkFBRSxPQUFPLEVBQUUsS0FBSyxFQUFFLDJCQUEyQixFQUFFLENBQUM7WUFDakYsT0FBTyxDQUFDLElBQUksR0FBRyxJQUFJLENBQUM7WUFDcEIsU0FBUztRQUNYLENBQUM7UUFDRCxJQUFJLEtBQUssS0FBSyxTQUFTLEVBQUUsQ0FBQztZQUN4QixNQUFNLEdBQUcsR0FBRyxJQUFJLENBQUMsRUFBRSxLQUFLLENBQUMsQ0FBQztZQUMxQixJQUFJLENBQUMsR0FBRyxJQUFJLEdBQUcsQ0FBQyxVQUFVLENBQUMsR0FBRyxDQUFDO2dCQUFFLE9BQU8sRUFBRSxLQUFLLEVBQUUsNEJBQTRCLEVBQUUsQ0FBQztZQUNoRixNQUFNLE1BQU0sR0FBRyxjQUFjLENBQUMsR0FBRyxDQUFDLENBQUM7WUFDbkMsSUFBSSxPQUFPLElBQUksTUFBTTtnQkFBRSxPQUFPLEVBQUUsS0FBSyxFQUFFLE1BQU0sQ0FBQyxLQUFLLEVBQUUsQ0FBQztZQUN0RCxPQUFPLENBQUMsS0FBSyxHQUFHLE1BQU0sQ0FBQyxLQUFLLENBQUM7WUFDN0IsU0FBUztRQUNYLENBQUM7UUFDRCxJQUFJLEtBQUssQ0FBQyxVQUFVLENBQUMsR0FBRyxDQUFDLEVBQUUsQ0FBQztZQUMxQixPQUFPLEVBQUUsS0FBSyxFQUFFLG1CQUFtQixLQUFLLEVBQUUsRUFBRSxDQUFDO1FBQy9DLENBQUM7UUFDRCxPQUFPLEVBQUUsS0FBSyxFQUFFLGdCQUFnQixFQUFFLENBQUM7SUFDckMsQ0FBQztJQUVELElBQUksQ0FBQyxPQUFPLENBQUMsSUFBSSxJQUFJLENBQUMsT0FBTyxDQUFDLEtBQUssRUFBRSxDQUFDO1FBQ3BDLE9BQU8sRUFBRSxLQUFLLEVBQUUsZ0JBQWdCLEVBQUUsQ0FBQztJQUNyQyxDQUFDO0lBRUQsT0FBTyxFQUFFLElBQUksRUFBRSxPQUFPLENBQUMsSUFBSSxFQUFFLEtBQUssRUFBRSxPQUFPLENBQUMsS0FBSyxFQUFFLElBQUksRUFBRSxPQUFPLENBQUMsSUFBSSxFQUFFLENBQUM7QUFDMUUsQ0FBQztBQUVELE1BQU0sVUFBVSxZQUFZLENBQUMsSUFBYztJQUN6QyxNQUFNLE1BQU0sR0FBRyxrQkFBa0IsQ0FBQyxJQUFJLENBQUMsQ0FBQztJQUN4QyxJQUFJLE9BQU8sSUFBSSxNQUFNLEVBQUUsQ0FBQztRQUN0QixPQUFPLGdCQUFnQixDQUFDLFlBQVksQ0FBQyxJQUFJLENBQUMsRUFBRSxNQUFNLENBQUMsS0FBSyxDQUFDLENBQUM7SUFDNUQsQ0FBQztJQUVELE1BQU0sT0FBTyxHQUFHLGlCQUFpQixDQUFDLEVBQUUsSUFBSSxFQUFFLE1BQU0sQ0FBQyxJQUFJLEVBQUUsS0FBSyxFQUFFLE1BQU0sQ0FBQyxLQUFLLEVBQUUsQ0FBQyxDQUFDO0lBQzlFLElBQUksTUFBTSxDQUFDLElBQUksRUFBRSxDQUFDO1FBQ2hCLE9BQU8sQ0FBQyxHQUFHLENBQUMsSUFBSSxDQUFDLFNBQVMsQ0FBQyxPQUFPLENBQUMsQ0FBQyxDQUFDO0lBQ3ZDLENBQUM7U0FBTSxJQUFJLENBQUMsT0FBTyxDQUFDLE9BQU8sRUFBRSxDQUFDO1FBQzVCLE9BQU8sQ0FBQyxLQUFLLENBQUMsT0FBTyxDQUFDLFNBQVMsRUFBRSxNQUFNLElBQUksdUJBQXVCLENBQUMsQ0FBQztJQUN0RSxDQUFDO1NBQU0sQ0FBQztRQUNOLE9BQU8sQ0FBQyxHQUFHLENBQUMsU0FBUyxDQUFDLENBQUM7SUFDekIsQ0FBQztJQUVELE9BQU8sT0FBTyxDQUFDLE9BQU8sQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUM7QUFDakMsQ0FBQyJ9 \ 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/discovery-throttle.d.ts b/packages/loopover-miner/lib/discovery-throttle.d.ts index 60f40ebb1b..842b45dedd 100644 --- a/packages/loopover-miner/lib/discovery-throttle.d.ts +++ b/packages/loopover-miner/lib/discovery-throttle.d.ts @@ -1,8 +1,13 @@ -export const DEFAULT_RATE_LIMIT_LOW_WATER_MARK: number; -export const DEFAULT_RATE_LIMIT_HIGH_WATER_MARK: number; -export function resolveThrottledConcurrency( - baseConcurrency: number, - rateLimitRemaining: number | null, - lowWaterMark: number, - highWaterMark: number, -): number; +/** At or below this remaining budget, serialize discovery to a single in-flight request. */ +export declare const DEFAULT_RATE_LIMIT_LOW_WATER_MARK = 50; +/** At or above this remaining budget, run at the full configured concurrency. */ +export declare const DEFAULT_RATE_LIMIT_HIGH_WATER_MARK = 250; +/** + * Resolve the concurrency the fanout may run at for the currently-recorded rate-limit budget. Returns an integer + * in `[1, baseConcurrency]`: + * - an unknown budget (`null`/non-finite — nothing recorded yet) runs at full `baseConcurrency`; + * - at or below `lowWaterMark` it clamps to a single in-flight request; + * - at or above `highWaterMark` it runs at full `baseConcurrency`; + * - in between it scales linearly with the remaining fraction of the low→high band. + */ +export declare function resolveThrottledConcurrency(baseConcurrency: number, rateLimitRemaining: number | null, lowWaterMark: number, highWaterMark: number): number; diff --git a/packages/loopover-miner/lib/discovery-throttle.js b/packages/loopover-miner/lib/discovery-throttle.js index 819a445559..a1158268bc 100644 --- a/packages/loopover-miner/lib/discovery-throttle.js +++ b/packages/loopover-miner/lib/discovery-throttle.js @@ -3,12 +3,10 @@ // into a 403. This pure helper maps the recorded remaining budget to an allowed in-flight concurrency so the // fanout tapers off as the budget approaches zero. It only decides *how many* requests may run; it never changes // which docs are fetched or how a policy verdict is derived from them. - /** At or below this remaining budget, serialize discovery to a single in-flight request. */ export const DEFAULT_RATE_LIMIT_LOW_WATER_MARK = 50; /** At or above this remaining budget, run at the full configured concurrency. */ export const DEFAULT_RATE_LIMIT_HIGH_WATER_MARK = 250; - /** * Resolve the concurrency the fanout may run at for the currently-recorded rate-limit budget. Returns an integer * in `[1, baseConcurrency]`: @@ -16,18 +14,17 @@ export const DEFAULT_RATE_LIMIT_HIGH_WATER_MARK = 250; * - at or below `lowWaterMark` it clamps to a single in-flight request; * - at or above `highWaterMark` it runs at full `baseConcurrency`; * - in between it scales linearly with the remaining fraction of the low→high band. - * @param {number} baseConcurrency - * @param {number|null} rateLimitRemaining - * @param {number} lowWaterMark - * @param {number} highWaterMark - * @returns {number} */ export function resolveThrottledConcurrency(baseConcurrency, rateLimitRemaining, lowWaterMark, highWaterMark) { - if (!Number.isFinite(rateLimitRemaining)) return baseConcurrency; - if (rateLimitRemaining <= lowWaterMark) return 1; - if (rateLimitRemaining >= highWaterMark) return baseConcurrency; - // remaining is strictly inside the (low, high) band, so the fraction is in (0, 1) and the ceil lands in - // [1, baseConcurrency] without any further clamping. - const fraction = (rateLimitRemaining - lowWaterMark) / (highWaterMark - lowWaterMark); - return Math.ceil(fraction * baseConcurrency); + if (!Number.isFinite(rateLimitRemaining)) + return baseConcurrency; + if (rateLimitRemaining <= lowWaterMark) + return 1; + if (rateLimitRemaining >= highWaterMark) + return baseConcurrency; + // remaining is strictly inside the (low, high) band, so the fraction is in (0, 1) and the ceil lands in + // [1, baseConcurrency] without any further clamping. + const fraction = (rateLimitRemaining - lowWaterMark) / (highWaterMark - lowWaterMark); + return Math.ceil(fraction * baseConcurrency); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZGlzY292ZXJ5LXRocm90dGxlLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiZGlzY292ZXJ5LXRocm90dGxlLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLCtHQUErRztBQUMvRyw4R0FBOEc7QUFDOUcsNkdBQTZHO0FBQzdHLGlIQUFpSDtBQUNqSCx1RUFBdUU7QUFFdkUsNEZBQTRGO0FBQzVGLE1BQU0sQ0FBQyxNQUFNLGlDQUFpQyxHQUFHLEVBQUUsQ0FBQztBQUNwRCxpRkFBaUY7QUFDakYsTUFBTSxDQUFDLE1BQU0sa0NBQWtDLEdBQUcsR0FBRyxDQUFDO0FBRXREOzs7Ozs7O0dBT0c7QUFDSCxNQUFNLFVBQVUsMkJBQTJCLENBQ3pDLGVBQXVCLEVBQ3ZCLGtCQUFpQyxFQUNqQyxZQUFvQixFQUNwQixhQUFxQjtJQUVyQixJQUFJLENBQUMsTUFBTSxDQUFDLFFBQVEsQ0FBQyxrQkFBa0IsQ0FBQztRQUFFLE9BQU8sZUFBZSxDQUFDO0lBQ2pFLElBQUssa0JBQTZCLElBQUksWUFBWTtRQUFFLE9BQU8sQ0FBQyxDQUFDO0lBQzdELElBQUssa0JBQTZCLElBQUksYUFBYTtRQUFFLE9BQU8sZUFBZSxDQUFDO0lBQzVFLHdHQUF3RztJQUN4RyxxREFBcUQ7SUFDckQsTUFBTSxRQUFRLEdBQUcsQ0FBRSxrQkFBNkIsR0FBRyxZQUFZLENBQUMsR0FBRyxDQUFDLGFBQWEsR0FBRyxZQUFZLENBQUMsQ0FBQztJQUNsRyxPQUFPLElBQUksQ0FBQyxJQUFJLENBQUMsUUFBUSxHQUFHLGVBQWUsQ0FBQyxDQUFDO0FBQy9DLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-miner/lib/discovery-throttle.ts b/packages/loopover-miner/lib/discovery-throttle.ts new file mode 100644 index 0000000000..432bd462b3 --- /dev/null +++ b/packages/loopover-miner/lib/discovery-throttle.ts @@ -0,0 +1,33 @@ +// Dynamic discovery back-off (#4844): the fanout already records GitHub's `x-ratelimit-remaining`, but nothing +// slowed its own concurrent fetching in response — a `discover` run could sprint at full concurrency straight +// into a 403. This pure helper maps the recorded remaining budget to an allowed in-flight concurrency so the +// fanout tapers off as the budget approaches zero. It only decides *how many* requests may run; it never changes +// which docs are fetched or how a policy verdict is derived from them. + +/** At or below this remaining budget, serialize discovery to a single in-flight request. */ +export const DEFAULT_RATE_LIMIT_LOW_WATER_MARK = 50; +/** At or above this remaining budget, run at the full configured concurrency. */ +export const DEFAULT_RATE_LIMIT_HIGH_WATER_MARK = 250; + +/** + * Resolve the concurrency the fanout may run at for the currently-recorded rate-limit budget. Returns an integer + * in `[1, baseConcurrency]`: + * - an unknown budget (`null`/non-finite — nothing recorded yet) runs at full `baseConcurrency`; + * - at or below `lowWaterMark` it clamps to a single in-flight request; + * - at or above `highWaterMark` it runs at full `baseConcurrency`; + * - in between it scales linearly with the remaining fraction of the low→high band. + */ +export function resolveThrottledConcurrency( + baseConcurrency: number, + rateLimitRemaining: number | null, + lowWaterMark: number, + highWaterMark: number, +): number { + if (!Number.isFinite(rateLimitRemaining)) return baseConcurrency; + if ((rateLimitRemaining as number) <= lowWaterMark) return 1; + if ((rateLimitRemaining as number) >= highWaterMark) return baseConcurrency; + // remaining is strictly inside the (low, high) band, so the fraction is in (0, 1) and the ceil lands in + // [1, baseConcurrency] without any further clamping. + const fraction = ((rateLimitRemaining as number) - lowWaterMark) / (highWaterMark - lowWaterMark); + return Math.ceil(fraction * baseConcurrency); +} 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/execute-local-write.d.ts b/packages/loopover-miner/lib/execute-local-write.d.ts index d168c8e362..092044c53e 100644 --- a/packages/loopover-miner/lib/execute-local-write.d.ts +++ b/packages/loopover-miner/lib/execute-local-write.d.ts @@ -1,14 +1,13 @@ import type { LocalWriteActionSpec } from "@loopover/engine"; - export type ExecuteLocalWriteResult = { - action: string; - stdout: string; - stderr: string; - code: number | null; - timedOut: boolean; + action: string; + stdout: string; + stderr: string; + code: number | null; + timedOut: boolean; }; - -export function executeLocalWrite( - spec: LocalWriteActionSpec, - options?: { cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }, -): Promise; +export declare function executeLocalWrite(spec: LocalWriteActionSpec, options?: { + cwd?: string; + env?: NodeJS.ProcessEnv; + timeoutMs?: number; +}): Promise; diff --git a/packages/loopover-miner/lib/execute-local-write.js b/packages/loopover-miner/lib/execute-local-write.js index 628ff60755..06f8e3028d 100644 --- a/packages/loopover-miner/lib/execute-local-write.js +++ b/packages/loopover-miner/lib/execute-local-write.js @@ -7,44 +7,36 @@ // the given working directory. Per local-write-tools.ts's own boundary comment, this always runs with // whatever `gh`/`git` credentials are already configured in that environment -- loopover never performs // the write itself. - import { spawn } from "node:child_process"; - const DEFAULT_TIMEOUT_MS = 120_000; - -/** - * @param {import("@loopover/engine").LocalWriteActionSpec} spec - * @param {{ cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs?: number }} [options] - * @returns {Promise<{ action: string, stdout: string, stderr: string, code: number | null, timedOut: boolean }>} - */ export function executeLocalWrite(spec, options = {}) { - const cwd = options.cwd ?? process.cwd(); - const env = options.env ?? process.env; - const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS; - - return new Promise((resolve) => { - const child = spawn("sh", ["-c", spec.command], { cwd, env, stdio: ["ignore", "pipe", "pipe"] }); - let stdout = ""; - let stderr = ""; - const timer = setTimeout(() => { - child.kill("SIGKILL"); - resolve({ action: spec.action, stdout, stderr, code: null, timedOut: true }); - }, timeoutMs); - child.stdout?.on("data", (chunk) => { - stdout += chunk.toString("utf8"); + const cwd = options.cwd ?? process.cwd(); + const env = options.env ?? process.env; + const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS; + return new Promise((resolve) => { + const child = spawn("sh", ["-c", spec.command], { cwd, env, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + resolve({ action: spec.action, stdout, stderr, code: null, timedOut: true }); + }, timeoutMs); + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + }); + child.on("error", (err) => { + // A spawn-level error (e.g. no `sh` on PATH) fires before the child ever produces output -- mirrors + // createRealCliSubprocessSpawn's own identical handling. + clearTimeout(timer); + resolve({ action: spec.action, stdout, stderr: err.message, code: null, timedOut: false }); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ action: spec.action, stdout, stderr, code, timedOut: false }); + }); }); - child.stderr?.on("data", (chunk) => { - stderr += chunk.toString("utf8"); - }); - child.on("error", (err) => { - // A spawn-level error (e.g. no `sh` on PATH) fires before the child ever produces output -- mirrors - // createRealCliSubprocessSpawn's own identical handling. - clearTimeout(timer); - resolve({ action: spec.action, stdout, stderr: err.message, code: null, timedOut: false }); - }); - child.on("close", (code) => { - clearTimeout(timer); - resolve({ action: spec.action, stdout, stderr, code, timedOut: false }); - }); - }); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZXhlY3V0ZS1sb2NhbC13cml0ZS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbImV4ZWN1dGUtbG9jYWwtd3JpdGUudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsa0dBQWtHO0FBQ2xHLHFHQUFxRztBQUNyRyx3R0FBd0c7QUFDeEcsb0dBQW9HO0FBQ3BHLGdHQUFnRztBQUNoRywyR0FBMkc7QUFDM0csc0dBQXNHO0FBQ3RHLHdHQUF3RztBQUN4RyxvQkFBb0I7QUFFcEIsT0FBTyxFQUFFLEtBQUssRUFBRSxNQUFNLG9CQUFvQixDQUFDO0FBRzNDLE1BQU0sa0JBQWtCLEdBQUcsT0FBTyxDQUFDO0FBVW5DLE1BQU0sVUFBVSxpQkFBaUIsQ0FDL0IsSUFBMEIsRUFDMUIsVUFBeUUsRUFBRTtJQUUzRSxNQUFNLEdBQUcsR0FBRyxPQUFPLENBQUMsR0FBRyxJQUFJLE9BQU8sQ0FBQyxHQUFHLEVBQUUsQ0FBQztJQUN6QyxNQUFNLEdBQUcsR0FBRyxPQUFPLENBQUMsR0FBRyxJQUFJLE9BQU8sQ0FBQyxHQUFHLENBQUM7SUFDdkMsTUFBTSxTQUFTLEdBQUcsTUFBTSxDQUFDLFFBQVEsQ0FBQyxPQUFPLENBQUMsU0FBUyxDQUFDLENBQUMsQ0FBQyxDQUFFLE9BQU8sQ0FBQyxTQUFvQixDQUFDLENBQUMsQ0FBQyxrQkFBa0IsQ0FBQztJQUUxRyxPQUFPLElBQUksT0FBTyxDQUFDLENBQUMsT0FBTyxFQUFFLEVBQUU7UUFDN0IsTUFBTSxLQUFLLEdBQUcsS0FBSyxDQUFDLElBQUksRUFBRSxDQUFDLElBQUksRUFBRSxJQUFJLENBQUMsT0FBTyxDQUFDLEVBQUUsRUFBRSxHQUFHLEVBQUUsR0FBRyxFQUFFLEtBQUssRUFBRSxDQUFDLFFBQVEsRUFBRSxNQUFNLEVBQUUsTUFBTSxDQUFDLEVBQUUsQ0FBQyxDQUFDO1FBQ2pHLElBQUksTUFBTSxHQUFHLEVBQUUsQ0FBQztRQUNoQixJQUFJLE1BQU0sR0FBRyxFQUFFLENBQUM7UUFDaEIsTUFBTSxLQUFLLEdBQUcsVUFBVSxDQUFDLEdBQUcsRUFBRTtZQUM1QixLQUFLLENBQUMsSUFBSSxDQUFDLFNBQVMsQ0FBQyxDQUFDO1lBQ3RCLE9BQU8sQ0FBQyxFQUFFLE1BQU0sRUFBRSxJQUFJLENBQUMsTUFBTSxFQUFFLE1BQU0sRUFBRSxNQUFNLEVBQUUsSUFBSSxFQUFFLElBQUksRUFBRSxRQUFRLEVBQUUsSUFBSSxFQUFFLENBQUMsQ0FBQztRQUMvRSxDQUFDLEVBQUUsU0FBUyxDQUFDLENBQUM7UUFDZCxLQUFLLENBQUMsTUFBTSxFQUFFLEVBQUUsQ0FBQyxNQUFNLEVBQUUsQ0FBQyxLQUFLLEVBQUUsRUFBRTtZQUNqQyxNQUFNLElBQUksS0FBSyxDQUFDLFFBQVEsQ0FBQyxNQUFNLENBQUMsQ0FBQztRQUNuQyxDQUFDLENBQUMsQ0FBQztRQUNILEtBQUssQ0FBQyxNQUFNLEVBQUUsRUFBRSxDQUFDLE1BQU0sRUFBRSxDQUFDLEtBQUssRUFBRSxFQUFFO1lBQ2pDLE1BQU0sSUFBSSxLQUFLLENBQUMsUUFBUSxDQUFDLE1BQU0sQ0FBQyxDQUFDO1FBQ25DLENBQUMsQ0FBQyxDQUFDO1FBQ0gsS0FBSyxDQUFDLEVBQUUsQ0FBQyxPQUFPLEVBQUUsQ0FBQyxHQUFHLEVBQUUsRUFBRTtZQUN4QixvR0FBb0c7WUFDcEcseURBQXlEO1lBQ3pELFlBQVksQ0FBQyxLQUFLLENBQUMsQ0FBQztZQUNwQixPQUFPLENBQUMsRUFBRSxNQUFNLEVBQUUsSUFBSSxDQUFDLE1BQU0sRUFBRSxNQUFNLEVBQUUsTUFBTSxFQUFFLEdBQUcsQ0FBQyxPQUFPLEVBQUUsSUFBSSxFQUFFLElBQUksRUFBRSxRQUFRLEVBQUUsS0FBSyxFQUFFLENBQUMsQ0FBQztRQUM3RixDQUFDLENBQUMsQ0FBQztRQUNILEtBQUssQ0FBQyxFQUFFLENBQUMsT0FBTyxFQUFFLENBQUMsSUFBSSxFQUFFLEVBQUU7WUFDekIsWUFBWSxDQUFDLEtBQUssQ0FBQyxDQUFDO1lBQ3BCLE9BQU8sQ0FBQyxFQUFFLE1BQU0sRUFBRSxJQUFJLENBQUMsTUFBTSxFQUFFLE1BQU0sRUFBRSxNQUFNLEVBQUUsSUFBSSxFQUFFLFFBQVEsRUFBRSxLQUFLLEVBQUUsQ0FBQyxDQUFDO1FBQzFFLENBQUMsQ0FBQyxDQUFDO0lBQ0wsQ0FBQyxDQUFDLENBQUM7QUFDTCxDQUFDIn0= \ No newline at end of file diff --git a/packages/loopover-miner/lib/execute-local-write.ts b/packages/loopover-miner/lib/execute-local-write.ts new file mode 100644 index 0000000000..ccf5bab37d --- /dev/null +++ b/packages/loopover-miner/lib/execute-local-write.ts @@ -0,0 +1,57 @@ +// Real executeLocalWrite implementation (#5132, Wave 3.5). Mirrors coding-agent-construction.js's +// createRealCliSubprocessSpawn pattern (real child_process, resolve-not-reject on error/timeout so a +// killed/errored process's partial output -- e.g. an auth failure line on stderr -- is never lost to an +// unhandled rejection) but for LocalWriteActionSpec.command: a single shell-safe string (built with +// packages/loopover-engine/src/miner/local-write-tools.ts's own single-quote escaping), not the +// cmd/args-array CliSubprocessSpawnFn contract the coding-agent driver itself uses. Runs it via `sh -c` in +// the given working directory. Per local-write-tools.ts's own boundary comment, this always runs with +// whatever `gh`/`git` credentials are already configured in that environment -- loopover never performs +// the write itself. + +import { spawn } from "node:child_process"; +import type { LocalWriteActionSpec } from "@loopover/engine"; + +const DEFAULT_TIMEOUT_MS = 120_000; + +export type ExecuteLocalWriteResult = { + action: string; + stdout: string; + stderr: string; + code: number | null; + timedOut: boolean; +}; + +export function executeLocalWrite( + spec: LocalWriteActionSpec, + options: { cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number } = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const env = options.env ?? process.env; + const timeoutMs = Number.isFinite(options.timeoutMs) ? (options.timeoutMs as number) : DEFAULT_TIMEOUT_MS; + + return new Promise((resolve) => { + const child = spawn("sh", ["-c", spec.command], { cwd, env, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + resolve({ action: spec.action, stdout, stderr, code: null, timedOut: true }); + }, timeoutMs); + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + }); + child.on("error", (err) => { + // A spawn-level error (e.g. no `sh` on PATH) fires before the child ever produces output -- mirrors + // createRealCliSubprocessSpawn's own identical handling. + clearTimeout(timer); + resolve({ action: spec.action, stdout, stderr: err.message, code: null, timedOut: false }); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ action: spec.action, stdout, stderr, code, timedOut: false }); + }); + }); +} diff --git a/packages/loopover-miner/lib/portfolio-queue-expiry.d.ts b/packages/loopover-miner/lib/portfolio-queue-expiry.d.ts index f52ae5ed8c..517f51a59d 100644 --- a/packages/loopover-miner/lib/portfolio-queue-expiry.d.ts +++ b/packages/loopover-miner/lib/portfolio-queue-expiry.d.ts @@ -1,20 +1,21 @@ +/** PURE — no IO, no Date, no random (#4827). Mirror of claim-ledger-expiry.js for the portfolio-queue store: a + * crashed/killed process leaves its item stuck 'in_progress' forever, so sweep leases older than a bound back to + * 'queued'. */ import type { QueueEntry, QueueLeaseEntry } from "./portfolio-queue.js"; - export declare const DEFAULT_MAX_LEASE_MS: number; - export type PortfolioQueueExpiryStore = { - listInProgress(): QueueLeaseEntry[]; - reclaimStuckItem(repoFullName: string, identifier: string): QueueEntry | null; + listInProgress(): QueueLeaseEntry[]; + reclaimStuckItem(repoFullName: string, identifier: string, apiBaseUrl?: string): QueueEntry | null; }; - -export function findStuckItems( - items: QueueLeaseEntry[], - nowMs: number, - maxLeaseMs: number, -): QueueLeaseEntry[]; - -export function sweepStuckItems( - store: PortfolioQueueExpiryStore, - nowMs: number, - maxLeaseMs?: number, -): QueueEntry[]; +/** + * Return in-flight items whose lease age is strictly greater than `maxLeaseMs`. An item whose age equals + * `maxLeaseMs` exactly is still within the window (not stuck). Items that are not 'in_progress', or whose + * `leasedAt` is missing/unparseable, are never returned. + */ +export declare function findStuckItems(items: QueueLeaseEntry[], nowMs: number, maxLeaseMs: number): QueueLeaseEntry[]; +/** + * Reclaim every stuck in-flight item back to 'queued', returning the reclaimed entries. `store.listInProgress()` + * supplies the lease-annotated rows and `store.reclaimStuckItem()` performs the atomic per-item flip — the same + * store/sweep split sweepExpiredClaims uses. + */ +export declare function sweepStuckItems(store: PortfolioQueueExpiryStore, nowMs: number, maxLeaseMs?: number): QueueEntry[]; diff --git a/packages/loopover-miner/lib/portfolio-queue-expiry.js b/packages/loopover-miner/lib/portfolio-queue-expiry.js index d88387897b..93d73b9544 100644 --- a/packages/loopover-miner/lib/portfolio-queue-expiry.js +++ b/packages/loopover-miner/lib/portfolio-queue-expiry.js @@ -1,51 +1,55 @@ /** PURE — no IO, no Date, no random (#4827). Mirror of claim-ledger-expiry.js for the portfolio-queue store: a * crashed/killed process leaves its item stuck 'in_progress' forever, so sweep leases older than a bound back to * 'queued'. */ - // A generous default: a real attempt rarely holds a single portfolio item for long, so 30 minutes without the row // leaving 'in_progress' strongly implies the owning process died rather than that it is still working. export const DEFAULT_MAX_LEASE_MS = 30 * 60 * 1000; - function leaseAgeMs(item, nowMs) { - const leasedAtMs = Date.parse(item.leasedAt); - if (!Number.isFinite(leasedAtMs)) return null; - return nowMs - leasedAtMs; + const leasedAtMs = Date.parse(item.leasedAt ?? ""); + if (!Number.isFinite(leasedAtMs)) + return null; + return nowMs - leasedAtMs; } - /** * Return in-flight items whose lease age is strictly greater than `maxLeaseMs`. An item whose age equals * `maxLeaseMs` exactly is still within the window (not stuck). Items that are not 'in_progress', or whose * `leasedAt` is missing/unparseable, are never returned. */ export function findStuckItems(items, nowMs, maxLeaseMs) { - if (!Number.isFinite(nowMs) || nowMs < 0) throw new Error("invalid_now_ms"); - if (!Number.isFinite(maxLeaseMs) || maxLeaseMs < 0) throw new Error("invalid_max_lease_ms"); - if (!Array.isArray(items)) throw new Error("invalid_items"); - - const stuck = []; - for (const item of items) { - if (item?.status !== "in_progress") continue; - const ageMs = leaseAgeMs(item, nowMs); - if (ageMs === null) continue; - if (ageMs > maxLeaseMs) stuck.push(item); - } - return stuck; + if (!Number.isFinite(nowMs) || nowMs < 0) + throw new Error("invalid_now_ms"); + if (!Number.isFinite(maxLeaseMs) || maxLeaseMs < 0) + throw new Error("invalid_max_lease_ms"); + if (!Array.isArray(items)) + throw new Error("invalid_items"); + const stuck = []; + for (const item of items) { + if (item?.status !== "in_progress") + continue; + const ageMs = leaseAgeMs(item, nowMs); + if (ageMs === null) + continue; + if (ageMs > maxLeaseMs) + stuck.push(item); + } + return stuck; } - /** * Reclaim every stuck in-flight item back to 'queued', returning the reclaimed entries. `store.listInProgress()` * supplies the lease-annotated rows and `store.reclaimStuckItem()` performs the atomic per-item flip — the same * store/sweep split sweepExpiredClaims uses. */ export function sweepStuckItems(store, nowMs, maxLeaseMs = DEFAULT_MAX_LEASE_MS) { - const inProgress = store.listInProgress(); - const stuck = findStuckItems(inProgress, nowMs, maxLeaseMs); - const reclaimed = []; - for (const item of stuck) { - // Echo the item's OWN apiBaseUrl back (#5563) rather than defaulting: two forge hosts can each have an - // in-flight item with the same owner/repo+identifier, and defaulting here would reclaim the wrong host's row. - const updated = store.reclaimStuckItem(item.repoFullName, item.identifier, item.apiBaseUrl); - if (updated) reclaimed.push(updated); - } - return reclaimed; + const inProgress = store.listInProgress(); + const stuck = findStuckItems(inProgress, nowMs, maxLeaseMs); + const reclaimed = []; + for (const item of stuck) { + // Echo the item's OWN apiBaseUrl back (#5563) rather than defaulting: two forge hosts can each have an + // in-flight item with the same owner/repo+identifier, and defaulting here would reclaim the wrong host's row. + const updated = store.reclaimStuckItem(item.repoFullName, item.identifier, item.apiBaseUrl); + if (updated) + reclaimed.push(updated); + } + return reclaimed; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicG9ydGZvbGlvLXF1ZXVlLWV4cGlyeS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInBvcnRmb2xpby1xdWV1ZS1leHBpcnkudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7O2dCQUVnQjtBQUloQixrSEFBa0g7QUFDbEgsdUdBQXVHO0FBQ3ZHLE1BQU0sQ0FBQyxNQUFNLG9CQUFvQixHQUFHLEVBQUUsR0FBRyxFQUFFLEdBQUcsSUFBSSxDQUFDO0FBT25ELFNBQVMsVUFBVSxDQUFDLElBQXFCLEVBQUUsS0FBYTtJQUN0RCxNQUFNLFVBQVUsR0FBRyxJQUFJLENBQUMsS0FBSyxDQUFDLElBQUksQ0FBQyxRQUFRLElBQUksRUFBRSxDQUFDLENBQUM7SUFDbkQsSUFBSSxDQUFDLE1BQU0sQ0FBQyxRQUFRLENBQUMsVUFBVSxDQUFDO1FBQUUsT0FBTyxJQUFJLENBQUM7SUFDOUMsT0FBTyxLQUFLLEdBQUcsVUFBVSxDQUFDO0FBQzVCLENBQUM7QUFFRDs7OztHQUlHO0FBQ0gsTUFBTSxVQUFVLGNBQWMsQ0FBQyxLQUF3QixFQUFFLEtBQWEsRUFBRSxVQUFrQjtJQUN4RixJQUFJLENBQUMsTUFBTSxDQUFDLFFBQVEsQ0FBQyxLQUFLLENBQUMsSUFBSSxLQUFLLEdBQUcsQ0FBQztRQUFFLE1BQU0sSUFBSSxLQUFLLENBQUMsZ0JBQWdCLENBQUMsQ0FBQztJQUM1RSxJQUFJLENBQUMsTUFBTSxDQUFDLFFBQVEsQ0FBQyxVQUFVLENBQUMsSUFBSSxVQUFVLEdBQUcsQ0FBQztRQUFFLE1BQU0sSUFBSSxLQUFLLENBQUMsc0JBQXNCLENBQUMsQ0FBQztJQUM1RixJQUFJLENBQUMsS0FBSyxDQUFDLE9BQU8sQ0FBQyxLQUFLLENBQUM7UUFBRSxNQUFNLElBQUksS0FBSyxDQUFDLGVBQWUsQ0FBQyxDQUFDO0lBRTVELE1BQU0sS0FBSyxHQUFzQixFQUFFLENBQUM7SUFDcEMsS0FBSyxNQUFNLElBQUksSUFBSSxLQUFLLEVBQUUsQ0FBQztRQUN6QixJQUFJLElBQUksRUFBRSxNQUFNLEtBQUssYUFBYTtZQUFFLFNBQVM7UUFDN0MsTUFBTSxLQUFLLEdBQUcsVUFBVSxDQUFDLElBQUksRUFBRSxLQUFLLENBQUMsQ0FBQztRQUN0QyxJQUFJLEtBQUssS0FBSyxJQUFJO1lBQUUsU0FBUztRQUM3QixJQUFJLEtBQUssR0FBRyxVQUFVO1lBQUUsS0FBSyxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsQ0FBQztJQUMzQyxDQUFDO0lBQ0QsT0FBTyxLQUFLLENBQUM7QUFDZixDQUFDO0FBRUQ7Ozs7R0FJRztBQUNILE1BQU0sVUFBVSxlQUFlLENBQzdCLEtBQWdDLEVBQ2hDLEtBQWEsRUFDYixhQUFxQixvQkFBb0I7SUFFekMsTUFBTSxVQUFVLEdBQUcsS0FBSyxDQUFDLGNBQWMsRUFBRSxDQUFDO0lBQzFDLE1BQU0sS0FBSyxHQUFHLGNBQWMsQ0FBQyxVQUFVLEVBQUUsS0FBSyxFQUFFLFVBQVUsQ0FBQyxDQUFDO0lBQzVELE1BQU0sU0FBUyxHQUFpQixFQUFFLENBQUM7SUFDbkMsS0FBSyxNQUFNLElBQUksSUFBSSxLQUFLLEVBQUUsQ0FBQztRQUN6Qix1R0FBdUc7UUFDdkcsOEdBQThHO1FBQzlHLE1BQU0sT0FBTyxHQUFHLEtBQUssQ0FBQyxnQkFBZ0IsQ0FBQyxJQUFJLENBQUMsWUFBWSxFQUFFLElBQUksQ0FBQyxVQUFVLEVBQUUsSUFBSSxDQUFDLFVBQVUsQ0FBQyxDQUFDO1FBQzVGLElBQUksT0FBTztZQUFFLFNBQVMsQ0FBQyxJQUFJLENBQUMsT0FBTyxDQUFDLENBQUM7SUFDdkMsQ0FBQztJQUNELE9BQU8sU0FBUyxDQUFDO0FBQ25CLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-miner/lib/portfolio-queue-expiry.ts b/packages/loopover-miner/lib/portfolio-queue-expiry.ts new file mode 100644 index 0000000000..cf7c826c50 --- /dev/null +++ b/packages/loopover-miner/lib/portfolio-queue-expiry.ts @@ -0,0 +1,62 @@ +/** PURE — no IO, no Date, no random (#4827). Mirror of claim-ledger-expiry.js for the portfolio-queue store: a + * crashed/killed process leaves its item stuck 'in_progress' forever, so sweep leases older than a bound back to + * 'queued'. */ + +import type { QueueEntry, QueueLeaseEntry } from "./portfolio-queue.js"; + +// A generous default: a real attempt rarely holds a single portfolio item for long, so 30 minutes without the row +// leaving 'in_progress' strongly implies the owning process died rather than that it is still working. +export const DEFAULT_MAX_LEASE_MS = 30 * 60 * 1000; + +export type PortfolioQueueExpiryStore = { + listInProgress(): QueueLeaseEntry[]; + reclaimStuckItem(repoFullName: string, identifier: string, apiBaseUrl?: string): QueueEntry | null; +}; + +function leaseAgeMs(item: QueueLeaseEntry, nowMs: number): number | null { + const leasedAtMs = Date.parse(item.leasedAt ?? ""); + if (!Number.isFinite(leasedAtMs)) return null; + return nowMs - leasedAtMs; +} + +/** + * Return in-flight items whose lease age is strictly greater than `maxLeaseMs`. An item whose age equals + * `maxLeaseMs` exactly is still within the window (not stuck). Items that are not 'in_progress', or whose + * `leasedAt` is missing/unparseable, are never returned. + */ +export function findStuckItems(items: QueueLeaseEntry[], nowMs: number, maxLeaseMs: number): QueueLeaseEntry[] { + if (!Number.isFinite(nowMs) || nowMs < 0) throw new Error("invalid_now_ms"); + if (!Number.isFinite(maxLeaseMs) || maxLeaseMs < 0) throw new Error("invalid_max_lease_ms"); + if (!Array.isArray(items)) throw new Error("invalid_items"); + + const stuck: QueueLeaseEntry[] = []; + for (const item of items) { + if (item?.status !== "in_progress") continue; + const ageMs = leaseAgeMs(item, nowMs); + if (ageMs === null) continue; + if (ageMs > maxLeaseMs) stuck.push(item); + } + return stuck; +} + +/** + * Reclaim every stuck in-flight item back to 'queued', returning the reclaimed entries. `store.listInProgress()` + * supplies the lease-annotated rows and `store.reclaimStuckItem()` performs the atomic per-item flip — the same + * store/sweep split sweepExpiredClaims uses. + */ +export function sweepStuckItems( + store: PortfolioQueueExpiryStore, + nowMs: number, + maxLeaseMs: number = DEFAULT_MAX_LEASE_MS, +): QueueEntry[] { + const inProgress = store.listInProgress(); + const stuck = findStuckItems(inProgress, nowMs, maxLeaseMs); + const reclaimed: QueueEntry[] = []; + for (const item of stuck) { + // Echo the item's OWN apiBaseUrl back (#5563) rather than defaulting: two forge hosts can each have an + // in-flight item with the same owner/repo+identifier, and defaulting here would reclaim the wrong host's row. + const updated = store.reclaimStuckItem(item.repoFullName, item.identifier, item.apiBaseUrl); + if (updated) reclaimed.push(updated); + } + return reclaimed; +} diff --git a/packages/loopover-miner/lib/prompt-injection-defense.d.ts b/packages/loopover-miner/lib/prompt-injection-defense.d.ts index 55d6af8706..f26df5b8aa 100644 --- a/packages/loopover-miner/lib/prompt-injection-defense.d.ts +++ b/packages/loopover-miner/lib/prompt-injection-defense.d.ts @@ -1,5 +1,11 @@ -export const PROMPT_INJECTION_RE: RegExp; - -export function hasPromptInjection(text: string | null | undefined): boolean; - -export function neutralizePromptInjection(text: string | null | undefined): { text: string; injected: boolean }; +export declare const PROMPT_INJECTION_RE: RegExp; +/** True when the text contains an agent-manipulation / prompt-injection pattern. */ +export declare function hasPromptInjection(text: string | null | undefined): boolean; +/** + * Replace injection-like spans with a defanged marker so the literal manipulation never reaches the + * coding agent verbatim. Returns the neutralized text + whether anything was flagged. + */ +export declare function neutralizePromptInjection(text: string | null | undefined): { + text: string; + injected: boolean; +}; diff --git a/packages/loopover-miner/lib/prompt-injection-defense.js b/packages/loopover-miner/lib/prompt-injection-defense.js index 0498277240..8302365199 100644 --- a/packages/loopover-miner/lib/prompt-injection-defense.js +++ b/packages/loopover-miner/lib/prompt-injection-defense.js @@ -11,38 +11,33 @@ // duplicated here rather than shared, matching how src/review/prompt-injection.ts itself documents being // a self-contained port of its own upstream (reviewbot's src/core/prompt-injection.ts). Keep the two // regex sources in sync by hand if either evolves. - const INJECTION_SOURCE = [ - "\\b(?:ignore|disregard|forget)\\b[^.]{0,40}\\b(?:previous|prior|above|earlier|all|the|any)\\b[^.]{0,24}\\b(?:instructions?|prompts?|rules?|rubric|policy|guidelines?|directions?)\\b", - "\\b(?:override|bypass)\\b[^.]{0,40}\\b(?:previous|prior|above|earlier|all|any)\\b[^.]{0,24}\\b(?:instructions?|prompts?)\\b|\\b(?:override|bypass)\\s+the\\s+(?:rules?|rubric|policy|guidelines?|directions?)\\b[^.]{0,40}\\b(?:approve|merge|accept|whitelist|allow|pass|scor(?:e|ing))\\b", - "\\byou are now\\s+(?:an?\\s+(?:\\w+\\s+)?(?:ai|assistant|language model|reviewer|maintainer|admin|moderator|bot|developer|owner|system)|(?:unrestricted|uncensored|unfiltered|unbound|jailbroken))\\b", - "\\b(?:this is|here is|below is)\\s+the\\s+(?:system|developer)\\s+prompt\\b|\\b(?:system|developer)\\s+prompt\\s*:", - "\\b(?:approve|merge|accept|whitelist|allow|pass)\\s+this\\s+(?:submission|pr|pull[ -]?request|entry|request|content|review)\\b|\\b(?:please|kindly|just)\\s+(?:approve|merge|accept|whitelist|allow|pass)\\s+the\\s+(?:submission|pr|pull[ -]?request|entry|request|content|review)\\b", - "\\bas an?\\s+(?:ai|assistant|language model)\\b[^.]{0,30}\\b(?:you must\\s+(?:ignore|approve|obey|disregard|comply)|ignore\\s+(?:previous|prior|all|the|any)|approve\\s+(?:this|the))\\b", - "\\b(?:print|reveal|output|repeat|leak)\\b[^.]{0,30}\\byour\\s+(?:system prompt|rubric|instructions?)\\b|\\b(?:print|reveal|output|repeat|leak)\\b[^.]{0,30}\\bthe\\s+(?:system|developer)\\s+prompt\\b[^.]{0,40}\\byou\\s+(?:were\\s+)?(?:given|sent|provided|received)\\b", - "\\b(?:pretend|roleplay)\\b[^.]{0,24}\\byou\\s+are\\b", + "\\b(?:ignore|disregard|forget)\\b[^.]{0,40}\\b(?:previous|prior|above|earlier|all|the|any)\\b[^.]{0,24}\\b(?:instructions?|prompts?|rules?|rubric|policy|guidelines?|directions?)\\b", + "\\b(?:override|bypass)\\b[^.]{0,40}\\b(?:previous|prior|above|earlier|all|any)\\b[^.]{0,24}\\b(?:instructions?|prompts?)\\b|\\b(?:override|bypass)\\s+the\\s+(?:rules?|rubric|policy|guidelines?|directions?)\\b[^.]{0,40}\\b(?:approve|merge|accept|whitelist|allow|pass|scor(?:e|ing))\\b", + "\\byou are now\\s+(?:an?\\s+(?:\\w+\\s+)?(?:ai|assistant|language model|reviewer|maintainer|admin|moderator|bot|developer|owner|system)|(?:unrestricted|uncensored|unfiltered|unbound|jailbroken))\\b", + "\\b(?:this is|here is|below is)\\s+the\\s+(?:system|developer)\\s+prompt\\b|\\b(?:system|developer)\\s+prompt\\s*:", + "\\b(?:approve|merge|accept|whitelist|allow|pass)\\s+this\\s+(?:submission|pr|pull[ -]?request|entry|request|content|review)\\b|\\b(?:please|kindly|just)\\s+(?:approve|merge|accept|whitelist|allow|pass)\\s+the\\s+(?:submission|pr|pull[ -]?request|entry|request|content|review)\\b", + "\\bas an?\\s+(?:ai|assistant|language model)\\b[^.]{0,30}\\b(?:you must\\s+(?:ignore|approve|obey|disregard|comply)|ignore\\s+(?:previous|prior|all|the|any)|approve\\s+(?:this|the))\\b", + "\\b(?:print|reveal|output|repeat|leak)\\b[^.]{0,30}\\byour\\s+(?:system prompt|rubric|instructions?)\\b|\\b(?:print|reveal|output|repeat|leak)\\b[^.]{0,30}\\bthe\\s+(?:system|developer)\\s+prompt\\b[^.]{0,40}\\byou\\s+(?:were\\s+)?(?:given|sent|provided|received)\\b", + "\\b(?:pretend|roleplay)\\b[^.]{0,24}\\byou\\s+are\\b", ].join("|"); - export const PROMPT_INJECTION_RE = new RegExp(INJECTION_SOURCE, "i"); - /** True when the text contains an agent-manipulation / prompt-injection pattern. */ export function hasPromptInjection(text) { - return !!text && PROMPT_INJECTION_RE.test(text); + return !!text && PROMPT_INJECTION_RE.test(text); } - /** * Replace injection-like spans with a defanged marker so the literal manipulation never reaches the * coding agent verbatim. Returns the neutralized text + whether anything was flagged. - * - * @param {string | null | undefined} text - * @returns {{ text: string, injected: boolean }} */ export function neutralizePromptInjection(text) { - if (!text) return { text: text ?? "", injected: false }; - let injected = false; - const cleaned = text.replace(new RegExp(INJECTION_SOURCE, "gi"), () => { - injected = true; - return "[external-instruction-redacted]"; - }); - return { text: cleaned, injected }; + if (!text) + return { text: text ?? "", injected: false }; + let injected = false; + const cleaned = text.replace(new RegExp(INJECTION_SOURCE, "gi"), () => { + injected = true; + return "[external-instruction-redacted]"; + }); + return { text: cleaned, injected }; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicHJvbXB0LWluamVjdGlvbi1kZWZlbnNlLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsicHJvbXB0LWluamVjdGlvbi1kZWZlbnNlLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLHNHQUFzRztBQUN0Ryx1R0FBdUc7QUFDdkcscUdBQXFHO0FBQ3JHLHNHQUFzRztBQUN0RyxrRUFBa0U7QUFDbEUsRUFBRTtBQUNGLHVHQUF1RztBQUN2RyxtR0FBbUc7QUFDbkcsdUdBQXVHO0FBQ3ZHLG9HQUFvRztBQUNwRyx5R0FBeUc7QUFDekcscUdBQXFHO0FBQ3JHLG1EQUFtRDtBQUVuRCxNQUFNLGdCQUFnQixHQUFHO0lBQ3ZCLHNMQUFzTDtJQUN0TCw2UkFBNlI7SUFDN1IsdU1BQXVNO0lBQ3ZNLG9IQUFvSDtJQUNwSCx3UkFBd1I7SUFDeFIsMExBQTBMO0lBQzFMLDRRQUE0UTtJQUM1USxzREFBc0Q7Q0FDdkQsQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFDLENBQUM7QUFFWixNQUFNLENBQUMsTUFBTSxtQkFBbUIsR0FBRyxJQUFJLE1BQU0sQ0FBQyxnQkFBZ0IsRUFBRSxHQUFHLENBQUMsQ0FBQztBQUVyRSxvRkFBb0Y7QUFDcEYsTUFBTSxVQUFVLGtCQUFrQixDQUFDLElBQStCO0lBQ2hFLE9BQU8sQ0FBQyxDQUFDLElBQUksSUFBSSxtQkFBbUIsQ0FBQyxJQUFJLENBQUMsSUFBSSxDQUFDLENBQUM7QUFDbEQsQ0FBQztBQUVEOzs7R0FHRztBQUNILE1BQU0sVUFBVSx5QkFBeUIsQ0FBQyxJQUErQjtJQUN2RSxJQUFJLENBQUMsSUFBSTtRQUFFLE9BQU8sRUFBRSxJQUFJLEVBQUUsSUFBSSxJQUFJLEVBQUUsRUFBRSxRQUFRLEVBQUUsS0FBSyxFQUFFLENBQUM7SUFDeEQsSUFBSSxRQUFRLEdBQUcsS0FBSyxDQUFDO0lBQ3JCLE1BQU0sT0FBTyxHQUFHLElBQUksQ0FBQyxPQUFPLENBQUMsSUFBSSxNQUFNLENBQUMsZ0JBQWdCLEVBQUUsSUFBSSxDQUFDLEVBQUUsR0FBRyxFQUFFO1FBQ3BFLFFBQVEsR0FBRyxJQUFJLENBQUM7UUFDaEIsT0FBTyxpQ0FBaUMsQ0FBQztJQUMzQyxDQUFDLENBQUMsQ0FBQztJQUNILE9BQU8sRUFBRSxJQUFJLEVBQUUsT0FBTyxFQUFFLFFBQVEsRUFBRSxDQUFDO0FBQ3JDLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-miner/lib/prompt-injection-defense.ts b/packages/loopover-miner/lib/prompt-injection-defense.ts new file mode 100644 index 0000000000..0256e36fd1 --- /dev/null +++ b/packages/loopover-miner/lib/prompt-injection-defense.ts @@ -0,0 +1,45 @@ +// Detect + defang prompt-injection / agent-manipulation text in UNTRUSTED third-party repo content (a +// customer's own issue title/body) before it reaches the coding agent's own instructions (#4795). Such +// content is DATA, never instructions -- but a coding agent operating with real write authority on a +// customer's repository can still be steered by it, so we both flag it (a strong negative signal) and +// redact the literal manipulation so it can't be obeyed verbatim. +// +// SELF-CONTAINED NATIVE PORT: byte-faithful to src/review/prompt-injection.ts's proven regex (the same +// reviewer-manipulation shape, now defending the coding agent's own instructions instead of the AI +// reviewer's prompt). No cross-package import -- packages/loopover-miner never depends on root src/ (a +// separate Cloudflare Worker deployable, see package.json's own dependency list), so the pattern is +// duplicated here rather than shared, matching how src/review/prompt-injection.ts itself documents being +// a self-contained port of its own upstream (reviewbot's src/core/prompt-injection.ts). Keep the two +// regex sources in sync by hand if either evolves. + +const INJECTION_SOURCE = [ + "\\b(?:ignore|disregard|forget)\\b[^.]{0,40}\\b(?:previous|prior|above|earlier|all|the|any)\\b[^.]{0,24}\\b(?:instructions?|prompts?|rules?|rubric|policy|guidelines?|directions?)\\b", + "\\b(?:override|bypass)\\b[^.]{0,40}\\b(?:previous|prior|above|earlier|all|any)\\b[^.]{0,24}\\b(?:instructions?|prompts?)\\b|\\b(?:override|bypass)\\s+the\\s+(?:rules?|rubric|policy|guidelines?|directions?)\\b[^.]{0,40}\\b(?:approve|merge|accept|whitelist|allow|pass|scor(?:e|ing))\\b", + "\\byou are now\\s+(?:an?\\s+(?:\\w+\\s+)?(?:ai|assistant|language model|reviewer|maintainer|admin|moderator|bot|developer|owner|system)|(?:unrestricted|uncensored|unfiltered|unbound|jailbroken))\\b", + "\\b(?:this is|here is|below is)\\s+the\\s+(?:system|developer)\\s+prompt\\b|\\b(?:system|developer)\\s+prompt\\s*:", + "\\b(?:approve|merge|accept|whitelist|allow|pass)\\s+this\\s+(?:submission|pr|pull[ -]?request|entry|request|content|review)\\b|\\b(?:please|kindly|just)\\s+(?:approve|merge|accept|whitelist|allow|pass)\\s+the\\s+(?:submission|pr|pull[ -]?request|entry|request|content|review)\\b", + "\\bas an?\\s+(?:ai|assistant|language model)\\b[^.]{0,30}\\b(?:you must\\s+(?:ignore|approve|obey|disregard|comply)|ignore\\s+(?:previous|prior|all|the|any)|approve\\s+(?:this|the))\\b", + "\\b(?:print|reveal|output|repeat|leak)\\b[^.]{0,30}\\byour\\s+(?:system prompt|rubric|instructions?)\\b|\\b(?:print|reveal|output|repeat|leak)\\b[^.]{0,30}\\bthe\\s+(?:system|developer)\\s+prompt\\b[^.]{0,40}\\byou\\s+(?:were\\s+)?(?:given|sent|provided|received)\\b", + "\\b(?:pretend|roleplay)\\b[^.]{0,24}\\byou\\s+are\\b", +].join("|"); + +export const PROMPT_INJECTION_RE = new RegExp(INJECTION_SOURCE, "i"); + +/** True when the text contains an agent-manipulation / prompt-injection pattern. */ +export function hasPromptInjection(text: string | null | undefined): boolean { + return !!text && PROMPT_INJECTION_RE.test(text); +} + +/** + * Replace injection-like spans with a defanged marker so the literal manipulation never reaches the + * coding agent verbatim. Returns the neutralized text + whether anything was flagged. + */ +export function neutralizePromptInjection(text: string | null | undefined): { text: string; injected: boolean } { + if (!text) return { text: text ?? "", injected: false }; + let injected = false; + const cleaned = text.replace(new RegExp(INJECTION_SOURCE, "gi"), () => { + injected = true; + return "[external-instruction-redacted]"; + }); + return { text: cleaned, injected }; +} 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); +} diff --git a/packages/loopover-miner/lib/slop-assessment.d.ts b/packages/loopover-miner/lib/slop-assessment.d.ts index 833e490e67..fd4f17bdf3 100644 --- a/packages/loopover-miner/lib/slop-assessment.d.ts +++ b/packages/loopover-miner/lib/slop-assessment.d.ts @@ -1,3 +1,2 @@ import type { SlopAssessment, SlopAssessmentInput } from "@loopover/engine"; - -export function runSlopAssessment(input: SlopAssessmentInput): SlopAssessment; +export declare function runSlopAssessment(input: SlopAssessmentInput): SlopAssessment; diff --git a/packages/loopover-miner/lib/slop-assessment.js b/packages/loopover-miner/lib/slop-assessment.js index 98ec8d132d..fa18443839 100644 --- a/packages/loopover-miner/lib/slop-assessment.js +++ b/packages/loopover-miner/lib/slop-assessment.js @@ -1,5 +1,4 @@ import { buildSlopAssessment } from "@loopover/engine"; - // Production runSlopAssessment binding (#5133, Wave 3.5 follow-up to #2334). `attempt-runner.js`'s // `deps.runSlopAssessment` (via #2333's iterate-loop -> self-review-adapter's `SelfReviewAdapterDeps`) had // no production implementation anywhere in this package -- only the test double in @@ -10,11 +9,7 @@ import { buildSlopAssessment } from "@loopover/engine"; // a real binding could be a direct pass-through with no mapping logic once the deterministic scorer itself // became portable -- which #5133 did (`src/signals/slop.ts`'s PR-side scorer is now extracted to // `packages/loopover-engine/src/signals/slop.ts`, byte-parity-verified against the live gate's own copy). - -/** - * @param {import("@loopover/engine").SlopAssessmentInput} input - * @returns {import("@loopover/engine").SlopAssessment} - */ export function runSlopAssessment(input) { - return buildSlopAssessment(input); + return buildSlopAssessment(input); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2xvcC1hc3Nlc3NtZW50LmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsic2xvcC1hc3Nlc3NtZW50LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sRUFBRSxtQkFBbUIsRUFBRSxNQUFNLGtCQUFrQixDQUFDO0FBR3ZELG1HQUFtRztBQUNuRywyR0FBMkc7QUFDM0csbUZBQW1GO0FBQ25GLGtIQUFrSDtBQUNsSCw4R0FBOEc7QUFDOUcsc0dBQXNHO0FBQ3RHLCtHQUErRztBQUMvRywyR0FBMkc7QUFDM0csaUdBQWlHO0FBQ2pHLDBHQUEwRztBQUUxRyxNQUFNLFVBQVUsaUJBQWlCLENBQUMsS0FBMEI7SUFDMUQsT0FBTyxtQkFBbUIsQ0FBQyxLQUFLLENBQUMsQ0FBQztBQUNwQyxDQUFDIn0= \ No newline at end of file diff --git a/packages/loopover-miner/lib/slop-assessment.ts b/packages/loopover-miner/lib/slop-assessment.ts new file mode 100644 index 0000000000..0736e9d6b2 --- /dev/null +++ b/packages/loopover-miner/lib/slop-assessment.ts @@ -0,0 +1,17 @@ +import { buildSlopAssessment } from "@loopover/engine"; +import type { SlopAssessment, SlopAssessmentInput } from "@loopover/engine"; + +// Production runSlopAssessment binding (#5133, Wave 3.5 follow-up to #2334). `attempt-runner.js`'s +// `deps.runSlopAssessment` (via #2333's iterate-loop -> self-review-adapter's `SelfReviewAdapterDeps`) had +// no production implementation anywhere in this package -- only the test double in +// `test/unit/miner-attempt-runner.test.ts` existed. `packages/loopover-engine/src/miner/self-review-adapter.ts`'s +// own header comment already anticipated this exact binding: `SelfReviewSlopInput`/`SelfReviewSlopAssessment` +// are a deliberate, hand-kept STRUCTURAL MIRROR of `buildSlopAssessment`'s own `SlopAssessmentInput`/ +// `SlopAssessment` (down to reusing the SAME canonical `AdvisoryFinding` type for `findings`), specifically so +// a real binding could be a direct pass-through with no mapping logic once the deterministic scorer itself +// became portable -- which #5133 did (`src/signals/slop.ts`'s PR-side scorer is now extracted to +// `packages/loopover-engine/src/signals/slop.ts`, byte-parity-verified against the live gate's own copy). + +export function runSlopAssessment(input: SlopAssessmentInput): SlopAssessment { + return buildSlopAssessment(input); +} diff --git a/packages/loopover-miner/lib/version.d.ts b/packages/loopover-miner/lib/version.d.ts index 1b7404ce8e..1be9f1defc 100644 --- a/packages/loopover-miner/lib/version.d.ts +++ b/packages/loopover-miner/lib/version.d.ts @@ -1,3 +1,4 @@ -export const MINER_PACKAGE_VERSION: string; - -export function resolveMinerVersion(env?: Record): string; +/** Package.json semver at import time — the laptop npm-install default. */ +export declare const MINER_PACKAGE_VERSION: string; +/** Resolved miner release id: `LOOPOVER_MINER_VERSION` wins when set (fleet Docker image builds). */ +export declare function resolveMinerVersion(env?: Record): string; diff --git a/packages/loopover-miner/lib/version.js b/packages/loopover-miner/lib/version.js index 866e1f6b41..c3ff373653 100644 --- a/packages/loopover-miner/lib/version.js +++ b/packages/loopover-miner/lib/version.js @@ -1,10 +1,9 @@ import ownPackageJson from "../package.json" with { type: "json" }; - /** Package.json semver at import time — the laptop npm-install default. */ export const MINER_PACKAGE_VERSION = ownPackageJson.version; - /** Resolved miner release id: `LOOPOVER_MINER_VERSION` wins when set (fleet Docker image builds). */ export function resolveMinerVersion(env = process.env) { - const override = typeof env.LOOPOVER_MINER_VERSION === "string" ? env.LOOPOVER_MINER_VERSION.trim() : ""; - return override || MINER_PACKAGE_VERSION; + const override = typeof env.LOOPOVER_MINER_VERSION === "string" ? env.LOOPOVER_MINER_VERSION.trim() : ""; + return override || MINER_PACKAGE_VERSION; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidmVyc2lvbi5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInZlcnNpb24udHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxjQUFjLE1BQU0saUJBQWlCLENBQUMsT0FBTyxJQUFJLEVBQUUsTUFBTSxFQUFFLENBQUM7QUFFbkUsMkVBQTJFO0FBQzNFLE1BQU0sQ0FBQyxNQUFNLHFCQUFxQixHQUFXLGNBQWMsQ0FBQyxPQUFPLENBQUM7QUFFcEUscUdBQXFHO0FBQ3JHLE1BQU0sVUFBVSxtQkFBbUIsQ0FBQyxNQUEwQyxPQUFPLENBQUMsR0FBRztJQUN2RixNQUFNLFFBQVEsR0FBRyxPQUFPLEdBQUcsQ0FBQyxzQkFBc0IsS0FBSyxRQUFRLENBQUMsQ0FBQyxDQUFDLEdBQUcsQ0FBQyxzQkFBc0IsQ0FBQyxJQUFJLEVBQUUsQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDO0lBQ3pHLE9BQU8sUUFBUSxJQUFJLHFCQUFxQixDQUFDO0FBQzNDLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-miner/lib/version.ts b/packages/loopover-miner/lib/version.ts new file mode 100644 index 0000000000..b32adac6ee --- /dev/null +++ b/packages/loopover-miner/lib/version.ts @@ -0,0 +1,10 @@ +import ownPackageJson from "../package.json" with { type: "json" }; + +/** Package.json semver at import time — the laptop npm-install default. */ +export const MINER_PACKAGE_VERSION: string = ownPackageJson.version; + +/** Resolved miner release id: `LOOPOVER_MINER_VERSION` wins when set (fleet Docker image builds). */ +export function resolveMinerVersion(env: Record = process.env): string { + const override = typeof env.LOOPOVER_MINER_VERSION === "string" ? env.LOOPOVER_MINER_VERSION.trim() : ""; + return override || MINER_PACKAGE_VERSION; +}