@bli-cockpit/cli 0.2.254 → 0.2.256
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.
- package/dist/commands/codex-mcp-table.js +185 -0
- package/dist/commands/doctor-registration.js +5 -1
- package/dist/commands/local-args-tower-notes.js +18 -1
- package/dist/commands/local-command-shapes-notes.js +1 -0
- package/dist/commands/local-help-commands-tower.js +2 -46
- package/dist/commands/local-help-notes.js +50 -0
- package/dist/commands/memory-install-codex.js +21 -72
- package/dist/commands/memory-install-toml.js +17 -1
- package/dist/commands/notes-record-capture.js +152 -0
- package/dist/commands/notes-record-wav.js +244 -0
- package/dist/commands/notes-record.js +214 -0
- package/dist/commands/notes.js +10 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/toml-duplicate-keys.js +192 -0
- package/dist/commands/tower-mcp-codex.js +19 -68
- package/dist/evidence-upload-deadline.js +22 -0
- package/dist/evidence-upload-direct.js +22 -11
- package/dist/evidence-upload-object.js +11 -1
- package/dist/evidence-upload-transport.js +7 -4
- package/dist/image-describe-client.js +7 -6
- package/package.json +4 -4
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds the first key a TOML file defines twice (BLI-22766).
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: `codex` refuses to start when `~/.codex/config.toml` defines
|
|
5
|
+
* one key twice — for example `[mcp_servers.bli-memory]` holding an inline
|
|
6
|
+
* `env = { … }` AND a separate `[mcp_servers.bli-memory.env]` table. It prints
|
|
7
|
+
* `config.toml:180:25: duplicate key` and does nothing else, so every Codex
|
|
8
|
+
* worker on that machine dies silently. `cockpit doctor` needs to say so, by
|
|
9
|
+
* file and line.
|
|
10
|
+
*
|
|
11
|
+
* This is NOT a TOML parser, for the same reason `memory-install-toml.ts` is
|
|
12
|
+
* not one: the public CLI ships no TOML dependency. It models only what makes a
|
|
13
|
+
* key "defined": table headers, array-of-table headers, `key = value` lines
|
|
14
|
+
* (dotted keys included) and inline tables. It skips over multi-line strings
|
|
15
|
+
* and multi-line arrays so their contents are never mistaken for keys. The
|
|
16
|
+
* tests check its answer against a real TOML parser on the same fixtures.
|
|
17
|
+
*
|
|
18
|
+
* What it does NOT catch: syntax errors that are not duplicate keys (a missing
|
|
19
|
+
* quote, a bad date). Those still break Codex; this check reports only the
|
|
20
|
+
* class of breakage our own writer could cause.
|
|
21
|
+
*/
|
|
22
|
+
export function findTomlDuplicateKey(text) {
|
|
23
|
+
const values = new Set();
|
|
24
|
+
const tables = new Set();
|
|
25
|
+
const dottedTables = new Set();
|
|
26
|
+
const arrayTables = new Map();
|
|
27
|
+
let current = [];
|
|
28
|
+
let pending = null;
|
|
29
|
+
const lines = text.split(/\r?\n/u);
|
|
30
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
31
|
+
const line = lines[index] ?? "";
|
|
32
|
+
if (pending) {
|
|
33
|
+
pending = continuePending(line, pending);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const trimmed = stripComment(line).trim();
|
|
37
|
+
if (!trimmed)
|
|
38
|
+
continue;
|
|
39
|
+
if (trimmed.startsWith("[[")) {
|
|
40
|
+
const raw = trimmed.slice(2, trimmed.lastIndexOf("]]"));
|
|
41
|
+
const resolved = resolveArrayPrefixes(splitKeyPath(raw), arrayTables, false);
|
|
42
|
+
const id = encode(resolved);
|
|
43
|
+
const count = (arrayTables.get(id) ?? 0) + 1;
|
|
44
|
+
arrayTables.set(id, count);
|
|
45
|
+
current = [...resolved, `#${count}`];
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (trimmed.startsWith("[")) {
|
|
49
|
+
const resolved = resolveArrayPrefixes(splitKeyPath(trimmed.slice(1, trimmed.lastIndexOf("]"))), arrayTables, true);
|
|
50
|
+
const id = encode(resolved);
|
|
51
|
+
if (tables.has(id) || dottedTables.has(id) || hasValuePrefix(values, resolved)) {
|
|
52
|
+
return duplicate(index, resolved);
|
|
53
|
+
}
|
|
54
|
+
tables.add(id);
|
|
55
|
+
current = resolved;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const equals = indexOutsideQuotes(trimmed, "=");
|
|
59
|
+
if (equals <= 0)
|
|
60
|
+
continue;
|
|
61
|
+
const full = [...current, ...splitKeyPath(trimmed.slice(0, equals))];
|
|
62
|
+
const id = encode(full);
|
|
63
|
+
if (hasValuePrefix(values, full) || tables.has(id) || dottedTables.has(id)) {
|
|
64
|
+
return duplicate(index, full);
|
|
65
|
+
}
|
|
66
|
+
if (hasTableUnder(tables, full))
|
|
67
|
+
return duplicate(index, full);
|
|
68
|
+
values.add(id);
|
|
69
|
+
for (let length = current.length + 1; length < full.length; length += 1) {
|
|
70
|
+
dottedTables.add(encode(full.slice(0, length)));
|
|
71
|
+
}
|
|
72
|
+
pending = openPending(trimmed.slice(equals + 1).trim());
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
function duplicate(index, path) {
|
|
77
|
+
return { line: index + 1, key: path.filter((part) => !part.startsWith("#")).join(".") };
|
|
78
|
+
}
|
|
79
|
+
function encode(path) {
|
|
80
|
+
return JSON.stringify(path);
|
|
81
|
+
}
|
|
82
|
+
function hasValuePrefix(values, path) {
|
|
83
|
+
for (let length = 1; length <= path.length; length += 1) {
|
|
84
|
+
if (values.has(encode(path.slice(0, length))))
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
function hasTableUnder(tables, path) {
|
|
90
|
+
const prefix = encode(path).slice(0, -1);
|
|
91
|
+
for (const table of tables) {
|
|
92
|
+
if (table.startsWith(`${prefix},`))
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
/** `[a.b]` after `[[a]]` means "b inside the LAST a" — give it that element's path. */
|
|
98
|
+
function resolveArrayPrefixes(path, arrayTables, includeLast) {
|
|
99
|
+
const resolved = [];
|
|
100
|
+
path.forEach((part, index) => {
|
|
101
|
+
resolved.push(part);
|
|
102
|
+
if (!includeLast && index === path.length - 1)
|
|
103
|
+
return;
|
|
104
|
+
const count = arrayTables.get(encode(resolved));
|
|
105
|
+
if (count !== undefined)
|
|
106
|
+
resolved.push(`#${count}`);
|
|
107
|
+
});
|
|
108
|
+
return resolved;
|
|
109
|
+
}
|
|
110
|
+
/** Decides whether a value continues onto later lines. */
|
|
111
|
+
function openPending(value) {
|
|
112
|
+
for (const closer of ['"""', "'''"]) {
|
|
113
|
+
if (value.startsWith(closer)) {
|
|
114
|
+
return value.indexOf(closer, closer.length) >= 0 ? null : { kind: "string", closer };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (value.startsWith("[") || value.startsWith("{")) {
|
|
118
|
+
const depth = bracketDepth(value, 0);
|
|
119
|
+
return depth > 0 ? { kind: "brackets", depth } : null;
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
function continuePending(line, pending) {
|
|
124
|
+
if (pending.kind === "string")
|
|
125
|
+
return line.includes(pending.closer) ? null : pending;
|
|
126
|
+
const depth = bracketDepth(line, pending.depth);
|
|
127
|
+
return depth > 0 ? { kind: "brackets", depth } : null;
|
|
128
|
+
}
|
|
129
|
+
function bracketDepth(raw, start) {
|
|
130
|
+
let depth = start;
|
|
131
|
+
let quote = null;
|
|
132
|
+
for (const character of raw) {
|
|
133
|
+
if (quote) {
|
|
134
|
+
if (character === quote)
|
|
135
|
+
quote = null;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (character === '"' || character === "'")
|
|
139
|
+
quote = character;
|
|
140
|
+
else if (character === "#")
|
|
141
|
+
break;
|
|
142
|
+
else if (character === "[" || character === "{")
|
|
143
|
+
depth += 1;
|
|
144
|
+
else if (character === "]" || character === "}")
|
|
145
|
+
depth -= 1;
|
|
146
|
+
}
|
|
147
|
+
return depth;
|
|
148
|
+
}
|
|
149
|
+
function indexOutsideQuotes(raw, target) {
|
|
150
|
+
let quote = null;
|
|
151
|
+
for (let index = 0; index < raw.length; index += 1) {
|
|
152
|
+
const character = raw[index];
|
|
153
|
+
if (quote) {
|
|
154
|
+
if (character === quote)
|
|
155
|
+
quote = null;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (character === '"' || character === "'")
|
|
159
|
+
quote = character;
|
|
160
|
+
else if (character === target)
|
|
161
|
+
return index;
|
|
162
|
+
}
|
|
163
|
+
return -1;
|
|
164
|
+
}
|
|
165
|
+
function stripComment(raw) {
|
|
166
|
+
const index = indexOutsideQuotes(raw, "#");
|
|
167
|
+
return index < 0 ? raw : raw.slice(0, index);
|
|
168
|
+
}
|
|
169
|
+
function splitKeyPath(raw) {
|
|
170
|
+
const parts = [];
|
|
171
|
+
let current = "";
|
|
172
|
+
let quote = null;
|
|
173
|
+
for (const character of raw) {
|
|
174
|
+
if (quote) {
|
|
175
|
+
if (character === quote)
|
|
176
|
+
quote = null;
|
|
177
|
+
else
|
|
178
|
+
current += character;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (character === '"' || character === "'")
|
|
182
|
+
quote = character;
|
|
183
|
+
else if (character === ".") {
|
|
184
|
+
parts.push(current.trim());
|
|
185
|
+
current = "";
|
|
186
|
+
}
|
|
187
|
+
else
|
|
188
|
+
current += character;
|
|
189
|
+
}
|
|
190
|
+
parts.push(current.trim());
|
|
191
|
+
return parts;
|
|
192
|
+
}
|
|
@@ -12,46 +12,25 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { TOWER_MCP_SERVER_ID } from "./tower-mcp-contract.js";
|
|
15
|
-
import {
|
|
15
|
+
import { MEMORY_MCP_SERVER_ID, } from "./memory-install-contract.js";
|
|
16
|
+
import { findTomlTableSpan } from "./memory-install-toml.js";
|
|
17
|
+
import { applyCodexServerTable, codexConfigUnparseable, codexServerTableMatches, } from "./codex-mcp-table.js";
|
|
16
18
|
const TOWER_TOML_PATH = ["mcp_servers", TOWER_MCP_SERVER_ID];
|
|
19
|
+
/** BLI Memory's writer runs first on the same command and repairs its own table. */
|
|
20
|
+
const SIBLING_TOML_PATH = ["mcp_servers", MEMORY_MCP_SERVER_ID];
|
|
17
21
|
export function codexConfigFile(homeDir) {
|
|
18
22
|
return path.join(homeDir, ".codex", "config.toml");
|
|
19
23
|
}
|
|
20
24
|
export async function installCodexTowerIntegration(options) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
try {
|
|
31
|
-
await options.io.writeText(file, next);
|
|
32
|
-
}
|
|
33
|
-
catch (error) {
|
|
34
|
-
return {
|
|
35
|
-
target: "tower_codex_mcp",
|
|
36
|
-
status: "failed",
|
|
37
|
-
reason: "write_failed",
|
|
38
|
-
path: file,
|
|
39
|
-
detail: error instanceof Error ? error.message : String(error),
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
// BLI-2541: parse the table back out of the stored bytes, same discipline
|
|
43
|
-
// as every other target this installer writes.
|
|
44
|
-
const stored = await options.io.readText(file);
|
|
45
|
-
if (stored === null || !tableMatches(stored, options.config)) {
|
|
46
|
-
return {
|
|
47
|
-
target: "tower_codex_mcp",
|
|
48
|
-
status: "failed",
|
|
49
|
-
reason: "read_back_mismatch",
|
|
50
|
-
path: file,
|
|
51
|
-
detail: "the table on disk does not parse back to the entry that was written",
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
return { target: "tower_codex_mcp", status: "installed", reason: "wrote_entry", path: file };
|
|
25
|
+
return applyCodexServerTable({
|
|
26
|
+
file: codexConfigFile(options.homeDir),
|
|
27
|
+
target: "tower_codex_mcp",
|
|
28
|
+
tablePath: TOWER_TOML_PATH,
|
|
29
|
+
siblingPath: SIBLING_TOML_PATH,
|
|
30
|
+
config: options.config,
|
|
31
|
+
dryRun: options.dryRun,
|
|
32
|
+
io: options.io,
|
|
33
|
+
});
|
|
55
34
|
}
|
|
56
35
|
export async function inspectCodexTowerIntegration(options) {
|
|
57
36
|
const file = codexConfigFile(options.homeDir);
|
|
@@ -59,42 +38,14 @@ export async function inspectCodexTowerIntegration(options) {
|
|
|
59
38
|
if (raw === null) {
|
|
60
39
|
return { target: "tower_codex_mcp", status: "missing", reason: "file_absent", path: file };
|
|
61
40
|
}
|
|
62
|
-
|
|
41
|
+
// BLI-22766: a file Codex cannot load is named first, whatever our table says.
|
|
42
|
+
const broken = codexConfigUnparseable(raw, file, "tower_codex_mcp");
|
|
43
|
+
if (broken)
|
|
44
|
+
return broken;
|
|
45
|
+
if (codexServerTableMatches(raw, TOWER_TOML_PATH, options.config)) {
|
|
63
46
|
return { target: "tower_codex_mcp", status: "installed", reason: "already_current", path: file };
|
|
64
47
|
}
|
|
65
48
|
return findTomlTableSpan(raw, TOWER_TOML_PATH)
|
|
66
49
|
? { target: "tower_codex_mcp", status: "mismatch", reason: "entry_differs", path: file }
|
|
67
50
|
: { target: "tower_codex_mcp", status: "missing", reason: "entry_absent", path: file };
|
|
68
|
-
}
|
|
69
|
-
function renderTowerTable(config) {
|
|
70
|
-
const entries = [
|
|
71
|
-
["command", config.mcp_server.command],
|
|
72
|
-
["args", config.mcp_server.args],
|
|
73
|
-
];
|
|
74
|
-
if (Object.keys(config.mcp_server.env).length > 0) {
|
|
75
|
-
entries.push(["env", config.mcp_server.env]);
|
|
76
|
-
}
|
|
77
|
-
return renderTomlTable(TOWER_TOML_PATH, entries);
|
|
78
|
-
}
|
|
79
|
-
function tableMatches(raw, config) {
|
|
80
|
-
const table = readTomlTable(raw, TOWER_TOML_PATH);
|
|
81
|
-
if (!table)
|
|
82
|
-
return false;
|
|
83
|
-
if (table["command"] !== config.mcp_server.command)
|
|
84
|
-
return false;
|
|
85
|
-
const args = table["args"];
|
|
86
|
-
if (!Array.isArray(args))
|
|
87
|
-
return config.mcp_server.args.length === 0;
|
|
88
|
-
if (args.length !== config.mcp_server.args.length)
|
|
89
|
-
return false;
|
|
90
|
-
if (!args.every((entry, index) => entry === config.mcp_server.args[index]))
|
|
91
|
-
return false;
|
|
92
|
-
const env = table["env"];
|
|
93
|
-
for (const [key, value] of Object.entries(config.mcp_server.env)) {
|
|
94
|
-
if (!env || Array.isArray(env) || typeof env === "string")
|
|
95
|
-
return false;
|
|
96
|
-
if (env[key] !== value)
|
|
97
|
-
return false;
|
|
98
|
-
}
|
|
99
|
-
return true;
|
|
100
51
|
}
|
|
@@ -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
|
-
},
|
|
21
|
+
}, deadlineMs);
|
|
21
22
|
status = response.status;
|
|
22
23
|
await response.body?.cancel();
|
|
23
24
|
if (response.ok) {
|
|
24
25
|
noteSyncProgress();
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
35
|
+
return "refused";
|
|
31
36
|
}
|
|
32
37
|
catch (error) {
|
|
33
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
31
|
-
},
|
|
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
|
|
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:
|
|
28
|
-
|
|
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.
|
|
3
|
+
"version": "0.2.256",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@bli-cockpit/memory-mcp": "0.1.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
30
|
+
"@bli-cockpit/memory-mcp": "0.1.76",
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.157",
|
|
32
|
+
"@bli-cockpit/telemetry-core": "0.1.83"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|