hillclimb 0.8.12 → 0.8.13

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.
Files changed (2) hide show
  1. package/dist/main.js +158 -33
  2. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -14361,7 +14361,7 @@ async function uploadArtifact(client, contributionId, filename, mimeType, buffer
14361
14361
  }
14362
14362
 
14363
14363
  // package.json
14364
- var version = "0.8.12";
14364
+ var version = "0.8.13";
14365
14365
 
14366
14366
  // src/version.ts
14367
14367
  var CLI_VERSION = version;
@@ -16120,11 +16120,20 @@ function deleteRef(repoRoot, refName) {
16120
16120
  function captureSnapshotSha(repoRoot) {
16121
16121
  return captureWorkingCommitSha(repoRoot);
16122
16122
  }
16123
- function snapshotTreeBytes(repoRoot, treeSha) {
16124
- return gitBuffer(repoRoot, ["ls-tree", "-r", "-l", "-z", treeSha]).toString("utf-8").split("\0").reduce((sum, entry) => {
16125
- const size = entry.slice(0, entry.indexOf(" ")).trim().split(/\s+/)[3];
16126
- return sum + (/^\d+$/.test(size ?? "") ? Number(size) : 0);
16127
- }, 0);
16123
+ function snapshotBlobInventory(repoRoot, treeish) {
16124
+ const blobs = /* @__PURE__ */ new Map();
16125
+ for (const entry of gitBuffer(repoRoot, [
16126
+ "ls-tree",
16127
+ "-r",
16128
+ "-l",
16129
+ "-z",
16130
+ treeish
16131
+ ]).toString("utf-8").split("\0")) {
16132
+ const [, type, sha, size] = entry.slice(0, entry.indexOf(" ")).trim().split(/\s+/);
16133
+ if (type === "blob" && sha && /^\d+$/.test(size ?? ""))
16134
+ blobs.set(sha, Number(size));
16135
+ }
16136
+ return blobs;
16128
16137
  }
16129
16138
  function formatSize(bytes) {
16130
16139
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -17320,6 +17329,8 @@ async function sweepStaleLockFiles(ttlMs = STALE_LOCK_TTL_MS3, now = Date.now())
17320
17329
  // src/git-traces/pending-captures.ts
17321
17330
  var MAX_CAPTURES = 128;
17322
17331
  var MAX_PENDING_BYTES2 = 2 * 1024 * 1024 * 1024;
17332
+ var PendingCaptureLimitError = class extends Error {
17333
+ };
17323
17334
  var activeCapture = new AsyncLocalStorage();
17324
17335
  function currentPendingCapture(repoRoot) {
17325
17336
  const context = activeCapture.getStore();
@@ -17410,10 +17421,7 @@ function listPendingCaptures2(repoRoot, tool, sessionId) {
17410
17421
  function hasPendingCaptures(repoRoot, tool, sessionId) {
17411
17422
  return listPendingCaptures2(repoRoot, tool, sessionId).length > 0;
17412
17423
  }
17413
- function pendingBytes(records) {
17414
- const first = records[0];
17415
- if (!first) return 0;
17416
- const directory = queueDirectory(first.repoRoot, first.tool, first.sessionId);
17424
+ function queueDiskBytes(directory) {
17417
17425
  let diskBytes = 0;
17418
17426
  if (fs19.existsSync(directory)) {
17419
17427
  for (const entry of fs19.readdirSync(directory, { withFileTypes: true })) {
@@ -17428,11 +17436,57 @@ function pendingBytes(records) {
17428
17436
  }
17429
17437
  }
17430
17438
  }
17431
- return diskBytes + records.reduce(
17432
- (sum, record) => sum + record.treeBytes + (fs19.existsSync(path18.join(captureDirectory(record), "capture.json")) ? 0 : Buffer.byteLength(JSON.stringify(record))),
17439
+ return diskBytes;
17440
+ }
17441
+ function chargedBytes(record) {
17442
+ return record.retainedBytes ?? record.treeBytes;
17443
+ }
17444
+ function pendingBytes(records) {
17445
+ const first = records[0];
17446
+ if (!first) return 0;
17447
+ return queueDiskBytes(
17448
+ queueDirectory(first.repoRoot, first.tool, first.sessionId)
17449
+ ) + records.reduce(
17450
+ (sum, record) => sum + chargedBytes(record) + (fs19.existsSync(path18.join(captureDirectory(record), "capture.json")) ? 0 : Buffer.byteLength(JSON.stringify(record))),
17433
17451
  0
17434
17452
  );
17435
17453
  }
17454
+ function measureTreeRetention(repoRoot, treeSha, bases, context) {
17455
+ const finish = startCaptureStage("tree-inventory", context);
17456
+ let status = "failed";
17457
+ let details;
17458
+ try {
17459
+ const blobs = snapshotBlobInventory(repoRoot, treeSha);
17460
+ const shared = /* @__PURE__ */ new Set();
17461
+ let basesRead = 0;
17462
+ for (const base of new Set(
17463
+ bases.filter((base2) => !!base2)
17464
+ )) {
17465
+ try {
17466
+ const baseBlobs = base === treeSha ? blobs : snapshotBlobInventory(repoRoot, base);
17467
+ for (const sha of baseBlobs.keys()) shared.add(sha);
17468
+ basesRead++;
17469
+ } catch (err) {
17470
+ if (!isMissingObjectError(err)) throw err;
17471
+ appendLog(
17472
+ "warn",
17473
+ `git-traces: capture base is missing for retention accounting; charging unshared content (repo=${repoRoot}, base=${base})`
17474
+ );
17475
+ }
17476
+ }
17477
+ let treeBytes = 0;
17478
+ let retainedBytes = 0;
17479
+ for (const [sha, size] of blobs) {
17480
+ treeBytes += size;
17481
+ if (!shared.has(sha)) retainedBytes += size;
17482
+ }
17483
+ status = "ok";
17484
+ details = `treeBytes=${treeBytes}, treeFiles=${blobs.size}, retainedBytes=${retainedBytes}, bases=${basesRead}`;
17485
+ return { treeBytes, treeFiles: blobs.size, retainedBytes };
17486
+ } finally {
17487
+ finish(status, details);
17488
+ }
17489
+ }
17436
17490
  async function withCaptureQueueLock(repoRoot, tool, sessionId, work) {
17437
17491
  const lockTool = `${tool}:capture-queue-v1`;
17438
17492
  await acquireLock3(repoRoot, lockTool, sessionId);
@@ -17456,17 +17510,33 @@ function storePendingCapture(params) {
17456
17510
  params.tool,
17457
17511
  params.sessionId
17458
17512
  );
17513
+ const retention = measureTreeRetention(
17514
+ params.repoRoot,
17515
+ input.treeSha,
17516
+ [
17517
+ existing.at(-1)?.treeSha,
17518
+ params.seed.lastSnapshotTreeSha,
17519
+ params.seed.baselineTreeSha,
17520
+ input.headSha
17521
+ ],
17522
+ `repo=${params.repoRoot}, tool=${params.tool}, session=${params.sessionId}, recordedAt=${params.recordedAt}`
17523
+ );
17459
17524
  const record = {
17460
17525
  ...identity,
17461
17526
  ...input,
17527
+ ...retention,
17462
17528
  version: 1,
17463
17529
  id: crypto8.randomUUID(),
17464
17530
  sequence: Math.max(Date.now(), (existing.at(-1)?.sequence ?? 0) + 1),
17465
17531
  uploads: {}
17466
17532
  };
17467
- if (existing.length >= MAX_CAPTURES || pendingBytes([...existing, record]) > MAX_PENDING_BYTES2) {
17468
- throw new Error(
17469
- `Pending Git capture limit reached (count=${existing.length}, maxCount=${MAX_CAPTURES}, maxBytes=${MAX_PENDING_BYTES2}); existing captures retained`
17533
+ const pendingBefore = queueDiskBytes(
17534
+ queueDirectory(params.repoRoot, params.tool, params.sessionId)
17535
+ ) + existing.reduce((sum, item) => sum + chargedBytes(item), 0);
17536
+ const incomingBytes = retention.retainedBytes + Buffer.byteLength(JSON.stringify(record));
17537
+ if (existing.length >= MAX_CAPTURES || pendingBefore + incomingBytes > MAX_PENDING_BYTES2) {
17538
+ throw new PendingCaptureLimitError(
17539
+ `Pending Git capture limit reached (count=${existing.length}, maxCount=${MAX_CAPTURES}, pendingBytes=${pendingBefore}, incomingBytes=${incomingBytes}, treeBytes=${retention.treeBytes}, maxBytes=${MAX_PENDING_BYTES2}); existing captures retained`
17470
17540
  );
17471
17541
  }
17472
17542
  const directory = captureDirectory(record);
@@ -17508,7 +17578,7 @@ function storePendingCapture(params) {
17508
17578
  durableDetails = `capture=${record.id}, durableCapturedAt=${durableCapturedAt}`;
17509
17579
  appendLog(
17510
17580
  "info",
17511
- `git-traces: capture queued (repo=${record.repoRoot}, session=${record.sessionId}, capture=${record.id}, recordedAt=${record.recordedAt}, captureStartedAt=${record.captureStartedAt}, capturedAt=${record.capturedAt}, sourceCapturedAt=${record.capturedAt}, durableCapturedAt=${durableCapturedAt}, pending=${existing.length + 1}, oldestPendingAgeMs=${Date.now() - (existing[0]?.capturedAt ?? record.capturedAt)})`
17581
+ `git-traces: capture queued (repo=${record.repoRoot}, session=${record.sessionId}, capture=${record.id}, recordedAt=${record.recordedAt}, captureStartedAt=${record.captureStartedAt}, capturedAt=${record.capturedAt}, sourceCapturedAt=${record.capturedAt}, durableCapturedAt=${durableCapturedAt}, pending=${existing.length + 1}, oldestPendingAgeMs=${Date.now() - (existing[0]?.capturedAt ?? record.capturedAt)}, treeBytes=${record.treeBytes}, treeFiles=${record.treeFiles}, retainedBytes=${record.retainedBytes}, pendingBytes=${pendingBefore + incomingBytes})`
17512
17582
  );
17513
17583
  return record;
17514
17584
  } finally {
@@ -17694,6 +17764,8 @@ var TOOL_LABELS = {
17694
17764
  "copilot-chat": "GitHub Copilot Chat",
17695
17765
  opencode: "opencode"
17696
17766
  };
17767
+ var PendingQueueConflictError = class extends Error {
17768
+ };
17697
17769
  async function loadConfiguredRepos() {
17698
17770
  const file = await loadProjects();
17699
17771
  return Object.entries(file.projects).map(([repoRoot, config]) => ({
@@ -19456,7 +19528,7 @@ async function queueStopCapture(repo, tool, sessionId, recordedAt, sessionEnd =
19456
19528
  const queued = listPendingCaptures2(repo.repoRoot, tool, sessionId);
19457
19529
  const state = candidates.scoped?.state ?? queued[0]?.seed;
19458
19530
  if (candidates.legacy && queued.length > 0)
19459
- throw new Error(
19531
+ throw new PendingQueueConflictError(
19460
19532
  "Legacy state appeared while Git captures were pending; preserve the queue for session reconciliation"
19461
19533
  );
19462
19534
  if (!state || candidates.legacy || !state.baselineTreeSha || !state.baselineMetadata)
@@ -19518,7 +19590,6 @@ async function queueStopCapture(repo, tool, sessionId, recordedAt, sessionEnd =
19518
19590
  headSha,
19519
19591
  captureStartedAt,
19520
19592
  capturedAt,
19521
- treeBytes: snapshotTreeBytes(repo.repoRoot, treeSha),
19522
19593
  metadata,
19523
19594
  omittedFiles,
19524
19595
  scan: scanOut[0]
@@ -19545,13 +19616,30 @@ async function queueStopCapture(repo, tool, sessionId, recordedAt, sessionEnd =
19545
19616
  }
19546
19617
  );
19547
19618
  } catch (err) {
19619
+ if (err instanceof PendingCaptureLimitError) {
19620
+ appendLog(
19621
+ "error",
19622
+ `git-traces: Stop capture rejected for repo ${repo.repoRoot} (session=${sessionId}, sessionEnd=${sessionEnd}): ${formatError(err)}`
19623
+ );
19624
+ return "rejected";
19625
+ }
19548
19626
  appendLog(
19549
19627
  "error",
19550
19628
  `git-traces: Stop failed for repo ${repo.repoRoot} during immutable capture (session=${sessionId}): ${formatError(err)}`
19551
19629
  );
19552
- return "failed";
19630
+ return err instanceof PendingQueueConflictError ? "conflict" : "failed";
19553
19631
  }
19554
19632
  }
19633
+ async function drainRetainedCaptures(repo, tool, sessionId, capture, lineageCheck) {
19634
+ if (capture === "conflict" || !sessionId || !hasPendingCaptures(repo.repoRoot, tool, sessionId))
19635
+ return void 0;
19636
+ const outcome = await drainStopCaptures(repo, tool, sessionId, lineageCheck);
19637
+ appendLog(
19638
+ outcome === "failed" ? "warn" : "info",
19639
+ `git-traces: retained captures drained after ${capture} capture (repo=${repo.repoRoot}, session=${sessionId}, outcome=${outcome})`
19640
+ );
19641
+ return outcome;
19642
+ }
19555
19643
  async function drainStopCaptures(repo, tool, sessionId, lineageCheck) {
19556
19644
  const drainTool = `${tool}:capture-drain-v1`;
19557
19645
  try {
@@ -19566,7 +19654,7 @@ async function drainStopCaptures(repo, tool, sessionId, lineageCheck) {
19566
19654
  "info",
19567
19655
  `git-traces: capture pending behind active drainer (repo=${repo.repoRoot}, session=${sessionId})`
19568
19656
  );
19569
- return "skipped";
19657
+ return "pending";
19570
19658
  }
19571
19659
  let locked = true;
19572
19660
  let uploaded = false;
@@ -19750,10 +19838,12 @@ async function handleStop(payload, tool) {
19750
19838
  let uploaded = 0;
19751
19839
  let unchanged = 0;
19752
19840
  let skipped = missingConfig;
19841
+ let pending = 0;
19753
19842
  let noState = missingStateRoots.filter(
19754
19843
  (root) => !targetsByRoot.has(root)
19755
19844
  ).length;
19756
19845
  let failed = initializationFailed;
19846
+ let rejected = 0;
19757
19847
  const captures = /* @__PURE__ */ new Map();
19758
19848
  for (const repo of targetList) {
19759
19849
  captures.set(
@@ -19762,23 +19852,33 @@ async function handleStop(payload, tool) {
19762
19852
  );
19763
19853
  }
19764
19854
  for (const repo of targetList) {
19765
- const capture = captures.get(repo.repoRoot);
19766
- const outcome = capture === "failed" ? "failed" : capture === "queued" && sessionId ? await drainStopCaptures(repo, tool, sessionId, lineageCheck) : await processStopRepo(
19767
- repo,
19768
- tool,
19769
- sessionId,
19770
- recordedAt,
19771
- lineageCheck
19772
- );
19855
+ const capture = captures.get(repo.repoRoot) ?? "failed";
19856
+ let outcome;
19857
+ if (capture === "queued" && sessionId) {
19858
+ outcome = await drainStopCaptures(repo, tool, sessionId, lineageCheck);
19859
+ } else if (capture === "queued" || capture === "direct") {
19860
+ outcome = await processStopRepo(
19861
+ repo,
19862
+ tool,
19863
+ sessionId,
19864
+ recordedAt,
19865
+ lineageCheck
19866
+ );
19867
+ } else {
19868
+ outcome = "failed";
19869
+ if (capture === "rejected") rejected++;
19870
+ await drainRetainedCaptures(repo, tool, sessionId, capture, lineageCheck);
19871
+ }
19773
19872
  if (outcome === "uploaded") uploaded++;
19774
19873
  else if (outcome === "unchanged") unchanged++;
19775
19874
  else if (outcome === "skipped") skipped++;
19875
+ else if (outcome === "pending") pending++;
19776
19876
  else if (outcome === "no-state") noState++;
19777
19877
  else failed++;
19778
19878
  }
19779
19879
  appendLog(
19780
19880
  "info",
19781
- `git-traces: Stop summary (session=${sessionId ?? "<none>"}, tool=${tool}, triggerRepo=${project.repoRoot}, configured=${repos.length}, frozenStates=${frozenStates}, uploaded=${uploaded}, unchanged=${unchanged}, skipped=${skipped}, noState=${noState}, failed=${failed})`
19881
+ `git-traces: Stop summary (session=${sessionId ?? "<none>"}, tool=${tool}, triggerRepo=${project.repoRoot}, configured=${repos.length}, frozenStates=${frozenStates}, uploaded=${uploaded}, unchanged=${unchanged}, skipped=${skipped}, noState=${noState}, failed=${failed}, rejected=${rejected}, pending=${pending})`
19782
19882
  );
19783
19883
  }
19784
19884
  async function cleanupSessionStateForRepo(repoRoot, tool, sessionId) {
@@ -19871,7 +19971,9 @@ async function handleSessionEnd(payload, tool) {
19871
19971
  let uploaded = 0;
19872
19972
  let unchanged = 0;
19873
19973
  let skipped = 0;
19974
+ let pending = 0;
19874
19975
  let failed = 0;
19976
+ let rejected = 0;
19875
19977
  const finalCaptures = /* @__PURE__ */ new Map();
19876
19978
  for (const repoRoot of repoRoots) {
19877
19979
  const repo = repoByRoot.get(path19.resolve(repoRoot)) ?? (project && path19.resolve(project.repoRoot) === path19.resolve(repoRoot) ? { repoRoot: project.repoRoot, config: project.config } : null);
@@ -19891,8 +19993,24 @@ async function handleSessionEnd(payload, tool) {
19891
19993
  );
19892
19994
  continue;
19893
19995
  }
19894
- const finalCapture = finalCaptures.get(repoRoot);
19895
- const finalOutcome = finalCapture === "failed" ? "failed" : finalCapture === "queued" && sessionId ? await drainStopCaptures(repo, tool, sessionId, lineageCheck) : await processStopRepo(
19996
+ const finalCapture = finalCaptures.get(repoRoot) ?? "failed";
19997
+ if (finalCapture !== "queued" && finalCapture !== "direct") {
19998
+ failed++;
19999
+ if (finalCapture === "rejected") rejected++;
20000
+ appendLog(
20001
+ "warn",
20002
+ `git-traces: preserving SessionEnd state for repo ${repoRoot} after ${finalCapture} final capture`
20003
+ );
20004
+ await drainRetainedCaptures(
20005
+ repo,
20006
+ tool,
20007
+ sessionId,
20008
+ finalCapture,
20009
+ lineageCheck
20010
+ );
20011
+ continue;
20012
+ }
20013
+ const finalOutcome = finalCapture === "queued" && sessionId ? await drainStopCaptures(repo, tool, sessionId, lineageCheck) : await processStopRepo(
19896
20014
  repo,
19897
20015
  tool,
19898
20016
  sessionId,
@@ -19902,7 +20020,14 @@ async function handleSessionEnd(payload, tool) {
19902
20020
  );
19903
20021
  if (finalOutcome === "uploaded") uploaded++;
19904
20022
  else if (finalOutcome === "unchanged") unchanged++;
19905
- else if (finalOutcome === "skipped") {
20023
+ else if (finalOutcome === "pending") {
20024
+ pending++;
20025
+ appendLog(
20026
+ "info",
20027
+ `git-traces: final capture queued behind active drainer for repo ${repoRoot}`
20028
+ );
20029
+ continue;
20030
+ } else if (finalOutcome === "skipped") {
19906
20031
  skipped++;
19907
20032
  appendLog(
19908
20033
  "warn",
@@ -19924,7 +20049,7 @@ async function handleSessionEnd(payload, tool) {
19924
20049
  }
19925
20050
  appendLog(
19926
20051
  "info",
19927
- `git-traces: SessionEnd summary (session=${sessionId ?? "<none>"}, tool=${tool}, triggerRepo=${triggerRepo}, states=${repoRoots.size}, uploaded=${uploaded}, unchanged=${unchanged}, skipped=${skipped}, cleaned=${cleaned}, noState=${noState}, failed=${failed})`
20052
+ `git-traces: SessionEnd summary (session=${sessionId ?? "<none>"}, tool=${tool}, triggerRepo=${triggerRepo}, states=${repoRoots.size}, uploaded=${uploaded}, unchanged=${unchanged}, skipped=${skipped}, cleaned=${cleaned}, noState=${noState}, failed=${failed}, rejected=${rejected}, pending=${pending})`
19928
20053
  );
19929
20054
  }
19930
20055
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hillclimb",
3
- "version": "0.8.12",
3
+ "version": "0.8.13",
4
4
  "description": "Extract and export AI coding tool logs grouped by repo",
5
5
  "license": "MIT",
6
6
  "type": "module",