@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.
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Sending this tick's attributed transcripts, one worktree at a time.
3
+ *
4
+ * The whole loop shares ONE raw-evidence budget (D7b), so a parent-folder sync
5
+ * over many worktrees honours a single byte/object cap rather than N times it,
6
+ * and the Claude growth damper lives here because it is the only step that can
7
+ * see both what the cursor already made durable and what is about to be sent.
8
+ */
9
+ import { startLocalWorkContext, startLocalWorkContextForAttributedTarget, } from "../local-state.js";
10
+ import { LocalUploadBlockedError, syncLocalAmbientEnvelope, } from "../upload.js";
11
+ import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET, } from "../adapters/raw-evidence.js";
12
+ import { liveSyncTargetKey, liveSyncTargetWorktrees, matchesLiveSyncWorktree, } from "./session-sync-attribution.js";
13
+ const CLAUDE_DAMP_GROWTH_BYTES = 256 * 1024;
14
+ const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
15
+ /**
16
+ * Sync every worktree this tick is responsible for, in order, under one shared
17
+ * raw-evidence budget.
18
+ *
19
+ * The budget is per-sync (D7b), not per-worktree: a parent-folder sync over
20
+ * many worktrees honors a single byte/object cap rather than N times it.
21
+ */
22
+ export async function syncEveryTargetWorktree(options) {
23
+ const { run, scan } = options;
24
+ const syncWorktrees = liveSyncTargetWorktrees(run.worktrees, [
25
+ ...scan.codexAttribution.results,
26
+ ...scan.claudeAttribution.results,
27
+ ]);
28
+ const discoveredWorktreeKeys = new Set(run.worktrees.map(liveSyncTargetKey));
29
+ // sessionId -> prior durable pointer for damped sessions (drives the
30
+ // growth_damped count + the skip_main decision).
31
+ const dampedClaudePointers = new Map();
32
+ const rawEvidenceBudget = {
33
+ remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
34
+ remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
35
+ };
36
+ const outcomes = [];
37
+ let everyWorktreeUploaded = true;
38
+ for (const worktree of syncWorktrees) {
39
+ const outcome = await syncOneWorktree({
40
+ run,
41
+ now: options.now,
42
+ plan: options.plan,
43
+ scan,
44
+ worktree,
45
+ syncWorktrees,
46
+ rawEvidenceBudget,
47
+ // A target git discovery never produced is one the fallback attribution
48
+ // synthesized, and it syncs through its own attributed work context.
49
+ isDiscoveredWorktree: discoveredWorktreeKeys.has(liveSyncTargetKey(worktree)),
50
+ recordDampedClaudeSession: (sessionId, priorPointer) => {
51
+ dampedClaudePointers.set(sessionId, priorPointer);
52
+ },
53
+ });
54
+ everyWorktreeUploaded =
55
+ everyWorktreeUploaded && outcome.sync.status === "uploaded";
56
+ outcomes.push(outcome);
57
+ }
58
+ return {
59
+ outcomes,
60
+ everyWorktreeUploaded,
61
+ claudePriorDurablePointers: claudeDurablePointersFromCursor(options.plan.claudeCursorBefore),
62
+ growthDampedSessionCount: dampedClaudePointers.size,
63
+ };
64
+ }
65
+ /** Every Claude session the cursor already holds a durable pointer for. */
66
+ function claudeDurablePointersFromCursor(claudeCursorBefore) {
67
+ const pointersBySessionId = new Map();
68
+ for (const [sessionId, entry] of Object.entries(claudeCursorBefore.sessions)) {
69
+ if (entry.uploaded_object_key) {
70
+ pointersBySessionId.set(sessionId, entry.uploaded_object_key);
71
+ }
72
+ }
73
+ return pointersBySessionId;
74
+ }
75
+ /**
76
+ * Sync one worktree's attributed transcripts, starting a work context first
77
+ * when this target needs one.
78
+ *
79
+ * A newly cloned repo has no work context yet. Capture is permissive and ticket
80
+ * binding comes later, so a `missing_context` refusal starts general ambient
81
+ * capture for that repo and retries, instead of blocking every other repo's
82
+ * sync until someone runs `cockpit start` by hand.
83
+ */
84
+ async function syncOneWorktree(options) {
85
+ const { run, worktree } = options;
86
+ const attributedSyntheticTarget = options.isDiscoveredWorktree
87
+ ? null
88
+ : worktree;
89
+ const contextOptions = {
90
+ homeDir: run.homeDir,
91
+ repoRoot: worktree.repo_root,
92
+ activeTicketId: run.activeTicketId,
93
+ operatorId: run.operatorId,
94
+ sessionId: run.sessionId,
95
+ ...(attributedSyntheticTarget ? {} : { branch: run.branch }),
96
+ };
97
+ const ensureContext = () => attributedSyntheticTarget
98
+ ? startLocalWorkContextForAttributedTarget(contextOptions, attributedSyntheticTarget)
99
+ : startLocalWorkContext(contextOptions);
100
+ let context = null;
101
+ if (run.startContexts || attributedSyntheticTarget) {
102
+ context = await ensureContext();
103
+ }
104
+ const syncOptions = ambientEnvelopeForWorktree(options);
105
+ let sync;
106
+ try {
107
+ sync = await syncLocalAmbientEnvelope(syncOptions);
108
+ }
109
+ catch (error) {
110
+ if (error instanceof LocalUploadBlockedError &&
111
+ error.blocker === "missing_context") {
112
+ context = await ensureContext();
113
+ sync = await syncLocalAmbientEnvelope(syncOptions);
114
+ }
115
+ else {
116
+ throw error;
117
+ }
118
+ }
119
+ return { worktree, context, sync };
120
+ }
121
+ /**
122
+ * Assemble what one worktree is about to upload: its sibling worktree
123
+ * inventory, its Codex mains, its Claude mains and sidecars, and its share of
124
+ * the sync-wide raw-evidence budget.
125
+ */
126
+ function ambientEnvelopeForWorktree(options) {
127
+ const { run, worktree, scan } = options;
128
+ const claudeSessionFiles = claudeSessionFilesForWorktree({
129
+ worktree,
130
+ claudeResults: scan.claudeAttribution.results,
131
+ claudeCursorBefore: options.plan.claudeCursorBefore,
132
+ now: options.now,
133
+ recordDampedClaudeSession: options.recordDampedClaudeSession,
134
+ });
135
+ return {
136
+ homeDir: run.homeDir,
137
+ repoRoot: worktree.repo_root,
138
+ dashboardUrl: run.dashboardUrl,
139
+ worktreeInventory: worktreeInventoryForRepo(worktree, options.syncWorktrees),
140
+ codexSessionFiles: scan.codexAttribution.results
141
+ .filter((result) => matchesLiveSyncWorktree(result, worktree))
142
+ .map((result) => ({
143
+ local_path: result.file_path,
144
+ codex_session_id: result.codex_session_id,
145
+ })),
146
+ codexAttributionScan: scan.codexAttribution,
147
+ claudeSessionFiles,
148
+ claudeAttributionScan: scan.claudeAttribution,
149
+ rawEvidenceBudget: options.rawEvidenceBudget,
150
+ fetch: run.fetchImpl,
151
+ };
152
+ }
153
+ /**
154
+ * This worktree's Claude mains and sidecars, with the growth damper applied.
155
+ *
156
+ * A damped main is still reported — `skip_main` means "do not re-upload the
157
+ * body this tick", not "forget this session" — and its prior durable pointer is
158
+ * recorded so the session report can say `reused_existing` instead of flipping
159
+ * the row to not_uploaded.
160
+ */
161
+ function claudeSessionFilesForWorktree(options) {
162
+ return options.claudeResults
163
+ .filter((result) => matchesLiveSyncWorktree(result, options.worktree))
164
+ .map((result) => {
165
+ const damped = !result.main_file_oversized &&
166
+ shouldDampClaudeMain(result, options.claudeCursorBefore, options.now);
167
+ if (damped) {
168
+ options.recordDampedClaudeSession(result.claude_session_id, options.claudeCursorBefore.sessions[result.claude_session_id]
169
+ ?.uploaded_object_key ?? null);
170
+ }
171
+ return {
172
+ local_path: result.file_path,
173
+ claude_session_id: result.claude_session_id,
174
+ main_file_oversized: result.main_file_oversized,
175
+ skip_main: damped,
176
+ sidecar_files: result.sidecar_files
177
+ .filter((sidecar) => !sidecar.skipped_reason)
178
+ .map((sidecar) => ({ local_path: sidecar.local_path })),
179
+ };
180
+ });
181
+ }
182
+ function shouldDampClaudeMain(result, cursor, now) {
183
+ const entry = cursor.sessions[result.claude_session_id];
184
+ if (!entry ||
185
+ !entry.uploaded_object_key ||
186
+ !entry.uploaded_at ||
187
+ entry.uploaded_byte_size == null) {
188
+ return false;
189
+ }
190
+ const grew = result.byte_size > entry.uploaded_byte_size;
191
+ if (!grew)
192
+ return false; // unchanged content reuses via the object cursor
193
+ const growth = result.byte_size - entry.uploaded_byte_size;
194
+ const ageMs = now.getTime() - Date.parse(entry.uploaded_at);
195
+ return (growth <= CLAUDE_DAMP_GROWTH_BYTES &&
196
+ Number.isFinite(ageMs) &&
197
+ ageMs <= CLAUDE_DAMP_MAX_AGE_MS);
198
+ }
199
+ function worktreeInventoryForRepo(current, worktrees) {
200
+ return worktrees
201
+ .filter((worktree) => worktree.repo_fingerprint
202
+ ? worktree.repo_fingerprint === current.repo_fingerprint
203
+ : worktree.repo_label === current.repo_label)
204
+ .map((worktree) => ({
205
+ repo: worktree.repo_root,
206
+ repo_label: worktree.repo_label,
207
+ repo_fingerprint: worktree.repo_fingerprint,
208
+ repo_origin_url: worktree.repo_origin_url ?? undefined,
209
+ head_sha: worktree.head_sha ?? undefined,
210
+ worktree_label: worktree.worktree_label,
211
+ worktree_fingerprint: worktree.worktree_fingerprint,
212
+ worktree_is_primary: worktree.worktree_is_primary,
213
+ branch: worktree.branch,
214
+ }));
215
+ }