@loopover/miner 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEPLOYMENT.md +156 -0
- package/Dockerfile +38 -0
- package/README.md +269 -0
- package/bin/gittensory-miner-mcp.d.ts +64 -0
- package/bin/gittensory-miner-mcp.js +279 -0
- package/bin/gittensory-miner.js +196 -0
- package/docs/coding-agent-driver.md +140 -0
- package/docs/config-precedence.md +96 -0
- package/docs/cross-repo-discovery-phase1.md +55 -0
- package/docs/discovery-index-contract.md +60 -0
- package/docs/discovery-plane-operator-guide.md +101 -0
- package/docs/env-reference.md +30 -0
- package/docs/fleet-run-manifest.md +50 -0
- package/docs/miner-goal-spec.md +76 -0
- package/docs/miner-selfimprove-calibration.md +105 -0
- package/docs/observability.md +60 -0
- package/docs/operations-runbook.md +209 -0
- package/docs/repo-agnostic-capability-audit.md +129 -0
- package/docs/unattended-scheduling.md +107 -0
- package/expected-engine.version +1 -0
- package/lib/ams-policy.d.ts +20 -0
- package/lib/ams-policy.js +68 -0
- package/lib/attempt-cli.d.ts +99 -0
- package/lib/attempt-cli.js +523 -0
- package/lib/attempt-input-builder.d.ts +24 -0
- package/lib/attempt-input-builder.js +91 -0
- package/lib/attempt-log.d.ts +37 -0
- package/lib/attempt-log.js +177 -0
- package/lib/attempt-runner.d.ts +59 -0
- package/lib/attempt-runner.js +164 -0
- package/lib/attempt-worktree.d.ts +31 -0
- package/lib/attempt-worktree.js +94 -0
- package/lib/calibration-cli.d.ts +1 -0
- package/lib/calibration-cli.js +92 -0
- package/lib/calibration-run.d.ts +161 -0
- package/lib/calibration-run.js +232 -0
- package/lib/calibration-types.d.ts +46 -0
- package/lib/calibration-types.js +74 -0
- package/lib/calibration.d.ts +24 -0
- package/lib/calibration.js +99 -0
- package/lib/ci-poller.d.ts +33 -0
- package/lib/ci-poller.js +234 -0
- package/lib/claim-adjudication.d.ts +21 -0
- package/lib/claim-adjudication.js +36 -0
- package/lib/claim-conflict-resolver.d.ts +29 -0
- package/lib/claim-conflict-resolver.js +101 -0
- package/lib/claim-ledger-cli.d.ts +59 -0
- package/lib/claim-ledger-cli.js +320 -0
- package/lib/claim-ledger-expiry.d.ts +20 -0
- package/lib/claim-ledger-expiry.js +41 -0
- package/lib/claim-ledger.d.ts +63 -0
- package/lib/claim-ledger.js +303 -0
- package/lib/cli-error.d.ts +3 -0
- package/lib/cli-error.js +27 -0
- package/lib/cli.d.ts +3 -0
- package/lib/cli.js +72 -0
- package/lib/coding-agent-construction.d.ts +17 -0
- package/lib/coding-agent-construction.js +103 -0
- package/lib/coding-agent-house-rules.d.ts +24 -0
- package/lib/coding-agent-house-rules.js +64 -0
- package/lib/coding-task-spec.d.ts +44 -0
- package/lib/coding-task-spec.js +183 -0
- package/lib/config-precedence.d.ts +25 -0
- package/lib/config-precedence.js +38 -0
- package/lib/deny-check.d.ts +11 -0
- package/lib/deny-check.js +76 -0
- package/lib/deny-hook-synthesis.d.ts +93 -0
- package/lib/deny-hook-synthesis.js +399 -0
- package/lib/deny-hooks.d.ts +28 -0
- package/lib/deny-hooks.js +155 -0
- package/lib/deployment-docs-audit.d.ts +41 -0
- package/lib/deployment-docs-audit.js +115 -0
- package/lib/discover-cli.d.ts +94 -0
- package/lib/discover-cli.js +284 -0
- package/lib/discovery-throttle.d.ts +8 -0
- package/lib/discovery-throttle.js +33 -0
- package/lib/env-file-indirection.d.ts +4 -0
- package/lib/env-file-indirection.js +45 -0
- package/lib/event-ledger-cli.d.ts +82 -0
- package/lib/event-ledger-cli.js +266 -0
- package/lib/event-ledger.d.ts +37 -0
- package/lib/event-ledger.js +210 -0
- package/lib/execute-local-write.d.ts +14 -0
- package/lib/execute-local-write.js +50 -0
- package/lib/feasibility-cli.d.ts +25 -0
- package/lib/feasibility-cli.js +80 -0
- package/lib/forge-config.d.ts +17 -0
- package/lib/forge-config.js +37 -0
- package/lib/governor-action-mode.d.ts +26 -0
- package/lib/governor-action-mode.js +47 -0
- package/lib/governor-chokepoint-persisted.d.ts +18 -0
- package/lib/governor-chokepoint-persisted.js +46 -0
- package/lib/governor-chokepoint.d.ts +14 -0
- package/lib/governor-chokepoint.js +52 -0
- package/lib/governor-kill-switch.d.ts +26 -0
- package/lib/governor-kill-switch.js +47 -0
- package/lib/governor-ledger-cli.d.ts +32 -0
- package/lib/governor-ledger-cli.js +155 -0
- package/lib/governor-ledger.d.ts +46 -0
- package/lib/governor-ledger.js +203 -0
- package/lib/governor-open-pr.d.ts +21 -0
- package/lib/governor-open-pr.js +27 -0
- package/lib/governor-pause-cli.d.ts +23 -0
- package/lib/governor-pause-cli.js +171 -0
- package/lib/governor-run-halt.d.ts +37 -0
- package/lib/governor-run-halt.js +58 -0
- package/lib/governor-state.d.ts +63 -0
- package/lib/governor-state.js +363 -0
- package/lib/governor-write-rate-limit.d.ts +30 -0
- package/lib/governor-write-rate-limit.js +64 -0
- package/lib/harness-submission-trigger.d.ts +69 -0
- package/lib/harness-submission-trigger.js +138 -0
- package/lib/http-retry.d.ts +12 -0
- package/lib/http-retry.js +52 -0
- package/lib/laptop-init.d.ts +53 -0
- package/lib/laptop-init.js +332 -0
- package/lib/live-issue-snapshot.d.ts +16 -0
- package/lib/live-issue-snapshot.js +117 -0
- package/lib/local-store.d.ts +18 -0
- package/lib/local-store.js +62 -0
- package/lib/logger.d.ts +58 -0
- package/lib/logger.js +167 -0
- package/lib/loop-cli.d.ts +66 -0
- package/lib/loop-cli.js +540 -0
- package/lib/loop-closure.d.ts +35 -0
- package/lib/loop-closure.js +66 -0
- package/lib/loop-reentry.d.ts +51 -0
- package/lib/loop-reentry.js +124 -0
- package/lib/manage-poll.d.ts +79 -0
- package/lib/manage-poll.js +254 -0
- package/lib/manage-status.d.ts +70 -0
- package/lib/manage-status.js +241 -0
- package/lib/metrics-cli.d.ts +9 -0
- package/lib/metrics-cli.js +50 -0
- package/lib/migrate-cli.d.ts +24 -0
- package/lib/migrate-cli.js +111 -0
- package/lib/miner-goal-spec.d.ts +12 -0
- package/lib/miner-goal-spec.js +67 -0
- package/lib/opportunity-fanout.d.ts +86 -0
- package/lib/opportunity-fanout.js +597 -0
- package/lib/opportunity-ranker.d.ts +36 -0
- package/lib/opportunity-ranker.js +118 -0
- package/lib/orb-export.d.ts +54 -0
- package/lib/orb-export.js +206 -0
- package/lib/plan-store-cli.d.ts +37 -0
- package/lib/plan-store-cli.js +151 -0
- package/lib/plan-store.d.ts +51 -0
- package/lib/plan-store.js +231 -0
- package/lib/policy-doc-cache.d.ts +25 -0
- package/lib/policy-doc-cache.js +79 -0
- package/lib/policy-verdict-cache.d.ts +35 -0
- package/lib/policy-verdict-cache.js +106 -0
- package/lib/portfolio-dashboard.d.ts +30 -0
- package/lib/portfolio-dashboard.js +105 -0
- package/lib/portfolio-discovery.d.ts +29 -0
- package/lib/portfolio-discovery.js +100 -0
- package/lib/portfolio-queue-cli.d.ts +87 -0
- package/lib/portfolio-queue-cli.js +509 -0
- package/lib/portfolio-queue-expiry.d.ts +20 -0
- package/lib/portfolio-queue-expiry.js +51 -0
- package/lib/portfolio-queue-manager.d.ts +50 -0
- package/lib/portfolio-queue-manager.js +134 -0
- package/lib/portfolio-queue.d.ts +62 -0
- package/lib/portfolio-queue.js +351 -0
- package/lib/pr-disposition-poller.d.ts +26 -0
- package/lib/pr-disposition-poller.js +163 -0
- package/lib/pr-number-parse.d.ts +4 -0
- package/lib/pr-number-parse.js +23 -0
- package/lib/pr-outcome.d.ts +41 -0
- package/lib/pr-outcome.js +90 -0
- package/lib/prediction-ledger.d.ts +47 -0
- package/lib/prediction-ledger.js +222 -0
- package/lib/pretooluse-hook.d.ts +31 -0
- package/lib/pretooluse-hook.js +93 -0
- package/lib/process-lifecycle.d.ts +31 -0
- package/lib/process-lifecycle.js +106 -0
- package/lib/purge-cli.d.ts +44 -0
- package/lib/purge-cli.js +198 -0
- package/lib/rejection-signal.d.ts +6 -0
- package/lib/rejection-signal.js +101 -0
- package/lib/rejection-state-machine.d.ts +37 -0
- package/lib/rejection-state-machine.js +81 -0
- package/lib/rejection-templates.d.ts +12 -0
- package/lib/rejection-templates.js +71 -0
- package/lib/replay-objective-anchor.d.ts +79 -0
- package/lib/replay-objective-anchor.js +179 -0
- package/lib/replay-snapshot.d.ts +41 -0
- package/lib/replay-snapshot.js +288 -0
- package/lib/replay-task-generation.d.ts +126 -0
- package/lib/replay-task-generation.js +223 -0
- package/lib/repo-clone.d.ts +19 -0
- package/lib/repo-clone.js +102 -0
- package/lib/run-state-cli.d.ts +27 -0
- package/lib/run-state-cli.js +154 -0
- package/lib/run-state.d.ts +37 -0
- package/lib/run-state.js +150 -0
- package/lib/schema-version.d.ts +17 -0
- package/lib/schema-version.js +71 -0
- package/lib/self-review-context.d.ts +28 -0
- package/lib/self-review-context.js +339 -0
- package/lib/slop-assessment.d.ts +3 -0
- package/lib/slop-assessment.js +20 -0
- package/lib/stack-detection.d.ts +41 -0
- package/lib/stack-detection.js +248 -0
- package/lib/status.d.ts +64 -0
- package/lib/status.js +441 -0
- package/lib/store-maintenance.d.ts +31 -0
- package/lib/store-maintenance.js +183 -0
- package/lib/submission-freshness-check.d.ts +32 -0
- package/lib/submission-freshness-check.js +93 -0
- package/lib/update-check.d.ts +36 -0
- package/lib/update-check.js +161 -0
- package/lib/version.d.ts +3 -0
- package/lib/version.js +10 -0
- package/lib/worktree-allocator.d.ts +39 -0
- package/lib/worktree-allocator.js +262 -0
- package/package.json +50 -0
- package/schema/miner-goal-spec.schema.json +111 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_MINER_GOAL_SPEC,
|
|
3
|
+
parseMinerGoalSpecContent,
|
|
4
|
+
rankMetadataOpportunities,
|
|
5
|
+
} from "@loopover/engine";
|
|
6
|
+
|
|
7
|
+
function finiteEpochMs(value) {
|
|
8
|
+
return Number.isFinite(value) ? value : Date.now();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function finiteNonNegativeInt(value) {
|
|
12
|
+
if (!Number.isFinite(value)) return 0;
|
|
13
|
+
return Math.max(0, Math.floor(value));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeCandidate(candidate) {
|
|
17
|
+
if (!candidate || typeof candidate !== "object") return null;
|
|
18
|
+
const repoFullName =
|
|
19
|
+
typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : "";
|
|
20
|
+
const issueNumber = candidate.issueNumber;
|
|
21
|
+
const title = typeof candidate.title === "string" ? candidate.title.trim() : "";
|
|
22
|
+
const [owner, repo, extra] = repoFullName.split("/");
|
|
23
|
+
if (!owner || !repo || extra !== undefined) return null;
|
|
24
|
+
if (!Number.isInteger(issueNumber) || issueNumber <= 0 || !title) return null;
|
|
25
|
+
const canonicalRepoFullName = `${owner}/${repo}`;
|
|
26
|
+
const labels = Array.isArray(candidate.labels)
|
|
27
|
+
? candidate.labels
|
|
28
|
+
.filter((label) => typeof label === "string" && label.trim())
|
|
29
|
+
.map((label) => label.trim())
|
|
30
|
+
: [];
|
|
31
|
+
return {
|
|
32
|
+
owner,
|
|
33
|
+
repo,
|
|
34
|
+
repoFullName: canonicalRepoFullName,
|
|
35
|
+
issueNumber,
|
|
36
|
+
title,
|
|
37
|
+
labels,
|
|
38
|
+
commentsCount: Number.isFinite(candidate.commentsCount) ? candidate.commentsCount : 0,
|
|
39
|
+
createdAt: typeof candidate.createdAt === "string" ? candidate.createdAt : null,
|
|
40
|
+
updatedAt: typeof candidate.updatedAt === "string" ? candidate.updatedAt : null,
|
|
41
|
+
htmlUrl: typeof candidate.htmlUrl === "string" ? candidate.htmlUrl : null,
|
|
42
|
+
aiPolicyAllowed: candidate.aiPolicyAllowed !== false,
|
|
43
|
+
aiPolicySource:
|
|
44
|
+
candidate.aiPolicySource === "AI-USAGE.md" ||
|
|
45
|
+
candidate.aiPolicySource === "CONTRIBUTING.md" ||
|
|
46
|
+
candidate.aiPolicySource === "none"
|
|
47
|
+
? candidate.aiPolicySource
|
|
48
|
+
: "none",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildGoalSpecsByRepo(options = {}) {
|
|
53
|
+
const goalSpecsByRepo = { ...(options.goalSpecsByRepo ?? {}) };
|
|
54
|
+
const rawContentByRepo = options.goalSpecContentByRepo ?? {};
|
|
55
|
+
for (const [repoFullName, content] of Object.entries(rawContentByRepo)) {
|
|
56
|
+
if (typeof content !== "string" || !content.trim()) continue;
|
|
57
|
+
goalSpecsByRepo[repoFullName] = parseMinerGoalSpecContent(content).spec;
|
|
58
|
+
}
|
|
59
|
+
return goalSpecsByRepo;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildRankContext(options = {}) {
|
|
63
|
+
return {
|
|
64
|
+
nowMs: finiteEpochMs(options.nowMs),
|
|
65
|
+
highRiskDuplicateClusters: finiteNonNegativeInt(options.highRiskDuplicateClusters),
|
|
66
|
+
openPullRequests: finiteNonNegativeInt(options.openPullRequests),
|
|
67
|
+
goalSpecsByRepo: buildGoalSpecsByRepo(options),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function collectCandidates(candidates) {
|
|
72
|
+
const input = Array.isArray(candidates) ? candidates : [];
|
|
73
|
+
let skippedInvalid = 0;
|
|
74
|
+
const normalized = [];
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
for (const candidate of input) {
|
|
77
|
+
const entry = normalizeCandidate(candidate);
|
|
78
|
+
if (!entry) {
|
|
79
|
+
skippedInvalid += 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const key = `${entry.repoFullName.toLowerCase()}#${entry.issueNumber}`;
|
|
83
|
+
if (seen.has(key)) continue;
|
|
84
|
+
seen.add(key);
|
|
85
|
+
normalized.push(entry);
|
|
86
|
+
}
|
|
87
|
+
return { normalized, skippedInvalid };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function rankedUsesDefaultGoalSpec(ranked, options = {}) {
|
|
91
|
+
const goalSpecsByRepo = buildGoalSpecsByRepo(options);
|
|
92
|
+
const specRepos = Object.keys(goalSpecsByRepo);
|
|
93
|
+
if (ranked.length === 0) return specRepos.length === 0;
|
|
94
|
+
return ranked.some((issue) => {
|
|
95
|
+
const target = issue.repoFullName.trim().toLowerCase();
|
|
96
|
+
return !specRepos.some((repo) => repo.trim().toLowerCase() === target);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Rank metadata-only fan-out candidates locally. Never clones source, never uploads metadata, and never writes to
|
|
102
|
+
* GitHub — it only composes deterministic engine signals and returns the sorted list.
|
|
103
|
+
*/
|
|
104
|
+
export function rankCandidateIssues(candidates, options = {}) {
|
|
105
|
+
const { normalized } = collectCandidates(candidates);
|
|
106
|
+
return rankMetadataOpportunities(normalized, buildRankContext(options));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function rankCandidateIssuesWithSummary(candidates, options = {}) {
|
|
110
|
+
const { normalized, skippedInvalid } = collectCandidates(candidates);
|
|
111
|
+
const ranked = rankMetadataOpportunities(normalized, buildRankContext(options));
|
|
112
|
+
return {
|
|
113
|
+
issues: ranked,
|
|
114
|
+
skippedInvalid,
|
|
115
|
+
usedDefaultGoalSpec: rankedUsesDefaultGoalSpec(ranked, options),
|
|
116
|
+
defaultGoalSpec: DEFAULT_MINER_GOAL_SPEC,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { NormalizedPrOutcomePayload, PrOutcomeLedgerReader } from "./pr-outcome.js";
|
|
2
|
+
|
|
3
|
+
/** OPT-IN default: a laptop miner exports nothing unless a contributor turns it on. */
|
|
4
|
+
export const ORB_EXPORT_ENABLED_BY_DEFAULT: false;
|
|
5
|
+
|
|
6
|
+
/** One anonymized outcome in an export batch — no raw repo name, PR number, or free-text reason. */
|
|
7
|
+
export interface OrbExportRow {
|
|
8
|
+
repoHash: string;
|
|
9
|
+
prHash: string;
|
|
10
|
+
decision: string;
|
|
11
|
+
reasonBucket: string;
|
|
12
|
+
closedAt: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The local orb-export store: the per-instance anonymization secret + export cursor, in local SQLite. */
|
|
16
|
+
export interface OrbExportStore {
|
|
17
|
+
dbPath: string;
|
|
18
|
+
getOrCreateAnonSecret(): string;
|
|
19
|
+
getCursor(): string | null;
|
|
20
|
+
setCursor(cursor: string): void;
|
|
21
|
+
close(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A pr_outcome record as produced by `readPrOutcomes` (the local ledger's latest-per-PR reduction). */
|
|
25
|
+
export type OrbExportOutcome = NormalizedPrOutcomePayload & { repoFullName: string };
|
|
26
|
+
|
|
27
|
+
export function resolveOrbExportDbPath(env?: Record<string, string | undefined>): string;
|
|
28
|
+
|
|
29
|
+
export function hmacAnonymize(value: string | number, secret: string): string;
|
|
30
|
+
|
|
31
|
+
export function buildAnonymizedOrbBatch(
|
|
32
|
+
outcomes: Iterable<OrbExportOutcome> | Map<string, OrbExportOutcome>,
|
|
33
|
+
secret: string,
|
|
34
|
+
): OrbExportRow[];
|
|
35
|
+
|
|
36
|
+
export function openOrbExportStore(dbPath?: string): OrbExportStore;
|
|
37
|
+
|
|
38
|
+
export function collectOrbExportBatch(options?: {
|
|
39
|
+
store: OrbExportStore;
|
|
40
|
+
eventLedger: PrOutcomeLedgerReader;
|
|
41
|
+
enabled?: boolean;
|
|
42
|
+
}): OrbExportRow[] | null;
|
|
43
|
+
|
|
44
|
+
export type ParsedOrbExportArgs = { json: boolean; enable: boolean; dryRun: boolean } | { error: string };
|
|
45
|
+
|
|
46
|
+
export function parseOrbExportArgs(args: string[]): ParsedOrbExportArgs;
|
|
47
|
+
|
|
48
|
+
export function runOrbExportCli(
|
|
49
|
+
args: string[],
|
|
50
|
+
options?: {
|
|
51
|
+
openOrbExportStore?: () => OrbExportStore;
|
|
52
|
+
initEventLedger?: () => PrOutcomeLedgerReader;
|
|
53
|
+
},
|
|
54
|
+
): number;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
6
|
+
import { readPrOutcomes } from "./pr-outcome.js";
|
|
7
|
+
import { initEventLedger } from "./event-ledger.js";
|
|
8
|
+
import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js";
|
|
9
|
+
|
|
10
|
+
// Optional anonymized Orb telemetry export (#4277). The self-host Orb collector (src/selfhost/orb-collector.ts,
|
|
11
|
+
// #1255) is ALWAYS-ON for a maintainer's own instance; a miner runs on a third-party contributor's laptop with a
|
|
12
|
+
// much lower consent bar, so this export is OPT-IN (default OFF) — hence "optional". It mirrors the collector's
|
|
13
|
+
// privacy posture: repo/PR identifiers are HMAC-anonymized with a per-instance DEDICATED secret (generated once,
|
|
14
|
+
// persisted locally, single-purpose), and only a fixed low-cardinality reason bucket + the decision leave — never
|
|
15
|
+
// raw repo names or free text. The data source is the local pr_outcome ledger (pr-outcome.js), not a hosted D1.
|
|
16
|
+
// This module builds the anonymized batch and manages the local secret + cursor; performing the network POST is the
|
|
17
|
+
// caller's job, so this stays pure over its inputs + local store and needs no network to test.
|
|
18
|
+
|
|
19
|
+
/** OPT-IN: a laptop miner exports nothing unless a contributor explicitly turns it on. */
|
|
20
|
+
export const ORB_EXPORT_ENABLED_BY_DEFAULT = false;
|
|
21
|
+
|
|
22
|
+
const ANON_SECRET_KEY = "anon_secret";
|
|
23
|
+
const CURSOR_KEY = "export_cursor";
|
|
24
|
+
const defaultDbFileName = "orb-export.sqlite3";
|
|
25
|
+
|
|
26
|
+
export function resolveOrbExportDbPath(env = process.env) {
|
|
27
|
+
const explicitPath =
|
|
28
|
+
typeof env.GITTENSORY_MINER_ORB_EXPORT_DB === "string" ? env.GITTENSORY_MINER_ORB_EXPORT_DB.trim() : "";
|
|
29
|
+
if (explicitPath) return explicitPath;
|
|
30
|
+
|
|
31
|
+
const explicitConfigDir =
|
|
32
|
+
typeof env.GITTENSORY_MINER_CONFIG_DIR === "string" ? env.GITTENSORY_MINER_CONFIG_DIR.trim() : "";
|
|
33
|
+
if (explicitConfigDir) return join(explicitConfigDir, defaultDbFileName);
|
|
34
|
+
|
|
35
|
+
const configHome =
|
|
36
|
+
typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim()
|
|
37
|
+
? env.XDG_CONFIG_HOME.trim()
|
|
38
|
+
: join(homedir(), ".config");
|
|
39
|
+
return join(configHome, "gittensory-miner", defaultDbFileName);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeDbPath(dbPath) {
|
|
43
|
+
const path = (dbPath ?? resolveOrbExportDbPath()).trim();
|
|
44
|
+
if (!path) throw new Error("invalid_orb_export_db_path");
|
|
45
|
+
return path;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** HMAC a value with the per-instance secret — mirrors orb-collector.ts's hmacField (sha256, first 24 hex). */
|
|
49
|
+
export function hmacAnonymize(value, secret) {
|
|
50
|
+
if (typeof secret !== "string" || !secret) throw new Error("invalid_anon_secret");
|
|
51
|
+
return createHmac("sha256", secret).update(String(value)).digest("hex").slice(0, 24);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Turn the local pr_outcome map (pr-outcome.js `readPrOutcomes`) into an anonymized export batch: repo and PR
|
|
56
|
+
* identifiers are HMAC-hashed, and only the `decision` + a low-cardinality `reasonBucket` (already one of the
|
|
57
|
+
* miner's `REJECTION_REASONS`, else `"none"`) + `closedAt` leave. Pure and deterministic (rows sorted by prHash).
|
|
58
|
+
* Accepts either the Map `readPrOutcomes` returns or any iterable of outcome records.
|
|
59
|
+
*/
|
|
60
|
+
export function buildAnonymizedOrbBatch(outcomes, secret) {
|
|
61
|
+
const iterable = outcomes && typeof outcomes.values === "function" ? outcomes.values() : outcomes;
|
|
62
|
+
const rows = [];
|
|
63
|
+
for (const outcome of iterable ?? []) {
|
|
64
|
+
if (!outcome || typeof outcome.repoFullName !== "string" || !outcome.repoFullName.trim()) continue;
|
|
65
|
+
if (!Number.isInteger(outcome.prNumber) || outcome.prNumber <= 0) continue;
|
|
66
|
+
rows.push({
|
|
67
|
+
repoHash: hmacAnonymize(outcome.repoFullName, secret),
|
|
68
|
+
prHash: hmacAnonymize(`${outcome.repoFullName}:${outcome.prNumber}`, secret),
|
|
69
|
+
decision: outcome.decision,
|
|
70
|
+
reasonBucket: typeof outcome.reason === "string" && outcome.reason ? outcome.reason : "none",
|
|
71
|
+
closedAt: typeof outcome.closedAt === "string" && outcome.closedAt ? outcome.closedAt : null,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
rows.sort((a, b) => a.prHash.localeCompare(b.prHash));
|
|
75
|
+
return rows;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Open/create the local orb-export store: a small key/value SQLite table holding the per-instance anonymization
|
|
80
|
+
* secret and the export cursor. Mirrors the other miner ledgers' node:sqlite pattern — a `0o700` config dir and a
|
|
81
|
+
* `0o600` file, since the secret must never leave this machine.
|
|
82
|
+
*/
|
|
83
|
+
export function openOrbExportStore(dbPath = resolveOrbExportDbPath()) {
|
|
84
|
+
const resolvedPath = normalizeDbPath(dbPath);
|
|
85
|
+
mkdirSync(dirname(resolvedPath), { recursive: true, mode: 0o700 });
|
|
86
|
+
const db = new DatabaseSync(resolvedPath);
|
|
87
|
+
chmodSync(resolvedPath, 0o600);
|
|
88
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
89
|
+
db.exec(`CREATE TABLE IF NOT EXISTS orb_export_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
|
|
90
|
+
|
|
91
|
+
const getStatement = db.prepare("SELECT value FROM orb_export_meta WHERE key = ?");
|
|
92
|
+
const setStatement = db.prepare(
|
|
93
|
+
"INSERT INTO orb_export_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
94
|
+
);
|
|
95
|
+
const readValue = (key) => {
|
|
96
|
+
const row = getStatement.get(key);
|
|
97
|
+
return row && typeof row.value === "string" ? row.value : null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
dbPath: resolvedPath,
|
|
102
|
+
/** The per-instance DEDICATED anonymization secret — generated once (256-bit) and persisted, then reused
|
|
103
|
+
* forever so a repo/PR always hashes the same way. Single-purpose: only this export uses it. */
|
|
104
|
+
getOrCreateAnonSecret() {
|
|
105
|
+
const existing = readValue(ANON_SECRET_KEY);
|
|
106
|
+
if (existing) return existing;
|
|
107
|
+
const generated = randomBytes(32).toString("hex");
|
|
108
|
+
setStatement.run(ANON_SECRET_KEY, generated);
|
|
109
|
+
return generated;
|
|
110
|
+
},
|
|
111
|
+
/** The export watermark (opaque string), or null before the first export. */
|
|
112
|
+
getCursor() {
|
|
113
|
+
return readValue(CURSOR_KEY);
|
|
114
|
+
},
|
|
115
|
+
setCursor(cursor) {
|
|
116
|
+
setStatement.run(CURSOR_KEY, String(cursor));
|
|
117
|
+
},
|
|
118
|
+
close() {
|
|
119
|
+
db.close();
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Collect the anonymized Orb export batch from the local pr_outcome ledger. OPT-IN: returns null (exports nothing)
|
|
126
|
+
* unless `enabled` is true — a third-party contributor's laptop must explicitly turn this on. Never performs the
|
|
127
|
+
* network POST itself; the caller sends the returned batch to the Orb ingest endpoint and then advances the store
|
|
128
|
+
* cursor, so this function stays pure over its inputs and the local store.
|
|
129
|
+
*/
|
|
130
|
+
export function collectOrbExportBatch({ store, eventLedger, enabled = ORB_EXPORT_ENABLED_BY_DEFAULT } = {}) {
|
|
131
|
+
if (!enabled) return null;
|
|
132
|
+
if (!store || typeof store.getOrCreateAnonSecret !== "function") throw new Error("invalid_orb_export_store");
|
|
133
|
+
const outcomes = readPrOutcomes(eventLedger);
|
|
134
|
+
return buildAnonymizedOrbBatch(outcomes, store.getOrCreateAnonSecret());
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const ORB_EXPORT_USAGE = "Usage: gittensory-miner orb export [--enable] [--dry-run] [--json]";
|
|
138
|
+
|
|
139
|
+
export function parseOrbExportArgs(args) {
|
|
140
|
+
const options = { json: false, enable: false, dryRun: false };
|
|
141
|
+
for (const token of args) {
|
|
142
|
+
if (token === "--json") {
|
|
143
|
+
options.json = true;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (token === "--enable") {
|
|
147
|
+
options.enable = true;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
// #4847: openOrbExportStore() itself creates the local SQLite file (a real write) even before any secret is
|
|
151
|
+
// generated, so a dry run reports what would happen and returns before opening any store at all.
|
|
152
|
+
if (token === "--dry-run") {
|
|
153
|
+
options.dryRun = true;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
return { error: ORB_EXPORT_USAGE };
|
|
157
|
+
}
|
|
158
|
+
return options;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** CLI entry for the anonymized Orb telemetry batch-builder (#4833 wires the previously caller-less exporter).
|
|
162
|
+
* OPT-IN: prints nothing to export unless `--enable` is passed. Only builds the anonymized batch (repo/PR
|
|
163
|
+
* identifiers HMAC-hashed) — never performs the network POST. */
|
|
164
|
+
export function runOrbExportCli(args, options = {}) {
|
|
165
|
+
const parsed = parseOrbExportArgs(args);
|
|
166
|
+
if ("error" in parsed) {
|
|
167
|
+
return reportCliFailure(argsWantJson(args), parsed.error);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (parsed.dryRun) {
|
|
171
|
+
const dryRunResult = { outcome: "dry_run", enabled: parsed.enable };
|
|
172
|
+
if (parsed.json) {
|
|
173
|
+
console.log(JSON.stringify(dryRunResult, null, 2));
|
|
174
|
+
} else if (parsed.enable) {
|
|
175
|
+
console.log("DRY RUN: would build and report an anonymized Orb export batch. No local writes were made.");
|
|
176
|
+
} else {
|
|
177
|
+
console.log("DRY RUN: orb export is opt-in and disabled — pass --enable to build an anonymized batch. No local writes were made.");
|
|
178
|
+
}
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Open the stores INSIDE the try so a bad config path / SQLite open failure returns 2 instead of crashing the
|
|
183
|
+
// process; the finally guards each close with `?.` since either initializer may have thrown before assigning.
|
|
184
|
+
const ownsStore = options.openOrbExportStore === undefined;
|
|
185
|
+
const ownsLedger = options.initEventLedger === undefined;
|
|
186
|
+
let store;
|
|
187
|
+
let eventLedger;
|
|
188
|
+
try {
|
|
189
|
+
store = (options.openOrbExportStore ?? openOrbExportStore)();
|
|
190
|
+
eventLedger = (options.initEventLedger ?? initEventLedger)();
|
|
191
|
+
const batch = collectOrbExportBatch({ store, eventLedger, enabled: parsed.enable });
|
|
192
|
+
if (batch === null) {
|
|
193
|
+
if (parsed.json) console.log(JSON.stringify({ enabled: false, batch: null }, null, 2));
|
|
194
|
+
else console.log("orb export is opt-in and disabled — pass --enable to build an anonymized batch");
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
if (parsed.json) console.log(JSON.stringify({ enabled: true, batch }, null, 2));
|
|
198
|
+
else console.log(`${batch.length} anonymized event(s)`);
|
|
199
|
+
return 0;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
return reportCliFailure(parsed.json, describeCliError(error));
|
|
202
|
+
} finally {
|
|
203
|
+
if (ownsStore) store?.close();
|
|
204
|
+
if (ownsLedger) eventLedger?.close();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { PlanRecord, PlanStatus, PlanStore } from "./plan-store.js";
|
|
2
|
+
|
|
3
|
+
export type ParsedPlanListArgs =
|
|
4
|
+
| {
|
|
5
|
+
json: boolean;
|
|
6
|
+
status: PlanStatus | null;
|
|
7
|
+
}
|
|
8
|
+
| { error: string };
|
|
9
|
+
|
|
10
|
+
export type ParsedPlanShowArgs =
|
|
11
|
+
| {
|
|
12
|
+
planId: string;
|
|
13
|
+
json: boolean;
|
|
14
|
+
}
|
|
15
|
+
| { error: string };
|
|
16
|
+
|
|
17
|
+
export function parsePlanListArgs(args: string[]): ParsedPlanListArgs;
|
|
18
|
+
|
|
19
|
+
export function parsePlanShowArgs(args: string[]): ParsedPlanShowArgs;
|
|
20
|
+
|
|
21
|
+
export function renderPlanTable(plans: PlanRecord[]): string;
|
|
22
|
+
|
|
23
|
+
export function runPlanList(
|
|
24
|
+
args: string[],
|
|
25
|
+
options?: { openPlanStore?: () => PlanStore },
|
|
26
|
+
): number;
|
|
27
|
+
|
|
28
|
+
export function runPlanShow(
|
|
29
|
+
args: string[],
|
|
30
|
+
options?: { openPlanStore?: () => PlanStore },
|
|
31
|
+
): number;
|
|
32
|
+
|
|
33
|
+
export function runPlanCli(
|
|
34
|
+
subcommand: string | undefined,
|
|
35
|
+
args: string[],
|
|
36
|
+
options?: { openPlanStore?: () => PlanStore },
|
|
37
|
+
): number;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { PLAN_STATUSES, openPlanStore } from "./plan-store.js";
|
|
2
|
+
import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js";
|
|
3
|
+
|
|
4
|
+
const PLAN_LIST_USAGE =
|
|
5
|
+
"Usage: gittensory-miner plan list [--status pending|running|completed|failed] [--json]";
|
|
6
|
+
const PLAN_SHOW_USAGE = "Usage: gittensory-miner plan show <planId> [--json]";
|
|
7
|
+
|
|
8
|
+
function parseJsonFlag(args) {
|
|
9
|
+
const options = { json: false };
|
|
10
|
+
const positional = [];
|
|
11
|
+
|
|
12
|
+
for (const token of args) {
|
|
13
|
+
if (token === "--json") {
|
|
14
|
+
options.json = true;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (token.startsWith("-")) {
|
|
18
|
+
return { error: `Unknown option: ${token}` };
|
|
19
|
+
}
|
|
20
|
+
positional.push(token);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return { positional, ...options };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parsePlanListArgs(args) {
|
|
27
|
+
const options = { json: false, status: null };
|
|
28
|
+
const positional = [];
|
|
29
|
+
|
|
30
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
31
|
+
const token = args[index];
|
|
32
|
+
if (token === "--json") {
|
|
33
|
+
options.json = true;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (token === "--status") {
|
|
37
|
+
const status = args[index + 1];
|
|
38
|
+
if (!status || status.startsWith("-")) return { error: PLAN_LIST_USAGE };
|
|
39
|
+
if (!PLAN_STATUSES.includes(status)) {
|
|
40
|
+
return { error: `Invalid status: ${status}. Expected one of ${PLAN_STATUSES.join(", ")}.` };
|
|
41
|
+
}
|
|
42
|
+
options.status = status;
|
|
43
|
+
index += 1;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (token.startsWith("-")) return { error: `Unknown option: ${token}` };
|
|
47
|
+
positional.push(token);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (positional.length > 0) return { error: PLAN_LIST_USAGE };
|
|
51
|
+
return options;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parsePlanShowArgs(args) {
|
|
55
|
+
const parsed = parseJsonFlag(args);
|
|
56
|
+
if ("error" in parsed) return parsed;
|
|
57
|
+
if (parsed.positional.length !== 1) return { error: PLAN_SHOW_USAGE };
|
|
58
|
+
|
|
59
|
+
const planId = parsed.positional[0]?.trim();
|
|
60
|
+
if (!planId) return { error: PLAN_SHOW_USAGE };
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
planId,
|
|
64
|
+
json: parsed.json,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function display(value) {
|
|
69
|
+
if (value === null || value === undefined) return "-";
|
|
70
|
+
return String(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function renderPlanTable(plans) {
|
|
74
|
+
if (!Array.isArray(plans) || plans.length === 0) return "no saved plans";
|
|
75
|
+
const header = [
|
|
76
|
+
"plan-id".padEnd(20),
|
|
77
|
+
"status".padEnd(10),
|
|
78
|
+
"steps".padStart(5),
|
|
79
|
+
"updated-at".padEnd(24),
|
|
80
|
+
].join(" ");
|
|
81
|
+
const lines = plans.map((record) =>
|
|
82
|
+
[
|
|
83
|
+
record.planId.padEnd(20),
|
|
84
|
+
record.status.padEnd(10),
|
|
85
|
+
String(record.plan.steps.length).padStart(5),
|
|
86
|
+
display(record.updatedAt).padEnd(24),
|
|
87
|
+
].join(" "),
|
|
88
|
+
);
|
|
89
|
+
return [header, ...lines].join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function withPlanStore(options, run) {
|
|
93
|
+
const ownsStore = options.openPlanStore === undefined;
|
|
94
|
+
const planStore = (options.openPlanStore ?? openPlanStore)();
|
|
95
|
+
try {
|
|
96
|
+
return run(planStore);
|
|
97
|
+
} finally {
|
|
98
|
+
if (ownsStore) planStore.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function runPlanList(args, options = {}) {
|
|
103
|
+
const parsed = parsePlanListArgs(args);
|
|
104
|
+
if ("error" in parsed) {
|
|
105
|
+
return reportCliFailure(argsWantJson(args), parsed.error);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
return withPlanStore(options, (planStore) => {
|
|
110
|
+
const plans = planStore.listPlans({ status: parsed.status });
|
|
111
|
+
if (parsed.json) {
|
|
112
|
+
console.log(JSON.stringify({ plans }, null, 2));
|
|
113
|
+
} else {
|
|
114
|
+
console.log(renderPlanTable(plans));
|
|
115
|
+
}
|
|
116
|
+
return 0;
|
|
117
|
+
});
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return reportCliFailure(parsed.json, describeCliError(error));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function runPlanShow(args, options = {}) {
|
|
124
|
+
const parsed = parsePlanShowArgs(args);
|
|
125
|
+
if ("error" in parsed) {
|
|
126
|
+
return reportCliFailure(argsWantJson(args), parsed.error);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
return withPlanStore(options, (planStore) => {
|
|
131
|
+
const plan = planStore.loadPlan(parsed.planId);
|
|
132
|
+
if (!plan) {
|
|
133
|
+
return reportCliFailure(parsed.json, "plan_not_found");
|
|
134
|
+
}
|
|
135
|
+
if (parsed.json) {
|
|
136
|
+
console.log(JSON.stringify({ plan }, null, 2));
|
|
137
|
+
} else {
|
|
138
|
+
console.log(`${plan.status} (${plan.plan.steps.length} steps)`);
|
|
139
|
+
}
|
|
140
|
+
return 0;
|
|
141
|
+
});
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return reportCliFailure(parsed.json, describeCliError(error));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function runPlanCli(subcommand, args, options = {}) {
|
|
148
|
+
if (subcommand === "list") return runPlanList(args, options);
|
|
149
|
+
if (subcommand === "show") return runPlanShow(args, options);
|
|
150
|
+
return reportCliFailure(argsWantJson(args), `Unknown plan subcommand: ${subcommand ?? ""}. ${PLAN_LIST_USAGE}`);
|
|
151
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type PlanStepStatus = "pending" | "running" | "completed" | "failed" | "skipped";
|
|
2
|
+
|
|
3
|
+
export type PlanStep = {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
actionClass?: string;
|
|
7
|
+
dependsOn: string[];
|
|
8
|
+
status: PlanStepStatus;
|
|
9
|
+
attempts: number;
|
|
10
|
+
maxAttempts: number;
|
|
11
|
+
lastError?: string | null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type PlanDag = {
|
|
15
|
+
steps: PlanStep[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type PlanStatus = "pending" | "running" | "completed" | "failed";
|
|
19
|
+
|
|
20
|
+
export type PlanRecord = {
|
|
21
|
+
planId: string;
|
|
22
|
+
plan: PlanDag;
|
|
23
|
+
status: PlanStatus;
|
|
24
|
+
updatedAt: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ListPlansFilter = {
|
|
28
|
+
status?: PlanStatus | null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type PlanStore = {
|
|
32
|
+
dbPath: string;
|
|
33
|
+
savePlan(planId: string, plan: PlanDag): PlanRecord;
|
|
34
|
+
loadPlan(planId: string): PlanRecord | null;
|
|
35
|
+
listPlans(filter?: ListPlansFilter): PlanRecord[];
|
|
36
|
+
close(): void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const PLAN_STATUSES: readonly PlanStatus[];
|
|
40
|
+
|
|
41
|
+
export function resolvePlanStoreDbPath(env?: Record<string, string | undefined>): string;
|
|
42
|
+
|
|
43
|
+
export function openPlanStore(dbPath?: string): PlanStore;
|
|
44
|
+
|
|
45
|
+
export function savePlan(planId: string, plan: PlanDag): PlanRecord;
|
|
46
|
+
|
|
47
|
+
export function loadPlan(planId: string): PlanRecord | null;
|
|
48
|
+
|
|
49
|
+
export function listPlans(filter?: ListPlansFilter): PlanRecord[];
|
|
50
|
+
|
|
51
|
+
export function closeDefaultPlanStore(): void;
|