@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,52 @@
|
|
|
1
|
+
// Bounded retry-with-backoff around a single HTTP call (#4829). The miner's pollers (ci-poller and others)
|
|
2
|
+
// previously let a single brief 5xx from GitHub kill the whole poll loop, because their own attempt loop
|
|
3
|
+
// only re-polls while a conclusion is genuinely "pending", never after a server error. This wraps ONE fetch so a
|
|
4
|
+
// transient SERVER error (a 5xx RESPONSE) is retried a bounded number of times, DISTINCT from that pending-
|
|
5
|
+
// polling, sleeping an exponential backoff between attempts and giving up after `maxAttempts`. A 2xx/3xx/4xx
|
|
6
|
+
// response is returned immediately, and a THROWN error (a network-level failure) propagates unchanged rather than
|
|
7
|
+
// being retried — the pollers' existing failure-mode contract (#4281) deliberately bubbles those to the caller.
|
|
8
|
+
// Pure control flow over injected `fetchFn`/`sleepFn`/`backoffMs` — no real network or timers in tests.
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
11
|
+
const DEFAULT_BASE_BACKOFF_MS = 500;
|
|
12
|
+
const MAX_BACKOFF_MS = 10_000;
|
|
13
|
+
|
|
14
|
+
/** Clamp `maxAttempts` to a positive integer, flooring BEFORE the positivity test so a fractional value below 1
|
|
15
|
+
* falls back to the default rather than becoming a 0 that would skip every attempt. */
|
|
16
|
+
function normalizeMaxAttempts(raw) {
|
|
17
|
+
const numeric = Math.floor(Number(raw));
|
|
18
|
+
return Number.isFinite(numeric) && numeric >= 1 ? numeric : DEFAULT_MAX_ATTEMPTS;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Exponential backoff from a base delay, capped: attempt 1 → base, 2 → 2×base, 3 → 4×base, … ≤ MAX_BACKOFF_MS. */
|
|
22
|
+
export function defaultRetryBackoffMs(attempt) {
|
|
23
|
+
return Math.min(MAX_BACKOFF_MS, DEFAULT_BASE_BACKOFF_MS * 2 ** (Math.max(1, attempt) - 1));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const defaultSleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Perform `fetchFn(url, init)` with bounded retry on a transient 5xx response. A 5xx is retried (sleeping
|
|
30
|
+
* `backoffMs(attempt)` between attempts) up to `maxAttempts`; a 2xx/3xx/4xx response is returned immediately, and
|
|
31
|
+
* after the last attempt a lingering 5xx is returned as-is (the caller's own error handling still runs). A THROWN
|
|
32
|
+
* error is NOT retried — it propagates to the caller (the pollers' #4281 failure-mode contract).
|
|
33
|
+
*
|
|
34
|
+
* @param {(url: any, init?: any) => Promise<any>} fetchFn
|
|
35
|
+
* @param {any} url
|
|
36
|
+
* @param {any} [init]
|
|
37
|
+
* @param {{ maxAttempts?: number, sleepFn?: (ms: number) => Promise<unknown>, backoffMs?: (attempt: number) => number }} [options]
|
|
38
|
+
* @returns {Promise<any>} the fetch response
|
|
39
|
+
*/
|
|
40
|
+
export async function fetchWithRetry(fetchFn, url, init, options = {}) {
|
|
41
|
+
const maxAttempts = normalizeMaxAttempts(options.maxAttempts);
|
|
42
|
+
const sleepFn = typeof options.sleepFn === "function" ? options.sleepFn : defaultSleep;
|
|
43
|
+
const backoffMs = typeof options.backoffMs === "function" ? options.backoffMs : defaultRetryBackoffMs;
|
|
44
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
45
|
+
// A thrown error is intentionally NOT caught here — it propagates to the caller unchanged.
|
|
46
|
+
const response = await fetchFn(url, init);
|
|
47
|
+
// Retry only transient SERVER errors (5xx). Return 2xx/3xx/4xx immediately; on the final attempt a lingering
|
|
48
|
+
// 5xx is returned as-is.
|
|
49
|
+
if (response.status < 500 || attempt >= maxAttempts) return response;
|
|
50
|
+
await sleepFn(backoffMs(attempt));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type LaptopInitResult = {
|
|
2
|
+
stateDir: string;
|
|
3
|
+
dbPath: string;
|
|
4
|
+
created: boolean;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type DoctorCheck = {
|
|
8
|
+
name: string;
|
|
9
|
+
ok: boolean;
|
|
10
|
+
detail: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type GithubTokenVerification = {
|
|
14
|
+
ok: boolean;
|
|
15
|
+
login: string | null;
|
|
16
|
+
scopes: string[];
|
|
17
|
+
detail: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function resolveLaptopStateDbPath(env?: Record<string, string | undefined>): string;
|
|
21
|
+
|
|
22
|
+
export function initLaptopState(env?: Record<string, string | undefined>): LaptopInitResult;
|
|
23
|
+
|
|
24
|
+
export function checkLaptopStateSqlite(env?: Record<string, string | undefined>): DoctorCheck;
|
|
25
|
+
|
|
26
|
+
export function findExecutableOnPath(name: string, env?: Record<string, string | undefined>): string | null;
|
|
27
|
+
|
|
28
|
+
export function checkDockerPresent(options?: {
|
|
29
|
+
env?: Record<string, string | undefined>;
|
|
30
|
+
resolveDockerPath?: () => string | null;
|
|
31
|
+
}): DoctorCheck;
|
|
32
|
+
|
|
33
|
+
export function checkClaudeCliPresent(options?: {
|
|
34
|
+
env?: Record<string, string | undefined>;
|
|
35
|
+
resolveClaudePath?: () => string | null;
|
|
36
|
+
}): DoctorCheck;
|
|
37
|
+
|
|
38
|
+
export function checkCodexCliPresent(options?: {
|
|
39
|
+
env?: Record<string, string | undefined>;
|
|
40
|
+
resolveCodexPath?: () => string | null;
|
|
41
|
+
resolveCodexAuthPath?: () => string;
|
|
42
|
+
}): DoctorCheck;
|
|
43
|
+
|
|
44
|
+
export function resolveCodexAuthPath(env?: Record<string, string | undefined>): string;
|
|
45
|
+
|
|
46
|
+
export function verifyGithubToken(options?: {
|
|
47
|
+
githubToken?: string;
|
|
48
|
+
fetchImpl?: typeof fetch;
|
|
49
|
+
apiBaseUrl?: string;
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
}): Promise<GithubTokenVerification>;
|
|
52
|
+
|
|
53
|
+
export function runInit(args?: string[], env?: Record<string, string | undefined>): Promise<number>;
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { accessSync, chmodSync, constants, existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { delimiter, join } from "node:path";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { applySchemaMigrations } from "./schema-version.js";
|
|
6
|
+
import { reportCliFailure } from "./cli-error.js";
|
|
7
|
+
|
|
8
|
+
const githubApiBaseUrl = "https://api.github.com";
|
|
9
|
+
const githubApiVersion = "2022-11-28";
|
|
10
|
+
const classicRepoScopes = new Set(["repo", "public_repo"]);
|
|
11
|
+
const defaultDbFileName = "laptop-state.sqlite3";
|
|
12
|
+
|
|
13
|
+
/** Local state directory (mirrors `resolveMinerStateDir` in status.js — kept local to avoid import cycles). */
|
|
14
|
+
function resolveMinerStateDir(env = process.env) {
|
|
15
|
+
const explicitConfigDir = typeof env.GITTENSORY_MINER_CONFIG_DIR === "string"
|
|
16
|
+
? env.GITTENSORY_MINER_CONFIG_DIR.trim()
|
|
17
|
+
: "";
|
|
18
|
+
if (explicitConfigDir) return explicitConfigDir;
|
|
19
|
+
|
|
20
|
+
const configHome = typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim()
|
|
21
|
+
? env.XDG_CONFIG_HOME.trim()
|
|
22
|
+
: join(homedir(), ".config");
|
|
23
|
+
return join(configHome, "gittensory-miner");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Path to the laptop-mode SQLite bootstrap file inside the miner state directory. */
|
|
27
|
+
export function resolveLaptopStateDbPath(env = process.env) {
|
|
28
|
+
return join(resolveMinerStateDir(env), defaultDbFileName);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Create the state dir and SQLite file. Re-running is idempotent and never clobbers existing rows. */
|
|
32
|
+
export function initLaptopState(env = process.env) {
|
|
33
|
+
const stateDir = resolveMinerStateDir(env);
|
|
34
|
+
const dbPath = resolveLaptopStateDbPath(env);
|
|
35
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
36
|
+
const created = !existsSync(dbPath);
|
|
37
|
+
const db = new DatabaseSync(dbPath);
|
|
38
|
+
db.exec(`
|
|
39
|
+
CREATE TABLE IF NOT EXISTS laptop_meta (
|
|
40
|
+
key TEXT PRIMARY KEY,
|
|
41
|
+
value TEXT NOT NULL
|
|
42
|
+
)
|
|
43
|
+
`);
|
|
44
|
+
// Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet).
|
|
45
|
+
applySchemaMigrations(db, []);
|
|
46
|
+
if (created) {
|
|
47
|
+
db.prepare("INSERT INTO laptop_meta (key, value) VALUES ('initialized_at', ?)")
|
|
48
|
+
.run(new Date().toISOString());
|
|
49
|
+
}
|
|
50
|
+
chmodSync(dbPath, 0o600);
|
|
51
|
+
db.close();
|
|
52
|
+
return { stateDir, dbPath, created };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function checkLaptopStateSqlite(env = process.env) {
|
|
56
|
+
const dbPath = resolveLaptopStateDbPath(env);
|
|
57
|
+
if (!existsSync(dbPath)) {
|
|
58
|
+
return {
|
|
59
|
+
name: "laptop-state-sqlite",
|
|
60
|
+
ok: false,
|
|
61
|
+
detail: `${dbPath}: not found (run gittensory-miner init)`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const db = new DatabaseSync(dbPath, { readonly: true });
|
|
66
|
+
db.prepare("SELECT 1").get();
|
|
67
|
+
db.close();
|
|
68
|
+
return { name: "laptop-state-sqlite", ok: true, detail: dbPath };
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return {
|
|
71
|
+
name: "laptop-state-sqlite",
|
|
72
|
+
ok: false,
|
|
73
|
+
detail: `${dbPath}: ${error instanceof Error ? error.message : "not readable"}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Exported so callers that only need a presence boolean (e.g. status.js's `driver` section, #5164) can reuse
|
|
79
|
+
* this PATH scan directly instead of duplicating it or parsing a DoctorCheck's detail string. */
|
|
80
|
+
export function findExecutableOnPath(name, env = process.env) {
|
|
81
|
+
const pathValue = typeof env.PATH === "string" ? env.PATH : "";
|
|
82
|
+
for (const pathEntry of pathValue.split(delimiter)) {
|
|
83
|
+
if (!pathEntry) continue;
|
|
84
|
+
const candidate = join(pathEntry, name);
|
|
85
|
+
try {
|
|
86
|
+
accessSync(candidate, constants.X_OK);
|
|
87
|
+
return candidate;
|
|
88
|
+
} catch {
|
|
89
|
+
// Keep scanning: PATH often contains missing or unreadable entries.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Informational only — Docker is never required for laptop mode. */
|
|
96
|
+
export function checkDockerPresent(options = {}) {
|
|
97
|
+
const resolveDockerPath = options.resolveDockerPath
|
|
98
|
+
?? (() => findExecutableOnPath("docker", options.env));
|
|
99
|
+
const dockerPath = resolveDockerPath();
|
|
100
|
+
return {
|
|
101
|
+
name: "docker-present",
|
|
102
|
+
ok: true,
|
|
103
|
+
detail: dockerPath ? `found at ${dockerPath}` : "not installed (optional for laptop mode)",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Codex stores credentials at `$CODEX_HOME/auth.json`, else `$HOME/.codex/auth.json` — mirrors
|
|
108
|
+
// resolveCodexAuthPath in src/selfhost/ai.ts, kept local so the offline miner package never imports the
|
|
109
|
+
// Worker AI module. Exported so `doctor`'s provider-credential check (status.js, #5170) resolves the SAME
|
|
110
|
+
// path this file's own codex auth probe uses, instead of duplicating the location logic.
|
|
111
|
+
export function resolveCodexAuthPath(env = process.env) {
|
|
112
|
+
const base = env.CODEX_HOME ?? join(env.HOME ?? homedir(), ".codex");
|
|
113
|
+
return join(base, "auth.json");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function githubHeaders(githubToken) {
|
|
117
|
+
const headers = {
|
|
118
|
+
accept: "application/vnd.github+json",
|
|
119
|
+
"user-agent": "loopover-miner",
|
|
120
|
+
"x-github-api-version": githubApiVersion,
|
|
121
|
+
};
|
|
122
|
+
const token = typeof githubToken === "string" ? githubToken.trim() : "";
|
|
123
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
124
|
+
return headers;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function parseScopesHeader(scopesHeader) {
|
|
128
|
+
return typeof scopesHeader === "string" && scopesHeader.trim()
|
|
129
|
+
? scopesHeader.split(",").map((scope) => scope.trim()).filter(Boolean)
|
|
130
|
+
: [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function formatScopes(scopes) {
|
|
134
|
+
return scopes.length > 0 ? scopes.join(", ") : "none reported";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function hasRepoAccessScope(scopes) {
|
|
138
|
+
return scopes.some((scope) => classicRepoScopes.has(scope));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function readGithubErrorMessage(payload, status) {
|
|
142
|
+
if (payload && typeof payload === "object" && typeof payload.message === "string" && payload.message.trim()) {
|
|
143
|
+
return payload.message.trim();
|
|
144
|
+
}
|
|
145
|
+
return `GitHub returned HTTP ${status}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Validate a GitHub token with one authenticated API call.
|
|
150
|
+
*
|
|
151
|
+
* The classic OAuth scope header is advisory when GitHub reports it: if GitHub returns `repo` or
|
|
152
|
+
* `public_repo`, we treat the token as sufficiently scoped for miner setup. If GitHub omits the classic
|
|
153
|
+
* scope header altogether, the token is still considered valid and the response is reported as "scopes not
|
|
154
|
+
* reported" — that keeps fine-grained tokens usable while still surfacing the scopes GitHub did return.
|
|
155
|
+
*/
|
|
156
|
+
export async function verifyGithubToken(options = {}) {
|
|
157
|
+
const githubToken = typeof options.githubToken === "string" ? options.githubToken.trim() : "";
|
|
158
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
159
|
+
const apiBaseUrl =
|
|
160
|
+
typeof options.apiBaseUrl === "string" && options.apiBaseUrl.trim()
|
|
161
|
+
? options.apiBaseUrl.trim().replace(/\/+$/, "") || githubApiBaseUrl
|
|
162
|
+
: githubApiBaseUrl;
|
|
163
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 5000;
|
|
164
|
+
const controller = new AbortController();
|
|
165
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
166
|
+
|
|
167
|
+
let response;
|
|
168
|
+
try {
|
|
169
|
+
response = await fetchImpl(`${apiBaseUrl}/user`, {
|
|
170
|
+
method: "GET",
|
|
171
|
+
headers: githubHeaders(githubToken),
|
|
172
|
+
signal: controller.signal,
|
|
173
|
+
});
|
|
174
|
+
} catch (error) {
|
|
175
|
+
const detail = controller.signal.aborted
|
|
176
|
+
? `timed out after ${timeoutMs}ms`
|
|
177
|
+
: error instanceof Error
|
|
178
|
+
? error.message
|
|
179
|
+
: "request failed";
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
login: null,
|
|
183
|
+
scopes: [],
|
|
184
|
+
detail: `GITHUB_TOKEN verification failed: ${detail}`,
|
|
185
|
+
};
|
|
186
|
+
} finally {
|
|
187
|
+
clearTimeout(timeout);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const payload = await response.json().catch(() => null);
|
|
191
|
+
const scopesHeader = response.headers.get("x-oauth-scopes");
|
|
192
|
+
const scopesHeaderPresent = response.headers.has("x-oauth-scopes");
|
|
193
|
+
const scopes = parseScopesHeader(scopesHeader);
|
|
194
|
+
const login = payload && typeof payload === "object" && typeof payload.login === "string" ? payload.login.trim() : "";
|
|
195
|
+
|
|
196
|
+
if (!response.ok) {
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
login: null,
|
|
200
|
+
scopes,
|
|
201
|
+
detail: `GITHUB_TOKEN verification failed: ${readGithubErrorMessage(payload, response.status)}`,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (scopesHeaderPresent && scopes.length === 0) {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
login: login || null,
|
|
209
|
+
scopes,
|
|
210
|
+
detail: "GITHUB_TOKEN is valid, but GitHub returned an empty x-oauth-scopes header; reissue it with repo access for miner setup.",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (scopes.length > 0 && !hasRepoAccessScope(scopes)) {
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
login: login || null,
|
|
218
|
+
scopes,
|
|
219
|
+
detail: `GITHUB_TOKEN is valid, but GitHub reported only ${formatScopes(scopes)}; reissue it with repo access for miner setup.`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
ok: true,
|
|
225
|
+
login: login || null,
|
|
226
|
+
scopes,
|
|
227
|
+
detail:
|
|
228
|
+
scopes.length > 0
|
|
229
|
+
? `validated GitHub token for ${login || "unknown user"}; scopes: ${formatScopes(scopes)}`
|
|
230
|
+
: `validated GitHub token for ${login || "unknown user"}; GitHub did not report classic OAuth scopes`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** A coding-agent CLI is only needed once a driver provider is configured (#4289) — gated by
|
|
235
|
+
* `MINER_CODING_AGENT_PROVIDER` (#5165). When that provider is NOT the CLI being checked, absence is
|
|
236
|
+
* advisory (`ok: true`), mirroring checkDockerPresent's optional tone. When it IS configured and the CLI is
|
|
237
|
+
* missing, `ok: false` — every attempt will fail without it. The auth probe (once found) stays advisory
|
|
238
|
+
* either way, since an unauthenticated-but-installed CLI is a separate, already-visible warning. */
|
|
239
|
+
function codingAgentProviderConfiguredFor(env, providerName) {
|
|
240
|
+
return env.MINER_CODING_AGENT_PROVIDER === providerName;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Informational unless `MINER_CODING_AGENT_PROVIDER=claude-cli` (#5165), in which case a missing CLI fails
|
|
244
|
+
* doctor. The auth probe is read-only and never spawns the CLI: it surfaces, proactively, the SAME condition
|
|
245
|
+
* claude checks at call time — `CLAUDE_CODE_OAUTH_TOKEN` present (see createClaudeCodeAi, src/selfhost/ai.ts). */
|
|
246
|
+
export function checkClaudeCliPresent(options = {}) {
|
|
247
|
+
const env = options.env ?? process.env;
|
|
248
|
+
const claudePath = (options.resolveClaudePath ?? (() => findExecutableOnPath("claude", env)))();
|
|
249
|
+
if (!claudePath) {
|
|
250
|
+
const configured = codingAgentProviderConfiguredFor(env, "claude-cli");
|
|
251
|
+
return {
|
|
252
|
+
name: "claude-cli-present",
|
|
253
|
+
ok: !configured,
|
|
254
|
+
detail: configured
|
|
255
|
+
? "not installed — MINER_CODING_AGENT_PROVIDER is set to claude-cli, every attempt will fail without it"
|
|
256
|
+
: "not installed (optional until a coding-agent driver is configured)",
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const authed = typeof env.CLAUDE_CODE_OAUTH_TOKEN === "string" && env.CLAUDE_CODE_OAUTH_TOKEN.length > 0;
|
|
260
|
+
return {
|
|
261
|
+
name: "claude-cli-present",
|
|
262
|
+
ok: true,
|
|
263
|
+
detail: authed ? `found at ${claudePath} (authenticated)` : `found at ${claudePath} (not authenticated: set CLAUDE_CODE_OAUTH_TOKEN)`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Informational unless `MINER_CODING_AGENT_PROVIDER=codex-cli` (#5165), in which case a missing CLI fails
|
|
268
|
+
* doctor — mirrors {@link checkClaudeCliPresent}. The auth probe checks the same read-only condition
|
|
269
|
+
* assertCodexAuthConfigured uses at call time: codex's `auth.json` is readable. */
|
|
270
|
+
export function checkCodexCliPresent(options = {}) {
|
|
271
|
+
const env = options.env ?? process.env;
|
|
272
|
+
const codexPath = (options.resolveCodexPath ?? (() => findExecutableOnPath("codex", env)))();
|
|
273
|
+
if (!codexPath) {
|
|
274
|
+
const configured = codingAgentProviderConfiguredFor(env, "codex-cli");
|
|
275
|
+
return {
|
|
276
|
+
name: "codex-cli-present",
|
|
277
|
+
ok: !configured,
|
|
278
|
+
detail: configured
|
|
279
|
+
? "not installed — MINER_CODING_AGENT_PROVIDER is set to codex-cli, every attempt will fail without it"
|
|
280
|
+
: "not installed (optional until a coding-agent driver is configured)",
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const authPath = (options.resolveCodexAuthPath ?? (() => resolveCodexAuthPath(env)))();
|
|
284
|
+
let authed = false;
|
|
285
|
+
try {
|
|
286
|
+
accessSync(authPath, constants.R_OK);
|
|
287
|
+
authed = true;
|
|
288
|
+
} catch {
|
|
289
|
+
// auth.json missing or unreadable — codex would fail for lack of credentials at call time.
|
|
290
|
+
}
|
|
291
|
+
if (authed) {
|
|
292
|
+
return { name: "codex-cli-present", ok: true, detail: `found at ${codexPath} (authenticated)` };
|
|
293
|
+
}
|
|
294
|
+
// codex-cli IS the configured driver but auth.json is missing/expired: a more specific, actionable remediation
|
|
295
|
+
// than the generic advisory below, mirroring ORB's codexAuthReadinessProbe/assertCodexAuthConfigured wording
|
|
296
|
+
// (#5166). `ok` stays true either way (unchanged by this issue, see #5165) since the CLI itself IS present --
|
|
297
|
+
// only the CLI-absent case is a hard doctor failure.
|
|
298
|
+
const detail = codingAgentProviderConfiguredFor(env, "codex-cli")
|
|
299
|
+
? `found at ${codexPath} but auth.json is missing or expired — run \`codex auth\` to authenticate before attempts run`
|
|
300
|
+
: `found at ${codexPath} (not authenticated: run \`codex auth\`)`;
|
|
301
|
+
return { name: "codex-cli-present", ok: true, detail };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function runInit(args = [], env = process.env) {
|
|
305
|
+
const verifyToken = args.includes("--verify-token");
|
|
306
|
+
const jsonOutput = args.includes("--json");
|
|
307
|
+
let verification = null;
|
|
308
|
+
if (verifyToken) {
|
|
309
|
+
verification = await verifyGithubToken({ githubToken: env.GITHUB_TOKEN ?? "" });
|
|
310
|
+
if (!verification.ok) {
|
|
311
|
+
return reportCliFailure(jsonOutput, verification.detail, 1);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const result = initLaptopState(env);
|
|
316
|
+
if (jsonOutput) {
|
|
317
|
+
console.log(
|
|
318
|
+
JSON.stringify(
|
|
319
|
+
verification ? { ...result, tokenVerification: verification } : result,
|
|
320
|
+
null,
|
|
321
|
+
2,
|
|
322
|
+
),
|
|
323
|
+
);
|
|
324
|
+
} else {
|
|
325
|
+
console.log(`initialized ${result.stateDir}`);
|
|
326
|
+
console.log(`sqlite: ${result.dbPath}${result.created ? "" : " (already existed)"}`);
|
|
327
|
+
if (verification) {
|
|
328
|
+
console.log(`token: ${verification.detail}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return 0;
|
|
332
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { LiveIssueSnapshot } from "./submission-freshness-check.js";
|
|
2
|
+
|
|
3
|
+
// A narrower shape than `typeof fetch` on purpose: this module only ever calls it with a string URL and a
|
|
4
|
+
// plain POST init, and the ambient `fetch` type in this repo's TS program is Cloudflare-Workers-flavored
|
|
5
|
+
// (RequestInfo<CfProperties> | URL), which is both irrelevant here (this package runs under plain Node) and
|
|
6
|
+
// stricter than any real caller needs.
|
|
7
|
+
export type LiveIssueSnapshotFetch = (
|
|
8
|
+
url: string,
|
|
9
|
+
init: { method: string; headers: Record<string, string>; body: string },
|
|
10
|
+
) => Promise<{ ok: boolean; status: number; json: () => Promise<unknown> }>;
|
|
11
|
+
|
|
12
|
+
export function fetchLiveIssueSnapshot(
|
|
13
|
+
repoFullName: string,
|
|
14
|
+
issueNumber: number,
|
|
15
|
+
options?: { githubToken?: string; graphqlUrl?: string; fetchImpl?: LiveIssueSnapshotFetch },
|
|
16
|
+
): Promise<LiveIssueSnapshot | null>;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Real GitHub-backed fetchLiveIssueSnapshot (#5132, Wave 3.5). AttemptDeps.fetchLiveIssueSnapshot and
|
|
2
|
+
// SubmissionFreshnessDeps.fetchLiveIssueSnapshot (submission-freshness-check.js) share this one shape:
|
|
3
|
+
// "is this issue still open, and is it already addressed by another PR" -- the live-state answer
|
|
4
|
+
// checkSubmissionFreshness needs before every submission. Uses GitHub's GraphQL
|
|
5
|
+
// `closedByPullRequestsReferences` connection rather than a body-text/search-API heuristic: it's GitHub's
|
|
6
|
+
// own authoritative, closing-keyword-aware answer to "which PRs will close this issue" -- the same signal
|
|
7
|
+
// the platform itself uses to auto-close on merge, not a regex we'd have to keep in sync with GitHub's own
|
|
8
|
+
// closing-keyword parsing.
|
|
9
|
+
|
|
10
|
+
const DEFAULT_GRAPHQL_URL = "https://api.github.com/graphql";
|
|
11
|
+
const GITHUB_API_VERSION = "2022-11-28";
|
|
12
|
+
const MAX_REFERENCING_PRS = 50;
|
|
13
|
+
|
|
14
|
+
const LIVE_ISSUE_SNAPSHOT_QUERY = `
|
|
15
|
+
query($owner: String!, $repo: String!, $number: Int!, $maxPrs: Int!) {
|
|
16
|
+
repository(owner: $owner, name: $repo) {
|
|
17
|
+
issue(number: $number) {
|
|
18
|
+
state
|
|
19
|
+
closedByPullRequestsReferences(first: $maxPrs) {
|
|
20
|
+
nodes {
|
|
21
|
+
number
|
|
22
|
+
state
|
|
23
|
+
author { login }
|
|
24
|
+
createdAt
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
function githubGraphqlHeaders(githubToken) {
|
|
33
|
+
const headers = {
|
|
34
|
+
accept: "application/vnd.github+json",
|
|
35
|
+
"content-type": "application/json",
|
|
36
|
+
"user-agent": "loopover-miner",
|
|
37
|
+
"x-github-api-version": GITHUB_API_VERSION,
|
|
38
|
+
};
|
|
39
|
+
const token = typeof githubToken === "string" ? githubToken.trim() : "";
|
|
40
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
41
|
+
return headers;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeIssueOrPrState(rawState) {
|
|
45
|
+
return typeof rawState === "string" ? rawState.toLowerCase() : "";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeReferencingPr(node) {
|
|
49
|
+
if (!node || typeof node !== "object") return null;
|
|
50
|
+
if (!Number.isInteger(node.number) || node.number <= 0) return null;
|
|
51
|
+
const state = normalizeIssueOrPrState(node.state);
|
|
52
|
+
if (state !== "open" && state !== "closed" && state !== "merged") return null;
|
|
53
|
+
const authorLogin = typeof node.author?.login === "string" ? node.author.login : "";
|
|
54
|
+
// GitHub's real PR creation timestamp (ISO 8601), when present -- null otherwise (never fabricated). Not
|
|
55
|
+
// an ordering signal for the maintainer gate's own duplicate-cluster election (duplicate-winner.ts's own
|
|
56
|
+
// doc explains why: a PR can be backdated by editing an old placeholder to add the linked issue later), but
|
|
57
|
+
// it's the only real, publicly-observable claim-time proxy claim-conflict-resolver.js's own client-side
|
|
58
|
+
// caller has for a THIRD-PARTY PR -- unlike gittensory's own server, the miner has no continuous observation
|
|
59
|
+
// history to derive a true "first linked" timestamp from.
|
|
60
|
+
const createdAt = typeof node.createdAt === "string" ? node.createdAt : null;
|
|
61
|
+
return { number: node.number, state, authorLogin, createdAt };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseRepoFullName(repoFullName) {
|
|
65
|
+
if (typeof repoFullName !== "string") return null;
|
|
66
|
+
const [owner, repo, extra] = repoFullName.split("/");
|
|
67
|
+
if (!owner || !repo || extra !== undefined) return null;
|
|
68
|
+
return { owner, repo };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Real fetchLiveIssueSnapshot implementation: the live-state answer AttemptDeps/SubmissionFreshnessDeps
|
|
73
|
+
* need, built from a single GraphQL round-trip. Returns null on any malformed input, transport failure, or
|
|
74
|
+
* unrecognized GitHub response -- callers already treat a null snapshot as "state unavailable", so this
|
|
75
|
+
* never throws.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} repoFullName
|
|
78
|
+
* @param {number} issueNumber
|
|
79
|
+
* @param {{ githubToken?: string, graphqlUrl?: string, fetchImpl?: typeof fetch }} [options]
|
|
80
|
+
* @returns {Promise<import("./submission-freshness-check.js").LiveIssueSnapshot | null>}
|
|
81
|
+
*/
|
|
82
|
+
export async function fetchLiveIssueSnapshot(repoFullName, issueNumber, options = {}) {
|
|
83
|
+
const target = parseRepoFullName(repoFullName);
|
|
84
|
+
if (!target || !Number.isInteger(issueNumber) || issueNumber <= 0) return null;
|
|
85
|
+
|
|
86
|
+
const graphqlUrl =
|
|
87
|
+
typeof options.graphqlUrl === "string" && options.graphqlUrl.trim() ? options.graphqlUrl.trim() : DEFAULT_GRAPHQL_URL;
|
|
88
|
+
const githubToken = options.githubToken ?? process.env.GITHUB_TOKEN ?? "";
|
|
89
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
90
|
+
|
|
91
|
+
let response;
|
|
92
|
+
try {
|
|
93
|
+
response = await fetchImpl(graphqlUrl, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: githubGraphqlHeaders(githubToken),
|
|
96
|
+
body: JSON.stringify({
|
|
97
|
+
query: LIVE_ISSUE_SNAPSHOT_QUERY,
|
|
98
|
+
variables: { owner: target.owner, repo: target.repo, number: issueNumber, maxPrs: MAX_REFERENCING_PRS },
|
|
99
|
+
}),
|
|
100
|
+
});
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (!response.ok) return null;
|
|
105
|
+
|
|
106
|
+
const payload = await response.json().catch(() => null);
|
|
107
|
+
if (!payload || typeof payload !== "object" || payload.errors) return null;
|
|
108
|
+
|
|
109
|
+
const issue = payload.data?.repository?.issue;
|
|
110
|
+
const state = normalizeIssueOrPrState(issue?.state);
|
|
111
|
+
if (state !== "open" && state !== "closed") return null;
|
|
112
|
+
|
|
113
|
+
const nodes = Array.isArray(issue?.closedByPullRequestsReferences?.nodes) ? issue.closedByPullRequestsReferences.nodes : [];
|
|
114
|
+
const referencingPrs = nodes.map(normalizeReferencingPr).filter((pr) => pr !== null);
|
|
115
|
+
|
|
116
|
+
return { state, referencingPrs };
|
|
117
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
2
|
+
|
|
3
|
+
export function resolveLocalStoreDbPath(
|
|
4
|
+
defaultDbFileName: string,
|
|
5
|
+
explicitEnvVarName: string,
|
|
6
|
+
env?: Record<string, string | undefined>,
|
|
7
|
+
): string;
|
|
8
|
+
|
|
9
|
+
export function normalizeLocalStoreDbPath(
|
|
10
|
+
dbPath: string | null | undefined,
|
|
11
|
+
resolvedDefault: string,
|
|
12
|
+
invalidPathError: string,
|
|
13
|
+
): string;
|
|
14
|
+
|
|
15
|
+
export function openLocalStoreDb(
|
|
16
|
+
resolvedPath: string,
|
|
17
|
+
options?: { busyTimeoutMs?: number },
|
|
18
|
+
): DatabaseSync;
|
|
@@ -0,0 +1,62 @@
|
|
|
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 { registerCleanupResource } from "./process-lifecycle.js";
|
|
6
|
+
|
|
7
|
+
// Shared path-resolution + DB-open boilerplate for the package's local SQLite stores (#4272). This is a DRY pass
|
|
8
|
+
// only, not a merge: run-state.js, claim-ledger.js, portfolio-queue.js, and event-ledger.js each keep their own
|
|
9
|
+
// `.sqlite3` file, table, and env var — this module just extracts the ~15 lines each hand-duplicated
|
|
10
|
+
// (env-var/config-dir/XDG path resolution, mkdirSync(0o700) + chmodSync(0o600), and `PRAGMA busy_timeout`).
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a local store's DB path from, in order: an explicit env var, `GITTENSORY_MINER_CONFIG_DIR`,
|
|
14
|
+
* `XDG_CONFIG_HOME` (falling back to `~/.config`) — mirroring every store's prior hand-written resolver.
|
|
15
|
+
*/
|
|
16
|
+
export function resolveLocalStoreDbPath(defaultDbFileName, explicitEnvVarName, env = process.env) {
|
|
17
|
+
const explicitPath = typeof env[explicitEnvVarName] === "string" ? env[explicitEnvVarName].trim() : "";
|
|
18
|
+
if (explicitPath) return explicitPath;
|
|
19
|
+
|
|
20
|
+
const explicitConfigDir = typeof env.GITTENSORY_MINER_CONFIG_DIR === "string"
|
|
21
|
+
? env.GITTENSORY_MINER_CONFIG_DIR.trim()
|
|
22
|
+
: "";
|
|
23
|
+
if (explicitConfigDir) return join(explicitConfigDir, defaultDbFileName);
|
|
24
|
+
|
|
25
|
+
const configHome = typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim()
|
|
26
|
+
? env.XDG_CONFIG_HOME.trim()
|
|
27
|
+
: join(homedir(), ".config");
|
|
28
|
+
return join(configHome, "gittensory-miner", defaultDbFileName);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Trim and validate a caller-supplied (or resolved-default) DB path, throwing `invalidPathError` if it is empty. */
|
|
32
|
+
export function normalizeLocalStoreDbPath(dbPath, resolvedDefault, invalidPathError) {
|
|
33
|
+
const raw = dbPath ?? resolvedDefault;
|
|
34
|
+
if (typeof raw !== "string" || !raw.trim()) throw new Error(invalidPathError);
|
|
35
|
+
return raw.trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Open (creating parent dirs on first use) a local store's SQLite file with 0700/0600 permissions and a shared
|
|
40
|
+
* busy-timeout, so two instances of the same store on one file serialize writes instead of racing. Skips the
|
|
41
|
+
* mkdir/chmod steps for the special `:memory:` path, which has no on-disk file. `run-state.js` previously opened
|
|
42
|
+
* its DB with no busy-timeout at all (the one inconsistency among the four stores this issue found); folding it
|
|
43
|
+
* through this shared helper gives it the same wait-don't-fail behavior the other three already had.
|
|
44
|
+
*/
|
|
45
|
+
export function openLocalStoreDb(resolvedPath, options = {}) {
|
|
46
|
+
const busyTimeoutMs = options.busyTimeoutMs ?? 5000;
|
|
47
|
+
const isMemory = resolvedPath === ":memory:";
|
|
48
|
+
if (!isMemory) mkdirSync(dirname(resolvedPath), { recursive: true, mode: 0o700 });
|
|
49
|
+
const db = new DatabaseSync(resolvedPath);
|
|
50
|
+
if (!isMemory) chmodSync(resolvedPath, 0o600);
|
|
51
|
+
db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
|
|
52
|
+
// Crash-safety (#4826): register every opened store so a SIGINT/SIGTERM/uncaught-exception handler can close it
|
|
53
|
+
// mid-run instead of leaving it half-written. The normal `close()` unregisters first, so the happy path never
|
|
54
|
+
// double-closes and a long-running `loop` doesn't accumulate stale references.
|
|
55
|
+
const unregister = registerCleanupResource(db);
|
|
56
|
+
const originalClose = db.close.bind(db);
|
|
57
|
+
db.close = () => {
|
|
58
|
+
unregister();
|
|
59
|
+
return originalClose();
|
|
60
|
+
};
|
|
61
|
+
return db;
|
|
62
|
+
}
|