hillclimb 0.9.12 → 0.9.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 +143 -35
  2. package/package.json +1 -1
package/dist/main.js CHANGED
@@ -118,19 +118,28 @@ import https from "https";
118
118
  import net from "net";
119
119
  var DEFAULT_SOCKET_IDLE_TIMEOUT_MS = 12e4;
120
120
  var DEFAULT_DEADLINE_MS = 60 * 6e4;
121
+ var DEADLINE_FLOOR_BYTES_PER_SECOND = 128 * 1024;
122
+ var BUFFER_BODY_SLICE_BYTES = 64 * 1024;
123
+ function defaultPutDeadlineMs(bodyBytes) {
124
+ return Math.max(
125
+ DEFAULT_DEADLINE_MS,
126
+ Math.ceil(bodyBytes / DEADLINE_FLOOR_BYTES_PER_SECOND) * 1e3
127
+ );
128
+ }
121
129
  var MAX_RESPONSE_BODY_BYTES = 64 * 1024;
122
130
  async function putToPresignedUrl(url, headers, body, options) {
123
131
  const socketIdleTimeoutMs = options?.socketIdleTimeoutMs ?? DEFAULT_SOCKET_IDLE_TIMEOUT_MS;
124
- const deadlineMs = options?.deadlineMs ?? DEFAULT_DEADLINE_MS;
125
132
  const parsed = new URL(url);
126
133
  const transport = parsed.protocol === "http:" ? http : https;
127
134
  const contentLength = body.kind === "buffer" ? body.buffer.byteLength : body.sizeBytes;
135
+ const deadlineMs = options?.deadlineMs ?? defaultPutDeadlineMs(contentLength);
128
136
  const start = Date.now();
129
137
  return new Promise((resolve, reject) => {
130
138
  let inputStream;
131
139
  let progressTimer;
132
140
  let phase = "connecting";
133
141
  let requestFinished = false;
142
+ let bodyBytesWritten = 0;
134
143
  const seen = {};
135
144
  const snapshot = () => ({
136
145
  elapsedMs: Date.now() - start,
@@ -138,6 +147,7 @@ async function putToPresignedUrl(url, headers, body, options) {
138
147
  requestFinished,
139
148
  totalBodyBytes: contentLength,
140
149
  ...inputStream ? { bodyBytesRead: inputStream.bytesRead } : {},
150
+ ...body.kind === "buffer" ? { bodyBytesWritten } : {},
141
151
  ...seen,
142
152
  // Bun fills the peer address in only after 'connect'.
143
153
  remoteAddress: seen.remoteAddress ?? req.socket?.remoteAddress
@@ -150,6 +160,7 @@ async function putToPresignedUrl(url, headers, body, options) {
150
160
  clearTimeout(deadlineTimer);
151
161
  clearInterval(progressTimer);
152
162
  inputStream?.destroy();
163
+ if (!requestFinished) req.destroy();
153
164
  resolve({ ...result, attempt });
154
165
  };
155
166
  const settleReject = (err) => {
@@ -174,6 +185,7 @@ async function putToPresignedUrl(url, headers, body, options) {
174
185
  },
175
186
  (res) => {
176
187
  phase = "receiving-response";
188
+ seen.responseMs = Date.now() - start;
177
189
  seen.status = res.statusCode;
178
190
  const header2 = (name) => {
179
191
  const value = res.headers[name];
@@ -240,6 +252,7 @@ async function putToPresignedUrl(url, headers, body, options) {
240
252
  req.on("finish", () => {
241
253
  if (req.socket?.errored) return;
242
254
  requestFinished = true;
255
+ seen.finishMs = Date.now() - start;
243
256
  if (phase !== "receiving-response") phase = "awaiting-response";
244
257
  });
245
258
  if (options?.onProgress) {
@@ -248,6 +261,7 @@ async function putToPresignedUrl(url, headers, body, options) {
248
261
  elapsedMs: Date.now() - start,
249
262
  phase,
250
263
  socketBytesWritten: req.socket?.bytesWritten ?? 0,
264
+ ...body.kind === "buffer" ? { bodyBytesWritten } : {},
251
265
  totalBodyBytes: contentLength,
252
266
  requestFinished
253
267
  });
@@ -262,8 +276,33 @@ async function putToPresignedUrl(url, headers, body, options) {
262
276
  req.destroy(err);
263
277
  });
264
278
  req.on("error", settleReject);
265
- if (body.kind === "buffer") {
266
- req.end(body.buffer);
279
+ if (body.kind === "buffer" && process.versions.bun) {
280
+ const size = body.buffer.byteLength;
281
+ req.end(body.buffer, () => {
282
+ bodyBytesWritten = size;
283
+ });
284
+ } else if (body.kind === "buffer") {
285
+ const buffer = body.buffer;
286
+ let offset = 0;
287
+ const writeSlices = () => {
288
+ while (offset < buffer.byteLength) {
289
+ if (settled || req.destroyed) return;
290
+ const slice = buffer.subarray(
291
+ offset,
292
+ offset + BUFFER_BODY_SLICE_BYTES
293
+ );
294
+ offset += slice.byteLength;
295
+ const drained = req.write(slice, (err) => {
296
+ if (!err) bodyBytesWritten += slice.byteLength;
297
+ });
298
+ if (!drained) {
299
+ req.once("drain", writeSlices);
300
+ return;
301
+ }
302
+ }
303
+ req.end();
304
+ };
305
+ writeSlices();
267
306
  } else {
268
307
  inputStream = fs2.createReadStream(body.path);
269
308
  inputStream.on("error", (err) => {
@@ -388,6 +427,19 @@ function putRetryDelayMs(input) {
388
427
 
389
428
  // src/platform/client.ts
390
429
  var API_REQUEST_TIMEOUT_MS = 3e4;
430
+ async function digestPutBody(body) {
431
+ const sha256 = createHash("sha256");
432
+ const md5 = createHash("md5");
433
+ let size = 0;
434
+ const update = (chunk) => {
435
+ sha256.update(chunk);
436
+ md5.update(chunk);
437
+ size += chunk.length;
438
+ };
439
+ if (body.kind === "buffer") update(body.buffer);
440
+ else for await (const chunk of fs3.createReadStream(body.path)) update(chunk);
441
+ return { sha256: sha256.digest("hex"), md5: md5.digest("hex"), size };
442
+ }
391
443
  var PlatformError = class extends Error {
392
444
  constructor(message, status, code) {
393
445
  super(message);
@@ -639,30 +691,20 @@ var PlatformClient = class {
639
691
  async uploadReplayableArtifact(contributionId, input, body) {
640
692
  const sizeBytes = body.kind === "buffer" ? body.buffer.length : body.sizeBytes;
641
693
  const prefix = `/api/v1/contributions/${encodeURIComponent(contributionId)}`;
642
- let digest;
694
+ let digests;
643
695
  const verify = async (uploadId, options = {}) => {
644
- if (digest === void 0) {
645
- const hash = createHash("sha256");
646
- let localSize = 0;
647
- if (body.kind === "buffer") {
648
- hash.update(body.buffer);
649
- localSize = body.buffer.length;
650
- } else {
651
- for await (const chunk of fs3.createReadStream(body.path)) {
652
- hash.update(chunk);
653
- localSize += chunk.length;
654
- }
655
- }
656
- if (localSize !== sizeBytes)
696
+ if (digests === void 0) {
697
+ const local = await digestPutBody(body);
698
+ if (local.size !== sizeBytes)
657
699
  throw new PlatformError(
658
700
  "Prepared artifact size does not match its manifest"
659
701
  );
660
- digest = hash.digest("hex");
702
+ digests = local;
661
703
  }
662
- return this.verifyReplayArtifact(
704
+ return this.verifyStoredArtifact(
663
705
  `${prefix}/uploads/${encodeURIComponent(uploadId)}/download`,
664
706
  sizeBytes,
665
- digest,
707
+ digests,
666
708
  options
667
709
  );
668
710
  };
@@ -739,7 +781,12 @@ var PlatformClient = class {
739
781
  }
740
782
  await put(target);
741
783
  }
742
- async verifyReplayArtifact(relative, sizeBytes, expectedDigest, options = {}) {
784
+ /**
785
+ * Does storage hold exactly these bytes for an upload? Absent answers
786
+ * `exists: false`; different bytes throw UPLOAD_REPLAY_MISMATCH.
787
+ */
788
+ async verifyStoredArtifact(relative, sizeBytes, digests, options = {}) {
789
+ const logPrefix = options.logPrefix ?? "upload-replay";
743
790
  const redirect = await fetch(this.url(relative), {
744
791
  headers: this.headers(),
745
792
  redirect: "manual",
@@ -773,24 +820,42 @@ var PlatformClient = class {
773
820
  "Upload verification requires a secure storage target"
774
821
  );
775
822
  }
776
- let response;
777
- try {
778
- response = await fetch(target, {
779
- redirect: "error",
780
- signal: AbortSignal.timeout(60 * 60 * 1e3)
781
- });
782
- } catch {
783
- throw new PlatformError("Stored artifact verification request failed");
784
- }
823
+ const get = async (range) => {
824
+ try {
825
+ return await fetch(target, {
826
+ redirect: "error",
827
+ ...range ? { headers: { range: "bytes=0-0" } } : {},
828
+ signal: AbortSignal.timeout(60 * 60 * 1e3)
829
+ });
830
+ } catch {
831
+ throw new PlatformError("Stored artifact verification request failed");
832
+ }
833
+ };
834
+ let response = await get(sizeBytes > 0);
785
835
  if (response.status === 404 || response.status === 403 && options.forbiddenIsAbsent) {
786
836
  await response.body?.cancel();
787
837
  if (response.status === 403)
788
838
  appendLog(
789
839
  "info",
790
- "upload-replay: storage refused the pending artifact (403); treating it as never written and letting the guarded PUT decide"
840
+ `${logPrefix}: storage refused the pending artifact (403); treating it as never written and letting the guarded PUT decide`
791
841
  );
792
842
  return { exists: false, url: location };
793
843
  }
844
+ if (response.status === 206) {
845
+ await response.body?.cancel();
846
+ const total = /\/(\d+)$/.exec(
847
+ response.headers.get("content-range") ?? ""
848
+ )?.[1];
849
+ const etag = response.headers.get("etag")?.replace(/"/g, "");
850
+ if (Number(total) === sizeBytes && etag === digests.md5) {
851
+ appendLog(
852
+ "info",
853
+ `${logPrefix}: verified immutable stored artifact by ETag (bytes=${sizeBytes})`
854
+ );
855
+ return { exists: true, url: location };
856
+ }
857
+ response = await get(false);
858
+ }
794
859
  if (!response.ok) {
795
860
  await response.body?.cancel();
796
861
  throw new PlatformError(
@@ -822,7 +887,7 @@ var PlatformClient = class {
822
887
  await reader?.cancel().catch(() => {
823
888
  });
824
889
  }
825
- if (bytes !== sizeBytes || hash.digest("hex") !== expectedDigest) {
890
+ if (bytes !== sizeBytes || hash.digest("hex") !== digests.sha256) {
826
891
  throw new PlatformError(
827
892
  "Stored artifact does not match the pending bytes",
828
893
  409,
@@ -831,10 +896,39 @@ var PlatformClient = class {
831
896
  }
832
897
  appendLog(
833
898
  "info",
834
- `upload-replay: verified immutable stored artifact (bytes=${bytes})`
899
+ `${logPrefix}: verified immutable stored artifact (bytes=${bytes})`
835
900
  );
836
901
  return { exists: true, url: location };
837
902
  }
903
+ /**
904
+ * The whole body left this machine but no response came back. The object
905
+ * may still have landed: on Windows the OS finishes sending in the
906
+ * background after we close, and a response can be lost on the way back.
907
+ * Storage is the source of truth, so ask it before failing the upload.
908
+ */
909
+ async storedAfterLostResponse(refresh, body, sizeBytes, logPrefix, label) {
910
+ try {
911
+ const digests = await digestPutBody(body);
912
+ if (digests.size !== sizeBytes) return false;
913
+ const { exists } = await this.verifyStoredArtifact(
914
+ `/api/v1/contributions/${encodeURIComponent(refresh.contributionId)}/uploads/${encodeURIComponent(refresh.uploadId)}/download`,
915
+ sizeBytes,
916
+ digests,
917
+ { forbiddenIsAbsent: true, logPrefix: `${logPrefix} delivery check` }
918
+ );
919
+ appendLog(
920
+ exists ? "warn" : "info",
921
+ exists ? `${logPrefix} no response after the full body was sent, but storage holds exactly these bytes; treating the upload as complete | ${label}` : `${logPrefix} no response after the full body was sent, and storage does not hold the object | ${label}`
922
+ );
923
+ return exists;
924
+ } catch (error) {
925
+ appendLog(
926
+ "warn",
927
+ `${logPrefix} delivery check failed after a lost response: ${formatError(error)} | ${label}`
928
+ );
929
+ return false;
930
+ }
931
+ }
838
932
  async uploadToPresignedUrl(presignedUrl, headers, body, refresh) {
839
933
  await this.putToPresignedUrlLogged(
840
934
  presignedUrl,
@@ -877,7 +971,7 @@ var PlatformClient = class {
877
971
  res = await putToPresignedUrl(presignedUrl, headers, body, {
878
972
  onProgress: (progress) => appendLog(
879
973
  "info",
880
- `[PUT ${host}] progress (attempt=${attempt}, phase=${progress.phase}, elapsedMs=${progress.elapsedMs}, requestFinished=${progress.requestFinished}, socketBytesWritten=${progress.socketBytesWritten}, totalBodyBytes=${progress.totalBodyBytes}) | ${label}`
974
+ `[PUT ${host}] progress (attempt=${attempt}, phase=${progress.phase}, elapsedMs=${progress.elapsedMs}, requestFinished=${progress.requestFinished}, socketBytesWritten=${progress.socketBytesWritten}${progress.bodyBytesWritten !== void 0 ? `, bodyBytesWritten=${progress.bodyBytesWritten}` : ""}, totalBodyBytes=${progress.totalBodyBytes}) | ${label}`
881
975
  )
882
976
  });
883
977
  } catch (err) {
@@ -900,6 +994,14 @@ var PlatformClient = class {
900
994
  "error",
901
995
  `[PUT ${host}] FAILED ${Date.now() - start}ms ${formatError(err)} (attempt ${attempt}/${maxAttempts}) ${diagnostics2}`
902
996
  );
997
+ if (refresh && err.putAttempt?.requestFinished && await this.storedAfterLostResponse(
998
+ refresh,
999
+ body,
1000
+ sizeBytes,
1001
+ `[PUT ${host}]`,
1002
+ label
1003
+ ))
1004
+ return;
903
1005
  throw err;
904
1006
  }
905
1007
  const elapsedMs = Date.now() - start;
@@ -1002,6 +1104,12 @@ function formatPutAttempt(attempt) {
1002
1104
  );
1003
1105
  if (attempt.bodyBytesRead !== void 0)
1004
1106
  fields.push(`bodyBytesRead=${attempt.bodyBytesRead}`);
1107
+ if (attempt.bodyBytesWritten !== void 0)
1108
+ fields.push(`bodyBytesWritten=${attempt.bodyBytesWritten}`);
1109
+ if (attempt.finishMs !== void 0)
1110
+ fields.push(`finishMs=${attempt.finishMs}`);
1111
+ if (attempt.responseMs !== void 0)
1112
+ fields.push(`responseMs=${attempt.responseMs}`);
1005
1113
  if (attempt.connectMs !== void 0)
1006
1114
  fields.push(`connectMs=${attempt.connectMs}`);
1007
1115
  if (attempt.tlsMs !== void 0) fields.push(`tlsMs=${attempt.tlsMs}`);
@@ -18764,7 +18872,7 @@ async function uploadArtifact(client, contributionId, filename, mimeType, buffer
18764
18872
  }
18765
18873
 
18766
18874
  // package.json
18767
- var version = "0.9.12";
18875
+ var version = "0.9.13";
18768
18876
 
18769
18877
  // src/version.ts
18770
18878
  var CLI_VERSION = version;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hillclimb",
3
- "version": "0.9.12",
3
+ "version": "0.9.13",
4
4
  "description": "Extract and export AI coding tool logs grouped by repo",
5
5
  "license": "MIT",
6
6
  "type": "module",