Skip to content

Commit 153d232

Browse files
committed
feat(miner): wire the opt-in discovery-plane client into discover and attempt
Adds packages/loopover-miner/lib/discovery-index-client.js, gated fully behind LOOPOVER_MINER_DISCOVERY_PLANE (default off, zero behavior change when unset). Wires two integration points: - discover-cli.js's runDiscover supplements the local fan-out with hosted discovery-index results for the same scope, deduped against local candidates (local always wins on a duplicate). - attempt-cli.js submits a soft-claim right after the local claim-ledger claim succeeds (before work begins), and a paired release on every terminal outcome, using discovery-soft-claim.ts's buildSoftClaimRequest directly against the claim-ledger's own record shape -- no translation needed, the shapes already match by design. The opt-in check happens at each attempt-cli.js call site, not left to the client function's own internal check alone, so a disabled plane costs zero calls and a test-injected override can't bypass the gate. A second, independent opt-in (LOOPOVER_MINER_DISCOVERY_TELEMETRY) emits a low-cardinality anonymized operational event via the package's own logger -- no hosted telemetry collector exists for the discovery plane yet, so this is a structured local log line for now. Regenerates env-reference.md/ams-env-reference.ts for the four new env vars and updates DEPLOYMENT.md's discovery-plane section, which was marked a placeholder pending this wiring. Closes #7168
1 parent e9e7039 commit 153d232

13 files changed

Lines changed: 799 additions & 5 deletions

apps/loopover-ui/src/lib/ams-env-reference.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,26 @@ export const AMS_ENV_REFERENCE_ROWS: MinerEnvReferenceRow[] = [
5151
firstReference: "lib/deny-hook-synthesis.js",
5252
defaultValue: "",
5353
},
54+
{
55+
name: "LOOPOVER_MINER_DISCOVERY_INDEX_URL",
56+
firstReference: "lib/discovery-index-client.js",
57+
defaultValue: "",
58+
},
59+
{
60+
name: "LOOPOVER_MINER_DISCOVERY_PLANE",
61+
firstReference: "lib/discovery-index-client.js",
62+
defaultValue: "",
63+
},
64+
{
65+
name: "LOOPOVER_MINER_DISCOVERY_SHARED_SECRET",
66+
firstReference: "lib/discovery-index-client.js",
67+
defaultValue: "",
68+
},
69+
{
70+
name: "LOOPOVER_MINER_DISCOVERY_TELEMETRY",
71+
firstReference: "lib/discovery-index-client.js",
72+
defaultValue: "",
73+
},
5474
{
5575
name: "LOOPOVER_MINER_EVENT_LEDGER_DB",
5676
firstReference: "lib/event-ledger.js",
@@ -194,6 +214,10 @@ export const AMS_ENV_REFERENCE_MARKDOWN = [
194214
'| `LOOPOVER_MINER_CONFIG_DIR` | `lib/deny-hook-synthesis.js` | `""` |',
195215
"| `LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB` | `lib/contribution-profile-cache.js` | (none) |",
196216
'| `LOOPOVER_MINER_DENY_HOOK_SYNTHESIS_DB` | `lib/deny-hook-synthesis.js` | `""` |',
217+
'| `LOOPOVER_MINER_DISCOVERY_INDEX_URL` | `lib/discovery-index-client.js` | `""` |',
218+
'| `LOOPOVER_MINER_DISCOVERY_PLANE` | `lib/discovery-index-client.js` | `""` |',
219+
'| `LOOPOVER_MINER_DISCOVERY_SHARED_SECRET` | `lib/discovery-index-client.js` | `""` |',
220+
'| `LOOPOVER_MINER_DISCOVERY_TELEMETRY` | `lib/discovery-index-client.js` | `""` |',
197221
"| `LOOPOVER_MINER_EVENT_LEDGER_DB` | `lib/event-ledger.js` | (none) |",
198222
"| `LOOPOVER_MINER_GOVERNOR_LEDGER_DB` | `lib/governor-ledger.js` | (none) |",
199223
"| `LOOPOVER_MINER_GOVERNOR_STATE_DB` | `lib/governor-state.js` | (none) |",

packages/loopover-miner/DEPLOYMENT.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,4 +207,13 @@ and `LOOPOVER_MINER_CONFIG_DIR` are covered above under the fleet/state notes; t
207207

208208
## Optional hosted discovery plane (opt-in)
209209

210-
The Phase 6 **hosted discovery-index** is **off by default** — unlike Orb fleet export (`ORB_AIR_GAP` is the only opt-out). Operators who want cross-fleet metadata queries or soft-claim coordination must opt in explicitly. See [`docs/discovery-plane-operator-guide.md`](docs/discovery-plane-operator-guide.md) ([#4309](https://github.com/JSONbored/loopover/issues/4309), placeholder until [#4300](https://github.com/JSONbored/loopover/issues/4300) / [#4301](https://github.com/JSONbored/loopover/issues/4301) / [#4302](https://github.com/JSONbored/loopover/issues/4302) ship).
210+
The Phase 6 **hosted discovery-index** is **off by default** — unlike Orb fleet export (`ORB_AIR_GAP` is the only opt-out). Operators who want cross-fleet metadata queries or soft-claim coordination must opt in explicitly, via `lib/discovery-index-client.js` ([#7168](https://github.com/JSONbored/loopover/issues/7168), wired against the hosted server from [#7164](https://github.com/JSONbored/loopover/issues/7164)/[#7166](https://github.com/JSONbored/loopover/issues/7166)):
211+
212+
| Variable | Read by | Purpose |
213+
| --- | --- | --- |
214+
| `LOOPOVER_MINER_DISCOVERY_PLANE` | `lib/discovery-index-client.js` | Master opt-in (truthy string, off by default). No hosted discovery-index traffic or telemetry unless set. |
215+
| `LOOPOVER_MINER_DISCOVERY_INDEX_URL` | `lib/discovery-index-client.js` | Base URL of the hosted discovery-index service. Required for the plane to do anything once enabled. |
216+
| `LOOPOVER_MINER_DISCOVERY_SHARED_SECRET` | `lib/discovery-index-client.js` | Optional bearer secret for the hosted endpoint, if it requires one. |
217+
| `LOOPOVER_MINER_DISCOVERY_TELEMETRY` | `lib/discovery-index-client.js` | Second, independent opt-in for anonymized operational telemetry — can stay off while the plane itself is queried/claimed against. |
218+
219+
See [`docs/discovery-plane-operator-guide.md`](docs/discovery-plane-operator-guide.md) for the full invariant list (metadata-only, no compensation signals, credentials stay local).

packages/loopover-miner/docs/env-reference.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ Generated by `npm run miner:env-reference`. Do not edit manually.
1313
| `LOOPOVER_MINER_CONFIG_DIR` | `lib/deny-hook-synthesis.js` | `""` |
1414
| `LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB` | `lib/contribution-profile-cache.js` | (none) |
1515
| `LOOPOVER_MINER_DENY_HOOK_SYNTHESIS_DB` | `lib/deny-hook-synthesis.js` | `""` |
16+
| `LOOPOVER_MINER_DISCOVERY_INDEX_URL` | `lib/discovery-index-client.js` | `""` |
17+
| `LOOPOVER_MINER_DISCOVERY_PLANE` | `lib/discovery-index-client.js` | `""` |
18+
| `LOOPOVER_MINER_DISCOVERY_SHARED_SECRET` | `lib/discovery-index-client.js` | `""` |
19+
| `LOOPOVER_MINER_DISCOVERY_TELEMETRY` | `lib/discovery-index-client.js` | `""` |
1620
| `LOOPOVER_MINER_EVENT_LEDGER_DB` | `lib/event-ledger.js` | (none) |
1721
| `LOOPOVER_MINER_GOVERNOR_LEDGER_DB` | `lib/governor-ledger.js` | (none) |
1822
| `LOOPOVER_MINER_GOVERNOR_STATE_DB` | `lib/governor-state.js` | (none) |

packages/loopover-miner/lib/attempt-cli.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { resolveMinerGoalSpec } from "./miner-goal-spec.js";
1515
import type { ClaimConflictResult, resolveClaimConflict } from "./claim-conflict-resolver.js";
1616
import type { recordOwnSubmission } from "./governor-state.js";
1717
import type { getAttemptHistory } from "./portfolio-queue.js";
18+
import type { submitSoftClaim } from "./discovery-index-client.js";
1819

1920
type CommonAttemptResultFields = {
2021
repoFullName: string;
@@ -96,6 +97,9 @@ export type RunAttemptOptions = {
9697
resolveClaimConflict?: typeof resolveClaimConflict;
9798
recordOwnSubmission?: typeof recordOwnSubmission;
9899
getAttemptHistory?: typeof getAttemptHistory;
100+
/** Hosted soft-claim coordination at work-start/work-end, when the plane is enabled (#7168). Defaults to
101+
* discovery-index-client.js's own submitSoftClaim. */
102+
submitSoftClaim?: typeof submitSoftClaim;
99103
/** Invoked with the real structured result at every return point, in addition to (never instead of) the
100104
* plain exit-code return -- the loop orchestrator's real hook into what actually happened. */
101105
onResult?: (result: AttemptCliResult) => void;

packages/loopover-miner/lib/attempt-cli.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { getAttemptHistory } from "./portfolio-queue.js";
4040
import { loadReputationHistory, recordOwnSubmission } from "./governor-state.js";
4141
import { runMinerAttempt } from "./attempt-runner.js";
4242
import { resolveGitHubToken } from "./github-token-resolution.js";
43+
import { isDiscoveryPlaneEnabled, submitSoftClaim } from "./discovery-index-client.js";
4344

4445
const ATTEMPT_USAGE =
4546
"Usage: loopover-miner attempt <owner/repo> <issue#> --miner-login <login> [--base <branch>] [--live] [--dry-run] [--json]";
@@ -210,6 +211,7 @@ export async function runAttempt(args, options = {}) {
210211
let allocation = null;
211212
let worktreeResult = null;
212213
let claimedIssue = false;
214+
let claimRecord = null;
213215

214216
try {
215217
allocator = (options.openWorktreeAllocator ?? openWorktreeAllocator)();
@@ -517,8 +519,19 @@ export async function runAttempt(args, options = {}) {
517519
return 11;
518520
}
519521

520-
const claimRecord = claimResult.claim;
522+
claimRecord = claimResult.claim;
521523
claimedIssue = true;
524+
// Hosted soft-claim coordination (#7168), opt-in via LOOPOVER_MINER_DISCOVERY_PLANE -- gated HERE at the
525+
// call site (not left to submitSoftClaim's own internal check alone) so a disabled plane costs zero calls,
526+
// matching discover-cli.js's supplementWithDiscoveryIndex gating; a caller-injected options.submitSoftClaim
527+
// (tests, or a future programmatic caller) can't accidentally bypass the opt-in this way either. Awaited
528+
// (not fire-and-forget) so a sibling instance racing the same issue is genuinely less likely to start
529+
// duplicate work in the window before this attempt's claim reaches the shared index -- the whole point of
530+
// coordinating BEFORE work begins, not after.
531+
if (isDiscoveryPlaneEnabled(env)) {
532+
const submitClaim = options.submitSoftClaim ?? submitSoftClaim;
533+
await submitClaim(claimRecord, { env });
534+
}
522535

523536
const runAttemptPipeline = options.runMinerAttempt ?? runMinerAttempt;
524537
let result;
@@ -701,6 +714,13 @@ export async function runAttempt(args, options = {}) {
701714
// unexpected throw) releases the soft-claim -- a claim that outlives its own attempt process would
702715
// wrongly tell a sibling miner this issue is still in flight.
703716
if (claimedIssue && claimLedger) claimLedger.releaseClaim(parsed.repoFullName, parsed.issueNumber);
717+
// Paired hosted release (#7168): same call-site opt-in gate as the claim submission above. Only fires when
718+
// the initial claim submission actually ran (claimRecord is only set once claimedIssue is), so a run that
719+
// never reached the claim point (e.g. blocked_max_concurrent_claims) has nothing to release remotely.
720+
if (claimedIssue && claimRecord && isDiscoveryPlaneEnabled(env)) {
721+
const submitClaim = options.submitSoftClaim ?? submitSoftClaim;
722+
await submitClaim({ ...claimRecord, status: "released" }, { env });
723+
}
704724
if (allocation && allocator) allocator.release(attemptId);
705725
allocator?.close();
706726
claimLedger?.close();

packages/loopover-miner/lib/discover-cli.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { PolicyVerdictCacheStore } from "./policy-verdict-cache.js";
1515
import type { EnqueueRankedDiscoverySummary } from "./portfolio-discovery.js";
1616
import type { PortfolioQueueStore } from "./portfolio-queue.js";
1717
import type { RankedCandidatesStore } from "./ranked-candidates.js";
18+
import type { queryDiscoveryIndex } from "./discovery-index-client.js";
1819

1920
export type ParsedDiscoverArgs =
2021
| {
@@ -62,6 +63,8 @@ export type DiscoverResult = {
6263
};
6364

6465
export type RunDiscoverOptions = {
66+
/** Read for the discovery-index opt-in gate (#7168) -- defaults to `process.env`. */
67+
env?: Record<string, string | undefined>;
6568
githubToken?: string;
6669
apiBaseUrl?: string;
6770
/** Per-tenant credential env var name (#4784); defaults to GITHUB_TOKEN. Overridden by a `--token-env` flag. */
@@ -94,6 +97,9 @@ export type RunDiscoverOptions = {
9497
rankedIssues: RankedCandidateIssue[],
9598
options: { queueStore: PortfolioQueueStore },
9699
) => EnqueueRankedDiscoverySummary;
100+
/** Supplements the local fan-out with hosted discovery-index results for the same scope, when the plane is
101+
* enabled (#7168). Defaults to discovery-index-client.js's own queryDiscoveryIndex. */
102+
queryDiscoveryIndex?: typeof queryDiscoveryIndex;
97103
/** Invoked with the real structured result at each success return point (dry-run and full-run), in addition
98104
* to (never instead of) the plain exit-code return -- mirrors `RunAttemptOptions.onResult`. Never fires on a
99105
* parse-error/unexpected-error `reportCliFailure` branch, matching runAttempt's own asymmetry (#6522). */

packages/loopover-miner/lib/discover-cli.js

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { extractContributionProfile } from "./contribution-profile-extract.js";
1515
import { initContributionProfileCache } from "./contribution-profile-cache.js";
1616
import { filterCandidatesByProfiles } from "./contribution-profile-filter.js";
1717
import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js";
18+
import { isDiscoveryPlaneEnabled, queryDiscoveryIndex, recordDiscoveryTelemetry } from "./discovery-index-client.js";
1819

1920
const DISCOVER_USAGE =
2021
"Usage: loopover-miner discover <owner/repo> [<owner/repo>...] | --search <query> [--dry-run] [--json] [--api-base-url <url>] [--token-env <VAR>]";
@@ -36,6 +37,35 @@ export function sanitizeDiscoverDisplayText(value) {
3637
.slice(0, MAX_DISCOVER_TITLE_DISPLAY_LENGTH);
3738
}
3839

40+
function dedupeKey(repoFullName, issueNumber) {
41+
return `${repoFullName.toLowerCase()}#${issueNumber}`;
42+
}
43+
44+
/**
45+
* Supplements `fanOut.issues` with hosted discovery-index results for the same scope (#7168) -- a complete
46+
* no-op (returns `fanOut` unchanged) unless the plane is enabled, so a run with the flag unset behaves exactly
47+
* as before this feature existed. Local results always win on a duplicate issue (the discovery-index candidate
48+
* is dropped, not merged over it) -- this instance's own live fan-out is more current than a cached shared
49+
* index entry. Discovery-index candidates lack `assignees` (not part of the public contract), so they're
50+
* annotated with an empty array to match opportunity-fanout.js's own candidate shape; contribution-profile-
51+
* filter.js's assignee-exclusion rule treats that identically to "no assignees on this issue".
52+
*/
53+
async function supplementWithDiscoveryIndex(fanOut, queryScope, options) {
54+
const env = options.env ?? process.env;
55+
if (!isDiscoveryPlaneEnabled(env)) return fanOut;
56+
const queryIndex = options.queryDiscoveryIndex ?? queryDiscoveryIndex;
57+
const response = await queryIndex(queryScope, { env });
58+
recordDiscoveryTelemetry("discover_query", response.candidates.length > 0 ? "supplemented" : "empty", { env });
59+
if (response.candidates.length === 0) return fanOut;
60+
61+
const seen = new Set(fanOut.issues.map((issue) => dedupeKey(issue.repoFullName, issue.issueNumber)));
62+
const supplemented = response.candidates
63+
.filter((candidate) => !seen.has(dedupeKey(candidate.repoFullName, candidate.issueNumber)))
64+
.map((candidate) => ({ ...candidate, assignees: [] }));
65+
if (supplemented.length === 0) return fanOut;
66+
return { ...fanOut, issues: [...fanOut.issues, ...supplemented] };
67+
}
68+
3969
function parseRepoTarget(value) {
4070
const trimmed = typeof value === "string" ? value.trim() : "";
4171
const [owner, repo, extra] = trimmed.split("/");
@@ -211,6 +241,12 @@ export async function runDiscover(args, options = {}) {
211241
// Eligibility filtering (#6798): resolve each candidate repo's ContributionProfile and drop candidates the
212242
// repo's own conventions would reject, BEFORE ranking. Safe by default -- see resolveContributionProfilesForDiscover.
213243
const resolveProfiles = options.resolveContributionProfiles ?? resolveContributionProfilesForDiscover;
244+
// Same scope this run already asks GitHub about (#7168) -- the discovery-index supplement, when enabled,
245+
// asks the shared hosted index about the identical targets/search rather than a different query entirely.
246+
const discoveryQueryScope =
247+
parsed.search !== null
248+
? { repos: [], orgs: [], searchTerms: [parsed.search] }
249+
: { repos: parsed.targets.map((target) => `${target.owner}/${target.repo}`), orgs: [], searchTerms: [] };
214250

215251
// #4847: fetch + rank are read-only GitHub GETs and pure local computation, so a dry run still does them for
216252
// real (that's the useful "what would this discover?" output) -- but it never opens any local store (portfolio
@@ -220,10 +256,11 @@ export async function runDiscover(args, options = {}) {
220256
if (parsed.dryRun) {
221257
const fanOutOptions = { apiBaseUrl, forge: options.forge, policyDocCache: null, policyVerdictCache: null };
222258
try {
223-
const fanOut =
259+
let fanOut =
224260
parsed.search !== null
225261
? await searchTargets(parsed.search, githubToken, fanOutOptions)
226262
: await fetchTargets(parsed.targets, githubToken, fanOutOptions);
263+
fanOut = await supplementWithDiscoveryIndex(fanOut, discoveryQueryScope, options);
227264
// #6798: same eligibility filter as the real path, so a dry run shows the exact candidate set a real run
228265
// would enqueue (and the same excluded set), rather than an unfiltered preview.
229266
const repoFullNames = [...new Set(fanOut.issues.map((issue) => issue.repoFullName))];
@@ -322,10 +359,11 @@ export async function runDiscover(args, options = {}) {
322359
const fanOutOptions = { apiBaseUrl, forge: options.forge, policyDocCache, policyVerdictCache };
323360

324361
try {
325-
const fanOut =
362+
let fanOut =
326363
parsed.search !== null
327364
? await searchTargets(parsed.search, githubToken, fanOutOptions)
328365
: await fetchTargets(parsed.targets, githubToken, fanOutOptions);
366+
fanOut = await supplementWithDiscoveryIndex(fanOut, discoveryQueryScope, options);
329367

330368
// Eligibility filter (#6798): drop candidates a target repo's own conventions would reject, before ranking.
331369
// A repo with no trustworthy eligibility profile keeps every candidate (filterCandidatesByProfiles' safe
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { DiscoveryIndexQuery, DiscoveryIndexResponse } from "@loopover/engine";
2+
3+
export const DISCOVERY_PLANE_FLAG: string;
4+
export const DISCOVERY_INDEX_URL_FLAG: string;
5+
export const DISCOVERY_TELEMETRY_FLAG: string;
6+
7+
export type DiscoveryIndexClientOptions = {
8+
env?: Record<string, string | undefined>;
9+
/** Always called as `fetchImpl(url, init)` with a plain string URL -- narrower than `typeof fetch` on
10+
* purpose, since that's the only shape this module ever actually calls it with. */
11+
fetchImpl?: (url: string, init: RequestInit) => Promise<Response>;
12+
requestTimeoutMs?: number;
13+
};
14+
15+
/** The shape claim-ledger.js's rowToClaim (and claimIssueWithinCap(...).claim) already produces -- passed
16+
* straight into @loopover/engine's buildSoftClaimRequest with no translation. */
17+
export type SoftClaimLedgerRecord = {
18+
repoFullName: string;
19+
issueNumber: number;
20+
claimedAt: string;
21+
status: "active" | "released" | "expired";
22+
note?: string | null;
23+
};
24+
25+
export function isDiscoveryPlaneEnabled(env?: Record<string, string | undefined>): boolean;
26+
27+
export function isDiscoveryTelemetryEnabled(env?: Record<string, string | undefined>): boolean;
28+
29+
export function queryDiscoveryIndex(
30+
query: Partial<DiscoveryIndexQuery>,
31+
options?: DiscoveryIndexClientOptions,
32+
): Promise<DiscoveryIndexResponse>;
33+
34+
export function submitSoftClaim(
35+
claim: SoftClaimLedgerRecord,
36+
options?: DiscoveryIndexClientOptions,
37+
): Promise<{ sent: boolean }>;
38+
39+
export function recordDiscoveryTelemetry(
40+
event: string,
41+
outcome: string,
42+
options?: { env?: Record<string, string | undefined> },
43+
): void;

0 commit comments

Comments
 (0)