@bli-cockpit/cli 0.1.29 → 0.1.31
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/README.md +7 -1
- package/dist/adapters/attribution-core.js +70 -1
- package/dist/adapters/claude-attribution.js +6 -5
- package/dist/adapters/codex-attribution.js +6 -5
- package/dist/adapters/common.js +2 -5
- package/dist/adapters/raw-evidence.js +29 -8
- package/dist/backfill-lock.js +108 -0
- package/dist/commands/backfill.js +964 -0
- package/dist/commands/local-args.js +59 -1
- package/dist/commands/local.js +305 -8
- package/dist/commands/session-sync.js +22 -14
- package/dist/cursors/backfill-cursor.js +130 -0
- package/dist/evidence-upload-client.js +16 -0
- package/dist/upload.js +114 -25
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -50,6 +50,9 @@ installs the latest public CLI from npm, then reruns onboarding checks to
|
|
|
50
50
|
refresh pairing, agent rules, autostart, and an initial sync against saved
|
|
51
51
|
roots. `cockpit upgrade` is a compatibility alias. `--repo <path>` remains
|
|
52
52
|
supported for older prompts and the agent ticket-binding guardrail.
|
|
53
|
+
When onboarding reports archived local sessions without a completed backfill,
|
|
54
|
+
follow [`docs/runbooks/cockpit-backfill.md`](../../docs/runbooks/cockpit-backfill.md):
|
|
55
|
+
run `cockpit update`, then run `cockpit backfill --all` as a separate command.
|
|
53
56
|
|
|
54
57
|
On machines where Codex or Claude agents will do ticketed work, `cockpit
|
|
55
58
|
onboard` refreshes `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` after harvest
|
|
@@ -176,6 +179,8 @@ Local files:
|
|
|
176
179
|
- `~/.local/state/bli-cockpit/cursors/raw-evidence.json`: hashes and labels of
|
|
177
180
|
already-durable evidence so repeated syncs skip re-uploading the same
|
|
178
181
|
content (no raw content is stored in the cursor).
|
|
182
|
+
- `~/.local/state/bli-cockpit/cursors/backfill.json`: high-water progress for
|
|
183
|
+
the explicit `cockpit backfill` command.
|
|
179
184
|
- `.codex-autorunner/contextspace/active_context.md` in the work repo when a
|
|
180
185
|
work context is active.
|
|
181
186
|
|
|
@@ -220,7 +225,8 @@ cockpit sessions --workspace "$PWD" --json
|
|
|
220
225
|
```
|
|
221
226
|
|
|
222
227
|
This preview includes both active and archived Codex sessions in the bounded
|
|
223
|
-
|
|
228
|
+
scan window, plus Claude Code sessions when Claude collection is enabled. Use
|
|
229
|
+
`--since-days N` or `--all` when debugging older local history.
|
|
224
230
|
|
|
225
231
|
Remote metadata path:
|
|
226
232
|
|
|
@@ -19,6 +19,7 @@ export const SCORE_BRANCH_MATCH = 0.15;
|
|
|
19
19
|
export const SCORE_HEAD_SHA_MATCH = 0.05;
|
|
20
20
|
export const ATTRIBUTION_MIN_SCORE = 0.4;
|
|
21
21
|
export const ATTRIBUTION_MIN_MARGIN = 0.15;
|
|
22
|
+
export const ATTRIBUTED_FALLBACK_MAX_SCORE = 0.39;
|
|
22
23
|
export const SESSION_FILE_UUID_PATTERN = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
|
|
23
24
|
export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
|
|
24
25
|
const originLabel = options.originLabel ?? "origin_url_match";
|
|
@@ -71,6 +72,9 @@ export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
|
|
|
71
72
|
const best = scored[0];
|
|
72
73
|
const secondBestScore = scored[1]?.score ?? 0;
|
|
73
74
|
if (!best || best.score === 0) {
|
|
75
|
+
if (repoPathAbsentFromDisk([...signals.cwds, ...signals.workspaceRoots], options.collectionRoots ?? [])) {
|
|
76
|
+
return skipped("repo_not_on_disk");
|
|
77
|
+
}
|
|
74
78
|
const reason = signals.cwds.length > 0 || signals.workspaceRoots.length > 0
|
|
75
79
|
? "cwd_outside_scanned_worktrees"
|
|
76
80
|
: "no_matching_worktree_signals";
|
|
@@ -78,7 +82,8 @@ export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
|
|
|
78
82
|
}
|
|
79
83
|
const score = clampScore(best.score);
|
|
80
84
|
const pathScore = clampScore(best.pathScore);
|
|
81
|
-
if (best.
|
|
85
|
+
if (best.pathScore > 0 &&
|
|
86
|
+
best.score >= ATTRIBUTION_MIN_SCORE &&
|
|
82
87
|
best.score - secondBestScore >= ATTRIBUTION_MIN_MARGIN) {
|
|
83
88
|
return {
|
|
84
89
|
state: "attributed",
|
|
@@ -89,6 +94,16 @@ export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
|
|
|
89
94
|
worktree: best.worktree,
|
|
90
95
|
};
|
|
91
96
|
}
|
|
97
|
+
if (best.pathScore === 0) {
|
|
98
|
+
const fallback = fallbackToKnownRepoPrimaryWorkContext({
|
|
99
|
+
originLabel,
|
|
100
|
+
scored,
|
|
101
|
+
signals,
|
|
102
|
+
worktrees,
|
|
103
|
+
});
|
|
104
|
+
if (fallback)
|
|
105
|
+
return fallback;
|
|
106
|
+
}
|
|
92
107
|
return {
|
|
93
108
|
state: "ambiguous",
|
|
94
109
|
reason: best.score - secondBestScore < ATTRIBUTION_MIN_MARGIN
|
|
@@ -110,6 +125,54 @@ function unattributed(reason) {
|
|
|
110
125
|
worktree: null,
|
|
111
126
|
};
|
|
112
127
|
}
|
|
128
|
+
function skipped(reason) {
|
|
129
|
+
return {
|
|
130
|
+
state: "skipped",
|
|
131
|
+
reason,
|
|
132
|
+
signals: [],
|
|
133
|
+
attribution_score: 0,
|
|
134
|
+
path_score: 0,
|
|
135
|
+
worktree: null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function fallbackToKnownRepoPrimaryWorkContext(options) {
|
|
139
|
+
const originCandidates = options.worktrees.filter((worktree) => worktree.repo_origin_url &&
|
|
140
|
+
options.signals.originUrls.includes(worktree.repo_origin_url));
|
|
141
|
+
if (originCandidates.length === 0)
|
|
142
|
+
return null;
|
|
143
|
+
const primaryByClone = originCandidates.filter((worktree) => worktree.worktree_is_primary);
|
|
144
|
+
const cloneCandidates = primaryByClone.length > 0 ? primaryByClone : originCandidates;
|
|
145
|
+
const cloneRoots = new Set(cloneCandidates.map((worktree) => path.resolve(worktree.repo_root)));
|
|
146
|
+
if (cloneRoots.size > 1) {
|
|
147
|
+
return {
|
|
148
|
+
state: "ambiguous",
|
|
149
|
+
reason: "multiple_repos_share_origin",
|
|
150
|
+
signals: [options.originLabel],
|
|
151
|
+
attribution_score: ATTRIBUTED_FALLBACK_MAX_SCORE,
|
|
152
|
+
path_score: 0,
|
|
153
|
+
worktree: null,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const primary = primaryByClone[0] ??
|
|
157
|
+
originCandidates.find((worktree) => worktree.worktree_is_primary) ??
|
|
158
|
+
originCandidates[0] ??
|
|
159
|
+
null;
|
|
160
|
+
if (!primary)
|
|
161
|
+
return null;
|
|
162
|
+
const primaryScore = options.scored.find((entry) => entry.worktree.worktree_fingerprint === primary.worktree_fingerprint);
|
|
163
|
+
const signals = new Set([
|
|
164
|
+
...(primaryScore?.matched ?? [options.originLabel]),
|
|
165
|
+
"repo_primary_fallback",
|
|
166
|
+
]);
|
|
167
|
+
return {
|
|
168
|
+
state: "attributed_fallback",
|
|
169
|
+
reason: "known_repo_primary_fallback",
|
|
170
|
+
signals: [...signals],
|
|
171
|
+
attribution_score: clampScore(Math.min(primaryScore?.score ?? SCORE_ORIGIN_MATCH, ATTRIBUTED_FALLBACK_MAX_SCORE)),
|
|
172
|
+
path_score: 0,
|
|
173
|
+
worktree: primary,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
113
176
|
/**
|
|
114
177
|
* For each path signal, the single worktree whose root most specifically
|
|
115
178
|
* contains it (the longest containing root). Returns the set of worktree
|
|
@@ -141,6 +204,12 @@ export function isPathWithin(candidate, root) {
|
|
|
141
204
|
return (normalizedCandidate === normalizedRoot ||
|
|
142
205
|
normalizedCandidate.startsWith(normalizedRoot + path.sep));
|
|
143
206
|
}
|
|
207
|
+
function repoPathAbsentFromDisk(paths, collectionRoots) {
|
|
208
|
+
if (paths.length === 0 || collectionRoots.length === 0)
|
|
209
|
+
return false;
|
|
210
|
+
const roots = collectionRoots.map((root) => path.resolve(root));
|
|
211
|
+
return paths.some((value) => roots.some((root) => isPathWithin(value, root)));
|
|
212
|
+
}
|
|
144
213
|
export function clampScore(value) {
|
|
145
214
|
return Math.min(1, Math.max(0, Number(value.toFixed(4))));
|
|
146
215
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SECRET_FILE_SEGMENT_PATTERN, containsSecretLikeContent, } from "@bli-cockpit/telemetry-core";
|
|
1
|
+
import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, SECRET_FILE_SEGMENT_PATTERN, containsSecretLikeContent, } from "@bli-cockpit/telemetry-core";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import { createReadStream } from "node:fs";
|
|
@@ -24,7 +24,7 @@ export const CLAUDE_ATTRIBUTION_DEFAULT_SINCE_MINUTES = 24 * 60;
|
|
|
24
24
|
export const CLAUDE_ATTRIBUTION_DEFAULT_SESSION_LIMIT = 50;
|
|
25
25
|
// Upload cap, NOT an attribution cap (D7): an oversized main file is still
|
|
26
26
|
// scored and attributed via a streamed read; only the file's bytes stay local.
|
|
27
|
-
export const CLAUDE_SESSION_MAX_FILE_BYTES =
|
|
27
|
+
export const CLAUDE_SESSION_MAX_FILE_BYTES = RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES;
|
|
28
28
|
export const CLAUDE_SESSION_MAX_SIDECAR_FILES = 40;
|
|
29
29
|
// JSONL lines holding a large tool result can be multi-MiB; a streamed read of
|
|
30
30
|
// an oversized main caps the per-line buffer so one giant line cannot blow
|
|
@@ -48,7 +48,7 @@ export async function scanAndAttributeClaudeSessions(options) {
|
|
|
48
48
|
.slice(0, limit);
|
|
49
49
|
const results = [];
|
|
50
50
|
for (const session of sessions) {
|
|
51
|
-
results.push(await attributeOneSession(session, options.worktrees));
|
|
51
|
+
results.push(await attributeOneSession(session, options.worktrees, options.collectionRoots ?? []));
|
|
52
52
|
}
|
|
53
53
|
const sumBy = (pick) => results.reduce((total, result) => total + pick(result), 0);
|
|
54
54
|
const countState = (state) => results.filter((result) => result.state === state).length;
|
|
@@ -69,6 +69,7 @@ export async function scanAndAttributeClaudeSessions(options) {
|
|
|
69
69
|
sidecar_stat_failed_count: discovery.sidecarStatFailedCount,
|
|
70
70
|
counts: {
|
|
71
71
|
attributed: countState("attributed"),
|
|
72
|
+
attributed_fallback: countState("attributed_fallback"),
|
|
72
73
|
ambiguous: countState("ambiguous"),
|
|
73
74
|
unattributed: countState("unattributed"),
|
|
74
75
|
skipped: countState("skipped"),
|
|
@@ -211,7 +212,7 @@ async function discoverSidecars(subagentsDir) {
|
|
|
211
212
|
statFailedCount,
|
|
212
213
|
};
|
|
213
214
|
}
|
|
214
|
-
async function attributeOneSession(session, worktrees) {
|
|
215
|
+
async function attributeOneSession(session, worktrees, collectionRoots) {
|
|
215
216
|
const fileName = path.basename(session.mainFile);
|
|
216
217
|
const base = {
|
|
217
218
|
file_path: session.mainFile,
|
|
@@ -303,7 +304,7 @@ async function attributeOneSession(session, worktrees) {
|
|
|
303
304
|
originUrls: signals.repository_urls,
|
|
304
305
|
branches: signals.branches,
|
|
305
306
|
headShas: [],
|
|
306
|
-
}, worktrees, { originLabel: "pr_repo_match" });
|
|
307
|
+
}, worktrees, { collectionRoots, originLabel: "pr_repo_match" });
|
|
307
308
|
const extraSignals = [];
|
|
308
309
|
if (base.main_file_oversized)
|
|
309
310
|
extraSignals.push("main_file_oversized");
|
|
@@ -21,8 +21,8 @@ import { sanitizeSessionId, scoreSignalsAgainstWorktrees, sessionIdFromFileName,
|
|
|
21
21
|
export { sanitizeSessionId, sessionIdFromFileName };
|
|
22
22
|
export const CODEX_ATTRIBUTION_DEFAULT_SINCE_MINUTES = 24 * 60;
|
|
23
23
|
export const CODEX_ATTRIBUTION_DEFAULT_SESSION_LIMIT = 50;
|
|
24
|
-
export const
|
|
25
|
-
export const
|
|
24
|
+
export const CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES = 14 * 24 * 60;
|
|
25
|
+
export const CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT = 500;
|
|
26
26
|
// Compatibility export only. Codex attribution streams full files for metadata
|
|
27
27
|
// and no longer rejects sessions by file size; raw evidence collection enforces
|
|
28
28
|
// upload budgets and content guards later.
|
|
@@ -42,7 +42,7 @@ export async function scanAndAttributeCodexSessions(options) {
|
|
|
42
42
|
const files = discovery.files.slice(0, limit);
|
|
43
43
|
const results = [];
|
|
44
44
|
for (const file of files) {
|
|
45
|
-
results.push(await attributeOneSession(file, options.worktrees));
|
|
45
|
+
results.push(await attributeOneSession(file, options.worktrees, options.collectionRoots ?? []));
|
|
46
46
|
}
|
|
47
47
|
return {
|
|
48
48
|
results,
|
|
@@ -57,6 +57,7 @@ export async function scanAndAttributeCodexSessions(options) {
|
|
|
57
57
|
secret_path_skipped_count: discovery.secretPathSkippedCount,
|
|
58
58
|
counts: {
|
|
59
59
|
attributed: results.filter((entry) => entry.state === "attributed").length,
|
|
60
|
+
attributed_fallback: results.filter((entry) => entry.state === "attributed_fallback").length,
|
|
60
61
|
ambiguous: results.filter((entry) => entry.state === "ambiguous").length,
|
|
61
62
|
unattributed: results.filter((entry) => entry.state === "unattributed")
|
|
62
63
|
.length,
|
|
@@ -148,7 +149,7 @@ function dedupePaths(values) {
|
|
|
148
149
|
}
|
|
149
150
|
return out;
|
|
150
151
|
}
|
|
151
|
-
async function attributeOneSession(file, worktrees) {
|
|
152
|
+
async function attributeOneSession(file, worktrees, collectionRoots) {
|
|
152
153
|
const fileName = path.basename(file.file);
|
|
153
154
|
const base = {
|
|
154
155
|
file_path: file.file,
|
|
@@ -200,7 +201,7 @@ async function attributeOneSession(file, worktrees) {
|
|
|
200
201
|
originUrls: signals.repository_urls,
|
|
201
202
|
branches: signals.branches,
|
|
202
203
|
headShas: signals.commit_hashes,
|
|
203
|
-
}, worktrees);
|
|
204
|
+
}, worktrees, { collectionRoots });
|
|
204
205
|
return { ...base, ...outcome };
|
|
205
206
|
}
|
|
206
207
|
async function readCodexMetadataSignals(filePath) {
|
package/dist/adapters/common.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SourceScanResultSchema, } from "@bli-cockpit/telemetry-core";
|
|
1
|
+
import { SourceScanResultSchema, parseTicketIdFromText, } from "@bli-cockpit/telemetry-core";
|
|
2
2
|
import { LOCAL_COLLECTOR_VERSION } from "../local-state.js";
|
|
3
3
|
export function makeSourceAdapterIdentity(captureSource, adapterName) {
|
|
4
4
|
return {
|
|
@@ -34,7 +34,4 @@ export function makeSourceScan(context, captureSource, adapterName, status, reas
|
|
|
34
34
|
diagnostic_labels: [reasonLabel],
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
|
-
export
|
|
38
|
-
const match = value.match(/\b[A-Z][A-Z0-9]{1,12}-\d+\b/);
|
|
39
|
-
return match?.[0] ?? null;
|
|
40
|
-
}
|
|
37
|
+
export { parseTicketIdFromText };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EvidenceCompletenessPayloadSchema, SECRET_FILE_SEGMENT_PATTERN, SourceScanResultSchema, containsSecretLikeContent, redactSecretLikeContent, } from "@bli-cockpit/telemetry-core";
|
|
1
|
+
import { EvidenceCompletenessPayloadSchema, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, SECRET_FILE_SEGMENT_PATTERN, SourceScanResultSchema, containsSecretLikeContent, redactSecretLikeContent, } from "@bli-cockpit/telemetry-core";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import crypto from "node:crypto";
|
|
4
4
|
import fs from "node:fs/promises";
|
|
@@ -13,14 +13,12 @@ const MAX_GIT_DIFF_BYTES = 2 * 1024 * 1024;
|
|
|
13
13
|
const GIT_DIFF_TIMEOUT_MS = 3_000;
|
|
14
14
|
export const RAW_EVIDENCE_BUCKET = "ambient-raw-evidence";
|
|
15
15
|
export const RAW_EVIDENCE_RETENTION_MODE = "remote_durable";
|
|
16
|
-
// Per-sync upload budgets enforced at COLLECTION time (D7b).
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
// (content addressing makes this convergent).
|
|
21
|
-
export const RAW_EVIDENCE_DEFAULT_BYTE_BUDGET = 512 * 1024 * 1024;
|
|
16
|
+
// Per-sync upload budgets enforced at COLLECTION time (D7b). A single marathon
|
|
17
|
+
// transcript can now approach 512 MiB, so 2 GiB leaves room for several files
|
|
18
|
+
// without starving the sync; overflow still defers and converges next run.
|
|
19
|
+
export const RAW_EVIDENCE_DEFAULT_BYTE_BUDGET = 2 * 1024 * 1024 * 1024;
|
|
22
20
|
export const RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET = 300;
|
|
23
|
-
const CLAUDE_MAX_COLLECT_FILE_BYTES =
|
|
21
|
+
const CLAUDE_MAX_COLLECT_FILE_BYTES = RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES;
|
|
24
22
|
export async function collectRawEvidencePack(context, options) {
|
|
25
23
|
const startedAt = context.now.toISOString();
|
|
26
24
|
const packId = rawEvidencePackId(context);
|
|
@@ -587,6 +585,29 @@ async function collectOneEvidenceFile(collection, options) {
|
|
|
587
585
|
});
|
|
588
586
|
return false;
|
|
589
587
|
}
|
|
588
|
+
if (options.maxFileBytes) {
|
|
589
|
+
let stat;
|
|
590
|
+
try {
|
|
591
|
+
stat = await fs.stat(options.filePath);
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
collection.skipped.push({
|
|
595
|
+
kind: options.kind,
|
|
596
|
+
label: fileName,
|
|
597
|
+
reason: "file_read_failed",
|
|
598
|
+
});
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
if (stat.size > options.maxFileBytes) {
|
|
602
|
+
markCapApplied(collection, options.kind, "max_file_bytes");
|
|
603
|
+
collection.skipped.push({
|
|
604
|
+
kind: options.kind,
|
|
605
|
+
label: fileName,
|
|
606
|
+
reason: "file_too_large",
|
|
607
|
+
});
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
590
611
|
let raw;
|
|
591
612
|
try {
|
|
592
613
|
raw = await fs.readFile(options.filePath);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const BACKFILL_LOCK_FILENAME = "backfill.lock";
|
|
5
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
6
|
+
export const BACKFILL_LOCK_STALE_TAKEOVER_MS = 5 * 60_000;
|
|
7
|
+
export async function acquireBackfillLock(paths, now = new Date()) {
|
|
8
|
+
const lockPath = backfillLockPath(paths);
|
|
9
|
+
await fs.mkdir(paths.cursors_dir, { recursive: true, mode: 0o700 });
|
|
10
|
+
const token = crypto.randomUUID();
|
|
11
|
+
const existing = await readBackfillLockRecord(lockPath);
|
|
12
|
+
if (existing &&
|
|
13
|
+
now.getTime() - existing.heartbeat_ms <= BACKFILL_LOCK_STALE_TAKEOVER_MS) {
|
|
14
|
+
return { acquired: false, held_since: existing.heartbeat_at };
|
|
15
|
+
}
|
|
16
|
+
let owned = await tryExclusiveCreate(lockPath, token, now);
|
|
17
|
+
if (!owned) {
|
|
18
|
+
const current = await readBackfillLockRecord(lockPath);
|
|
19
|
+
if (current &&
|
|
20
|
+
now.getTime() - current.heartbeat_ms <= BACKFILL_LOCK_STALE_TAKEOVER_MS) {
|
|
21
|
+
return { acquired: false, held_since: current.heartbeat_at };
|
|
22
|
+
}
|
|
23
|
+
await writeBackfillLock(lockPath, token, now);
|
|
24
|
+
owned = true;
|
|
25
|
+
}
|
|
26
|
+
const heartbeat = async () => {
|
|
27
|
+
const current = await readBackfillLockRecord(lockPath);
|
|
28
|
+
if (current && current.token === token) {
|
|
29
|
+
await writeBackfillLock(lockPath, token, new Date());
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const timer = setInterval(() => {
|
|
33
|
+
void heartbeat().catch(() => undefined);
|
|
34
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
35
|
+
timer.unref?.();
|
|
36
|
+
return {
|
|
37
|
+
acquired: true,
|
|
38
|
+
handle: {
|
|
39
|
+
heartbeat,
|
|
40
|
+
release: async () => {
|
|
41
|
+
clearInterval(timer);
|
|
42
|
+
const current = await readBackfillLockRecord(lockPath);
|
|
43
|
+
if (current && current.token === token) {
|
|
44
|
+
await fs.rm(lockPath, { force: true }).catch(() => undefined);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export async function inspectBackfillLock(paths, now = new Date()) {
|
|
51
|
+
const record = await readBackfillLockRecord(backfillLockPath(paths));
|
|
52
|
+
if (!record ||
|
|
53
|
+
now.getTime() - record.heartbeat_ms > BACKFILL_LOCK_STALE_TAKEOVER_MS) {
|
|
54
|
+
return { held: false, held_since: null };
|
|
55
|
+
}
|
|
56
|
+
return { held: true, held_since: record.heartbeat_at };
|
|
57
|
+
}
|
|
58
|
+
export function backfillLockPath(paths) {
|
|
59
|
+
return path.join(paths.cursors_dir, BACKFILL_LOCK_FILENAME);
|
|
60
|
+
}
|
|
61
|
+
async function tryExclusiveCreate(lockPath, token, now) {
|
|
62
|
+
try {
|
|
63
|
+
const handle = await fs.open(lockPath, "wx", 0o600);
|
|
64
|
+
await handle.writeFile(serializeBackfillLock(token, now));
|
|
65
|
+
await handle.close();
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function writeBackfillLock(lockPath, token, now) {
|
|
73
|
+
await fs.writeFile(lockPath, serializeBackfillLock(token, now), {
|
|
74
|
+
mode: 0o600,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function serializeBackfillLock(token, now) {
|
|
78
|
+
const record = {
|
|
79
|
+
pid: process.pid,
|
|
80
|
+
token,
|
|
81
|
+
heartbeat_at: now.toISOString(),
|
|
82
|
+
heartbeat_ms: now.getTime(),
|
|
83
|
+
};
|
|
84
|
+
return `${JSON.stringify(record)}\n`;
|
|
85
|
+
}
|
|
86
|
+
async function readBackfillLockRecord(lockPath) {
|
|
87
|
+
try {
|
|
88
|
+
const raw = JSON.parse(await fs.readFile(lockPath, "utf8"));
|
|
89
|
+
if (!raw || typeof raw !== "object")
|
|
90
|
+
return null;
|
|
91
|
+
const record = raw;
|
|
92
|
+
const heartbeatMs = record["heartbeat_ms"];
|
|
93
|
+
if (typeof heartbeatMs !== "number" || !Number.isFinite(heartbeatMs)) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
pid: typeof record["pid"] === "number" ? record["pid"] : -1,
|
|
98
|
+
token: typeof record["token"] === "string" ? record["token"] : "",
|
|
99
|
+
heartbeat_at: typeof record["heartbeat_at"] === "string"
|
|
100
|
+
? record["heartbeat_at"]
|
|
101
|
+
: new Date(heartbeatMs).toISOString(),
|
|
102
|
+
heartbeat_ms: heartbeatMs,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|