diff --git a/packages/loopover-miner/lib/calibration-cli.d.ts b/packages/loopover-miner/lib/calibration-cli.d.ts index e8d749add8..7d68704600 100644 --- a/packages/loopover-miner/lib/calibration-cli.d.ts +++ b/packages/loopover-miner/lib/calibration-cli.d.ts @@ -1,9 +1,18 @@ -import type { PredictedVerdictRecord, ObservedOutcomeRecord } from "./calibration-types.js"; -import type { PredictionLedgerEntry } from "./prediction-ledger.js"; import type { LedgerEntry } from "./event-ledger.js"; - -export function runCalibrationCli(args?: string[], env?: Record): number; - -export function toPredictionRecords(rows: PredictionLedgerEntry[]): PredictedVerdictRecord[]; - -export function toOutcomeRecords(events: LedgerEntry[]): ObservedOutcomeRecord[]; +import type { PredictionLedgerEntry } from "./prediction-ledger.js"; +import type { PredictedVerdictRecord, ObservedOutcomeRecord } from "./calibration-types.js"; +/** Map prediction-ledger rows to predicted-verdict records: the target id becomes a string key and the recorded + * prediction verdict is the `conclusion`. Exported so callers other than this CLI (the MCP calibration-report + * tool, #5821) can build the identical join without re-implementing the mapping. */ +export declare function toPredictionRecords(rows: PredictionLedgerEntry[]): PredictedVerdictRecord[]; +/** Reduce the append-only `pr_outcome` event stream to the LATEST observed outcome per (repo, PR), as + * observed-outcome records. `recordedAt` comes from the event's own timestamp (always present), so an outcome is + * never dropped for lacking a `closedAt`. Malformed payloads are skipped. Exported for the same reason as + * {@link toPredictionRecords} above. */ +export declare function toOutcomeRecords(events: LedgerEntry[]): ObservedOutcomeRecord[]; +/** + * Run `loopover-miner calibration [--json]`. Reads the prediction ledger + PR-outcome events, joins them into a + * calibration report, and prints it (a JSON dump under `--json`, else a per-project text summary). Returns the + * process exit code: 0 on success, 1 on an unknown option. + */ +export declare function runCalibrationCli(args?: string[], env?: Record): number; diff --git a/packages/loopover-miner/lib/calibration-cli.js b/packages/loopover-miner/lib/calibration-cli.js index d25ee0b6df..62f167c789 100644 --- a/packages/loopover-miner/lib/calibration-cli.js +++ b/packages/loopover-miner/lib/calibration-cli.js @@ -7,91 +7,87 @@ import { initEventLedger, resolveEventLedgerDbPath } from "./event-ledger.js"; import { MINER_PR_OUTCOME_EVENT } from "./pr-outcome.js"; import { initPredictionLedger, resolvePredictionLedgerDbPath } from "./prediction-ledger.js"; import { reportCliFailure, describeCliError } from "./cli-error.js"; - const CALIBRATION_USAGE = "Usage: loopover-miner calibration [--json]"; - /** Map prediction-ledger rows to predicted-verdict records: the target id becomes a string key and the recorded * prediction verdict is the `conclusion`. Exported so callers other than this CLI (the MCP calibration-report * tool, #5821) can build the identical join without re-implementing the mapping. */ export function toPredictionRecords(rows) { - return rows.map((row) => ({ - project: row.repoFullName, - targetId: String(row.targetId), - predictedDecision: row.conclusion, - recordedAt: row.ts, - })); + return rows.map((row) => ({ + project: row.repoFullName, + targetId: String(row.targetId), + predictedDecision: row.conclusion, + recordedAt: row.ts, + })); } - /** Reduce the append-only `pr_outcome` event stream to the LATEST observed outcome per (repo, PR), as * observed-outcome records. `recordedAt` comes from the event's own timestamp (always present), so an outcome is * never dropped for lacking a `closedAt`. Malformed payloads are skipped. Exported for the same reason as * {@link toPredictionRecords} above. */ export function toOutcomeRecords(events) { - const latest = new Map(); - for (const event of events) { - if (event?.type !== MINER_PR_OUTCOME_EVENT) continue; - const payload = event.payload; - if (!payload || !Number.isInteger(payload.prNumber) || typeof payload.decision !== "string") continue; - latest.set(`${event.repoFullName}:${payload.prNumber}`, { - project: event.repoFullName, - targetId: String(payload.prNumber), - outcomeDecision: payload.decision, - recordedAt: event.createdAt, - }); - } - return [...latest.values()]; + const latest = new Map(); + for (const event of events) { + if (event?.type !== MINER_PR_OUTCOME_EVENT) + continue; + const payload = event.payload; + if (!payload || !Number.isInteger(payload.prNumber) || typeof payload.decision !== "string") + continue; + latest.set(`${event.repoFullName}:${payload.prNumber}`, { + // ObservedOutcomeRecord.project is declared non-nullable, but LedgerEntry.repoFullName is `string | null` + // for other event kinds; a pr_outcome event always carries a real repoFullName in practice, so this passes + // the value through unchanged rather than substituting a fallback that would be a behavior change. + project: event.repoFullName, + targetId: String(payload.prNumber), + outcomeDecision: payload.decision, + recordedAt: event.createdAt, + }); + } + return [...latest.values()]; } - function renderReportText(report) { - if (!report.hasSignal) { - console.log("calibration: no decided predictions yet (predictions need a realized merge/close outcome)."); - return; - } - for (const row of report.rows) { - const merge = row.mergePrecision === null ? "n/a" : `${Math.round(row.mergePrecision * 100)}%`; - const close = row.closePrecision === null ? "n/a" : `${Math.round(row.closePrecision * 100)}%`; - console.log( - `${row.project}: ${row.decided} decided | ` + - `merge ${row.mergeConfirmed}/${row.wouldMerge} (${merge}) | ` + - `close ${row.closeConfirmed}/${row.wouldClose} (${close}) | hold ${row.hold}`, - ); - } + if (!report.hasSignal) { + console.log("calibration: no decided predictions yet (predictions need a realized merge/close outcome)."); + return; + } + for (const row of report.rows) { + const merge = row.mergePrecision === null ? "n/a" : `${Math.round(row.mergePrecision * 100)}%`; + const close = row.closePrecision === null ? "n/a" : `${Math.round(row.closePrecision * 100)}%`; + console.log(`${row.project}: ${row.decided} decided | ` + + `merge ${row.mergeConfirmed}/${row.wouldMerge} (${merge}) | ` + + `close ${row.closeConfirmed}/${row.wouldClose} (${close}) | hold ${row.hold}`); + } } - /** * Run `loopover-miner calibration [--json]`. Reads the prediction ledger + PR-outcome events, joins them into a * calibration report, and prints it (a JSON dump under `--json`, else a per-project text summary). Returns the * process exit code: 0 on success, 1 on an unknown option. - * @param {string[]} [args] - * @param {NodeJS.ProcessEnv} [env] - * @returns {number} */ export function runCalibrationCli(args = [], env = process.env) { - const json = args.includes("--json"); - // This command takes no positional arguments, so anything that is not `--json` is a mistake -- including a - // bare positional (`calibration foo`), which a `startsWith("-")` check silently let through (#5834). Mirrors - // the strict zero-positional discipline `ledger list` (event-ledger-cli.js) already applies. - const unknown = args.find((token) => token !== "--json"); - if (unknown) { - return reportCliFailure(json, `Unknown option: ${unknown}. ${CALIBRATION_USAGE}`, 1); - } - - let predictionStore; - let eventLedger; - try { - predictionStore = initPredictionLedger(resolvePredictionLedgerDbPath(env)); - eventLedger = initEventLedger(resolveEventLedgerDbPath(env)); - const report = buildCalibrationReport( - toPredictionRecords(predictionStore.readPredictions()), - toOutcomeRecords(eventLedger.readEvents()), - ); - if (json) console.log(JSON.stringify(report, null, 2)); - else renderReportText(report); - return 0; - } catch (error) { - return reportCliFailure(json, describeCliError(error)); - } finally { - predictionStore?.close(); - eventLedger?.close(); - } + const json = args.includes("--json"); + // This command takes no positional arguments, so anything that is not `--json` is a mistake -- including a + // bare positional (`calibration foo`), which a `startsWith("-")` check silently let through (#5834). Mirrors + // the strict zero-positional discipline `ledger list` (event-ledger-cli.js) already applies. + const unknown = args.find((token) => token !== "--json"); + if (unknown) { + return reportCliFailure(json, `Unknown option: ${unknown}. ${CALIBRATION_USAGE}`, 1); + } + let predictionStore; + let eventLedger; + try { + predictionStore = initPredictionLedger(resolvePredictionLedgerDbPath(env)); + eventLedger = initEventLedger(resolveEventLedgerDbPath(env)); + const report = buildCalibrationReport(toPredictionRecords(predictionStore.readPredictions()), toOutcomeRecords(eventLedger.readEvents())); + if (json) + console.log(JSON.stringify(report, null, 2)); + else + renderReportText(report); + return 0; + } + catch (error) { + return reportCliFailure(json, describeCliError(error)); + } + finally { + predictionStore?.close(); + eventLedger?.close(); + } } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2FsaWJyYXRpb24tY2xpLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiY2FsaWJyYXRpb24tY2xpLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLDJHQUEyRztBQUMzRywwR0FBMEc7QUFDMUcscUdBQXFHO0FBQ3JHLHFHQUFxRztBQUNyRyxPQUFPLEVBQUUsc0JBQXNCLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUMxRCxPQUFPLEVBQUUsZUFBZSxFQUFFLHdCQUF3QixFQUFFLE1BQU0sbUJBQW1CLENBQUM7QUFFOUUsT0FBTyxFQUFFLHNCQUFzQixFQUFFLE1BQU0saUJBQWlCLENBQUM7QUFDekQsT0FBTyxFQUFFLG9CQUFvQixFQUFFLDZCQUE2QixFQUFFLE1BQU0sd0JBQXdCLENBQUM7QUFHN0YsT0FBTyxFQUFFLGdCQUFnQixFQUFFLGdCQUFnQixFQUFFLE1BQU0sZ0JBQWdCLENBQUM7QUFFcEUsTUFBTSxpQkFBaUIsR0FBRyw0Q0FBNEMsQ0FBQztBQUV2RTs7cUZBRXFGO0FBQ3JGLE1BQU0sVUFBVSxtQkFBbUIsQ0FBQyxJQUE2QjtJQUMvRCxPQUFPLElBQUksQ0FBQyxHQUFHLENBQUMsQ0FBQyxHQUFHLEVBQUUsRUFBRSxDQUFDLENBQUM7UUFDeEIsT0FBTyxFQUFFLEdBQUcsQ0FBQyxZQUFZO1FBQ3pCLFFBQVEsRUFBRSxNQUFNLENBQUMsR0FBRyxDQUFDLFFBQVEsQ0FBQztRQUM5QixpQkFBaUIsRUFBRSxHQUFHLENBQUMsVUFBVTtRQUNqQyxVQUFVLEVBQUUsR0FBRyxDQUFDLEVBQUU7S0FDbkIsQ0FBQyxDQUFDLENBQUM7QUFDTixDQUFDO0FBRUQ7Ozt5Q0FHeUM7QUFDekMsTUFBTSxVQUFVLGdCQUFnQixDQUFDLE1BQXFCO0lBQ3BELE1BQU0sTUFBTSxHQUFHLElBQUksR0FBRyxFQUFpQyxDQUFDO0lBQ3hELEtBQUssTUFBTSxLQUFLLElBQUksTUFBTSxFQUFFLENBQUM7UUFDM0IsSUFBSSxLQUFLLEVBQUUsSUFBSSxLQUFL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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/calibration-cli.ts b/packages/loopover-miner/lib/calibration-cli.ts new file mode 100644 index 0000000000..8721121d6b --- /dev/null +++ b/packages/loopover-miner/lib/calibration-cli.ts @@ -0,0 +1,100 @@ +// `loopover-miner calibration [--json]` (#4849): a read-only report joining the miner's own predicted gate +// verdicts (prediction-ledger) with the realized PR outcomes it later observed (event-ledger `pr_outcome` +// events), via the pure buildCalibrationReport join. Opens both local stores, maps their rows to the +// calibration record shapes, renders, and closes. Never modifies the live scoring/calibration logic. +import { buildCalibrationReport } from "./calibration.js"; +import { initEventLedger, resolveEventLedgerDbPath } from "./event-ledger.js"; +import type { LedgerEntry } from "./event-ledger.js"; +import { MINER_PR_OUTCOME_EVENT } from "./pr-outcome.js"; +import { initPredictionLedger, resolvePredictionLedgerDbPath } from "./prediction-ledger.js"; +import type { PredictionLedgerEntry } from "./prediction-ledger.js"; +import type { PredictedVerdictRecord, ObservedOutcomeRecord, CalibrationReport } from "./calibration-types.js"; +import { reportCliFailure, describeCliError } from "./cli-error.js"; + +const CALIBRATION_USAGE = "Usage: loopover-miner calibration [--json]"; + +/** Map prediction-ledger rows to predicted-verdict records: the target id becomes a string key and the recorded + * prediction verdict is the `conclusion`. Exported so callers other than this CLI (the MCP calibration-report + * tool, #5821) can build the identical join without re-implementing the mapping. */ +export function toPredictionRecords(rows: PredictionLedgerEntry[]): PredictedVerdictRecord[] { + return rows.map((row) => ({ + project: row.repoFullName, + targetId: String(row.targetId), + predictedDecision: row.conclusion, + recordedAt: row.ts, + })); +} + +/** Reduce the append-only `pr_outcome` event stream to the LATEST observed outcome per (repo, PR), as + * observed-outcome records. `recordedAt` comes from the event's own timestamp (always present), so an outcome is + * never dropped for lacking a `closedAt`. Malformed payloads are skipped. Exported for the same reason as + * {@link toPredictionRecords} above. */ +export function toOutcomeRecords(events: LedgerEntry[]): ObservedOutcomeRecord[] { + const latest = new Map(); + for (const event of events) { + if (event?.type !== MINER_PR_OUTCOME_EVENT) continue; + const payload = event.payload; + if (!payload || !Number.isInteger(payload.prNumber) || typeof payload.decision !== "string") continue; + latest.set(`${event.repoFullName}:${payload.prNumber}`, { + // ObservedOutcomeRecord.project is declared non-nullable, but LedgerEntry.repoFullName is `string | null` + // for other event kinds; a pr_outcome event always carries a real repoFullName in practice, so this passes + // the value through unchanged rather than substituting a fallback that would be a behavior change. + project: event.repoFullName as string, + targetId: String(payload.prNumber), + outcomeDecision: payload.decision, + recordedAt: event.createdAt, + }); + } + return [...latest.values()]; +} + +function renderReportText(report: CalibrationReport): void { + if (!report.hasSignal) { + console.log("calibration: no decided predictions yet (predictions need a realized merge/close outcome)."); + return; + } + for (const row of report.rows) { + const merge = row.mergePrecision === null ? "n/a" : `${Math.round(row.mergePrecision * 100)}%`; + const close = row.closePrecision === null ? "n/a" : `${Math.round(row.closePrecision * 100)}%`; + console.log( + `${row.project}: ${row.decided} decided | ` + + `merge ${row.mergeConfirmed}/${row.wouldMerge} (${merge}) | ` + + `close ${row.closeConfirmed}/${row.wouldClose} (${close}) | hold ${row.hold}`, + ); + } +} + +/** + * Run `loopover-miner calibration [--json]`. Reads the prediction ledger + PR-outcome events, joins them into a + * calibration report, and prints it (a JSON dump under `--json`, else a per-project text summary). Returns the + * process exit code: 0 on success, 1 on an unknown option. + */ +export function runCalibrationCli(args: string[] = [], env: Record = process.env): number { + const json = args.includes("--json"); + // This command takes no positional arguments, so anything that is not `--json` is a mistake -- including a + // bare positional (`calibration foo`), which a `startsWith("-")` check silently let through (#5834). Mirrors + // the strict zero-positional discipline `ledger list` (event-ledger-cli.js) already applies. + const unknown = args.find((token) => token !== "--json"); + if (unknown) { + return reportCliFailure(json, `Unknown option: ${unknown}. ${CALIBRATION_USAGE}`, 1); + } + + let predictionStore; + let eventLedger; + try { + predictionStore = initPredictionLedger(resolvePredictionLedgerDbPath(env)); + eventLedger = initEventLedger(resolveEventLedgerDbPath(env)); + const report = buildCalibrationReport( + toPredictionRecords(predictionStore.readPredictions()), + toOutcomeRecords(eventLedger.readEvents()), + ); + if (json) console.log(JSON.stringify(report, null, 2)); + else renderReportText(report); + return 0; + } catch (error) { + return reportCliFailure(json, describeCliError(error)); + } finally { + predictionStore?.close(); + eventLedger?.close(); + } +} diff --git a/packages/loopover-miner/lib/feasibility-cli.d.ts b/packages/loopover-miner/lib/feasibility-cli.d.ts index 38fb85a5de..ef1e18181d 100644 --- a/packages/loopover-miner/lib/feasibility-cli.d.ts +++ b/packages/loopover-miner/lib/feasibility-cli.d.ts @@ -1,25 +1,15 @@ -import type { - FeasibilityClaimStatus, - FeasibilityDuplicateClusterRisk, - FeasibilityGateInput, - FeasibilityGateResult, - FeasibilityIssueStatus, -} from "@loopover/engine"; - -export type ParsedFeasibilityArgs = - | { - claimStatus: FeasibilityClaimStatus; - duplicateClusterRisk: FeasibilityDuplicateClusterRisk; - issueStatus: FeasibilityIssueStatus; - found: boolean; - json: boolean; - } - | { error: string }; - +import type { FeasibilityClaimStatus, FeasibilityDuplicateClusterRisk, FeasibilityGateInput, FeasibilityGateResult, FeasibilityIssueStatus } from "@loopover/engine"; +export type ParsedFeasibilityArgs = { + claimStatus: FeasibilityClaimStatus; + duplicateClusterRisk: FeasibilityDuplicateClusterRisk; + issueStatus: FeasibilityIssueStatus; + found: boolean; + json: boolean; +} | { + error: string; +}; +export declare function parseFeasibilityArgs(args: string[]): ParsedFeasibilityArgs; export type RunFeasibilityCliOptions = { - buildFeasibilityVerdict?: (input: FeasibilityGateInput) => FeasibilityGateResult; + buildFeasibilityVerdict?: (input: FeasibilityGateInput) => FeasibilityGateResult; }; - -export function parseFeasibilityArgs(args: string[]): ParsedFeasibilityArgs; - -export function runFeasibilityCli(args: string[], options?: RunFeasibilityCliOptions): number; +export declare function runFeasibilityCli(args: string[], options?: RunFeasibilityCliOptions): number; diff --git a/packages/loopover-miner/lib/feasibility-cli.js b/packages/loopover-miner/lib/feasibility-cli.js index 24f96d8d21..98bc75d044 100644 --- a/packages/loopover-miner/lib/feasibility-cli.js +++ b/packages/loopover-miner/lib/feasibility-cli.js @@ -3,78 +3,83 @@ * npm-registry update check other subcommands opt into. */ import { buildFeasibilityVerdict } from "@loopover/engine"; import { argsWantJson, reportCliFailure } from "./cli-error.js"; - const CLAIM_STATUSES = ["unclaimed", "claimed", "solved", "unknown"]; const DUPLICATE_CLUSTER_RISKS = ["none", "low", "medium", "high"]; -const ISSUE_STATUSES = ["ready", "needs_proof", "hold", "do_not_use", "duplicate", "invalid", "missing"]; - -const FEASIBILITY_USAGE = - "Usage: loopover-miner feasibility [--not-found] [--json]\n" + - ` claimStatus: ${CLAIM_STATUSES.join("|")}\n` + - ` duplicateClusterRisk: ${DUPLICATE_CLUSTER_RISKS.join("|")}\n` + - ` issueStatus: ${ISSUE_STATUSES.join("|")}`; - +const ISSUE_STATUSES = [ + "ready", + "needs_proof", + "hold", + "do_not_use", + "duplicate", + "invalid", + "missing", +]; +/** Plain `Array.includes` doesn't narrow a `string` argument down to the array's literal element type, so this + * small type-guard wrapper does it explicitly wherever a parsed CLI token needs to become one of these enums. */ +function isOneOf(value, allowed) { + return allowed.includes(value); +} +const FEASIBILITY_USAGE = "Usage: loopover-miner feasibility [--not-found] [--json]\n" + + ` claimStatus: ${CLAIM_STATUSES.join("|")}\n` + + ` duplicateClusterRisk: ${DUPLICATE_CLUSTER_RISKS.join("|")}\n` + + ` issueStatus: ${ISSUE_STATUSES.join("|")}`; export function parseFeasibilityArgs(args) { - const options = { json: false, found: true }; - const positional = []; - - for (const token of args) { - if (token === "--json") { - options.json = true; - continue; + const options = { json: false, found: true }; + const positional = []; + for (const token of args) { + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--not-found") { + options.found = false; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + positional.push(token); + } + if (positional.length !== 3) { + return { error: FEASIBILITY_USAGE }; + } + // positional.length === 3 was just verified above, so this tuple cast is safe. + const [claimStatus, duplicateClusterRisk, issueStatus] = positional; + if (!isOneOf(claimStatus, CLAIM_STATUSES)) { + return { error: `claimStatus must be one of: ${CLAIM_STATUSES.join(", ")}.` }; } - if (token === "--not-found") { - options.found = false; - continue; + if (!isOneOf(duplicateClusterRisk, DUPLICATE_CLUSTER_RISKS)) { + return { error: `duplicateClusterRisk must be one of: ${DUPLICATE_CLUSTER_RISKS.join(", ")}.` }; } - if (token.startsWith("-")) { - return { error: `Unknown option: ${token}` }; + if (!isOneOf(issueStatus, ISSUE_STATUSES)) { + return { error: `issueStatus must be one of: ${ISSUE_STATUSES.join(", ")}.` }; } - positional.push(token); - } - - if (positional.length !== 3) { - return { error: FEASIBILITY_USAGE }; - } - - const [claimStatus, duplicateClusterRisk, issueStatus] = positional; - if (!CLAIM_STATUSES.includes(claimStatus)) { - return { error: `claimStatus must be one of: ${CLAIM_STATUSES.join(", ")}.` }; - } - if (!DUPLICATE_CLUSTER_RISKS.includes(duplicateClusterRisk)) { - return { error: `duplicateClusterRisk must be one of: ${DUPLICATE_CLUSTER_RISKS.join(", ")}.` }; - } - if (!ISSUE_STATUSES.includes(issueStatus)) { - return { error: `issueStatus must be one of: ${ISSUE_STATUSES.join(", ")}.` }; - } - - return { - claimStatus, - duplicateClusterRisk, - issueStatus, - found: options.found, - json: options.json, - }; + return { + claimStatus, + duplicateClusterRisk, + issueStatus, + found: options.found, + json: options.json, + }; } - export function runFeasibilityCli(args, options = {}) { - const parsed = parseFeasibilityArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - const buildVerdict = options.buildFeasibilityVerdict ?? buildFeasibilityVerdict; - const verdict = buildVerdict({ - found: parsed.found, - claimStatus: parsed.claimStatus, - duplicateClusterRisk: parsed.duplicateClusterRisk, - issueStatus: parsed.issueStatus, - }); - - if (parsed.json) { - console.log(JSON.stringify(verdict, null, 2)); - } else { - console.log(`${verdict.verdict}: ${verdict.summary}`); - } - return 0; + const parsed = parseFeasibilityArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + const buildVerdict = options.buildFeasibilityVerdict ?? buildFeasibilityVerdict; + const verdict = buildVerdict({ + found: parsed.found, + claimStatus: parsed.claimStatus, + duplicateClusterRisk: parsed.duplicateClusterRisk, + issueStatus: parsed.issueStatus, + }); + if (parsed.json) { + console.log(JSON.stringify(verdict, null, 2)); + } + else { + console.log(`${verdict.verdict}: ${verdict.summary}`); + } + return 0; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZmVhc2liaWxpdHktY2xpLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiZmVhc2liaWxpdHktY2xpLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzsyREFFMkQ7QUFDM0QsT0FBTyxFQUFFLHVCQUF1QixFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFRM0QsT0FBTyxFQUFFLFlBQVksRUFBRSxnQkFBZ0IsRUFBRSxNQUFNLGdCQUFnQixDQUFDO0FBRWhFLE1BQU0sY0FBYyxHQUFHLENBQUMsV0FBVyxFQUFFLFNBQVMsRUFBRSxRQUFRLEVBQUUsU0FBUyxDQUFzRCxDQUFDO0FBQzFILE1BQU0sdUJBQXVCLEdBQUcsQ0FBQyxNQUFNLEVBQUUsS0FBSyxFQUFFLFFBQVEsRUFBRSxNQUFNLENBQStELENBQUM7QUFDaEksTUFBTSxjQUFjLEdBQUc7SUFDckIsT0FBTztJQUNQLGFBQWE7SUFDYixNQUFNO0lBQ04sWUFBWTtJQUNaLFdBQVc7SUFDWCxTQUFTO0lBQ1QsU0FBUztDQUMyQyxDQUFDO0FBRXZEO2tIQUNrSDtBQUNsSCxTQUFTLE9BQU8sQ0FBbUIsS0FBYSxFQUFFLE9BQXFCO0lBQ3JFLE9BQVEsT0FBNkIsQ0FBQyxRQUFRLENBQUMsS0FBSyxDQUFDLENBQUM7QUFDeEQsQ0FBQztBQUVELE1BQU0saUJBQWlCLEdBQ3JCLCtHQUErRztJQUMvRyxrQkFBa0IsY0FBYyxDQUFDLElBQUksQ0FBQyxHQUFHLENBQUMsSUFBSTtJQUM5QywyQkFBMkIsdUJBQXVCLENBQUMsSUFBSSxDQUFDLEdBQUcsQ0FBQyxJQUFJO0lBQ2hFLGtCQUFrQixjQUFjLENBQUMsSUFBSSxDQUFDLEdBQUcsQ0FBQyxFQUFFLENBQUM7QUFZL0MsTUFBTSxVQUFVLG9CQUFvQixDQUFDLElBQWM7SUFDakQsTUFBTSxPQUFPLEdBQUcsRUFBRSxJQUFJLEVBQUUsS0FBSyxFQUFFLEtBQUssRUFBRSxJQUFJLEVBQUUsQ0FBQztJQUM3QyxNQUFNLFVBQVUsR0FBYSxFQUFFLENBQUM7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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/feasibility-cli.ts b/packages/loopover-miner/lib/feasibility-cli.ts new file mode 100644 index 0000000000..47f47b9002 --- /dev/null +++ b/packages/loopover-miner/lib/feasibility-cli.ts @@ -0,0 +1,116 @@ +/** `feasibility` CLI command (#4270): a thin parse -> execute -> render wrapper around the engine's pure + * `buildFeasibilityVerdict` composer. Purely local — no network, no filesystem — so it never needs the + * npm-registry update check other subcommands opt into. */ +import { buildFeasibilityVerdict } from "@loopover/engine"; +import type { + FeasibilityClaimStatus, + FeasibilityDuplicateClusterRisk, + FeasibilityGateInput, + FeasibilityGateResult, + FeasibilityIssueStatus, +} from "@loopover/engine"; +import { argsWantJson, reportCliFailure } from "./cli-error.js"; + +const CLAIM_STATUSES = ["unclaimed", "claimed", "solved", "unknown"] as const satisfies readonly FeasibilityClaimStatus[]; +const DUPLICATE_CLUSTER_RISKS = ["none", "low", "medium", "high"] as const satisfies readonly FeasibilityDuplicateClusterRisk[]; +const ISSUE_STATUSES = [ + "ready", + "needs_proof", + "hold", + "do_not_use", + "duplicate", + "invalid", + "missing", +] as const satisfies readonly FeasibilityIssueStatus[]; + +/** Plain `Array.includes` doesn't narrow a `string` argument down to the array's literal element type, so this + * small type-guard wrapper does it explicitly wherever a parsed CLI token needs to become one of these enums. */ +function isOneOf(value: string, allowed: readonly T[]): value is T { + return (allowed as readonly string[]).includes(value); +} + +const FEASIBILITY_USAGE = + "Usage: loopover-miner feasibility [--not-found] [--json]\n" + + ` claimStatus: ${CLAIM_STATUSES.join("|")}\n` + + ` duplicateClusterRisk: ${DUPLICATE_CLUSTER_RISKS.join("|")}\n` + + ` issueStatus: ${ISSUE_STATUSES.join("|")}`; + +export type ParsedFeasibilityArgs = + | { + claimStatus: FeasibilityClaimStatus; + duplicateClusterRisk: FeasibilityDuplicateClusterRisk; + issueStatus: FeasibilityIssueStatus; + found: boolean; + json: boolean; + } + | { error: string }; + +export function parseFeasibilityArgs(args: string[]): ParsedFeasibilityArgs { + const options = { json: false, found: true }; + const positional: string[] = []; + + for (const token of args) { + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--not-found") { + options.found = false; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + positional.push(token); + } + + if (positional.length !== 3) { + return { error: FEASIBILITY_USAGE }; + } + + // positional.length === 3 was just verified above, so this tuple cast is safe. + const [claimStatus, duplicateClusterRisk, issueStatus] = positional as [string, string, string]; + if (!isOneOf(claimStatus, CLAIM_STATUSES)) { + return { error: `claimStatus must be one of: ${CLAIM_STATUSES.join(", ")}.` }; + } + if (!isOneOf(duplicateClusterRisk, DUPLICATE_CLUSTER_RISKS)) { + return { error: `duplicateClusterRisk must be one of: ${DUPLICATE_CLUSTER_RISKS.join(", ")}.` }; + } + if (!isOneOf(issueStatus, ISSUE_STATUSES)) { + return { error: `issueStatus must be one of: ${ISSUE_STATUSES.join(", ")}.` }; + } + + return { + claimStatus, + duplicateClusterRisk, + issueStatus, + found: options.found, + json: options.json, + }; +} + +export type RunFeasibilityCliOptions = { + buildFeasibilityVerdict?: (input: FeasibilityGateInput) => FeasibilityGateResult; +}; + +export function runFeasibilityCli(args: string[], options: RunFeasibilityCliOptions = {}): number { + const parsed = parseFeasibilityArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + const buildVerdict = options.buildFeasibilityVerdict ?? buildFeasibilityVerdict; + const verdict = buildVerdict({ + found: parsed.found, + claimStatus: parsed.claimStatus, + duplicateClusterRisk: parsed.duplicateClusterRisk, + issueStatus: parsed.issueStatus, + }); + + if (parsed.json) { + console.log(JSON.stringify(verdict, null, 2)); + } else { + console.log(`${verdict.verdict}: ${verdict.summary}`); + } + return 0; +} diff --git a/packages/loopover-miner/lib/idea-feasibility-cli.d.ts b/packages/loopover-miner/lib/idea-feasibility-cli.d.ts index e4c49f7524..c2b86727fc 100644 --- a/packages/loopover-miner/lib/idea-feasibility-cli.d.ts +++ b/packages/loopover-miner/lib/idea-feasibility-cli.d.ts @@ -1,21 +1,14 @@ -import type { - FeasibilityClaimStatus, - FeasibilityDuplicateClusterRisk, -} from "@loopover/engine"; import type { AssessIdeaFeasibilityOptions } from "./idea-feasibility.js"; - -export type ParsedIdeaFeasibilityArgs = - | { - claimStatus: FeasibilityClaimStatus; - duplicateClusterRisk: FeasibilityDuplicateClusterRisk; - targetResolvable: boolean; - acceptanceHints: string[]; - json: boolean; - } - | { error: string }; - +import type { FeasibilityClaimStatus, FeasibilityDuplicateClusterRisk } from "@loopover/engine"; +export type ParsedIdeaFeasibilityArgs = { + claimStatus: FeasibilityClaimStatus; + duplicateClusterRisk: FeasibilityDuplicateClusterRisk; + targetResolvable: boolean; + acceptanceHints: string[]; + json: boolean; +} | { + error: string; +}; +export declare function parseIdeaFeasibilityArgs(args: string[]): ParsedIdeaFeasibilityArgs; export type RunIdeaFeasibilityCliOptions = AssessIdeaFeasibilityOptions; - -export function parseIdeaFeasibilityArgs(args: string[]): ParsedIdeaFeasibilityArgs; - -export function runIdeaFeasibilityCli(args: string[], options?: RunIdeaFeasibilityCliOptions): number; +export declare function runIdeaFeasibilityCli(args: string[], options?: RunIdeaFeasibilityCliOptions): number; diff --git a/packages/loopover-miner/lib/idea-feasibility-cli.js b/packages/loopover-miner/lib/idea-feasibility-cli.js index c93896d733..cf18ecb46c 100644 --- a/packages/loopover-miner/lib/idea-feasibility-cli.js +++ b/packages/loopover-miner/lib/idea-feasibility-cli.js @@ -6,89 +6,82 @@ * network, no filesystem — so it never needs the npm-registry update check other subcommands opt into. */ import { assessIdeaFeasibility } from "./idea-feasibility.js"; import { argsWantJson, reportCliFailure } from "./cli-error.js"; - const CLAIM_STATUSES = ["unclaimed", "claimed", "solved", "unknown"]; const DUPLICATE_CLUSTER_RISKS = ["none", "low", "medium", "high"]; - -const IDEA_FEASIBILITY_USAGE = - "Usage: loopover-miner idea-feasibility [--not-resolvable] [--hint ]... [--json]\n" + - ` claimStatus: ${CLAIM_STATUSES.join("|")}\n` + - ` duplicateClusterRisk: ${DUPLICATE_CLUSTER_RISKS.join("|")}\n` + - " --not-resolvable: the idea's target repo does not resolve to a repo the loop can act on (issueStatus=missing)\n" + - " --hint : an objective acceptance signal (repeatable); an idea declaring none is invalid (issueStatus=invalid)"; - +/** Plain `Array.includes` doesn't narrow a `string` argument down to the array's literal element type, so this + * small type-guard wrapper does it explicitly wherever a parsed CLI token needs to become one of these enums. */ +function isOneOf(value, allowed) { + return allowed.includes(value); +} +const IDEA_FEASIBILITY_USAGE = "Usage: loopover-miner idea-feasibility [--not-resolvable] [--hint ]... [--json]\n" + + ` claimStatus: ${CLAIM_STATUSES.join("|")}\n` + + ` duplicateClusterRisk: ${DUPLICATE_CLUSTER_RISKS.join("|")}\n` + + " --not-resolvable: the idea's target repo does not resolve to a repo the loop can act on (issueStatus=missing)\n" + + " --hint : an objective acceptance signal (repeatable); an idea declaring none is invalid (issueStatus=invalid)"; export function parseIdeaFeasibilityArgs(args) { - const options = { json: false, targetResolvable: true, acceptanceHints: [] }; - const positional = []; - - for (let i = 0; i < args.length; i += 1) { - const token = args[i]; - if (token === "--json") { - options.json = true; - continue; + const options = { json: false, targetResolvable: true, acceptanceHints: [] }; + const positional = []; + for (let i = 0; i < args.length; i += 1) { + const token = args[i]; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--not-resolvable") { + options.targetResolvable = false; + continue; + } + if (token === "--hint") { + const value = args[i + 1]; + // A whitespace-only hint is as empty as a missing one (#6766): it declares no testable success signal, so + // it gets the same rejection rather than sailing through as a real objective signal. + if (value === undefined || value.startsWith("-") || value.trim() === "") { + return { error: "--hint requires a value." }; + } + options.acceptanceHints.push(value); + i += 1; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + positional.push(token); } - if (token === "--not-resolvable") { - options.targetResolvable = false; - continue; + if (positional.length !== 2) { + return { error: IDEA_FEASIBILITY_USAGE }; } - if (token === "--hint") { - const value = args[i + 1]; - // A whitespace-only hint is as empty as a missing one (#6766): it declares no testable success signal, so - // it gets the same rejection rather than sailing through as a real objective signal. - if (value === undefined || value.startsWith("-") || value.trim() === "") { - return { error: "--hint requires a value." }; - } - options.acceptanceHints.push(value); - i += 1; - continue; + // positional.length === 2 was just verified above, so this tuple cast is safe. + const [claimStatus, duplicateClusterRisk] = positional; + if (!isOneOf(claimStatus, CLAIM_STATUSES)) { + return { error: `claimStatus must be one of: ${CLAIM_STATUSES.join(", ")}.` }; } - if (token.startsWith("-")) { - return { error: `Unknown option: ${token}` }; + if (!isOneOf(duplicateClusterRisk, DUPLICATE_CLUSTER_RISKS)) { + return { error: `duplicateClusterRisk must be one of: ${DUPLICATE_CLUSTER_RISKS.join(", ")}.` }; } - positional.push(token); - } - - if (positional.length !== 2) { - return { error: IDEA_FEASIBILITY_USAGE }; - } - - const [claimStatus, duplicateClusterRisk] = positional; - if (!CLAIM_STATUSES.includes(claimStatus)) { - return { error: `claimStatus must be one of: ${CLAIM_STATUSES.join(", ")}.` }; - } - if (!DUPLICATE_CLUSTER_RISKS.includes(duplicateClusterRisk)) { - return { error: `duplicateClusterRisk must be one of: ${DUPLICATE_CLUSTER_RISKS.join(", ")}.` }; - } - - return { - claimStatus, - duplicateClusterRisk, - targetResolvable: options.targetResolvable, - acceptanceHints: options.acceptanceHints, - json: options.json, - }; + return { + claimStatus, + duplicateClusterRisk, + targetResolvable: options.targetResolvable, + acceptanceHints: options.acceptanceHints, + json: options.json, + }; } - export function runIdeaFeasibilityCli(args, options = {}) { - const parsed = parseIdeaFeasibilityArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - const assessment = assessIdeaFeasibility( - { acceptanceHints: parsed.acceptanceHints }, - { - targetResolvable: parsed.targetResolvable, - claimStatus: parsed.claimStatus, - duplicateClusterRisk: parsed.duplicateClusterRisk, - }, - options, - ); - - if (parsed.json) { - console.log(JSON.stringify(assessment, null, 2)); - } else { - console.log(`${assessment.disposition}: ${assessment.summary}`); - } - return 0; + const parsed = parseIdeaFeasibilityArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + const assessment = assessIdeaFeasibility({ acceptanceHints: parsed.acceptanceHints }, { + targetResolvable: parsed.targetResolvable, + claimStatus: parsed.claimStatus, + duplicateClusterRisk: parsed.duplicateClusterRisk, + }, options); + if (parsed.json) { + console.log(JSON.stringify(assessment, null, 2)); + } + else { + console.log(`${assessment.disposition}: ${assessment.summary}`); + } + return 0; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaWRlYS1mZWFzaWJpbGl0eS1jbGkuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJpZGVhLWZlYXNpYmlsaXR5LWNsaS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7Ozs7MEdBSzBHO0FBQzFHLE9BQU8sRUFBRSxxQkFBcUIsRUFBRSxNQUFNLHVCQUF1QixDQUFDO0FBRTlELE9BQU8sRUFBRSxZQUFZLEVBQUUsZ0JBQWdCLEVBQUUsTUFBTSxnQkFBZ0IsQ0FBQztBQUdoRSxNQUFNLGNBQWMsR0FBRyxDQUFDLFdBQVcsRUFBRSxTQUFTLEVBQUUsUUFBUSxFQUFFLFNBQVMsQ0FBc0QsQ0FBQztBQUMxSCxNQUFNLHVCQUF1QixHQUFHLENBQUMsTUFBTSxFQUFFLEtBQUssRUFBRSxRQUFRLEVBQUUsTUFBTSxDQUErRCxDQUFDO0FBRWhJO2tIQUNrSDtBQUNsSCxTQUFTLE9BQU8sQ0FBbUIsS0FBYSxFQUFFLE9BQXFCO0lBQ3JFLE9BQVEsT0FBNkIsQ0FBQyxRQUFRLENBQUMsS0FBSyxDQUFDLENBQUM7QUFDeEQsQ0FBQztBQUVELE1BQU0sc0JBQXNCLEdBQzFCLDhIQUE4SDtJQUM5SCxrQkFBa0IsY0FBYyxDQUFDLElBQUksQ0FBQyxHQUFHLENBQUMsSUFBSTtJQUM5QywyQkFBMkIsdUJBQXVCLENBQUMsSUFBSSxDQUFDLEdBQUcsQ0FBQyxJQUFJO0lBQ2hFLG1IQUFtSDtJQUNuSCx1SEFBdUgsQ0FBQztBQVkxSCxNQUFNLFVBQVUsd0JBQXdCLENBQUMsSUFBYztJQUNyRCxNQUFNLE9BQU8sR0FBRyxFQUFFLElBQUksRUFBRSxLQUFLLEVBQUUsZ0JBQWdCLEVBQUUsSUFBSSxFQUFFLGVBQWUsRUFBRSxFQUFjLEVBQUUsQ0FBQztJQUN6RixNQUFNLFVBQVUsR0FBYSxFQUFFLENBQUM7SUFFaEMsS0FBSyxJQUFJLENBQUMsR0FBRyxDQUFDLEVBQUUsQ0FBQyxHQUFHLElBQUksQ0FBQyxNQUFNLEVBQUUsQ0FBQyxJQUFJLENBQUMsRUFBRSxDQUFD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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/idea-feasibility-cli.ts b/packages/loopover-miner/lib/idea-feasibility-cli.ts new file mode 100644 index 0000000000..0a6959afbc --- /dev/null +++ b/packages/loopover-miner/lib/idea-feasibility-cli.ts @@ -0,0 +1,115 @@ +/** `idea-feasibility` CLI command: the freeform-idea counterpart to the metadata `feasibility` CLI + * (feasibility-cli.js, #4270). It runs a freeform Rent-a-Loop idea submission (#4779) through the + * pre-compute feasibility gate (idea-feasibility.js, #5671) so a renter can no longer burn compute on an + * idea that was never going to succeed — the same parse -> execute -> render wrapper the metadata gate uses, + * only the idea's `issueStatus` is DERIVED from its own structure rather than supplied. Purely local — no + * network, no filesystem — so it never needs the npm-registry update check other subcommands opt into. */ +import { assessIdeaFeasibility } from "./idea-feasibility.js"; +import type { AssessIdeaFeasibilityOptions } from "./idea-feasibility.js"; +import { argsWantJson, reportCliFailure } from "./cli-error.js"; +import type { FeasibilityClaimStatus, FeasibilityDuplicateClusterRisk } from "@loopover/engine"; + +const CLAIM_STATUSES = ["unclaimed", "claimed", "solved", "unknown"] as const satisfies readonly FeasibilityClaimStatus[]; +const DUPLICATE_CLUSTER_RISKS = ["none", "low", "medium", "high"] as const satisfies readonly FeasibilityDuplicateClusterRisk[]; + +/** Plain `Array.includes` doesn't narrow a `string` argument down to the array's literal element type, so this + * small type-guard wrapper does it explicitly wherever a parsed CLI token needs to become one of these enums. */ +function isOneOf(value: string, allowed: readonly T[]): value is T { + return (allowed as readonly string[]).includes(value); +} + +const IDEA_FEASIBILITY_USAGE = + "Usage: loopover-miner idea-feasibility [--not-resolvable] [--hint ]... [--json]\n" + + ` claimStatus: ${CLAIM_STATUSES.join("|")}\n` + + ` duplicateClusterRisk: ${DUPLICATE_CLUSTER_RISKS.join("|")}\n` + + " --not-resolvable: the idea's target repo does not resolve to a repo the loop can act on (issueStatus=missing)\n" + + " --hint : an objective acceptance signal (repeatable); an idea declaring none is invalid (issueStatus=invalid)"; + +export type ParsedIdeaFeasibilityArgs = + | { + claimStatus: FeasibilityClaimStatus; + duplicateClusterRisk: FeasibilityDuplicateClusterRisk; + targetResolvable: boolean; + acceptanceHints: string[]; + json: boolean; + } + | { error: string }; + +export function parseIdeaFeasibilityArgs(args: string[]): ParsedIdeaFeasibilityArgs { + const options = { json: false, targetResolvable: true, acceptanceHints: [] as string[] }; + const positional: string[] = []; + + for (let i = 0; i < args.length; i += 1) { + const token = args[i]!; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--not-resolvable") { + options.targetResolvable = false; + continue; + } + if (token === "--hint") { + const value = args[i + 1]; + // A whitespace-only hint is as empty as a missing one (#6766): it declares no testable success signal, so + // it gets the same rejection rather than sailing through as a real objective signal. + if (value === undefined || value.startsWith("-") || value.trim() === "") { + return { error: "--hint requires a value." }; + } + options.acceptanceHints.push(value); + i += 1; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + positional.push(token); + } + + if (positional.length !== 2) { + return { error: IDEA_FEASIBILITY_USAGE }; + } + + // positional.length === 2 was just verified above, so this tuple cast is safe. + const [claimStatus, duplicateClusterRisk] = positional as [string, string]; + if (!isOneOf(claimStatus, CLAIM_STATUSES)) { + return { error: `claimStatus must be one of: ${CLAIM_STATUSES.join(", ")}.` }; + } + if (!isOneOf(duplicateClusterRisk, DUPLICATE_CLUSTER_RISKS)) { + return { error: `duplicateClusterRisk must be one of: ${DUPLICATE_CLUSTER_RISKS.join(", ")}.` }; + } + + return { + claimStatus, + duplicateClusterRisk, + targetResolvable: options.targetResolvable, + acceptanceHints: options.acceptanceHints, + json: options.json, + }; +} + +export type RunIdeaFeasibilityCliOptions = AssessIdeaFeasibilityOptions; + +export function runIdeaFeasibilityCli(args: string[], options: RunIdeaFeasibilityCliOptions = {}): number { + const parsed = parseIdeaFeasibilityArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + const assessment = assessIdeaFeasibility( + { acceptanceHints: parsed.acceptanceHints }, + { + targetResolvable: parsed.targetResolvable, + claimStatus: parsed.claimStatus, + duplicateClusterRisk: parsed.duplicateClusterRisk, + }, + options, + ); + + if (parsed.json) { + console.log(JSON.stringify(assessment, null, 2)); + } else { + console.log(`${assessment.disposition}: ${assessment.summary}`); + } + return 0; +} diff --git a/packages/loopover-miner/lib/metrics-cli.d.ts b/packages/loopover-miner/lib/metrics-cli.d.ts index ada117cd75..31bb34d447 100644 --- a/packages/loopover-miner/lib/metrics-cli.d.ts +++ b/packages/loopover-miner/lib/metrics-cli.d.ts @@ -1,9 +1,12 @@ import type { MinerPredictionMetricRow } from "@loopover/engine"; import type { PredictionLedger } from "./prediction-ledger.js"; - -export function collectPredictionMetricRows(ledger: PredictionLedger): MinerPredictionMetricRow[]; - -export function runMetrics( - args: string[], - options?: { initPredictionLedger?: () => PredictionLedger }, -): number; +/** + * Project prediction-ledger rows onto the engine renderer's metric-row shape -- the predicted `conclusion` only. + * The realized-outcome pairing (`correct`) is intentionally left unset: the miner has no outcome-join yet, so the + * correct/incorrect counters stay zero and only `predictions_total{conclusion}` moves -- exactly how the renderer + * is designed to degrade before outcome-pairing exists (see its header comment). + */ +export declare function collectPredictionMetricRows(ledger: PredictionLedger): MinerPredictionMetricRow[]; +export declare function runMetrics(args: string[], options?: { + initPredictionLedger?: () => PredictionLedger; +}): number; diff --git a/packages/loopover-miner/lib/metrics-cli.js b/packages/loopover-miner/lib/metrics-cli.js index cbfc1154b9..0aec1886ac 100644 --- a/packages/loopover-miner/lib/metrics-cli.js +++ b/packages/loopover-miner/lib/metrics-cli.js @@ -1,15 +1,12 @@ import { renderMinerPredictionMetrics } from "@loopover/engine"; import { initPredictionLedger } from "./prediction-ledger.js"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; - // `metrics` (#4838): render the miner's prediction-calibration counters as Prometheus text-exposition to stdout, // for a scrape wrapper or cron redirect. The counters are produced by the engine's already-built // renderMinerPredictionMetrics (packages/loopover-engine/src/miner-prediction-metrics.ts) -- this command only // reads the local prediction ledger and feeds it in, never touching the renderer itself. Strictly local + offline: // no network, no writes. - const METRICS_USAGE = "Usage: loopover-miner metrics"; - /** * Project prediction-ledger rows onto the engine renderer's metric-row shape -- the predicted `conclusion` only. * The realized-outcome pairing (`correct`) is intentionally left unset: the miner has no outcome-join yet, so the @@ -17,34 +14,35 @@ const METRICS_USAGE = "Usage: loopover-miner metrics"; * is designed to degrade before outcome-pairing exists (see its header comment). */ export function collectPredictionMetricRows(ledger) { - return ledger.readPredictions().map((entry) => ({ conclusion: entry.conclusion })); + return ledger.readPredictions().map((entry) => ({ conclusion: entry.conclusion })); } - // Open the local prediction ledger (or a test-injected one) for the duration of `run`, closing it only when we // opened it -- an injected ledger is owned by the caller. Mirrors event-ledger-cli.js's withEventLedger. function withPredictionLedger(options, run) { - const ownsLedger = options.initPredictionLedger === undefined; - const ledger = (options.initPredictionLedger ?? initPredictionLedger)(); - try { - return run(ledger); - } finally { - if (ownsLedger) ledger.close(); - } + const ownsLedger = options.initPredictionLedger === undefined; + const ledger = (options.initPredictionLedger ?? initPredictionLedger)(); + try { + return run(ledger); + } + finally { + if (ownsLedger) + ledger.close(); + } } - export function runMetrics(args, options = {}) { - if (args.length > 0) { - return reportCliFailure(argsWantJson(args), METRICS_USAGE); - } - - try { - return withPredictionLedger(options, (ledger) => { - // renderMinerPredictionMetrics returns a newline-terminated document; console.log re-adds the terminator, so - // trim it to emit exactly one trailing newline. - console.log(renderMinerPredictionMetrics(collectPredictionMetricRows(ledger)).trimEnd()); - return 0; - }); - } catch (error) { - return reportCliFailure(argsWantJson(args), describeCliError(error)); - } + if (args.length > 0) { + return reportCliFailure(argsWantJson(args), METRICS_USAGE); + } + try { + return withPredictionLedger(options, (ledger) => { + // renderMinerPredictionMetrics returns a newline-terminated document; console.log re-adds the terminator, so + // trim it to emit exactly one trailing newline. + console.log(renderMinerPredictionMetrics(collectPredictionMetricRows(ledger)).trimEnd()); + return 0; + }); + } + catch (error) { + return reportCliFailure(argsWantJson(args), describeCliError(error)); + } } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibWV0cmljcy1jbGkuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJtZXRyaWNzLWNsaS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEVBQUUsNEJBQTRCLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUVoRSxPQUFPLEVBQUUsb0JBQW9CLEVBQUUsTUFBTSx3QkFBd0IsQ0FBQztBQUU5RCxPQUFPLEVBQUUsWUFBWSxFQUFFLGdCQUFnQixFQUFFLGdCQUFnQixFQUFFLE1BQU0sZ0JBQWdCLENBQUM7QUFFbEYsaUhBQWlIO0FBQ2pILGlHQUFpRztBQUNqRywrR0FBK0c7QUFDL0csbUhBQW1IO0FBQ25ILHlCQUF5QjtBQUV6QixNQUFNLGFBQWEsR0FBRywrQkFBK0IsQ0FBQztBQUV0RDs7Ozs7R0FLRztBQUNILE1BQU0sVUFBVSwyQkFBMkIsQ0FBQyxNQUF3QjtJQUNsRSxPQUFPLE1BQU0sQ0FBQyxlQUFlLEVBQUUsQ0FBQyxHQUFHLENBQUMsQ0FBQyxLQUFLLEVBQUUsRUFBRSxDQUFDLENBQUMsRUFBRSxVQUFVLEVBQUUsS0FBSyxDQUFDLFVBQVUsRUFBRSxDQUFDLENBQUMsQ0FBQztBQUNyRixDQUFDO0FBRUQsK0dBQStHO0FBQy9HLHlHQUF5RztBQUN6RyxTQUFTLG9CQUFvQixDQUMzQixPQUEwRCxFQUMxRCxHQUFvQztJQUVwQyxNQUFNLFVBQVUsR0FBRyxPQUFPLENBQUMsb0JBQW9CLEtBQUssU0FBUyxDQUFDO0lBQzlELE1BQU0sTUFBTSxHQUFHLENBQUMsT0FBTyxDQUFDLG9CQUFvQixJQUFJLG9CQUFvQixDQUFDLEVBQUUsQ0FBQztJQUN4RSxJQUFJLENBQUM7UUFDSCxPQUFPLEdBQUcsQ0FBQyxNQUFNLENBQUMsQ0FBQztJQUNyQixDQUFDO1lBQVMsQ0FBQztRQUNULElBQUksVUFBVTtZQUFFLE1BQU0sQ0FBQyxLQUFLLEVBQUUsQ0FBQztJQUNqQyxDQUFDO0FBQ0gsQ0FBQztBQUVELE1BQU0sVUFBVSxVQUFVLENBQUMsSUFBYyxFQUFFLFVBQTZELEVBQUU7SUFDeEcsSUFBSSxJQUFJLENBQUMsTUFBTSxHQUFHLENBQUMsRUFBRSxDQUFDO1FBQ3BCLE9BQU8sZ0JBQWdCLENBQUMsWUFBWSxDQUFDLElBQUksQ0FBQyxFQUFFLGFBQWEsQ0FBQyxDQUFDO0lBQzdELENBQUM7SUFFRCxJQUFJLENBQUM7UUFDSCxPQUFPLG9CQUFvQixDQUFDLE9BQU8sRUFBRSxDQUFDLE1BQU0sRUFBRSxFQUFFO1lBQzlDLDZHQUE2RztZQUM3RyxnREFBZ0Q7WUFDaEQsT0FBTyxDQUFDLEdBQUcsQ0FBQyw0QkFBNEIsQ0FBQywyQkFBMkIsQ0FBQyxNQUFNLENBQUMsQ0FBQyxDQUFDLE9BQU8sRUFBRSxDQUFDLENBQUM7WUFDekYsT0FBTyxDQUFDLENBQUM7UUFDWCxDQUFDLENBQUMsQ0FBQztJQUNMLENBQUM7SUFBQyxPQUFPLEtBQUssRUFBRSxDQUFDO1FBQ2YsT0FBTyxnQkFBZ0IsQ0FBQyxZQUFZLENBQUMsSUFBSSxDQUFDLEVBQUUsZ0JBQWdCLENBQUMsS0FBSyxDQUFDLENBQUMsQ0FBQztJQUN2RSxDQUFDO0FBQ0gsQ0FBQyJ9 \ No newline at end of file diff --git a/packages/loopover-miner/lib/metrics-cli.ts b/packages/loopover-miner/lib/metrics-cli.ts new file mode 100644 index 0000000000..a94bb75bc6 --- /dev/null +++ b/packages/loopover-miner/lib/metrics-cli.ts @@ -0,0 +1,55 @@ +import { renderMinerPredictionMetrics } from "@loopover/engine"; +import type { MinerPredictionMetricRow } from "@loopover/engine"; +import { initPredictionLedger } from "./prediction-ledger.js"; +import type { PredictionLedger } from "./prediction-ledger.js"; +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; + +// `metrics` (#4838): render the miner's prediction-calibration counters as Prometheus text-exposition to stdout, +// for a scrape wrapper or cron redirect. The counters are produced by the engine's already-built +// renderMinerPredictionMetrics (packages/loopover-engine/src/miner-prediction-metrics.ts) -- this command only +// reads the local prediction ledger and feeds it in, never touching the renderer itself. Strictly local + offline: +// no network, no writes. + +const METRICS_USAGE = "Usage: loopover-miner metrics"; + +/** + * Project prediction-ledger rows onto the engine renderer's metric-row shape -- the predicted `conclusion` only. + * The realized-outcome pairing (`correct`) is intentionally left unset: the miner has no outcome-join yet, so the + * correct/incorrect counters stay zero and only `predictions_total{conclusion}` moves -- exactly how the renderer + * is designed to degrade before outcome-pairing exists (see its header comment). + */ +export function collectPredictionMetricRows(ledger: PredictionLedger): MinerPredictionMetricRow[] { + return ledger.readPredictions().map((entry) => ({ conclusion: entry.conclusion })); +} + +// Open the local prediction ledger (or a test-injected one) for the duration of `run`, closing it only when we +// opened it -- an injected ledger is owned by the caller. Mirrors event-ledger-cli.js's withEventLedger. +function withPredictionLedger( + options: { initPredictionLedger?: () => PredictionLedger }, + run: (ledger: PredictionLedger) => T, +): T { + const ownsLedger = options.initPredictionLedger === undefined; + const ledger = (options.initPredictionLedger ?? initPredictionLedger)(); + try { + return run(ledger); + } finally { + if (ownsLedger) ledger.close(); + } +} + +export function runMetrics(args: string[], options: { initPredictionLedger?: () => PredictionLedger } = {}): number { + if (args.length > 0) { + return reportCliFailure(argsWantJson(args), METRICS_USAGE); + } + + try { + return withPredictionLedger(options, (ledger) => { + // renderMinerPredictionMetrics returns a newline-terminated document; console.log re-adds the terminator, so + // trim it to emit exactly one trailing newline. + console.log(renderMinerPredictionMetrics(collectPredictionMetricRows(ledger)).trimEnd()); + return 0; + }); + } catch (error) { + return reportCliFailure(argsWantJson(args), describeCliError(error)); + } +} diff --git a/packages/loopover-miner/lib/migrate-cli.d.ts b/packages/loopover-miner/lib/migrate-cli.d.ts index aed01b9684..03b2de58f6 100644 --- a/packages/loopover-miner/lib/migrate-cli.d.ts +++ b/packages/loopover-miner/lib/migrate-cli.d.ts @@ -1,24 +1,21 @@ export type MigrateStatus = "skipped" | "up-to-date" | "migrated" | "failed"; - export type MigrateResult = { - name: string; - dbPath: string; - ok: boolean; - status: MigrateStatus; - detail: string; - versionBefore: number | null; - versionAfter: number | null; + name: string; + dbPath: string; + ok: boolean; + status: MigrateStatus; + detail: string; + versionBefore: number | null; + versionAfter: number | null; }; - export type MigrateStoreDescriptor = { - name: string; - resolveDbPath: (env?: Record) => string; - open: (dbPath: string) => { close: () => void }; + name: string; + resolveDbPath: (env?: Record) => string; + open: (dbPath: string) => { + close: () => void; + }; }; - -export function runMigrateChecks( - env?: Record, - stores?: MigrateStoreDescriptor[], -): MigrateResult[]; - -export function runMigrate(args?: string[], env?: Record): number; +/** `stores` is injectable so tests can exercise a store descriptor's failure paths (e.g. a non-Error throw) + * without depending on real node:sqlite error shapes; defaults to the real seven-store list. */ +export declare function runMigrateChecks(env?: Record, stores?: MigrateStoreDescriptor[]): MigrateResult[]; +export declare function runMigrate(args?: string[], env?: Record): number; diff --git a/packages/loopover-miner/lib/migrate-cli.js b/packages/loopover-miner/lib/migrate-cli.js index 623edd6823..96b189e4a1 100644 --- a/packages/loopover-miner/lib/migrate-cli.js +++ b/packages/loopover-miner/lib/migrate-cli.js @@ -25,123 +25,137 @@ import { openWorktreeAllocator, resolveWorktreeAllocatorDbPath } from "./worktre import { initContributionProfileCache, resolveContributionProfileCacheDbPath } from "./contribution-profile-cache.js"; import { initPolicyVerdictCacheStore, resolvePolicyVerdictCacheDbPath } from "./policy-verdict-cache.js"; import { initPolicyDocCacheStore, resolvePolicyDocCacheDbPath } from "./policy-doc-cache.js"; - const MIGRATE_USAGE = "Usage: loopover-miner migrate [--json]"; - const STORES = [ - { name: "event-ledger", resolveDbPath: resolveEventLedgerDbPath, open: initEventLedger }, - { name: "governor-ledger", resolveDbPath: resolveGovernorLedgerDbPath, open: initGovernorLedger }, - { name: "prediction-ledger", resolveDbPath: resolvePredictionLedgerDbPath, open: initPredictionLedger }, - { name: "portfolio-queue", resolveDbPath: resolvePortfolioQueueDbPath, open: initPortfolioQueueStore }, - { name: "claim-ledger", resolveDbPath: resolveClaimLedgerDbPath, open: openClaimLedger }, - { name: "run-state", resolveDbPath: resolveRunStateDbPath, open: initRunStateStore }, - { name: "plan-store", resolveDbPath: resolvePlanStoreDbPath, open: openPlanStore }, - { name: "governor-state", resolveDbPath: resolveGovernorStateDbPath, open: openGovernorState }, - { name: "attempt-log", resolveDbPath: resolveAttemptLogDbPath, open: initAttemptLog }, - { name: "replay-snapshot", resolveDbPath: resolveReplaySnapshotDbPath, open: openReplaySnapshotStore }, - { name: "worktree-allocator", resolveDbPath: resolveWorktreeAllocatorDbPath, open: (dbPath) => openWorktreeAllocator({ dbPath }) }, - { name: "contribution-profile", resolveDbPath: resolveContributionProfileCacheDbPath, open: initContributionProfileCache }, - { name: "policy-verdict-cache", resolveDbPath: resolvePolicyVerdictCacheDbPath, open: initPolicyVerdictCacheStore }, - { name: "policy-doc-cache", resolveDbPath: resolvePolicyDocCacheDbPath, open: initPolicyDocCacheStore }, + { name: "event-ledger", resolveDbPath: resolveEventLedgerDbPath, open: initEventLedger }, + { name: "governor-ledger", resolveDbPath: resolveGovernorLedgerDbPath, open: initGovernorLedger }, + { name: "prediction-ledger", resolveDbPath: resolvePredictionLedgerDbPath, open: initPredictionLedger }, + { name: "portfolio-queue", resolveDbPath: resolvePortfolioQueueDbPath, open: initPortfolioQueueStore }, + { name: "claim-ledger", resolveDbPath: resolveClaimLedgerDbPath, open: openClaimLedger }, + { name: "run-state", resolveDbPath: resolveRunStateDbPath, open: initRunStateStore }, + { name: "plan-store", resolveDbPath: resolvePlanStoreDbPath, open: openPlanStore }, + { name: "governor-state", resolveDbPath: resolveGovernorStateDbPath, open: openGovernorState }, + { name: "attempt-log", resolveDbPath: resolveAttemptLogDbPath, open: initAttemptLog }, + { + name: "replay-snapshot", + // resolveReplaySnapshotDbPath's own (not-yet-converted) .d.ts types `env` as `NodeJS.ProcessEnv`, unlike + // every sibling resolver here (`Record`) -- a pre-existing inconsistency, not + // introduced by this batch. process.env genuinely satisfies both shapes at runtime, so this cast is safe. + resolveDbPath: resolveReplaySnapshotDbPath, + open: openReplaySnapshotStore, + }, + { + name: "worktree-allocator", + resolveDbPath: resolveWorktreeAllocatorDbPath, + open: (dbPath) => openWorktreeAllocator({ dbPath }), + }, + { + name: "contribution-profile", + resolveDbPath: resolveContributionProfileCacheDbPath, + open: initContributionProfileCache, + }, + { name: "policy-verdict-cache", resolveDbPath: resolvePolicyVerdictCacheDbPath, open: initPolicyVerdictCacheStore }, + { name: "policy-doc-cache", resolveDbPath: resolvePolicyDocCacheDbPath, open: initPolicyDocCacheStore }, ]; - /** Read a store file's stamped schema version without ever creating it -- matches checkStoreIntegrity's * "not created yet" convention: an absent file has nothing to report a version for. */ function peekSchemaVersion(dbPath) { - const db = new DatabaseSync(dbPath, { readOnly: true }); - try { - return readSchemaVersion(db); - } finally { - db.close(); - } + const db = new DatabaseSync(dbPath, { readOnly: true }); + try { + return readSchemaVersion(db); + } + finally { + db.close(); + } } - /** * Bring one store's EXISTING on-disk schema up to date. Never throws: a store that fails to open/migrate is * reported as a failed result so one bad store cannot abort the whole sweep, matching doctor's per-store * isolation. A store file that does not exist yet is reported as a clean skip (nothing to migrate), never * created as a side effect of running this command. - * @returns {{ name: string, dbPath: string, ok: boolean, status: "skipped"|"up-to-date"|"migrated"|"failed", detail: string, versionBefore: number|null, versionAfter: number|null }} */ function migrateStore({ name, resolveDbPath, open }, env) { - const dbPath = resolveDbPath(env); - if (!existsSync(dbPath)) { - return { - name, - dbPath, - ok: true, - status: "skipped", - detail: "not created yet", - versionBefore: null, - versionAfter: null, - }; - } - // versionBefore is read INSIDE the same try as the migration itself: a corrupted file can throw on this very - // first read (a store that can't even be opened has no readable version either), and that must still surface - // as one failed store result rather than an uncaught exception aborting the whole sweep. - let versionBefore = null; - try { - versionBefore = peekSchemaVersion(dbPath); - const store = open(dbPath); - store.close(); - const versionAfter = peekSchemaVersion(dbPath); - return { - name, - dbPath, - ok: true, - status: versionAfter > versionBefore ? "migrated" : "up-to-date", - detail: `v${versionBefore} -> v${versionAfter}`, - versionBefore, - versionAfter, - }; - } catch (error) { - // applySchemaMigrations applies AND stamps each migration in its OWN transaction, so a failure part-way - // through a multi-migration sequence leaves the file at the LAST fully-applied version -- genuinely AHEAD - // of versionBefore. Re-read the real on-disk version instead of reporting a misleading "nothing changed". - // Guarded by its own try: the failure may itself be an unreadable/corrupt file (the same reason - // versionBefore can still be null here), in which case the pre-failure reading is all we can honestly - // report. - let versionAfter = versionBefore; + const dbPath = resolveDbPath(env); + if (!existsSync(dbPath)) { + return { + name, + dbPath, + ok: true, + status: "skipped", + detail: "not created yet", + versionBefore: null, + versionAfter: null, + }; + } + // versionBefore is read INSIDE the same try as the migration itself: a corrupted file can throw on this very + // first read (a store that can't even be opened has no readable version either), and that must still surface + // as one failed store result rather than an uncaught exception aborting the whole sweep. + let versionBefore = null; try { - versionAfter = peekSchemaVersion(dbPath); - } catch { - versionAfter = versionBefore; + versionBefore = peekSchemaVersion(dbPath); + const store = open(dbPath); + store.close(); + const versionAfter = peekSchemaVersion(dbPath); + return { + name, + dbPath, + ok: true, + status: versionAfter > versionBefore ? "migrated" : "up-to-date", + detail: `v${versionBefore} -> v${versionAfter}`, + versionBefore, + versionAfter, + }; + } + catch (error) { + // applySchemaMigrations applies AND stamps each migration in its OWN transaction, so a failure part-way + // through a multi-migration sequence leaves the file at the LAST fully-applied version -- genuinely AHEAD + // of versionBefore. Re-read the real on-disk version instead of reporting a misleading "nothing changed". + // Guarded by its own try: the failure may itself be an unreadable/corrupt file (the same reason + // versionBefore can still be null here), in which case the pre-failure reading is all we can honestly + // report. + let versionAfter = versionBefore; + try { + versionAfter = peekSchemaVersion(dbPath); + } + catch { + versionAfter = versionBefore; + } + return { + name, + dbPath, + ok: false, + status: "failed", + detail: error instanceof Error ? error.message : String(error), + versionBefore, + versionAfter, + }; } - return { - name, - dbPath, - ok: false, - status: "failed", - detail: error instanceof Error ? error.message : String(error), - versionBefore, - versionAfter, - }; - } } - /** `stores` is injectable so tests can exercise a store descriptor's failure paths (e.g. a non-Error throw) * without depending on real node:sqlite error shapes; defaults to the real seven-store list. */ export function runMigrateChecks(env = process.env, stores = STORES) { - return stores.map((store) => migrateStore(store, env)); + return stores.map((store) => migrateStore(store, env)); } - export function runMigrate(args = [], env = process.env) { - const json = argsWantJson(args); - // Validated BEFORE any store is opened: a typo'd flag must fail fast rather than silently run a full - // migration sweep that ignored what the operator actually typed (#5917). `--json` is the only flag this - // command takes, so anything else -- an unrecognized flag or a stray positional -- is rejected. - const unknown = args.find((token) => token !== "--json"); - if (unknown !== undefined) return reportCliFailure(json, `Unknown option: ${unknown}. ${MIGRATE_USAGE}`, 2); - - const results = runMigrateChecks(env); - const failed = results.filter((result) => !result.ok); - if (json) { - console.log(JSON.stringify({ ok: failed.length === 0, stores: results }, null, 2)); - } else { - for (const result of results) { - console.log(`${result.ok ? result.status.padEnd(10) : "FAIL "} ${result.name}: ${result.detail}`); + const json = argsWantJson(args); + // Validated BEFORE any store is opened: a typo'd flag must fail fast rather than silently run a full + // migration sweep that ignored what the operator actually typed (#5917). `--json` is the only flag this + // command takes, so anything else -- an unrecognized flag or a stray positional -- is rejected. + const unknown = args.find((token) => token !== "--json"); + if (unknown !== undefined) + return reportCliFailure(json, `Unknown option: ${unknown}. ${MIGRATE_USAGE}`, 2); + const results = runMigrateChecks(env); + const failed = results.filter((result) => !result.ok); + if (json) { + console.log(JSON.stringify({ ok: failed.length === 0, stores: results }, null, 2)); + } + else { + for (const result of results) { + console.log(`${result.ok ? result.status.padEnd(10) : "FAIL "} ${result.name}: ${result.detail}`); + } + if (failed.length > 0) + console.error(`migrate: ${failed.length} store(s) failed`); } - if (failed.length > 0) console.error(`migrate: ${failed.length} store(s) failed`); - } - return failed.length === 0 ? 0 : 1; + return failed.length === 0 ? 0 : 1; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibWlncmF0ZS1jbGkuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJtaWdyYXRlLWNsaS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSw2R0FBNkc7QUFDN0csNkdBQTZHO0FBQzdHLDhHQUE4RztBQUM5RywrR0FBK0c7QUFDL0cseUdBQXlHO0FBQ3pHLDJHQUEyRztBQUMzRyxnSEFBZ0g7QUFDaEgsMkdBQTJHO0FBQzNHLDhHQUE4RztBQUM5RyxPQUFPLEVBQUUsVUFBVSxFQUFFLE1BQU0sU0FBUyxDQUFDO0FBQ3JDLE9BQU8sRUFBRSxZQUFZLEVBQUUsTUFBTSxhQUFhLENBQUM7QUFDM0MsT0FBTyxFQUFFLGlCQUFpQixFQUFFLE1BQU0scUJBQXFCLENBQUM7QUFDeEQsT0FBTyxFQUFFLFlBQVksRUFBRSxnQkFBZ0IsRUFBRSxNQUFNLGdCQUFnQixDQUFDO0FBQ2hFLE9BQU8sRUFBRSxlQUFlLEVBQUUsd0JBQXdCLEVBQUUsTUFBTSxtQkFBbUIsQ0FBQztBQUM5RSxPQUFPLEVBQUUsZUFBZSxFQUFFLHdCQUF3QixFQUFFLE1BQU0sbUJBQW1CLENBQUM7QUFDOUUsT0FBTyxFQUFFLGtCQUFrQixFQUFFLDJCQUEyQixFQUFFLE1BQU0sc0JBQXNCLENBQUM7QUFDdkYsT0FBTyxFQUFFLG9CQUFvQixFQUFFLDZCQUE2QixFQUFFLE1BQU0sd0JBQXdCLENBQUM7QUFDN0YsT0FBTyxFQUFFLHVCQUF1QixFQUFFLDJCQUEyQixFQUFFLE1BQU0sc0JBQXNCLENBQUM7QUFDNUYsT0FBTyxFQUFFLGlCQUFpQixFQUFFLHFCQUFxQixFQUFFLE1BQU0sZ0JBQWdCLENBQUM7QUFDMUUsT0FBTyxFQUFFLGFBQWEsRUFBRSxzQkFBc0IsRUFBRSxNQUFNLGlCQUFpQixDQUFDO0FBQ3hFLE9BQU8sRUFBRSxpQkFBaUIsRUFBRSwwQkFBMEIsRUFBRSxNQUFNLHFCQUFxQixDQUFDO0FBQ3BGLE9BQU8sRUFBRSxjQUFjLEVBQUUsdUJBQXVCLEVBQUUsTUFBTSxrQkFB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/migrate-cli.ts b/packages/loopover-miner/lib/migrate-cli.ts new file mode 100644 index 0000000000..736bcfdc40 --- /dev/null +++ b/packages/loopover-miner/lib/migrate-cli.ts @@ -0,0 +1,182 @@ +// Proactive schema-migration runner for the miner's local SQLite stores (#4871). Every store already applies +// its own pending migrations (schema-version.js's applySchemaMigrations) as a side effect of being opened by +// whatever command happens to touch it first -- this command instead lets an operator PROACTIVELY bring every +// known store's EXISTING on-disk file up to date in one pass (e.g. right after upgrading, or before starting a +// fleet), without needing to guess which command happens to touch which store first. Mirrors status.js's +// storeIntegrityChecks [name, resolve*DbPath(env)] store list exactly (same eleven stores `doctor` already +// covers, #6768), but actually OPENS each store (rather than a read-only integrity probe) so its real open/init +// function's migration path runs for real. A store file that does not exist yet is skipped, not created -- +// "migrate" brings existing files up to date; it is not another way to bootstrap fresh state (that's `init`). +import { existsSync } from "node:fs"; +import { DatabaseSync } from "node:sqlite"; +import { readSchemaVersion } from "./schema-version.js"; +import { argsWantJson, reportCliFailure } from "./cli-error.js"; +import { openClaimLedger, resolveClaimLedgerDbPath } from "./claim-ledger.js"; +import { initEventLedger, resolveEventLedgerDbPath } from "./event-ledger.js"; +import { initGovernorLedger, resolveGovernorLedgerDbPath } from "./governor-ledger.js"; +import { initPredictionLedger, resolvePredictionLedgerDbPath } from "./prediction-ledger.js"; +import { initPortfolioQueueStore, resolvePortfolioQueueDbPath } from "./portfolio-queue.js"; +import { initRunStateStore, resolveRunStateDbPath } from "./run-state.js"; +import { openPlanStore, resolvePlanStoreDbPath } from "./plan-store.js"; +import { openGovernorState, resolveGovernorStateDbPath } from "./governor-state.js"; +import { initAttemptLog, resolveAttemptLogDbPath } from "./attempt-log.js"; +import { openReplaySnapshotStore, resolveReplaySnapshotDbPath } from "./replay-snapshot.js"; +import { openWorktreeAllocator, resolveWorktreeAllocatorDbPath } from "./worktree-allocator.js"; +import { initContributionProfileCache, resolveContributionProfileCacheDbPath } from "./contribution-profile-cache.js"; +import { initPolicyVerdictCacheStore, resolvePolicyVerdictCacheDbPath } from "./policy-verdict-cache.js"; +import { initPolicyDocCacheStore, resolvePolicyDocCacheDbPath } from "./policy-doc-cache.js"; + +const MIGRATE_USAGE = "Usage: loopover-miner migrate [--json]"; + +export type MigrateStatus = "skipped" | "up-to-date" | "migrated" | "failed"; + +export type MigrateResult = { + name: string; + dbPath: string; + ok: boolean; + status: MigrateStatus; + detail: string; + versionBefore: number | null; + versionAfter: number | null; +}; + +export type MigrateStoreDescriptor = { + name: string; + resolveDbPath: (env?: Record) => string; + open: (dbPath: string) => { close: () => void }; +}; + +const STORES: MigrateStoreDescriptor[] = [ + { name: "event-ledger", resolveDbPath: resolveEventLedgerDbPath, open: initEventLedger }, + { name: "governor-ledger", resolveDbPath: resolveGovernorLedgerDbPath, open: initGovernorLedger }, + { name: "prediction-ledger", resolveDbPath: resolvePredictionLedgerDbPath, open: initPredictionLedger }, + { name: "portfolio-queue", resolveDbPath: resolvePortfolioQueueDbPath, open: initPortfolioQueueStore }, + { name: "claim-ledger", resolveDbPath: resolveClaimLedgerDbPath, open: openClaimLedger }, + { name: "run-state", resolveDbPath: resolveRunStateDbPath, open: initRunStateStore }, + { name: "plan-store", resolveDbPath: resolvePlanStoreDbPath, open: openPlanStore }, + { name: "governor-state", resolveDbPath: resolveGovernorStateDbPath, open: openGovernorState }, + { name: "attempt-log", resolveDbPath: resolveAttemptLogDbPath, open: initAttemptLog }, + { + name: "replay-snapshot", + // resolveReplaySnapshotDbPath's own (not-yet-converted) .d.ts types `env` as `NodeJS.ProcessEnv`, unlike + // every sibling resolver here (`Record`) -- a pre-existing inconsistency, not + // introduced by this batch. process.env genuinely satisfies both shapes at runtime, so this cast is safe. + resolveDbPath: resolveReplaySnapshotDbPath as (env?: Record) => string, + open: openReplaySnapshotStore, + }, + { + name: "worktree-allocator", + resolveDbPath: resolveWorktreeAllocatorDbPath, + open: (dbPath: string) => openWorktreeAllocator({ dbPath }), + }, + { + name: "contribution-profile", + resolveDbPath: resolveContributionProfileCacheDbPath, + open: initContributionProfileCache, + }, + { name: "policy-verdict-cache", resolveDbPath: resolvePolicyVerdictCacheDbPath, open: initPolicyVerdictCacheStore }, + { name: "policy-doc-cache", resolveDbPath: resolvePolicyDocCacheDbPath, open: initPolicyDocCacheStore }, +]; + +/** Read a store file's stamped schema version without ever creating it -- matches checkStoreIntegrity's + * "not created yet" convention: an absent file has nothing to report a version for. */ +function peekSchemaVersion(dbPath: string): number { + const db = new DatabaseSync(dbPath, { readOnly: true }); + try { + return readSchemaVersion(db); + } finally { + db.close(); + } +} + +/** + * Bring one store's EXISTING on-disk schema up to date. Never throws: a store that fails to open/migrate is + * reported as a failed result so one bad store cannot abort the whole sweep, matching doctor's per-store + * isolation. A store file that does not exist yet is reported as a clean skip (nothing to migrate), never + * created as a side effect of running this command. + */ +function migrateStore({ name, resolveDbPath, open }: MigrateStoreDescriptor, env?: Record): MigrateResult { + const dbPath = resolveDbPath(env); + if (!existsSync(dbPath)) { + return { + name, + dbPath, + ok: true, + status: "skipped", + detail: "not created yet", + versionBefore: null, + versionAfter: null, + }; + } + // versionBefore is read INSIDE the same try as the migration itself: a corrupted file can throw on this very + // first read (a store that can't even be opened has no readable version either), and that must still surface + // as one failed store result rather than an uncaught exception aborting the whole sweep. + let versionBefore: number | null = null; + try { + versionBefore = peekSchemaVersion(dbPath); + const store = open(dbPath); + store.close(); + const versionAfter = peekSchemaVersion(dbPath); + return { + name, + dbPath, + ok: true, + status: versionAfter > versionBefore ? "migrated" : "up-to-date", + detail: `v${versionBefore} -> v${versionAfter}`, + versionBefore, + versionAfter, + }; + } catch (error) { + // applySchemaMigrations applies AND stamps each migration in its OWN transaction, so a failure part-way + // through a multi-migration sequence leaves the file at the LAST fully-applied version -- genuinely AHEAD + // of versionBefore. Re-read the real on-disk version instead of reporting a misleading "nothing changed". + // Guarded by its own try: the failure may itself be an unreadable/corrupt file (the same reason + // versionBefore can still be null here), in which case the pre-failure reading is all we can honestly + // report. + let versionAfter = versionBefore; + try { + versionAfter = peekSchemaVersion(dbPath); + } catch { + versionAfter = versionBefore; + } + return { + name, + dbPath, + ok: false, + status: "failed", + detail: error instanceof Error ? error.message : String(error), + versionBefore, + versionAfter, + }; + } +} + +/** `stores` is injectable so tests can exercise a store descriptor's failure paths (e.g. a non-Error throw) + * without depending on real node:sqlite error shapes; defaults to the real seven-store list. */ +export function runMigrateChecks( + env: Record = process.env, + stores: MigrateStoreDescriptor[] = STORES, +): MigrateResult[] { + return stores.map((store) => migrateStore(store, env)); +} + +export function runMigrate(args: string[] = [], env: Record = process.env): number { + const json = argsWantJson(args); + // Validated BEFORE any store is opened: a typo'd flag must fail fast rather than silently run a full + // migration sweep that ignored what the operator actually typed (#5917). `--json` is the only flag this + // command takes, so anything else -- an unrecognized flag or a stray positional -- is rejected. + const unknown = args.find((token) => token !== "--json"); + if (unknown !== undefined) return reportCliFailure(json, `Unknown option: ${unknown}. ${MIGRATE_USAGE}`, 2); + + const results = runMigrateChecks(env); + const failed = results.filter((result) => !result.ok); + if (json) { + console.log(JSON.stringify({ ok: failed.length === 0, stores: results }, null, 2)); + } else { + for (const result of results) { + console.log(`${result.ok ? result.status.padEnd(10) : "FAIL "} ${result.name}: ${result.detail}`); + } + if (failed.length > 0) console.error(`migrate: ${failed.length} store(s) failed`); + } + return failed.length === 0 ? 0 : 1; +} diff --git a/packages/loopover-miner/lib/tenant-cli.d.ts b/packages/loopover-miner/lib/tenant-cli.d.ts index a59c1dc4a1..158337cc22 100644 --- a/packages/loopover-miner/lib/tenant-cli.d.ts +++ b/packages/loopover-miner/lib/tenant-cli.d.ts @@ -1,34 +1,39 @@ -import type { createTenant, destroyTenant, listTenants, TenantRecord } from "./tenant-client.js"; - -export type ParsedTenantCreateArgs = - | { name: string; json: boolean; product?: string } - | { error: string }; - -export type ParsedTenantNameArgs = { name: string; json: boolean } | { error: string }; - -export type ParsedTenantListArgs = { json: boolean } | { error: string }; - +import { createTenant, destroyTenant, listTenants } from "./tenant-client.js"; +export type ParsedTenantCreateArgs = { + name: string; + json: boolean; + product?: string; +} | { + error: string; +}; +export type ParsedTenantNameArgs = { + name: string; + json: boolean; +} | { + error: string; +}; +export type ParsedTenantListArgs = { + json: boolean; +} | { + error: string; +}; export type RunTenantOptions = { - /** Read for the control-plane opt-in gate -- defaults to `process.env` inside the client. */ - env?: Record; - /** Injected fetch, forwarded to the client; defaults to the real global fetch. */ - fetchImpl?: (url: string, init: RequestInit) => Promise; - /** Injectable client functions so tests drive the CLI without a real control plane. */ - createTenant?: typeof createTenant; - listTenants?: typeof listTenants; - destroyTenant?: typeof destroyTenant; + /** Read for the control-plane opt-in gate -- defaults to `process.env` inside the client. */ + env?: Record; + /** Injected fetch, forwarded to the client; defaults to the real global fetch. */ + fetchImpl?: (url: string, init: RequestInit) => Promise; + /** Injectable client functions so tests drive the CLI without a real control plane. */ + createTenant?: typeof createTenant; + listTenants?: typeof listTenants; + destroyTenant?: typeof destroyTenant; }; - -export function parseTenantCreateArgs(args: string[]): ParsedTenantCreateArgs; - -export function parseTenantNameArgs(args: string[]): ParsedTenantNameArgs; - -export function parseTenantListArgs(args: string[]): ParsedTenantListArgs; - -export function runTenantCreate(args: string[], options?: RunTenantOptions): Promise; - -export function runTenantList(args: string[], options?: RunTenantOptions): Promise; - -export function runTenantDestroy(args: string[], options?: RunTenantOptions): Promise; - -export function runTenantCli(subcommand: string | undefined, args: string[], options?: RunTenantOptions): Promise; +/** Parse `create [--product

] [--json]`. Returns `{ name, product, json }` or `{ error }`. */ +export declare function parseTenantCreateArgs(args: string[]): ParsedTenantCreateArgs; +/** Parse ` [--json]` for the single-positional commands (destroy). Returns `{ name, json }` or `{ error }`. */ +export declare function parseTenantNameArgs(args: string[]): ParsedTenantNameArgs; +/** Parse `list [--json]` (no positional). Returns `{ json }` or `{ error }`. */ +export declare function parseTenantListArgs(args: string[]): ParsedTenantListArgs; +export declare function runTenantCreate(args: string[], options?: RunTenantOptions): Promise; +export declare function runTenantList(args: string[], options?: RunTenantOptions): Promise; +export declare function runTenantDestroy(args: string[], options?: RunTenantOptions): Promise; +export declare function runTenantCli(subcommand: string | undefined, args: string[], options?: RunTenantOptions): Promise; diff --git a/packages/loopover-miner/lib/tenant-cli.js b/packages/loopover-miner/lib/tenant-cli.js index 7c0ab374e9..489438cf38 100644 --- a/packages/loopover-miner/lib/tenant-cli.js +++ b/packages/loopover-miner/lib/tenant-cli.js @@ -7,133 +7,144 @@ * from the API -- this layer invents no state vocabulary of its own. */ import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; import { createTenant, destroyTenant, listTenants } from "./tenant-client.js"; - -const TENANT_USAGE = - "Usage: loopover-miner tenant [] [--product ] [--json]"; - +const TENANT_USAGE = "Usage: loopover-miner tenant [] [--product ] [--json]"; /** Parse `create [--product

] [--json]`. Returns `{ name, product, json }` or `{ error }`. */ export function parseTenantCreateArgs(args) { - let name = null; - let product = null; - let json = false; - for (let index = 0; index < args.length; index += 1) { - const token = args[index]; - if (token === "--json") { - json = true; - continue; + let name = null; + let product = null; + let json = false; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + json = true; + continue; + } + if (token === "--product") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) + return { error: TENANT_USAGE }; + product = value; + index += 1; + continue; + } + if (token.startsWith("-")) + return { error: `Unknown option: ${token}` }; + if (name !== null) + return { error: TENANT_USAGE }; + name = token; } - if (token === "--product") { - const value = args[index + 1]; - if (!value || value.startsWith("-")) return { error: TENANT_USAGE }; - product = value; - index += 1; - continue; - } - if (token.startsWith("-")) return { error: `Unknown option: ${token}` }; - if (name !== null) return { error: TENANT_USAGE }; - name = token; - } - if (name === null) return { error: TENANT_USAGE }; - return { name, json, ...(product !== null ? { product } : {}) }; + if (name === null) + return { error: TENANT_USAGE }; + return { name, json, ...(product !== null ? { product } : {}) }; } - /** Parse ` [--json]` for the single-positional commands (destroy). Returns `{ name, json }` or `{ error }`. */ export function parseTenantNameArgs(args) { - let name = null; - let json = false; - for (const token of args) { - if (token === "--json") { - json = true; - continue; + let name = null; + let json = false; + for (const token of args) { + if (token === "--json") { + json = true; + continue; + } + if (token.startsWith("-")) + return { error: `Unknown option: ${token}` }; + if (name !== null) + return { error: TENANT_USAGE }; + name = token; } - if (token.startsWith("-")) return { error: `Unknown option: ${token}` }; - if (name !== null) return { error: TENANT_USAGE }; - name = token; - } - if (name === null) return { error: TENANT_USAGE }; - return { name, json }; + if (name === null) + return { error: TENANT_USAGE }; + return { name, json }; } - /** Parse `list [--json]` (no positional). Returns `{ json }` or `{ error }`. */ export function parseTenantListArgs(args) { - let json = false; - for (const token of args) { - if (token === "--json") { - json = true; - continue; + let json = false; + for (const token of args) { + if (token === "--json") { + json = true; + continue; + } + return { error: `Unknown option: ${token}` }; } - return { error: `Unknown option: ${token}` }; - } - return { json }; + return { json }; } - function renderTenantRecord(record) { - const name = typeof record.name === "string" ? record.name : "(unknown)"; - const product = typeof record.product === "string" ? record.product : "(unknown)"; - const state = typeof record.state === "string" ? record.state : "(unknown)"; - return `${name} product=${product} state=${state}`; + const name = typeof record.name === "string" ? record.name : "(unknown)"; + const product = typeof record.product === "string" ? record.product : "(unknown)"; + const state = typeof record.state === "string" ? record.state : "(unknown)"; + return `${name} product=${product} state=${state}`; } - export async function runTenantCreate(args, options = {}) { - const parsed = parseTenantCreateArgs(args); - if ("error" in parsed) return reportCliFailure(argsWantJson(args), parsed.error); - const create = options.createTenant ?? createTenant; - try { - const record = await create(parsed.name, { - env: options.env, - fetchImpl: options.fetchImpl, - ...(parsed.product !== undefined ? { product: parsed.product } : {}), - }); - if (parsed.json) { - console.log(JSON.stringify(record, null, 2)); - } else { - console.log(`created ${renderTenantRecord(record)}`); + const parsed = parseTenantCreateArgs(args); + if ("error" in parsed) + return reportCliFailure(argsWantJson(args), parsed.error); + const create = options.createTenant ?? createTenant; + try { + const record = await create(parsed.name, { + env: options.env, + fetchImpl: options.fetchImpl, + ...(parsed.product !== undefined ? { product: parsed.product } : {}), + }); + if (parsed.json) { + console.log(JSON.stringify(record, null, 2)); + } + else { + console.log(`created ${renderTenantRecord(record)}`); + } + return 0; + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); } - return 0; - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } } - export async function runTenantList(args, options = {}) { - const parsed = parseTenantListArgs(args); - if ("error" in parsed) return reportCliFailure(argsWantJson(args), parsed.error); - const list = options.listTenants ?? listTenants; - try { - const records = await list({ env: options.env, fetchImpl: options.fetchImpl }); - if (parsed.json) { - console.log(JSON.stringify(records, null, 2)); - } else if (records.length === 0) { - console.log("no tenants"); - } else { - console.log(records.map(renderTenantRecord).join("\n")); + const parsed = parseTenantListArgs(args); + if ("error" in parsed) + return reportCliFailure(argsWantJson(args), parsed.error); + const list = options.listTenants ?? listTenants; + try { + const records = await list({ env: options.env, fetchImpl: options.fetchImpl }); + if (parsed.json) { + console.log(JSON.stringify(records, null, 2)); + } + else if (records.length === 0) { + console.log("no tenants"); + } + else { + console.log(records.map(renderTenantRecord).join("\n")); + } + return 0; + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); } - return 0; - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } } - export async function runTenantDestroy(args, options = {}) { - const parsed = parseTenantNameArgs(args); - if ("error" in parsed) return reportCliFailure(argsWantJson(args), parsed.error); - const destroy = options.destroyTenant ?? destroyTenant; - try { - const record = await destroy(parsed.name, { env: options.env, fetchImpl: options.fetchImpl }); - if (parsed.json) { - console.log(JSON.stringify(record, null, 2)); - } else { - console.log(`destroyed ${renderTenantRecord(record)}`); + const parsed = parseTenantNameArgs(args); + if ("error" in parsed) + return reportCliFailure(argsWantJson(args), parsed.error); + const destroy = options.destroyTenant ?? destroyTenant; + try { + const record = await destroy(parsed.name, { env: options.env, fetchImpl: options.fetchImpl }); + if (parsed.json) { + console.log(JSON.stringify(record, null, 2)); + } + else { + console.log(`destroyed ${renderTenantRecord(record)}`); + } + return 0; + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); } - return 0; - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } } - export async function runTenantCli(subcommand, args, options = {}) { - if (subcommand === "create") return runTenantCreate(args, options); - if (subcommand === "list") return runTenantList(args, options); - if (subcommand === "destroy") return runTenantDestroy(args, options); - return reportCliFailure(argsWantJson(args), TENANT_USAGE); + if (subcommand === "create") + return runTenantCreate(args, options); + if (subcommand === "list") + return runTenantList(args, options); + if (subcommand === "destroy") + return runTenantDestroy(args, options); + return reportCliFailure(argsWantJson(args), TENANT_USAGE); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidGVuYW50LWNsaS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInRlbmFudC1jbGkudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7Ozs7Ozt3RUFNd0U7QUFDeEUsT0FBTyxFQUFFLFlBQVksRUFBRSxnQkFBZ0IsRUFBRSxnQkFBZ0IsRUFBRSxNQUFNLGdCQUFnQixDQUFDO0FBQ2xGLE9BQU8sRUFBRSxZQUFZLEVBQUUsYUFBYSxFQUFFLFdBQVcsRUFBRSxNQUFNLG9CQUFvQixDQUFDO0FBRzlFLE1BQU0sWUFBWSxHQUFHLDRGQUE0RixDQUFDO0FBbUJsSCx3R0FBd0c7QUFDeEcsTUFBTSxVQUFVLHFCQUFxQixDQUFDLElBQWM7SUFDbEQsSUFBSSxJQUFJLEdBQWtCLElBQUksQ0FBQztJQUMvQixJQUFJLE9BQU8sR0FBa0IsSUFBSSxDQUFDO0lBQ2xDLElBQUksSUFBSSxHQUFHLEtBQUssQ0FBQztJQUNqQixLQUFLLElBQUksS0FBSyxHQUFHLENBQUMsRUFBRSxLQUFLLEdBQUcsSUFBSSxDQUFDLE1BQU0sRUFBRSxLQUFLLElBQUksQ0FBQyxFQUFFLENBQUM7UUFDcEQsTUFBTSxLQUFLLEdBQUcsSUFBSSxDQUFDLEtBQUssQ0FBRSxDQUFDO1FBQzNCLElBQUksS0FBSyxLQUFLLFFBQVEsRUFBRSxDQUFDO1lBQ3ZCLElBQUksR0FBRyxJQUFJLENBQUM7WUFDWixTQUFTO1FBQ1gsQ0FBQztRQUNELElBQUksS0FBSyxLQUFLLFdBQVcsRUFBRSxDQUFDO1lBQzFCLE1BQU0sS0FBSyxHQUFHLElBQUksQ0FBQyxLQUFLLEdBQUcsQ0FBQyxDQUFDLENBQUM7WUFDOUIsSUFBSSxDQUFDLEtBQUssSUFBSSxLQUFLLENBQUMsVUFBVSxDQUFDLEdBQUcsQ0FBQztnQkFBRSxPQUFPLEVBQUUsS0FBSyxFQUFFLFlBQVksRUFBRSxDQUFDO1lBQ3BFLE9BQU8sR0FBRyxLQUFLLENBQUM7WUFDaEIsS0FBSyxJQUFJLENBQUMsQ0FBQztZQUNYLFNBQVM7UUFDWCxD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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/tenant-cli.ts b/packages/loopover-miner/lib/tenant-cli.ts new file mode 100644 index 0000000000..b657b2e88d --- /dev/null +++ b/packages/loopover-miner/lib/tenant-cli.ts @@ -0,0 +1,156 @@ +/** `tenant` CLI command group (#7275): create / list / destroy hosted tenant instances against the #7173 ORB+AMS + * hosting control-plane's provisioning API (#7180). Thin composition layer -- argv parsing plus a call into + * tenant-client.js, which owns the env-gated, Bearer-authed, FAIL-LOUD HTTP surface. Every failure the client + * throws (disabled/unconfigured plane, unreachable host, non-2xx, malformed body) is reported here as a non-zero + * exit with the client's own message; there is deliberately no silent-degrade path, because provisioning a tenant + * is a deliberate admin action whose failure an operator must see. Lifecycle `state` values are printed verbatim + * from the API -- this layer invents no state vocabulary of its own. */ +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; +import { createTenant, destroyTenant, listTenants } from "./tenant-client.js"; +import type { TenantClientOptions, TenantRecord } from "./tenant-client.js"; + +const TENANT_USAGE = "Usage: loopover-miner tenant [] [--product ] [--json]"; + +export type ParsedTenantCreateArgs = { name: string; json: boolean; product?: string } | { error: string }; + +export type ParsedTenantNameArgs = { name: string; json: boolean } | { error: string }; + +export type ParsedTenantListArgs = { json: boolean } | { error: string }; + +export type RunTenantOptions = { + /** Read for the control-plane opt-in gate -- defaults to `process.env` inside the client. */ + env?: Record; + /** Injected fetch, forwarded to the client; defaults to the real global fetch. */ + fetchImpl?: (url: string, init: RequestInit) => Promise; + /** Injectable client functions so tests drive the CLI without a real control plane. */ + createTenant?: typeof createTenant; + listTenants?: typeof listTenants; + destroyTenant?: typeof destroyTenant; +}; + +/** Parse `create [--product

] [--json]`. Returns `{ name, product, json }` or `{ error }`. */ +export function parseTenantCreateArgs(args: string[]): ParsedTenantCreateArgs { + let name: string | null = null; + let product: string | null = null; + let json = false; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]!; + if (token === "--json") { + json = true; + continue; + } + if (token === "--product") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: TENANT_USAGE }; + product = value; + index += 1; + continue; + } + if (token.startsWith("-")) return { error: `Unknown option: ${token}` }; + if (name !== null) return { error: TENANT_USAGE }; + name = token; + } + if (name === null) return { error: TENANT_USAGE }; + return { name, json, ...(product !== null ? { product } : {}) }; +} + +/** Parse ` [--json]` for the single-positional commands (destroy). Returns `{ name, json }` or `{ error }`. */ +export function parseTenantNameArgs(args: string[]): ParsedTenantNameArgs { + let name: string | null = null; + let json = false; + for (const token of args) { + if (token === "--json") { + json = true; + continue; + } + if (token.startsWith("-")) return { error: `Unknown option: ${token}` }; + if (name !== null) return { error: TENANT_USAGE }; + name = token; + } + if (name === null) return { error: TENANT_USAGE }; + return { name, json }; +} + +/** Parse `list [--json]` (no positional). Returns `{ json }` or `{ error }`. */ +export function parseTenantListArgs(args: string[]): ParsedTenantListArgs { + let json = false; + for (const token of args) { + if (token === "--json") { + json = true; + continue; + } + return { error: `Unknown option: ${token}` }; + } + return { json }; +} + +function renderTenantRecord(record: TenantRecord): string { + const name = typeof record.name === "string" ? record.name : "(unknown)"; + const product = typeof record.product === "string" ? record.product : "(unknown)"; + const state = typeof record.state === "string" ? record.state : "(unknown)"; + return `${name} product=${product} state=${state}`; +} + +export async function runTenantCreate(args: string[], options: RunTenantOptions = {}): Promise { + const parsed = parseTenantCreateArgs(args); + if ("error" in parsed) return reportCliFailure(argsWantJson(args), parsed.error); + const create = options.createTenant ?? createTenant; + try { + const record = await create(parsed.name, { + env: options.env, + fetchImpl: options.fetchImpl, + ...(parsed.product !== undefined ? { product: parsed.product } : {}), + } as TenantClientOptions); + if (parsed.json) { + console.log(JSON.stringify(record, null, 2)); + } else { + console.log(`created ${renderTenantRecord(record)}`); + } + return 0; + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } +} + +export async function runTenantList(args: string[], options: RunTenantOptions = {}): Promise { + const parsed = parseTenantListArgs(args); + if ("error" in parsed) return reportCliFailure(argsWantJson(args), parsed.error); + const list = options.listTenants ?? listTenants; + try { + const records = await list({ env: options.env, fetchImpl: options.fetchImpl } as TenantClientOptions); + if (parsed.json) { + console.log(JSON.stringify(records, null, 2)); + } else if (records.length === 0) { + console.log("no tenants"); + } else { + console.log(records.map(renderTenantRecord).join("\n")); + } + return 0; + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } +} + +export async function runTenantDestroy(args: string[], options: RunTenantOptions = {}): Promise { + const parsed = parseTenantNameArgs(args); + if ("error" in parsed) return reportCliFailure(argsWantJson(args), parsed.error); + const destroy = options.destroyTenant ?? destroyTenant; + try { + const record = await destroy(parsed.name, { env: options.env, fetchImpl: options.fetchImpl } as TenantClientOptions); + if (parsed.json) { + console.log(JSON.stringify(record, null, 2)); + } else { + console.log(`destroyed ${renderTenantRecord(record)}`); + } + return 0; + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } +} + +export async function runTenantCli(subcommand: string | undefined, args: string[], options: RunTenantOptions = {}): Promise { + if (subcommand === "create") return runTenantCreate(args, options); + if (subcommand === "list") return runTenantList(args, options); + if (subcommand === "destroy") return runTenantDestroy(args, options); + return reportCliFailure(argsWantJson(args), TENANT_USAGE); +}