@bli-cockpit/cli 0.2.253 → 0.2.255

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.
@@ -90,7 +90,7 @@ async function applyCodexMcpTable(options) {
90
90
  if (tableMatches(raw, options.config)) {
91
91
  return { target: "codex_mcp", status: "already", reason: "already_current", path: file };
92
92
  }
93
- const next = upsertTomlTable(raw, MEMORY_TOML_PATH, renderMemoryTable(options.config));
93
+ const next = upsertTomlTable(raw, MEMORY_TOML_PATH, renderMemoryTable(options.config), "memory-install");
94
94
  if (options.dryRun) {
95
95
  return { target: "codex_mcp", status: "would_install", reason: "dry_run", path: file };
96
96
  }
@@ -58,6 +58,40 @@ export function readTomlTable(text, tablePath) {
58
58
  // caller reports a mismatch instead of pretending the field matched.
59
59
  out[key] = parseTomlValue(trimmed.slice(equals + 1).trim());
60
60
  }
61
+ // A sub-table spelling of a key we write inline (`[<path>.env]`) is the same
62
+ // value; the inline key wins when both exist, matching what the writer leaves.
63
+ for (const key of FOLDED_SUBTABLE_KEYS) {
64
+ if (key in out)
65
+ continue;
66
+ const folded = readFoldedSubtable(text, [...tablePath, key]);
67
+ if (folded)
68
+ out[key] = folded;
69
+ }
70
+ return out;
71
+ }
72
+ /**
73
+ * Sub-tables of our table that the renderer writes inline. Only these are
74
+ * absorbed; deeper or unrelated sub-tables (`.tools.x`) are never ours.
75
+ */
76
+ const FOLDED_SUBTABLE_KEYS = ["env"];
77
+ function readFoldedSubtable(text, path) {
78
+ const span = findTomlTableSpan(text, path);
79
+ if (!span)
80
+ return null;
81
+ const out = {};
82
+ for (const line of text.split("\n").slice(span.start_line + 1, span.end_line)) {
83
+ const trimmed = line.trim();
84
+ if (!trimmed || trimmed.startsWith("#"))
85
+ continue;
86
+ const equals = trimmed.indexOf("=");
87
+ if (equals <= 0)
88
+ continue;
89
+ const key = unquoteKey(trimmed.slice(0, equals).trim());
90
+ const value = parseTomlValue(trimmed.slice(equals + 1).trim());
91
+ if (!key || typeof value !== "string")
92
+ return null;
93
+ out[key] = value;
94
+ }
61
95
  return out;
62
96
  }
63
97
  /**
@@ -65,8 +99,19 @@ export function readTomlTable(text, tablePath) {
65
99
  * absent. Everything outside the span is untouched, including trailing
66
100
  * whitespace and comments.
67
101
  */
68
- export function upsertTomlTable(text, tablePath, renderedTable) {
102
+ export function upsertTomlTable(text, tablePath, renderedTable, logTag) {
69
103
  const block = renderedTable.replace(/\n+$/u, "");
104
+ // Writing `env` inline while `[<path>.env]` exists is a duplicate key, which
105
+ // Codex refuses to load. Drop the sub-table; the inline key carries the value.
106
+ for (const key of FOLDED_SUBTABLE_KEYS) {
107
+ if (!new RegExp(`^${key}\\s*=`, "mu").test(block))
108
+ continue;
109
+ const sub = findTomlTableSpan(text, [...tablePath, key]);
110
+ if (!sub)
111
+ continue;
112
+ text = removeTomlTable(text, [...tablePath, key]);
113
+ console.error(`[${logTag ?? "toml-table"}] env_subtable_folded ${JSON.stringify({ key, lines: sub.end_line - sub.start_line })}`);
114
+ }
70
115
  const span = findTomlTableSpan(text, tablePath);
71
116
  if (!span) {
72
117
  if (!text.trim())
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.253");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.255");
25
25
  return 0;
26
26
  }
27
27
 
@@ -23,7 +23,7 @@ export async function installCodexTowerIntegration(options) {
23
23
  if (tableMatches(raw, options.config)) {
24
24
  return { target: "tower_codex_mcp", status: "already", reason: "already_current", path: file };
25
25
  }
26
- const next = upsertTomlTable(raw, TOWER_TOML_PATH, renderTowerTable(options.config));
26
+ const next = upsertTomlTable(raw, TOWER_TOML_PATH, renderTowerTable(options.config), "tower-mcp");
27
27
  if (options.dryRun) {
28
28
  return { target: "tower_codex_mcp", status: "would_install", reason: "dry_run", path: file };
29
29
  }
@@ -8,6 +8,28 @@
8
8
  * the timer is ref'd, and the race rejects even when an injected or broken
9
9
  * fetch ignores the abort, so the caller always gets a named failure.
10
10
  */
11
+ /**
12
+ * One rule for how long a request carrying a body may take (BLI-22801).
13
+ *
14
+ * A fixed deadline is wrong for any body whose send time scales with its size:
15
+ * from Vietnam the path to our US hosts moves about 40 KB/s, so a 1.5 MB gzip
16
+ * chunk needs ~40 s and a 2 MB one over a minute, and a flat 60 s deadline
17
+ * timed out every attempt. The deadline is a floor budget (connect, TLS, the
18
+ * server's own work) plus the time the body needs at a floor throughput of
19
+ * 8 KiB/s, five times slower than the measured link, capped at 10 minutes.
20
+ * A truly stalled request still aborts, at that deadline at the latest.
21
+ *
22
+ * Chosen over a stalled-progress abort: Node's fetch exposes no upload byte
23
+ * progress without a streamed duplex body, which an injected fetch and the
24
+ * signed-URL PUT do not reliably support. A size-scaled total is deterministic.
25
+ */
26
+ export const UPLOAD_DEADLINE_BASE_MS = 30_000;
27
+ export const UPLOAD_DEADLINE_FLOOR_BYTES_PER_S = 8_192;
28
+ export const UPLOAD_DEADLINE_MAX_MS = 600_000;
29
+ export function uploadDeadlineMs(bodyBytes) {
30
+ const sendMs = Math.ceil(Math.max(0, bodyBytes) / UPLOAD_DEADLINE_FLOOR_BYTES_PER_S) * 1000;
31
+ return Math.min(UPLOAD_DEADLINE_MAX_MS, UPLOAD_DEADLINE_BASE_MS + sendMs);
32
+ }
11
33
  export const UPLOAD_DEADLINE_ERROR_NAME = "EvidenceUploadDeadlineError";
12
34
  export function isUploadDeadlineError(error) {
13
35
  return error instanceof Error && error.name === UPLOAD_DEADLINE_ERROR_NAME;
@@ -1,8 +1,6 @@
1
1
  import { encodeEvidenceBytes } from "@bli-cockpit/telemetry-core/evidence-storage-encoding";
2
- import { fetchWithDeadline, isUploadDeadlineError } from "./evidence-upload-deadline.js";
2
+ import { fetchWithDeadline, isUploadDeadlineError, uploadDeadlineMs } from "./evidence-upload-deadline.js";
3
3
  import { noteSyncProgress } from "./sync-lock.js";
4
- /** The signed capability is the only credential sent to Storage. Never log it,
5
- * its query string, provider bodies, or thrown errors that might contain it. */
6
4
  export async function putSignedChunk(options, signedUrl, bytes, uploadId, index) {
7
5
  // Identical manifest + gzip framing to the server chunk route staging write.
8
6
  const encoded = encodeEvidenceBytes(bytes);
@@ -10,36 +8,49 @@ export async function putSignedChunk(options, signedUrl, bytes, uploadId, index)
10
8
  upload_id: uploadId, chunk_index: index, chunk_encoding: "gzip",
11
9
  bytes_before: bytes.length, bytes_after: encoded.length,
12
10
  }));
11
+ const deadlineMs = uploadDeadlineMs(encoded.length);
12
+ let deadlineHit = false;
13
13
  const attempts = options.maxAttemptsPerRequest ?? 3;
14
14
  for (let attempt = 1; attempt <= attempts; attempt++) {
15
15
  let status = 0;
16
+ const startedAt = Date.now();
16
17
  try {
17
18
  const response = await fetchWithDeadline(options.fetchImpl, signedUrl, {
18
19
  method: "PUT", headers: { "Content-Type": "application/octet-stream", "x-upsert": "false" },
19
20
  body: new Uint8Array(encoded), redirect: "error",
20
- }, 60_000);
21
+ }, deadlineMs);
21
22
  status = response.status;
22
23
  await response.body?.cancel();
23
24
  if (response.ok) {
24
25
  noteSyncProgress();
25
- log("signed_chunk_uploaded", uploadId, index, attempt, status);
26
- return true;
26
+ const elapsedMs = Date.now() - startedAt;
27
+ log("signed_chunk_uploaded", uploadId, index, attempt, status, {
28
+ bytes: encoded.length, elapsed_ms: elapsedMs,
29
+ bytes_per_s: Math.round(encoded.length / Math.max(elapsedMs, 1) * 1000),
30
+ });
31
+ return "uploaded";
27
32
  }
28
33
  log("signed_upload_refused", uploadId, index, attempt, status);
29
34
  if (status < 500 && status !== 429)
30
- return false;
35
+ return "refused";
31
36
  }
32
37
  catch (error) {
33
- log(isUploadDeadlineError(error) ? "signed_upload_deadline_exceeded" : "signed_upload_network_failure", uploadId, index, attempt, status);
38
+ if (isUploadDeadlineError(error)) {
39
+ deadlineHit = true;
40
+ log("signed_upload_deadline_exceeded", uploadId, index, attempt, status, { bytes: encoded.length, deadline_ms: deadlineMs });
41
+ }
42
+ else {
43
+ log("signed_upload_network_failure", uploadId, index, attempt, status);
44
+ }
34
45
  }
35
46
  if (attempt < attempts)
36
47
  await (options.sleep ?? sleep)(250 * attempt);
37
48
  }
38
- return false;
49
+ return deadlineHit ? "deadline" : "refused";
39
50
  }
40
- function log(reason, uploadId, index, attempt, status) {
51
+ function log(reason, uploadId, index, attempt, status, extra = {}) {
41
52
  console.error("[evidence-upload] direct chunk", JSON.stringify({
42
- reason, upload_id: uploadId, chunk_index: index, attempt, http_status: status,
53
+ reason, upload_id: uploadId, chunk_index: index, attempt, http_status: status, ...extra,
43
54
  }));
44
55
  }
45
56
  function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
@@ -50,7 +50,17 @@ async function uploadObjectChunks(options, entry, disposition, chunkSizeBytes, o
50
50
  continue;
51
51
  const chunk = entry.bytes.subarray(index * chunkSizeBytes, Math.min((index + 1) * chunkSizeBytes, entry.bytes.byteLength));
52
52
  const offer = disposition.signed_uploads?.find(upload => upload.chunk_index === index);
53
- if (offer && await putSignedChunk(options, offer.signed_url, chunk, disposition.upload_id, index)) {
53
+ const direct = offer
54
+ ? await putSignedChunk(options, offer.signed_url, chunk, disposition.upload_id, index)
55
+ : "refused";
56
+ if (direct === "deadline") {
57
+ // BLI-22801: the chunk route would push the same bytes over the same slow link.
58
+ console.error("[evidence-upload] chunk route skipped", JSON.stringify({
59
+ reason: "signed_upload_deadline_no_fallback", upload_id: disposition.upload_id, chunk_index: index, chunk_bytes: chunk.length,
60
+ }));
61
+ return { ok: false, outcome: failedOutcome(entry.file, `chunk_${index}_failed_upload_deadline`, uploadedChunks) };
62
+ }
63
+ if (direct === "uploaded") {
54
64
  directChunks.push({ chunk_index: index, byte_size: chunk.length,
55
65
  chunk_hash_sha256: sha256(chunk) });
56
66
  uploadedChunks += 1;
@@ -1,6 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
  import { describeError } from "./health-detail.js";
3
- import { fetchWithDeadline, isUploadDeadlineError } from "./evidence-upload-deadline.js";
3
+ import { fetchWithDeadline, isUploadDeadlineError, uploadDeadlineMs } from "./evidence-upload-deadline.js";
4
4
  import { noteSyncProgress } from "./sync-lock.js";
5
5
  /**
6
6
  * The `send` stage: one retrying POST to an evidence-upload route, plus the
@@ -11,12 +11,13 @@ import { noteSyncProgress } from "./sync-lock.js";
11
11
  */
12
12
  const DEFAULT_MAX_ATTEMPTS = 3;
13
13
  const RETRY_DELAY_MS = 250;
14
- const REQUEST_DEADLINE_MS = 120_000;
15
14
  export async function requestJson(options, routePath, body) {
16
15
  const maxAttempts = options.maxAttemptsPerRequest ?? DEFAULT_MAX_ATTEMPTS;
17
16
  const sleep = options.sleep ?? defaultSleep;
18
17
  let lastStatus = 0;
19
18
  let lastBody = null;
19
+ const serialized = JSON.stringify(body);
20
+ const deadlineMs = uploadDeadlineMs(Buffer.byteLength(serialized));
20
21
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
21
22
  try {
22
23
  const response = await fetchWithDeadline(options.fetchImpl, `${options.dashboardUrl}${routePath}`, {
@@ -27,8 +28,8 @@ export async function requestJson(options, routePath, body) {
27
28
  ...(routePath.endsWith("/evidence/upload/begin")
28
29
  ? { "x-tower-evidence-direct": "staged-gzip-v1" } : {}),
29
30
  },
30
- body: JSON.stringify(body),
31
- }, REQUEST_DEADLINE_MS);
31
+ body: serialized,
32
+ }, deadlineMs);
32
33
  lastStatus = response.status;
33
34
  lastBody = await readResponseJson(response);
34
35
  if (response.ok || (response.status < 500 && response.status !== 429)) {
@@ -50,6 +51,8 @@ export async function requestJson(options, routePath, body) {
50
51
  route: routePath,
51
52
  attempt,
52
53
  max_attempts: maxAttempts,
54
+ body_bytes: Buffer.byteLength(serialized),
55
+ deadline_ms: deadlineMs,
53
56
  ...describeError(error),
54
57
  }));
55
58
  lastStatus = 0;
@@ -11,22 +11,23 @@
11
11
  */
12
12
  import { ImageDescribeResponseSchema, } from "@bli-cockpit/telemetry-core";
13
13
  import { describeError } from "./health-detail.js";
14
+ import { fetchWithDeadline, isUploadDeadlineError, uploadDeadlineMs } from "./evidence-upload-deadline.js";
14
15
  import { safeFailureDetail } from "./evidence-upload-transport.js";
15
16
  import { noteSyncProgress } from "./sync-lock.js";
16
- const REQUEST_TIMEOUT_MS = 60_000;
17
17
  const MAX_ATTEMPTS = 2;
18
18
  const RETRY_DELAY_MS = 500;
19
19
  export async function postImageToDescribeDoor(options, request) {
20
20
  const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
21
+ const payload = JSON.stringify(request);
22
+ const deadlineMs = uploadDeadlineMs(Buffer.byteLength(payload));
21
23
  let last = { ok: false, reason: "transport_error", httpStatus: 0, permanent: false };
22
24
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
23
25
  try {
24
- const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/images`, {
26
+ const response = await fetchWithDeadline(options.fetchImpl, `${options.dashboardUrl}/api/ambient/images`, {
25
27
  method: "POST",
26
28
  headers: { Authorization: `Bearer ${options.deviceToken}`, "Content-Type": "application/json" },
27
- body: JSON.stringify(request),
28
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
29
- });
29
+ body: payload,
30
+ }, deadlineMs);
30
31
  const body = await readJson(response);
31
32
  noteSyncProgress();
32
33
  if (response.ok) {
@@ -41,7 +42,7 @@ export async function postImageToDescribeDoor(options, request) {
41
42
  return last;
42
43
  }
43
44
  catch (error) {
44
- console.error("[image-describe] request failed before a status came back", JSON.stringify({ reason: "transport_error", attempt, max_attempts: MAX_ATTEMPTS, sha256_prefix: request.sha256.slice(0, 16), ...describeError(error) }));
45
+ console.error("[image-describe] request failed before a status came back", JSON.stringify({ reason: isUploadDeadlineError(error) ? "deadline_exceeded" : "transport_error", body_bytes: Buffer.byteLength(payload), deadline_ms: deadlineMs, attempt, max_attempts: MAX_ATTEMPTS, sha256_prefix: request.sha256.slice(0, 16), ...describeError(error) }));
45
46
  last = { ok: false, reason: "transport_error", httpStatus: 0, permanent: false };
46
47
  }
47
48
  if (attempt < MAX_ATTEMPTS)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.253",
3
+ "version": "0.2.255",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {