@bli-cockpit/cli 0.2.52 → 0.2.54

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.
@@ -1,217 +1,51 @@
1
- // Session attribution + ambient sync engine for the local collector. Split out
2
- // of commands/local.ts so the command runners read as a table of contents; this
3
- // is the core dual-source (Codex + Claude) scan/attribute/upload/report pass.
4
- //
5
- // `runAttributedWorktreeSync` is the whole story in nine steps, in the order the
6
- // pass runs them; everything below it is one of those steps. What a run REPORTS
7
- // the per-session rows and the CLI funnel counts lives next door in
8
- // `agent-session-report.ts` and is re-exported from here (BLI-3572).
1
+ /**
2
+ * One attributed sync pass: scan both session stores, attribute what they hold,
3
+ * upload it through every target worktree, write down what happened, and judge
4
+ * the tick.
5
+ *
6
+ * This file is the sequence and nothing else. Each step lives in a named
7
+ * sibling, and every public name below is still importable from
8
+ * `./session-sync.js`, which is the address `sync.ts`, `onboard.ts`,
9
+ * `collection-report.ts` and the suites already use:
10
+ *
11
+ * - `session-sync-types.ts` — the shapes each step hands to the next.
12
+ * - `session-sync-plan.ts` — what this tick is bound by before it reads a
13
+ * file: Claude on or off, the approved roots, both cursors, and how wide a
14
+ * retry has to reopen the window.
15
+ * - `session-sync-attribution.ts` — which sessions live sync may collect and
16
+ * which worktrees it collects them through, including the synthesized
17
+ * fallback targets git discovery never produced.
18
+ * - `session-sync-scan.ts` — reading both session stores under the window the
19
+ * plan chose, and settling each source's scan-retry bookkeeping.
20
+ * - `session-sync-upload.ts` — sending each worktree's transcripts under one
21
+ * shared raw-evidence budget, with the Claude growth damper.
22
+ * - `session-sync-record.ts` — the session report, then both cursors, in that
23
+ * order.
24
+ * - `session-sync-failures.ts` — the ONE place a label is written; the
25
+ * class-lock test reads that file (BLI-3551).
26
+ * - `session-sync-health.ts` — the verdict, and the notice a quiet-but-working
27
+ * machine still emits.
28
+ *
29
+ * What a run REPORTS — the per-session rows and the CLI funnel counts — lives
30
+ * next door in `agent-session-report.ts` and is re-exported from here
31
+ * (BLI-3572). A new sibling must also join `scripts/build-public-cli.mjs`
32
+ * `runtimeFiles`, or the repo tests stay green while the packed CLI breaks.
33
+ */
9
34
  import os from "node:os";
10
- import path from "node:path";
11
- import { getCollectorRuntimePaths, readLocalCollectorConfig, startLocalWorkContext, startLocalWorkContextForAttributedTarget, } from "../local-state.js";
12
- import { describeError } from "../health-detail.js";
13
- import { buildAgentSessionReport, buildAgentSessionSummary, claudeAttributionReadFailureCount, codexAttributionReadFailureCount, } from "./agent-session-report.js";
14
- import { flushPendingCodexSessionReports, LocalUploadBlockedError, queueCodexSessionReport, syncLocalAmbientEnvelope, } from "../upload.js";
15
- import { CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
16
- import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
17
- import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
18
- import { CLAUDE_CURSOR_FILENAME, countStaleSessions, emptyRawEvidenceCursorState, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor } from "../cursors/raw-evidence-cursor.js";
19
- import { normalizeCollectionRoots } from "../root-normalization.js";
20
- import { clearSourceRetryFailure, readLocalUploadSpoolState, recordSourceRetryFailure, } from "../spool/local-spool.js";
21
- import { isLiveRawEvidenceSyncAttribution } from "../raw-evidence-attribution-policy.js";
35
+ import { getCollectorRuntimePaths } from "../local-state.js";
36
+ import { buildAgentSessionReport, buildAgentSessionSummary, } from "./agent-session-report.js";
37
+ import { planSyncFromLocalState } from "./session-sync-plan.js";
38
+ import { scanAndAttributeBothSources } from "./session-sync-scan.js";
39
+ import { syncEveryTargetWorktree } from "./session-sync-upload.js";
40
+ import { reportSessionsAndAdvanceCursors } from "./session-sync-record.js";
41
+ import { decideSyncHealth } from "./session-sync-health.js";
22
42
  // What a run reports lives in `agent-session-report.ts`; it is re-exported here
23
43
  // because `./session-sync.js` is the address every caller already knows.
24
44
  export { ATTRIBUTION_STATE_RANK, buildAgentSessionReport, } from "./agent-session-report.js";
25
- /**
26
- * The label used when a sync fails and nothing on the way there said why.
27
- *
28
- * A deliberate sentinel rather than a fallback to `sync_failed`: it means the
29
- * gate is real but its reason is unrecorded, which is a bug in this file, and
30
- * it should be visible as one instead of blending into the generic bucket.
31
- */
32
- export const SYNC_FAILED_WITHOUT_REASON = "sync_failed_reason_not_recorded";
33
- const CLAUDE_FIRST_RUN_BACKFILL_MINUTES = 14 * 24 * 60;
34
- const CLAUDE_DAMP_GROWTH_BYTES = 256 * 1024;
35
- const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
36
- /**
37
- * Live sync remains reason-allowlisted even though historical backfill accepts
38
- * every deterministic fallback state.
39
- */
40
- export function isLiveSyncCollectableAttributionState(state, reason, hasApprovedWorkspace = false) {
41
- return isLiveRawEvidenceSyncAttribution(state, reason, hasApprovedWorkspace);
42
- }
43
- function cursorHasUndurableCollectableSession(cursor) {
44
- return Object.values(cursor.sessions).some((entry) => liveSyncCursorEntryRequiresRetry(entry));
45
- }
46
- export function liveSyncCursorEntryRequiresRetry(entry) {
47
- return (!entry.uploaded_object_key &&
48
- (isLiveSyncCollectableAttributionState(entry.state, entry.reason, Boolean(entry.worktree_fingerprint)) || entry.reason === "repo_not_on_disk"));
49
- }
50
- function allLocalHistorySinceMinutes(now) {
51
- // A cutoff just before the Unix epoch is effectively unbounded for Codex and
52
- // Claude session files while keeping date arithmetic finite and portable.
53
- return Math.ceil(now.getTime() / 60_000) + 24 * 60;
54
- }
55
- /** Whether the spool already recorded a source-scan retry for this source. */
56
- function sourceScanRetryIsPending(uploadSpool, source) {
57
- return Boolean(uploadSpool?.pending_source_retries.some((entry) => entry.source === source));
58
- }
59
- /**
60
- * Does this source have a reason to widen its scan window to all local
61
- * history instead of the normal live-sync window? Used to be two separately
62
- * written boolean chains, one per source, that happened to agree on shape by
63
- * hand — asymmetric to read even though the rule is identical for both
64
- * (BLI-3394): a legacy spooled upload with no recorded source, a scan-retry
65
- * the spool already remembers, a spooled upload that names this source, or an
66
- * undurable session this source's own cursor is still carrying.
67
- */
68
- function sourceHasPendingRetries(source, uploadSpool, legacyRetryPending, sourceScanRetryPending, cursor) {
69
- return (legacyRetryPending ||
70
- sourceScanRetryPending ||
71
- Boolean(uploadSpool?.pending_uploads.some((entry) => entry.retry_sources.includes(source))) ||
72
- cursorHasUndurableCollectableSession(cursor));
73
- }
74
- /**
75
- * Scan and attribute Codex sessions for this sync, widening the window to all
76
- * local history when a retry is pending. Codex has no first-run backfill
77
- * concept (that is a Claude-only window, see `scanClaudeSessionsForSync`) and
78
- * is always scanned, unlike Claude which can be disabled entirely.
79
- */
80
- async function scanCodexSessionsForSync(options) {
81
- const sinceMinutes = options.retryPending
82
- ? options.allHistorySinceMinutes
83
- : CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES;
84
- let scan = await scanAndAttributeCodexSessions({
85
- sessionsDirs: defaultCodexSessionDirs(options.homeDir),
86
- worktrees: options.worktrees,
87
- now: options.now,
88
- sinceMinutes,
89
- limit: CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT,
90
- collectionRoots: options.collectionRoots,
91
- });
92
- if (scan.session_limit_applied) {
93
- // Re-run once, bounded by exactly what the first pass discovered, so a
94
- // capped scan still returns every session it found instead of silently
95
- // truncating at the window's default limit.
96
- scan = await scanAndAttributeCodexSessions({
97
- sessionsDirs: defaultCodexSessionDirs(options.homeDir),
98
- worktrees: options.worktrees,
99
- now: options.now,
100
- sinceMinutes,
101
- limit: scan.discovered_file_count,
102
- collectionRoots: options.collectionRoots,
103
- });
104
- }
105
- return scan;
106
- }
107
- /**
108
- * Scan and attribute Claude sessions for this sync — the Claude counterpart to
109
- * `scanCodexSessionsForSync`. Disabled collection returns an empty scan
110
- * up front; a first sync (no cursor yet) widens the window to 14 days instead
111
- * of the normal 24h so it captures retroactive history.
112
- */
113
- async function scanClaudeSessionsForSync(options) {
114
- if (!options.claudeEnabled)
115
- return emptyClaudeScan();
116
- const sinceMinutes = options.retryPending
117
- ? options.allHistorySinceMinutes
118
- : options.firstRunBackfill
119
- ? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
120
- : undefined;
121
- const projectsDir = path.join(options.homeDir, ".claude", "projects");
122
- let scan = await scanAndAttributeClaudeSessions({
123
- projectsDir,
124
- worktrees: options.worktrees,
125
- now: options.now,
126
- collectionRoots: options.collectionRoots,
127
- sinceMinutes,
128
- });
129
- if (scan.session_limit_applied) {
130
- scan = await scanAndAttributeClaudeSessions({
131
- projectsDir,
132
- worktrees: options.worktrees,
133
- now: options.now,
134
- collectionRoots: options.collectionRoots,
135
- sinceMinutes,
136
- limit: scan.discovered_session_count,
137
- });
138
- }
139
- return scan;
140
- }
141
- export function sourceScanRetryReason(source, scan) {
142
- const reasons = new Set();
143
- const failure = sourceScanFailureReason(source, scan);
144
- if (failure)
145
- reasons.add(failure);
146
- // Kept HERE and nowhere else (BLI-3551): a repo that is not on disk is a
147
- // reason to widen the next scan window, because the transcript fallback can
148
- // still attribute it. It is not a reason to call this sync failed — see
149
- // `sourceScanFailureReason`.
150
- if (scan.results.some((result) => result.reason === "repo_not_on_disk")) {
151
- reasons.add("repo_not_on_disk");
152
- }
153
- return reasons.size > 0 ? [...reasons].sort().join(",") : null;
154
- }
155
- /**
156
- * The part of the scan outcome that is a genuine FAILURE: the session store
157
- * itself could not be read, so sessions that exist were not seen.
158
- *
159
- * Split from {@link sourceScanRetryReason} in BLI-3551. The two used to be one
160
- * function, so `repo_not_on_disk` — a label the attribution umbrella finding
161
- * already established is not a defect (nothing was deleted; the transcript
162
- * names a path git no longer tracks) — failed the sync on every tick for three
163
- * operators. A retry hint and a failure are different claims.
164
- */
165
- export function sourceScanFailureReason(source, scan) {
166
- const readFailureCount = source === "codex"
167
- ? codexAttributionReadFailureCount(scan)
168
- : claudeAttributionReadFailureCount(scan);
169
- return readFailureCount > 0 ? `${source}_session_store_read_failed` : null;
170
- }
171
- async function reconcileSourceScanRetry(options) {
172
- if (options.reason) {
173
- await recordSourceRetryFailure(options.paths, {
174
- source: options.source,
175
- reason: options.reason,
176
- attemptedAt: options.attemptedAt,
177
- });
178
- return;
179
- }
180
- if (options.pendingBefore) {
181
- await clearSourceRetryFailure(options.paths, options.source, options.attemptedAt);
182
- }
183
- }
184
- export function matchesLiveSyncWorktree(result, target) {
185
- return (isLiveRawEvidenceSyncAttribution(result.state, result.reason, result.worktree !== null) &&
186
- result.worktree !== null &&
187
- liveSyncTargetKey(result.worktree) === liveSyncTargetKey(target));
188
- }
189
- function liveSyncTargetKey(worktree) {
190
- return `${worktree.repo_fingerprint}:${worktree.worktree_fingerprint}`;
191
- }
192
- /**
193
- * Folder and deleted-repo fallbacks can intentionally synthesize a worktree
194
- * identity that is not present in git discovery. Include those identities as
195
- * sync targets so a wrapper-root session is not observed and then stranded.
196
- */
197
- export function liveSyncTargetWorktrees(discovered, results) {
198
- const targets = [...discovered];
199
- const seen = new Set(discovered.map(liveSyncTargetKey));
200
- for (const result of results) {
201
- const targetKey = result.worktree
202
- ? liveSyncTargetKey(result.worktree)
203
- : null;
204
- if (!isLiveSyncCollectableAttributionState(result.state, result.reason, result.worktree !== null) ||
205
- !result.worktree ||
206
- !targetKey ||
207
- seen.has(targetKey)) {
208
- continue;
209
- }
210
- seen.add(targetKey);
211
- targets.push(result.worktree);
212
- }
213
- return targets;
214
- }
45
+ export { isLiveSyncCollectableAttributionState, liveSyncCursorEntryRequiresRetry, liveSyncTargetWorktrees, matchesLiveSyncWorktree, } from "./session-sync-attribution.js";
46
+ export { sourceScanFailureReason, sourceScanRetryReason, } from "./session-sync-scan.js";
47
+ export { SYNC_FAILED_WITHOUT_REASON } from "./session-sync-failures.js";
48
+ export { nothingInRootCount } from "./session-sync-health.js";
215
49
  /**
216
50
  * Shared dual-source sync orchestration for single-repo and parent-folder
217
51
  * modes. Codex AND Claude Code sessions are scanned and attributed once across
@@ -295,781 +129,4 @@ export async function runAttributedWorktreeSync(options) {
295
129
  claudeAttribution: scan.claudeAttribution,
296
130
  summary,
297
131
  };
298
- }
299
- /**
300
- * Read the local state this sync is bound by — config, both cursors, the upload
301
- * spool — and turn it into the decisions the rest of the pass reads.
302
- *
303
- * Every read here degrades on purpose: a missing or corrupt local file becomes
304
- * an empty cursor or a null spool, because a collector that cannot read its own
305
- * bookkeeping must still collect.
306
- */
307
- async function planSyncFromLocalState(options) {
308
- const config = await readLocalCollectorConfig(options.paths).catch(() => null);
309
- const claudeEnabled = config?.collect_claude_jsonl !== false;
310
- const collectionRoots = normalizeCollectionRoots(options.approvedCollectionRoots ?? config?.default_repo_paths ?? []);
311
- const [codexCursorBefore, claudeCursorBefore, uploadSpool] = await Promise.all([
312
- readRawEvidenceCursor(options.paths).catch(() => emptyRawEvidenceCursorState()),
313
- claudeEnabled
314
- ? readRawEvidenceCursor(options.paths, {
315
- filename: CLAUDE_CURSOR_FILENAME,
316
- }).catch(() => emptyRawEvidenceCursorState())
317
- : Promise.resolve(emptyRawEvidenceCursorState()),
318
- readLocalUploadSpoolState(options.paths).catch(() => null),
319
- ]);
320
- // A spooled upload from a CLI old enough not to have recorded its source
321
- // could have come from either one, so both sources own it until it clears.
322
- const legacyRetryPending = Boolean(uploadSpool?.pending_uploads.some((entry) => entry.raw_evidence_file_count > 0 && entry.retry_sources.length === 0));
323
- const codexSourceRetryPending = sourceScanRetryIsPending(uploadSpool, "codex");
324
- const claudeSourceRetryPending = sourceScanRetryIsPending(uploadSpool, "claude_code");
325
- return {
326
- claudeEnabled,
327
- collectionRoots,
328
- codexCursorBefore,
329
- claudeCursorBefore,
330
- uploadSpool,
331
- codexSourceRetryPending,
332
- claudeSourceRetryPending,
333
- codexRetryPending: sourceHasPendingRetries("codex", uploadSpool, legacyRetryPending, codexSourceRetryPending, codexCursorBefore),
334
- claudeRetryPending: claudeEnabled &&
335
- sourceHasPendingRetries("claude_code", uploadSpool, legacyRetryPending, claudeSourceRetryPending, claudeCursorBefore),
336
- allHistorySinceMinutes: allLocalHistorySinceMinutes(options.now),
337
- };
338
- }
339
- /**
340
- * Scan and attribute both session sources, then settle each source's scan-retry
341
- * bookkeeping against what the scan actually found.
342
- *
343
- * Codex first, Claude second, and Claude's first-run window is decided between
344
- * them because it depends on whether the Claude cursor file exists yet.
345
- */
346
- async function scanAndAttributeBothSources(options) {
347
- const { plan } = options;
348
- const codexAttribution = await scanCodexSessionsForSync({
349
- homeDir: options.homeDir,
350
- worktrees: options.worktrees,
351
- now: options.now,
352
- collectionRoots: plan.collectionRoots,
353
- retryPending: plan.codexRetryPending,
354
- allHistorySinceMinutes: plan.allHistorySinceMinutes,
355
- });
356
- // First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
357
- // days so the first sync captures retroactive history instead of only 24h.
358
- const claudeCursorExists = await fileExists(path.join(options.paths.cursors_dir, CLAUDE_CURSOR_FILENAME));
359
- const firstRunBackfill = plan.claudeEnabled && !claudeCursorExists;
360
- const claudeAttribution = await scanClaudeSessionsForSync({
361
- claudeEnabled: plan.claudeEnabled,
362
- homeDir: options.homeDir,
363
- worktrees: options.worktrees,
364
- now: options.now,
365
- collectionRoots: plan.collectionRoots,
366
- retryPending: plan.claudeRetryPending,
367
- allHistorySinceMinutes: plan.allHistorySinceMinutes,
368
- firstRunBackfill,
369
- });
370
- await reconcileSourceScanRetry({
371
- paths: options.paths,
372
- source: "codex",
373
- attemptedAt: options.now.toISOString(),
374
- pendingBefore: plan.codexSourceRetryPending,
375
- reason: sourceScanRetryReason("codex", codexAttribution),
376
- });
377
- if (plan.claudeEnabled) {
378
- await reconcileSourceScanRetry({
379
- paths: options.paths,
380
- source: "claude_code",
381
- attemptedAt: options.now.toISOString(),
382
- pendingBefore: plan.claudeSourceRetryPending,
383
- reason: sourceScanRetryReason("claude_code", claudeAttribution),
384
- });
385
- }
386
- return { codexAttribution, claudeAttribution, firstRunBackfill };
387
- }
388
- /**
389
- * Sync every worktree this tick is responsible for, in order, under one shared
390
- * raw-evidence budget.
391
- *
392
- * The budget is per-sync (D7b), not per-worktree: a parent-folder sync over
393
- * many worktrees honors a single byte/object cap rather than N times it.
394
- */
395
- async function syncEveryTargetWorktree(options) {
396
- const { run, scan } = options;
397
- const syncWorktrees = liveSyncTargetWorktrees(run.worktrees, [
398
- ...scan.codexAttribution.results,
399
- ...scan.claudeAttribution.results,
400
- ]);
401
- const discoveredWorktreeKeys = new Set(run.worktrees.map(liveSyncTargetKey));
402
- // sessionId -> prior durable pointer for damped sessions (drives the
403
- // growth_damped count + the skip_main decision).
404
- const dampedClaudePointers = new Map();
405
- const rawEvidenceBudget = {
406
- remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
407
- remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
408
- };
409
- const outcomes = [];
410
- let everyWorktreeUploaded = true;
411
- for (const worktree of syncWorktrees) {
412
- const outcome = await syncOneWorktree({
413
- run,
414
- now: options.now,
415
- plan: options.plan,
416
- scan,
417
- worktree,
418
- syncWorktrees,
419
- rawEvidenceBudget,
420
- // A target git discovery never produced is one the fallback attribution
421
- // synthesized, and it syncs through its own attributed work context.
422
- isDiscoveredWorktree: discoveredWorktreeKeys.has(liveSyncTargetKey(worktree)),
423
- recordDampedClaudeSession: (sessionId, priorPointer) => {
424
- dampedClaudePointers.set(sessionId, priorPointer);
425
- },
426
- });
427
- everyWorktreeUploaded =
428
- everyWorktreeUploaded && outcome.sync.status === "uploaded";
429
- outcomes.push(outcome);
430
- }
431
- return {
432
- outcomes,
433
- everyWorktreeUploaded,
434
- claudePriorDurablePointers: claudeDurablePointersFromCursor(options.plan.claudeCursorBefore),
435
- growthDampedSessionCount: dampedClaudePointers.size,
436
- };
437
- }
438
- /** Every Claude session the cursor already holds a durable pointer for. */
439
- function claudeDurablePointersFromCursor(claudeCursorBefore) {
440
- const pointersBySessionId = new Map();
441
- for (const [sessionId, entry] of Object.entries(claudeCursorBefore.sessions)) {
442
- if (entry.uploaded_object_key) {
443
- pointersBySessionId.set(sessionId, entry.uploaded_object_key);
444
- }
445
- }
446
- return pointersBySessionId;
447
- }
448
- /**
449
- * Sync one worktree's attributed transcripts, starting a work context first
450
- * when this target needs one.
451
- *
452
- * A newly cloned repo has no work context yet. Capture is permissive and ticket
453
- * binding comes later, so a `missing_context` refusal starts general ambient
454
- * capture for that repo and retries, instead of blocking every other repo's
455
- * sync until someone runs `cockpit start` by hand.
456
- */
457
- async function syncOneWorktree(options) {
458
- const { run, worktree } = options;
459
- const attributedSyntheticTarget = options.isDiscoveredWorktree
460
- ? null
461
- : worktree;
462
- const contextOptions = {
463
- homeDir: run.homeDir,
464
- repoRoot: worktree.repo_root,
465
- activeTicketId: run.activeTicketId,
466
- operatorId: run.operatorId,
467
- sessionId: run.sessionId,
468
- ...(attributedSyntheticTarget ? {} : { branch: run.branch }),
469
- };
470
- const ensureContext = () => attributedSyntheticTarget
471
- ? startLocalWorkContextForAttributedTarget(contextOptions, attributedSyntheticTarget)
472
- : startLocalWorkContext(contextOptions);
473
- let context = null;
474
- if (run.startContexts || attributedSyntheticTarget) {
475
- context = await ensureContext();
476
- }
477
- const syncOptions = ambientEnvelopeForWorktree(options);
478
- let sync;
479
- try {
480
- sync = await syncLocalAmbientEnvelope(syncOptions);
481
- }
482
- catch (error) {
483
- if (error instanceof LocalUploadBlockedError &&
484
- error.blocker === "missing_context") {
485
- context = await ensureContext();
486
- sync = await syncLocalAmbientEnvelope(syncOptions);
487
- }
488
- else {
489
- throw error;
490
- }
491
- }
492
- return { worktree, context, sync };
493
- }
494
- /**
495
- * Assemble what one worktree is about to upload: its sibling worktree
496
- * inventory, its Codex mains, its Claude mains and sidecars, and its share of
497
- * the sync-wide raw-evidence budget.
498
- */
499
- function ambientEnvelopeForWorktree(options) {
500
- const { run, worktree, scan } = options;
501
- const claudeSessionFiles = claudeSessionFilesForWorktree({
502
- worktree,
503
- claudeResults: scan.claudeAttribution.results,
504
- claudeCursorBefore: options.plan.claudeCursorBefore,
505
- now: options.now,
506
- recordDampedClaudeSession: options.recordDampedClaudeSession,
507
- });
508
- return {
509
- homeDir: run.homeDir,
510
- repoRoot: worktree.repo_root,
511
- dashboardUrl: run.dashboardUrl,
512
- worktreeInventory: worktreeInventoryForRepo(worktree, options.syncWorktrees),
513
- codexSessionFiles: scan.codexAttribution.results
514
- .filter((result) => matchesLiveSyncWorktree(result, worktree))
515
- .map((result) => ({
516
- local_path: result.file_path,
517
- codex_session_id: result.codex_session_id,
518
- })),
519
- codexAttributionScan: scan.codexAttribution,
520
- claudeSessionFiles,
521
- claudeAttributionScan: scan.claudeAttribution,
522
- rawEvidenceBudget: options.rawEvidenceBudget,
523
- fetch: run.fetchImpl,
524
- };
525
- }
526
- /**
527
- * This worktree's Claude mains and sidecars, with the growth damper applied.
528
- *
529
- * A damped main is still reported — `skip_main` means "do not re-upload the
530
- * body this tick", not "forget this session" — and its prior durable pointer is
531
- * recorded so the session report can say `reused_existing` instead of flipping
532
- * the row to not_uploaded.
533
- */
534
- function claudeSessionFilesForWorktree(options) {
535
- return options.claudeResults
536
- .filter((result) => matchesLiveSyncWorktree(result, options.worktree))
537
- .map((result) => {
538
- const damped = !result.main_file_oversized &&
539
- shouldDampClaudeMain(result, options.claudeCursorBefore, options.now);
540
- if (damped) {
541
- options.recordDampedClaudeSession(result.claude_session_id, options.claudeCursorBefore.sessions[result.claude_session_id]
542
- ?.uploaded_object_key ?? null);
543
- }
544
- return {
545
- local_path: result.file_path,
546
- claude_session_id: result.claude_session_id,
547
- main_file_oversized: result.main_file_oversized,
548
- skip_main: damped,
549
- sidecar_files: result.sidecar_files
550
- .filter((sidecar) => !sidecar.skipped_reason)
551
- .map((sidecar) => ({ local_path: sidecar.local_path })),
552
- };
553
- });
554
- }
555
- /**
556
- * Report this tick's sessions and advance both cursors — in that order, which
557
- * is the load-bearing part.
558
- *
559
- * The report is queued to the spool BEFORE either cursor moves. If the process
560
- * exits in between, the spool keeps an exact retry copy; if queueing itself
561
- * fails, the cursors stay at their prior retryable positions instead of aging
562
- * an unreported session out of the live window.
563
- */
564
- async function reportSessionsAndAdvanceCursors(options) {
565
- const hadPendingSessionReports = (options.plan.uploadSpool?.pending_session_reports.length ?? 0) > 0;
566
- const queuedCurrentSessionReport = await queueSessionReportForDelivery({
567
- homeDir: options.run.homeDir,
568
- outcomes: options.outcomes,
569
- sessions: options.sessions,
570
- now: options.now,
571
- });
572
- const staleCounts = await recordBothSourceCursorObservations({
573
- paths: options.paths,
574
- plan: options.plan,
575
- scan: options.scan,
576
- sessions: options.sessions,
577
- now: options.now,
578
- });
579
- const report = await deliverOrExplainSessionReport({
580
- hadPendingSessionReports,
581
- queuedCurrentSessionReport,
582
- sessionCount: options.sessions.length,
583
- homeDir: options.run.homeDir,
584
- fetchImpl: options.run.fetchImpl,
585
- now: options.now,
586
- });
587
- return {
588
- report,
589
- reportRequired: options.sessions.length > 0 ||
590
- hadPendingSessionReports ||
591
- queuedCurrentSessionReport,
592
- ...staleCounts,
593
- };
594
- }
595
- /**
596
- * Persist the metadata-only session report to the spool, before either source
597
- * cursor advances.
598
- *
599
- * If the process exits after this point, the spool keeps an exact retry copy.
600
- * If queueing itself fails, the cursors remain at their prior retryable
601
- * positions instead of aging an unreported session out of the live window.
602
- * Returns whether a report for THIS tick was queued.
603
- */
604
- async function queueSessionReportForDelivery(options) {
605
- const firstUploaded = options.outcomes.find((outcome) => outcome.sync.status === "uploaded");
606
- if (!firstUploaded || options.sessions.length === 0)
607
- return false;
608
- const queued = await queueCodexSessionReport({
609
- homeDir: options.homeDir,
610
- dashboardUrl: firstUploaded.sync.dashboard_url,
611
- generatedAt: options.now.toISOString(),
612
- workContextId: firstUploaded.sync.work_context_id,
613
- repoLabel: firstUploaded.worktree.repo_label,
614
- branch: firstUploaded.worktree.branch,
615
- repoFingerprint: firstUploaded.worktree.repo_fingerprint,
616
- repoOriginUrl: firstUploaded.worktree.repo_origin_url,
617
- worktreeLabel: firstUploaded.worktree.worktree_label,
618
- worktreeFingerprint: firstUploaded.worktree.worktree_fingerprint,
619
- worktreeIsPrimary: firstUploaded.worktree.worktree_is_primary,
620
- sessions: options.sessions,
621
- });
622
- return Boolean(queued);
623
- }
624
- /**
625
- * Advance both source cursors to what this tick observed.
626
- *
627
- * The sessions cursor is an optimization; a broken local state dir must not
628
- * turn an already-completed sync into a CLI crash, so each side is best-effort
629
- * and names its own failure.
630
- */
631
- async function recordBothSourceCursorObservations(options) {
632
- const codexStaleCount = await recordCodexSessionObservationsForSync({
633
- paths: options.paths,
634
- codexAttribution: options.scan.codexAttribution,
635
- sessions: options.sessions,
636
- now: options.now,
637
- priorCursor: options.plan.codexCursorBefore,
638
- codexRetryPending: options.plan.codexRetryPending,
639
- });
640
- const claudeStaleCount = await recordClaudeSessionObservationsForSync({
641
- claudeEnabled: options.plan.claudeEnabled,
642
- paths: options.paths,
643
- claudeAttribution: options.scan.claudeAttribution,
644
- sessions: options.sessions,
645
- now: options.now,
646
- claudeCursorBefore: options.plan.claudeCursorBefore,
647
- claudeRetryPending: options.plan.claudeRetryPending,
648
- });
649
- return { codexStaleCount, claudeStaleCount };
650
- }
651
- /**
652
- * Flush the queued session reports — or, when there is nothing queued, say why
653
- * nothing was posted instead of returning a bare false.
654
- */
655
- async function deliverOrExplainSessionReport(options) {
656
- if (!options.hadPendingSessionReports && !options.queuedCurrentSessionReport) {
657
- return {
658
- posted: false,
659
- reason: options.sessionCount === 0
660
- ? "no_sessions_observed"
661
- : "no_successful_sync",
662
- };
663
- }
664
- return flushPendingCodexSessionReports({
665
- homeDir: options.homeDir,
666
- fetch: options.fetchImpl,
667
- now: options.now,
668
- });
669
- }
670
- /**
671
- * Decide whether this tick failed, and record every condition that decided it.
672
- *
673
- * This used to be one boolean chain: correct, and completely mute — a failed
674
- * sync exited 1 saying only `sync_failed` (BLI-2526). The conditions are the
675
- * same; they now write down what they decided, each beside the closed-registry
676
- * LABEL the health receipt is classified by (BLI-3551). Nothing parses the
677
- * rendered sentence back apart.
678
- */
679
- function decideSyncHealth(options) {
680
- const { outcomes } = options.worktreePass;
681
- const report = options.delivery.report;
682
- const ledger = createSyncFailureLedger();
683
- recordWorktreeDeliveryFailures(ledger, outcomes);
684
- recordSourceScanFailures(ledger, options.scan.codexAttribution, options.scan.claudeAttribution);
685
- const sessionsOutsideRoot = options.sessions.filter((session) => OUTSIDE_APPROVED_ROOT_REASONS.has(session.attribution_reason)).length;
686
- const nothingInRoot = nothingInRootCount({
687
- sessionCount: options.sessions.length,
688
- outsideRootCount: sessionsOutsideRoot,
689
- outcomes,
690
- reportPosted: report.posted,
691
- reportReason: report.reason,
692
- });
693
- // BLI-3551: a tick that observed only sessions from outside the operator's
694
- // approved roots has nothing to post, and that is the consent boundary
695
- // working — not a failure. It used to fail as
696
- // `session_report_unposted:no_successful_sync`, whose word "session" then
697
- // classified as `auth_failed`; one machine reported a broken credential 377
698
- // times in 38 hours while its token had eleven weeks left. The withhold
699
- // decision itself is untouched (adapters/attribution-core.ts) — only what it
700
- // is CALLED.
701
- ledger.fail(options.delivery.reportRequired && !report.posted && nothingInRoot === null, "session_report_unposted", `session_report_unposted:${report.reason ?? "unknown"}`);
702
- // `everyWorktreeUploaded` may already be false; the delivery recorder above
703
- // re-derives that from the same outcomes, so the two agree by construction.
704
- const ok = options.worktreePass.everyWorktreeUploaded && ledger.isEmpty();
705
- if (!ok && ledger.isEmpty()) {
706
- ledger.add({
707
- label: SYNC_FAILED_WITHOUT_REASON,
708
- rendered: SYNC_FAILED_WITHOUT_REASON,
709
- });
710
- }
711
- const notice = ok && nothingInRoot !== null ? `nothing_in_root:${nothingInRoot}` : null;
712
- if (nothingInRoot !== null && notice) {
713
- announceNothingInRoot(nothingInRoot, options.collectionRootCount);
714
- }
715
- const records = ledger.sortedRecords();
716
- return {
717
- ok,
718
- failure_reasons: records.map((record) => record.rendered),
719
- failure_records: records,
720
- notice,
721
- sessions_outside_root: sessionsOutsideRoot,
722
- };
723
- }
724
- /**
725
- * Every worktree that did not finish, and every raw-evidence gap it reported.
726
- *
727
- * The spooled reason is the most specific thing anyone has, so it leads, and it
728
- * names the worktree it belongs to — a fleet failure is usually one repo, and
729
- * "which one" is the first question asked.
730
- */
731
- function recordWorktreeDeliveryFailures(ledger, outcomes) {
732
- for (const { worktree, sync } of outcomes) {
733
- if (sync.status !== "uploaded") {
734
- ledger.add(sync.status === "spooled" && sync.failure_reason
735
- ? {
736
- label: sync.failure_class,
737
- rendered: `${worktree.worktree_label}:${sync.failure_reason}`,
738
- http_status: sync.failure_http_status,
739
- }
740
- : {
741
- label: "upload_not_completed",
742
- rendered: `${worktree.worktree_label}:upload_${sync.status}`,
743
- });
744
- }
745
- for (const reason of sync.raw_evidence_failure_reasons ?? []) {
746
- ledger.add({
747
- label: "raw_evidence_upload_failed",
748
- rendered: `raw_evidence:${reason}`,
749
- });
750
- }
751
- for (const reason of sync.raw_evidence_retry_reasons ?? []) {
752
- ledger.add({
753
- label: "raw_evidence_retry_required",
754
- rendered: `raw_evidence_retry:${reason}`,
755
- });
756
- }
757
- ledger.fail(sync.raw_evidence_deferred_byte_budget > 0, "deferred_byte_budget");
758
- ledger.fail(sync.raw_evidence_deferred_object_budget > 0, "deferred_object_budget");
759
- }
760
- }
761
- /**
762
- * What the scan itself got wrong: a window that hit its cap, sessions that
763
- * could not be read, or a session store that could not be read at all.
764
- *
765
- * BLI-3551: the scan's RETRY reason and the scan's FAILURE reason are two
766
- * different questions, and answering both with one function is what put
767
- * `claude_scan:repo_not_on_disk` on every tick of three machines. A repo that
768
- * is not on disk is a label on the session (the attribution umbrella finding:
769
- * nothing was deleted, the transcript simply names a path git no longer
770
- * knows). It still widens the next scan window; it is not a failed sync.
771
- */
772
- function recordSourceScanFailures(ledger, codexAttribution, claudeAttribution) {
773
- ledger.fail(codexAttribution.session_limit_applied, "codex_session_limit_applied");
774
- ledger.fail(claudeAttribution.session_limit_applied, "claude_session_limit_applied");
775
- ledger.fail(codexAttributionReadFailureCount(codexAttribution) > 0, "codex_session_read_failed");
776
- ledger.fail(claudeAttributionReadFailureCount(claudeAttribution) > 0, "claude_session_read_failed");
777
- const codexScanFailure = sourceScanFailureReason("codex", codexAttribution);
778
- if (codexScanFailure) {
779
- ledger.add({
780
- label: "codex_scan_read_failed",
781
- rendered: `codex_scan:${codexScanFailure}`,
782
- });
783
- }
784
- const claudeScanFailure = sourceScanFailureReason("claude_code", claudeAttribution);
785
- if (claudeScanFailure) {
786
- ledger.add({
787
- label: "claude_scan_read_failed",
788
- rendered: `claude_scan:${claudeScanFailure}`,
789
- });
790
- }
791
- }
792
- /**
793
- * The success branch says something too: this is the receipt that proves a
794
- * quiet machine is a working machine, and the count is what tells a coach that
795
- * someone is working entirely outside the approved boundary.
796
- */
797
- function announceNothingInRoot(sessionsOutsideRoot, collectionRootCount) {
798
- console.error("[session-sync] nothing to collect inside the approved roots", JSON.stringify({
799
- reason: "nothing_in_root",
800
- sessions_outside_root: sessionsOutsideRoot,
801
- collection_root_count: collectionRootCount,
802
- next_action: "widen the approved roots (an operator decision) if this machine should be collecting here",
803
- }));
804
- }
805
- function createSyncFailureLedger() {
806
- const recordsByRenderedReason = new Map();
807
- const add = (record) => {
808
- if (!recordsByRenderedReason.has(record.rendered)) {
809
- recordsByRenderedReason.set(record.rendered, record);
810
- }
811
- };
812
- return {
813
- add,
814
- fail(condition, label, rendered = label) {
815
- if (condition)
816
- add({ label, rendered });
817
- },
818
- isEmpty: () => recordsByRenderedReason.size === 0,
819
- sortedRecords: () => [...recordsByRenderedReason.values()].sort((a, b) => a.rendered.localeCompare(b.rendered)),
820
- };
821
- }
822
- /**
823
- * Reasons attribution gives when a session's working directory is not inside
824
- * any approved collection root.
825
- *
826
- * Exact labels, not a pattern — the same discipline the classifier now follows.
827
- * `attribution-core.ts` writes both of these and nothing else means
828
- * "outside the boundary".
829
- */
830
- const OUTSIDE_APPROVED_ROOT_REASONS = new Set([
831
- "cwd_outside_scanned_worktrees",
832
- "no_matching_worktree_signals",
833
- ]);
834
- /**
835
- * How many observed sessions were outside the approved roots, when that
836
- * accounts for ALL of them and nothing else went wrong — otherwise `null`.
837
- *
838
- * Deliberately narrow. It requires that no worktree was synced at all (so no
839
- * upload could have succeeded or failed), that every session observed this tick
840
- * names an outside-the-root reason, and that the unposted report is the
841
- * `no_successful_sync` shape rather than a spooled report that failed to flush.
842
- * Anything else keeps its failure.
843
- */
844
- export function nothingInRootCount(options) {
845
- if (options.reportPosted)
846
- return null;
847
- if (options.reportReason !== "no_successful_sync")
848
- return null;
849
- if (options.outcomes.length > 0)
850
- return null;
851
- if (options.sessionCount === 0)
852
- return null;
853
- return options.outsideRootCount === options.sessionCount
854
- ? options.outsideRootCount
855
- : null;
856
- }
857
- /**
858
- * Record this sync's Codex session observations into the Codex cursor and
859
- * return the stale count. Best-effort by design: a broken local state dir
860
- * must not turn an already-completed sync into a CLI crash, so a failure here
861
- * only logs — the stale count simply keeps whatever value it had when the
862
- * failure happened (0 if the read itself failed).
863
- *
864
- * Reads the cursor fresh from disk right before recording rather than reusing
865
- * `priorCursor`, so a concurrent writer's update is not clobbered. Claude's
866
- * counterpart does not do this extra read (see
867
- * `recordClaudeSessionObservationsForSync`) — a real, intentional asymmetry
868
- * kept as-is rather than forced to match.
869
- */
870
- async function recordCodexSessionObservationsForSync(options) {
871
- let staleCount = 0;
872
- try {
873
- const codexCursor = await readRawEvidenceCursor(options.paths);
874
- staleCount = recordSourceObservations({
875
- cursor: codexCursor,
876
- results: options.codexAttribution.results,
877
- sessions: options.sessions,
878
- source: "codex",
879
- sessionIdOf: (result) => result.codex_session_id,
880
- now: options.now,
881
- priorCursor: options.priorCursor,
882
- terminalizeMissingUndurable: options.codexRetryPending &&
883
- codexAttributionReadFailureCount(options.codexAttribution) === 0,
884
- });
885
- codexCursor.updated_at = options.now.toISOString();
886
- await writeRawEvidenceCursor(options.paths, codexCursor);
887
- }
888
- catch (error) {
889
- // Best-effort: stale counts read 0 and observations re-record next sync —
890
- // which is fine ONCE. A cursor write that keeps failing means the sessions
891
- // cursor never advances, every sync re-does the same work, and the only
892
- // symptom is a stale count that is permanently zero (BLI-3238).
893
- console.error("[session-sync] Codex session observations were not recorded", JSON.stringify({
894
- reason: "session_cursor_update_failed",
895
- source: "codex",
896
- session_count: options.sessions.length,
897
- ...describeError(error),
898
- }));
899
- }
900
- return staleCount;
901
- }
902
- /**
903
- * Record this sync's Claude session observations into the Claude cursor and
904
- * return the stale count — the Claude counterpart to
905
- * `recordCodexSessionObservationsForSync`. A no-op returning 0 when Claude
906
- * collection is disabled, matching the source-scan side's own disabled state.
907
- */
908
- async function recordClaudeSessionObservationsForSync(options) {
909
- if (!options.claudeEnabled)
910
- return 0;
911
- let staleCount = 0;
912
- try {
913
- staleCount = recordSourceObservations({
914
- cursor: options.claudeCursorBefore,
915
- results: options.claudeAttribution.results,
916
- sessions: options.sessions,
917
- source: "claude_code",
918
- sessionIdOf: (result) => result.claude_session_id,
919
- now: options.now,
920
- priorCursor: options.claudeCursorBefore,
921
- terminalizeMissingUndurable: options.claudeRetryPending &&
922
- claudeAttributionReadFailureCount(options.claudeAttribution) === 0,
923
- });
924
- options.claudeCursorBefore.updated_at = options.now.toISOString();
925
- await writeRawEvidenceCursor(options.paths, options.claudeCursorBefore, {
926
- filename: CLAUDE_CURSOR_FILENAME,
927
- sessionsOnly: true,
928
- });
929
- }
930
- catch (error) {
931
- // Best-effort: a broken Claude cursor must not fail the sync. It must
932
- // still say it is broken — otherwise the Claude half of collection
933
- // quietly repeats itself forever.
934
- console.error("[session-sync] Claude session observations were not recorded", JSON.stringify({
935
- reason: "session_cursor_update_failed",
936
- source: "claude_code",
937
- session_count: options.sessions.length,
938
- ...describeError(error),
939
- }));
940
- }
941
- return staleCount;
942
- }
943
- /**
944
- * Records per-source session observations into its cursor and returns the stale
945
- * count. Damped/reused Claude sessions carry forward their prior upload
946
- * timestamp + byte size so the 6h damping window keeps counting from the real
947
- * last upload (otherwise a slowly-growing file would never re-upload — D21).
948
- */
949
- function recordSourceObservations(options) {
950
- const seen = new Set(options.results.map((result) => options.sessionIdOf(result)));
951
- if (options.terminalizeMissingUndurable) {
952
- for (const [sessionId, entry] of Object.entries(options.cursor.sessions)) {
953
- if (seen.has(sessionId) ||
954
- !liveSyncCursorEntryRequiresRetry(entry)) {
955
- continue;
956
- }
957
- options.cursor.sessions[sessionId] = {
958
- ...entry,
959
- state: "skipped",
960
- reason: "retry_source_missing",
961
- last_seen_at: options.now.toISOString(),
962
- };
963
- }
964
- }
965
- const stale = countStaleSessions(options.cursor, seen);
966
- for (const result of options.results) {
967
- const sessionId = options.sessionIdOf(result);
968
- const reported = options.sessions.find((session) => session.source === options.source &&
969
- session.codex_session_id === sessionId);
970
- const uploadedThisSync = reported?.upload_state === "uploaded";
971
- const durableThisSync = reported?.upload_state === "uploaded" ||
972
- reported?.upload_state === "reused_existing";
973
- const prior = options.priorCursor?.sessions[sessionId];
974
- // D21 / no-flip-flop: a sync that is spooled (offline), budget-deferred, or
975
- // upload-failed for a session that was ALREADY durable must NOT wipe the
976
- // prior durable state — otherwise damping is forfeited forever and the
977
- // store row oscillates uploaded -> not_uploaded hourly. Carry the prior
978
- // durable pointer/timestamp/size forward unless we durably uploaded anew.
979
- const uploadedObjectKey = durableThisSync
980
- ? (reported?.raw_evidence_pointer_id ?? prior?.uploaded_object_key ?? null)
981
- : (prior?.uploaded_object_key ?? null);
982
- const uploadedAt = uploadedThisSync
983
- ? options.now.toISOString()
984
- : (prior?.uploaded_at ?? (durableThisSync ? options.now.toISOString() : null));
985
- const uploadedByteSize = uploadedThisSync
986
- ? result.byte_size
987
- : (prior?.uploaded_byte_size ??
988
- (durableThisSync ? result.byte_size : null));
989
- const entry = {
990
- file_hash_sha256: result.content_hash_sha256,
991
- file_mtime_ms: result.session_file_mtime_ms,
992
- byte_size: result.byte_size,
993
- // Durable byte offset reflects how many bytes are durable remotely (the
994
- // last uploaded size), not the current file size.
995
- byte_offset: uploadedByteSize ?? 0,
996
- state: result.state,
997
- reason: result.reason,
998
- worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
999
- uploaded_object_key: uploadedObjectKey,
1000
- uploaded_at: uploadedAt,
1001
- uploaded_byte_size: uploadedByteSize,
1002
- last_seen_at: options.now.toISOString(),
1003
- };
1004
- recordSessionObservation(options.cursor, sessionId, entry);
1005
- }
1006
- return stale;
1007
- }
1008
- function shouldDampClaudeMain(result, cursor, now) {
1009
- const entry = cursor.sessions[result.claude_session_id];
1010
- if (!entry ||
1011
- !entry.uploaded_object_key ||
1012
- !entry.uploaded_at ||
1013
- entry.uploaded_byte_size == null) {
1014
- return false;
1015
- }
1016
- const grew = result.byte_size > entry.uploaded_byte_size;
1017
- if (!grew)
1018
- return false; // unchanged content reuses via the object cursor
1019
- const growth = result.byte_size - entry.uploaded_byte_size;
1020
- const ageMs = now.getTime() - Date.parse(entry.uploaded_at);
1021
- return (growth <= CLAUDE_DAMP_GROWTH_BYTES &&
1022
- Number.isFinite(ageMs) &&
1023
- ageMs <= CLAUDE_DAMP_MAX_AGE_MS);
1024
- }
1025
- function emptyClaudeScan() {
1026
- return {
1027
- results: [],
1028
- discovered_session_count: 0,
1029
- scanned_session_count: 0,
1030
- since_minutes: 0,
1031
- session_limit: 0,
1032
- session_limit_applied: false,
1033
- max_file_bytes: 0,
1034
- max_sidecar_files: 0,
1035
- max_line_buffer_bytes: 0,
1036
- project_dirs_skipped: 0,
1037
- project_dir_read_failed_count: 0,
1038
- session_stat_failed_count: 0,
1039
- sidecar_dir_read_failed_count: 0,
1040
- sidecar_stat_failed_count: 0,
1041
- disabled_reason: "claude_collection_disabled_by_config",
1042
- counts: {
1043
- attributed: 0,
1044
- attributed_fallback: 0,
1045
- ambiguous: 0,
1046
- unattributed: 0,
1047
- skipped: 0,
1048
- mains_oversized: 0,
1049
- oversized_lines_skipped: 0,
1050
- sessions_schema_drift: 0,
1051
- sidecars_capped: 0,
1052
- },
1053
- };
1054
- }
1055
- function worktreeInventoryForRepo(current, worktrees) {
1056
- return worktrees
1057
- .filter((worktree) => worktree.repo_fingerprint
1058
- ? worktree.repo_fingerprint === current.repo_fingerprint
1059
- : worktree.repo_label === current.repo_label)
1060
- .map((worktree) => ({
1061
- repo: worktree.repo_root,
1062
- repo_label: worktree.repo_label,
1063
- repo_fingerprint: worktree.repo_fingerprint,
1064
- repo_origin_url: worktree.repo_origin_url ?? undefined,
1065
- head_sha: worktree.head_sha ?? undefined,
1066
- worktree_label: worktree.worktree_label,
1067
- worktree_fingerprint: worktree.worktree_fingerprint,
1068
- worktree_is_primary: worktree.worktree_is_primary,
1069
- branch: worktree.branch,
1070
- }));
1071
- }
1072
- async function fileExists(filePath) {
1073
- const { stat } = await import("node:fs/promises");
1074
- return stat(filePath).then(() => true, () => false);
1075
132
  }