@bli-cockpit/cli 0.2.276 → 0.2.278
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/adapters/raw-evidence-transcript-body.js +9 -4
- package/dist/adapters/raw-evidence-transcript-stream.js +4 -3
- package/dist/adapters/raw-evidence-volatile-reuse.js +10 -0
- package/dist/commands/image.js +36 -2
- package/dist/commands/local-args-tower-image.js +11 -3
- package/dist/commands/local-help-commands-tower-ops.js +2 -0
- package/dist/commands/notes-audio-direct.js +66 -11
- package/dist/commands/notes-record-capture.js +55 -5
- package/dist/commands/notes-record.js +83 -13
- package/dist/commands/notes-writes.js +4 -1
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/sources-verdict.js +2 -2
- package/dist/evidence-upload-object.js +2 -0
- package/dist/evidence-upload-plan.js +2 -0
- package/dist/evidence-upload-terminal.js +5 -1
- package/dist/image-description-session.js +42 -12
- package/dist/image-mode-state.js +5 -5
- package/dist/image-store-send.js +326 -0
- package/dist/image-store-stage.js +54 -0
- package/dist/image-store-state.js +78 -0
- package/package.json +4 -4
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* The order stays image replacement, slim, redact.
|
|
11
11
|
*/
|
|
12
12
|
import { isUtf8 } from "node:buffer";
|
|
13
|
-
import { mightHoldEmbeddedImages } from "@bli-cockpit/telemetry-core";
|
|
13
|
+
import { mightHoldEmbeddedImages, modeDropsImages, modeStoresImages } from "@bli-cockpit/telemetry-core";
|
|
14
14
|
import { describeError } from "../health-detail.js";
|
|
15
|
+
import { makeCaptureProvenance } from "./common.js";
|
|
15
16
|
import { roomToStreamToStaging } from "./raw-evidence-budget-admission.js";
|
|
16
17
|
import { readTranscriptWhole, streamTranscriptBody, volatilityOf } from "./raw-evidence-transcript-stream.js";
|
|
17
18
|
/** Which agent's transcript rules apply to this evidence kind; null for anything the image step does not know. */
|
|
@@ -41,6 +42,10 @@ export function holdDecodedText(source) {
|
|
|
41
42
|
export async function prepareTranscriptBody(collection, file) {
|
|
42
43
|
const session = collection.budget.images;
|
|
43
44
|
const agent = agentOfKind(file.kind);
|
|
45
|
+
// BLI-23329: `store_and_drop` talks to the upload doors for the images of this transcript, as this sync.
|
|
46
|
+
const provenance = session && modeStoresImages(session.mode) && collection.context
|
|
47
|
+
? makeCaptureProvenance(collection.context, "collector_runtime")
|
|
48
|
+
: undefined;
|
|
44
49
|
if (file.source.stream) {
|
|
45
50
|
// BLI-23317 stage D: the slimmed bytes are written to this pack's `files` directory as they are made,
|
|
46
51
|
// but only when the budget and the disk gate would admit a file of the source's size: never a byte ahead of the gate.
|
|
@@ -48,7 +53,7 @@ export async function prepareTranscriptBody(collection, file) {
|
|
|
48
53
|
if (refusal) {
|
|
49
54
|
console.error("[raw-evidence] streamed output is not written ahead of the staging gate", JSON.stringify({ reason: "stream_to_staging_declined", gate: refusal, kind: file.kind, input_bytes: file.source.stream.inputBytes ?? null }));
|
|
50
55
|
}
|
|
51
|
-
const streamed = await streamTranscriptBody(session, agent, { ...file.source.stream, sessionId: file.sessionId, kind: file.kind, stageDir: refusal ? undefined : collection.filesDir });
|
|
56
|
+
const streamed = await streamTranscriptBody(session, agent, { ...file.source.stream, sessionId: file.sessionId, kind: file.kind, stageDir: refusal ? undefined : collection.filesDir, ...(provenance ? { provenance } : {}) });
|
|
52
57
|
if (streamed) {
|
|
53
58
|
file.source.readBytes = streamed.readBytes;
|
|
54
59
|
return { held: streamed.held, volatile: streamed.volatile, pendingImages: streamed.pendingImages };
|
|
@@ -60,12 +65,12 @@ export async function prepareTranscriptBody(collection, file) {
|
|
|
60
65
|
if (!session || !agent)
|
|
61
66
|
return untouched(false, null);
|
|
62
67
|
// Drop mode slims every transcript, image-bearing or not; keep mode only describes the ones that can hold an image.
|
|
63
|
-
if (session.mode
|
|
68
|
+
if (!modeDropsImages(session.mode) && !mightHoldEmbeddedImages(file.source.bytes))
|
|
64
69
|
return untouched(false, null);
|
|
65
70
|
let held;
|
|
66
71
|
try {
|
|
67
72
|
held = holdDecodedText(file.source);
|
|
68
|
-
const prepared = await session.prepareTranscript({ text: held.text, source: agent, sessionId: file.sessionId });
|
|
73
|
+
const prepared = await session.prepareTranscript({ text: held.text, source: agent, sessionId: file.sessionId, ...(provenance ? { provenance } : {}) });
|
|
69
74
|
// BLI-3936: "volatile" = the next tick could build different bytes, or do image work, from
|
|
70
75
|
// this very file: an image still without a stored description (keep mode never replaces, so
|
|
71
76
|
// any image counts) or a slim step that failed and should be retried.
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* read gives (`file_read_failed`, `file_too_large`), never a shorter file.
|
|
13
13
|
*/
|
|
14
14
|
import fs from "node:fs/promises";
|
|
15
|
+
import { modeDropsImages } from "@bli-cockpit/telemetry-core";
|
|
15
16
|
import { describeError } from "../health-detail.js";
|
|
16
17
|
import { TranscriptNotStreamable, TranscriptReadError, TranscriptTooLargeError, } from "./transcript-text-stream.js";
|
|
17
18
|
const TAG = "[raw-evidence]";
|
|
@@ -22,10 +23,10 @@ const TAG = "[raw-evidence]";
|
|
|
22
23
|
* stored description means the bytes change only when one of them gains one, so the memo may keep the entry.
|
|
23
24
|
*/
|
|
24
25
|
export function volatilityOf(mode, prepared) {
|
|
25
|
-
const imagesPending = mode
|
|
26
|
+
const imagesPending = modeDropsImages(mode)
|
|
26
27
|
? prepared.imageCount > prepared.replacedCount
|
|
27
28
|
: prepared.imageCount > 0;
|
|
28
|
-
const slimFailed = mode
|
|
29
|
+
const slimFailed = modeDropsImages(mode) && prepared.slim === null;
|
|
29
30
|
const volatile = imagesPending || slimFailed;
|
|
30
31
|
return { volatile, pendingImages: volatile && !slimFailed ? prepared.pendingImages : null };
|
|
31
32
|
}
|
|
@@ -35,7 +36,7 @@ export async function streamTranscriptBody(session, agent, file) {
|
|
|
35
36
|
return null;
|
|
36
37
|
}
|
|
37
38
|
try {
|
|
38
|
-
const prepared = await session.prepareTranscriptFile({ filePath: file.filePath, source: agent, sessionId: file.sessionId, maxFileBytes: file.maxFileBytes, stageDir: file.stageDir, omitText: file.stageDir !== undefined });
|
|
39
|
+
const prepared = await session.prepareTranscriptFile({ filePath: file.filePath, source: agent, sessionId: file.sessionId, maxFileBytes: file.maxFileBytes, stageDir: file.stageDir, omitText: file.stageDir !== undefined, ...(file.provenance ? { provenance: file.provenance } : {}) });
|
|
39
40
|
const { volatile, pendingImages } = volatilityOf(session.mode, prepared);
|
|
40
41
|
console.error(TAG + " transcript streamed through image swap and slim", JSON.stringify({ kind: file.kind, byte_size: prepared.readBytes, changed: prepared.changed, images: prepared.imageCount, volatile, staged_bytes: prepared.staged?.byteLength ?? null }));
|
|
41
42
|
// BLI-23344: with a spool the text is not read back; the sanitizer reads the spool line by line.
|
|
@@ -10,6 +10,16 @@ export function trustVolatileEntry(collection, memo, known, kind, sourceKey, byt
|
|
|
10
10
|
const pending = known.pending_images ?? [];
|
|
11
11
|
if (!session)
|
|
12
12
|
return false;
|
|
13
|
+
// BLI-23329: in store mode the bytes change only when Tower confirms an image, never when a description arrives, so
|
|
14
|
+
// the gate asks about STORAGE progress: rebuild while the store could still take one of them (a closed session whose
|
|
15
|
+
// store failed is stored on the next tick with room), reuse once the budget is spent or every one is backing off.
|
|
16
|
+
if (session.mode === "store_and_drop") {
|
|
17
|
+
if (session.hasStoreRoom(pending)) {
|
|
18
|
+
logVolatileReuse(kind, sourceKey, known.content_hash, "rebuilt_store_has_room");
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
13
23
|
if (pending.some((sha256) => session.isDescribed(sha256))) {
|
|
14
24
|
memo.count("rebuilt_image_now_described", bytes);
|
|
15
25
|
logVolatileReuse(kind, sourceKey, known.content_hash, "rebuilt_image_now_described");
|
package/dist/commands/image.js
CHANGED
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
* large or not an image is refused here, before anything is sent.
|
|
13
13
|
*/
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
|
-
import { readFile, stat } from "node:fs/promises";
|
|
15
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { IMAGE_DESCRIBE_MAX_BYTES, formatImageMarker, imageSizeLabel, sniffImageMediaType, } from "@bli-cockpit/telemetry-core";
|
|
18
|
-
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
|
|
18
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor, fetchAgentDoorFile, openAgentDoor } from "./agent-door.js";
|
|
19
19
|
import { writeLine } from "./cli-io.js";
|
|
20
20
|
const TAG = "[image cli]";
|
|
21
21
|
const READ_DEADLINE_MS = 30_000;
|
|
@@ -94,7 +94,41 @@ async function describeImage(command, door, request) {
|
|
|
94
94
|
writeLine(out, `Transcript marker: ${formatImageMarker({ sha256: request.sha256, mediaType: request.media_type, byteSize: request.byte_size })}`);
|
|
95
95
|
return 0;
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* `show --bytes <file>` (BLI-23329): the stored image itself, written to a NEW file. The door decodes the evidence
|
|
99
|
+
* envelope and hashes the result; this side hashes again before a byte reaches the disk, so a body that is not the
|
|
100
|
+
* picture its name promises is never written. An existing file is never overwritten.
|
|
101
|
+
*/
|
|
102
|
+
async function saveImageBytes(command, door, file) {
|
|
103
|
+
const sha256 = command.sha256 ?? "";
|
|
104
|
+
const query = new URLSearchParams({ sha256, bytes: "1" });
|
|
105
|
+
const answer = await fetchAgentDoorFile(door, { path: `${DOOR_PATH}?${query.toString()}`, label: "image show bytes", timeoutMs: READ_DEADLINE_MS });
|
|
106
|
+
if (!answer.ok)
|
|
107
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
108
|
+
const actual = createHash("sha256").update(answer.bytes).digest("hex");
|
|
109
|
+
if (actual !== sha256) {
|
|
110
|
+
return failAgentDoor(door, TAG, "bytes_do_not_match_sha256", "Tower sent bytes whose sha256 is not the one you asked for, so nothing was written.");
|
|
111
|
+
}
|
|
112
|
+
const mediaType = sniffImageMediaType(answer.bytes);
|
|
113
|
+
const target = path.resolve(file);
|
|
114
|
+
try {
|
|
115
|
+
await writeFile(target, answer.bytes, { flag: "wx", mode: 0o600 });
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
const code = error?.code;
|
|
119
|
+
const reason = code === "EEXIST" ? "output_exists" : "output_unwritable";
|
|
120
|
+
return failAgentDoor(door, TAG, reason, code === "EEXIST" ? `${file} already exists; choose a new file name. Nothing was written.` : `Could not write ${file}. Nothing was written.`);
|
|
121
|
+
}
|
|
122
|
+
writeLine(door.io.stderr, `${TAG} bytes saved ${JSON.stringify({ sha256: sha256.slice(0, 16), byte_size: answer.bytes.length, media_type: mediaType ?? "unknown", verified: true })}`);
|
|
123
|
+
const result = { ok: true, sha256, media_type: mediaType, byte_size: answer.bytes.length, path: target, verified: true };
|
|
124
|
+
if (door.json)
|
|
125
|
+
return emitAgentDoor(door, result);
|
|
126
|
+
writeLine(door.io.stdout, `Wrote ${imageSizeLabel(answer.bytes.length)} ${mediaType?.replace("image/", "") ?? "image"} to ${target}; its sha256 matches ${sha256.slice(0, 16)}….`);
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
97
129
|
async function showImage(command, door) {
|
|
130
|
+
if (command.bytesFile)
|
|
131
|
+
return saveImageBytes(command, door, command.bytesFile);
|
|
98
132
|
const query = new URLSearchParams({ sha256: command.sha256 ?? "" });
|
|
99
133
|
const answer = await askAgentDoor(door, { path: `${DOOR_PATH}?${query.toString()}`, method: "GET", label: "image show", timeoutMs: READ_DEADLINE_MS });
|
|
100
134
|
if (!answer.ok)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* cockpit image describe <file> [--session <id>] [--json]
|
|
5
5
|
* cockpit image show <sha256-or-prefix> [--json]
|
|
6
|
+
* cockpit image show <sha256> --bytes <file> [--json]
|
|
6
7
|
*
|
|
7
8
|
* Tower swaps the base64 pictures inside collected transcripts for a short written description.
|
|
8
9
|
* `describe` sends one image file through that same door by hand; `show` looks a description up by
|
|
@@ -14,8 +15,8 @@ const ONE_VERB = "image takes one verb: describe or show. Example: `cockpit imag
|
|
|
14
15
|
const SESSION_ID_MAX_CHARS = 200;
|
|
15
16
|
export function parseImageArgs(args) {
|
|
16
17
|
const values = parseNamedArgs(args, {
|
|
17
|
-
allowedFlags: ["--session", "--home", "--dashboard-url", "--json"],
|
|
18
|
-
valueFlags: ["--session", "--home", "--dashboard-url"],
|
|
18
|
+
allowedFlags: ["--session", "--bytes", "--home", "--dashboard-url", "--json"],
|
|
19
|
+
valueFlags: ["--session", "--bytes", "--home", "--dashboard-url"],
|
|
19
20
|
});
|
|
20
21
|
const [verb, ...rest] = values.positionals;
|
|
21
22
|
if (verb !== "describe" && verb !== "show")
|
|
@@ -28,6 +29,9 @@ export function parseImageArgs(args) {
|
|
|
28
29
|
throw new Error("--session does not belong to `cockpit image show`.");
|
|
29
30
|
if (session !== undefined && session.length > SESSION_ID_MAX_CHARS)
|
|
30
31
|
throw new Error(`--session is at most ${SESSION_ID_MAX_CHARS} characters.`);
|
|
32
|
+
const bytesFile = optionalNonEmpty(values.flags.get("--bytes"));
|
|
33
|
+
if (bytesFile !== undefined && verb !== "show")
|
|
34
|
+
throw new Error("--bytes belongs to `cockpit image show`.");
|
|
31
35
|
const common = {
|
|
32
36
|
kind: "image",
|
|
33
37
|
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
@@ -36,7 +40,11 @@ export function parseImageArgs(args) {
|
|
|
36
40
|
};
|
|
37
41
|
if (verb === "describe")
|
|
38
42
|
return { ...common, action: "describe", file: rest[0], ...(session ? { session } : {}) };
|
|
39
|
-
|
|
43
|
+
const sha256 = readHashQuery(rest[0]);
|
|
44
|
+
if (bytesFile !== undefined && sha256.length !== 64) {
|
|
45
|
+
throw new Error("--bytes needs the whole sha256 (64 hex characters): a 16-character marker prefix names a description, not one stored image. `cockpit image show <prefix>` first, then the full hash it prints.");
|
|
46
|
+
}
|
|
47
|
+
return { ...common, action: "show", sha256, ...(bytesFile ? { bytesFile } : {}) };
|
|
40
48
|
}
|
|
41
49
|
/** A marker is pasted whole as often as not (`sha256:ab12…`), so accept that spelling and trim it. */
|
|
42
50
|
function readHashQuery(typed) {
|
|
@@ -298,10 +298,12 @@ export const TOWER_OPS_COMMAND_HELP = [
|
|
|
298
298
|
[
|
|
299
299
|
"Usage: cockpit image describe <file> [--session <id>] [--dashboard-url <url>] [--json]",
|
|
300
300
|
" cockpit image show <sha256-or-prefix> [--dashboard-url <url>] [--json]",
|
|
301
|
+
" cockpit image show <sha256> --bytes <file> [--dashboard-url <url>] [--json]",
|
|
301
302
|
"",
|
|
302
303
|
"Tower swaps the base64 pictures inside collected transcripts for a short written description kept against the image's sha256.",
|
|
303
304
|
" describe send one PNG, JPEG, GIF or WebP file (up to 3 MB) to the describe door; prints the description and the marker a transcript would carry",
|
|
304
305
|
" show look a stored description up by its sha256, or the first 12 to 64 characters of it (a marker carries 16)",
|
|
306
|
+
" show --bytes <file> write the stored IMAGE to a new file (needs the whole 64-character sha256). Tower keeps the image itself only for machines on the store_and_drop switch; the bytes are decoded, checked against the sha256 and refused if they differ. An existing file is never overwritten.",
|
|
305
307
|
"A file that is too large or not an image is refused here by name, before anything is sent. An image Tower already described answers from storage.",
|
|
306
308
|
"--json writes the door's body to stdout and nothing else; a refusal is { ok: false, error, detail } and exits 1.",
|
|
307
309
|
"The bli-tower MCP server has the twins: image_describe, image_show.",
|
|
@@ -12,10 +12,19 @@
|
|
|
12
12
|
* Step 2 goes to the storage host with the signed URL ONLY: the device token is
|
|
13
13
|
* sent to Tower's own routes in steps 1 and 3 and never to anyone else.
|
|
14
14
|
*
|
|
15
|
+
* A laptop recording (`recording`, from `cockpit notes record stop`, BLI-23470)
|
|
16
|
+
* also names its session to step 3, so Tower answers a repeat with the note it
|
|
17
|
+
* already made, and keeps the object key Tower issued in the session's
|
|
18
|
+
* `upload.json` before the PUT. The next try after a failed step 3 (a timeout
|
|
19
|
+
* after the server saved, a 5xx) asks step 3 again with that key and sends no
|
|
20
|
+
* byte; only when Tower says the object is not there (`upload_not_found`) does
|
|
21
|
+
* it upload again.
|
|
22
|
+
*
|
|
15
23
|
* Nothing here throws; every failure comes back as a closed reason label and
|
|
16
24
|
* the sentence Tower (or this file) wrote for it.
|
|
17
25
|
*/
|
|
18
26
|
import { createReadStream } from "node:fs";
|
|
27
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
19
28
|
import { Readable } from "node:stream";
|
|
20
29
|
import { writeLine } from "./cli-io.js";
|
|
21
30
|
import { ask, TAG } from "./notes-door.js";
|
|
@@ -23,8 +32,61 @@ import { noteAudioMime } from "./notes-file.js";
|
|
|
23
32
|
/** A signed upload URL lives two hours; the PUT must finish inside that. */
|
|
24
33
|
const PUT_DEADLINE_MS = 2 * 60 * 60 * 1000;
|
|
25
34
|
const SMALL_DEADLINE_MS = 60_000;
|
|
35
|
+
/** The object key a previous try of this session was issued, or null (no file, unreadable, another session's). */
|
|
36
|
+
async function keptObjectKey(recording) {
|
|
37
|
+
try {
|
|
38
|
+
const kept = JSON.parse(await readFile(recording.stateFile, "utf8"));
|
|
39
|
+
return kept.session === recording.session && typeof kept.objectKey === "string" && kept.objectKey ? kept.objectKey : null;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function keepObjectKey(door, recording, objectKey, size) {
|
|
46
|
+
try {
|
|
47
|
+
await writeFile(recording.stateFile, `${JSON.stringify({ session: recording.session, objectKey, bytes: size, issuedAt: new Date().toISOString() })}\n`, { mode: 0o600 });
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
// Not fatal: without it a retry sends the file again, and Tower's session check still makes one note.
|
|
51
|
+
writeLine(door.io.stderr, `${TAG} upload key not kept ${JSON.stringify({ session: recording.session, reason: error.code ?? "write_failed" })}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function confirmSaved(door, input, objectKey) {
|
|
55
|
+
const saved = await ask(door, {
|
|
56
|
+
path: "/api/notes/audio/saved", method: "POST", label: "notes audio saved", timeoutMs: SMALL_DEADLINE_MS,
|
|
57
|
+
body: {
|
|
58
|
+
object_key: objectKey, file_name: input.fileName,
|
|
59
|
+
...(input.exclude ? { exclusions: input.exclude } : {}),
|
|
60
|
+
...(input.folder ? { folder_path: input.folder } : {}),
|
|
61
|
+
...(input.recording ? { recording_session: input.recording.session } : {}),
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
if (!saved.ok)
|
|
65
|
+
return { ok: false, reason: saved.reason, detail: saved.detail };
|
|
66
|
+
return { ok: true, body: saved.body };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A recording whose file already reached storage on an earlier try: ask `/saved` again with the kept key. Null means
|
|
70
|
+
* "upload it" (nothing kept, or Tower says the object is not there); any other answer is final for this try.
|
|
71
|
+
*/
|
|
72
|
+
async function resumeKept(door, input, recording) {
|
|
73
|
+
const kept = await keptObjectKey(recording);
|
|
74
|
+
if (!kept)
|
|
75
|
+
return null;
|
|
76
|
+
writeLine(door.io.stderr, `${TAG} upload resumed ${JSON.stringify({ session: recording.session, bytes: input.size, reason: "object_key_kept" })}`);
|
|
77
|
+
const saved = await confirmSaved(door, input, kept);
|
|
78
|
+
if (saved.ok || saved.reason !== "upload_not_found")
|
|
79
|
+
return saved;
|
|
80
|
+
writeLine(door.io.stderr, `${TAG} upload resumed ${JSON.stringify({ session: recording.session, reason: "kept_object_not_in_storage" })}`);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
26
83
|
export async function uploadAudioDirect(door, input) {
|
|
27
84
|
const started = Date.now();
|
|
85
|
+
if (input.recording) {
|
|
86
|
+
const resumed = await resumeKept(door, input, input.recording);
|
|
87
|
+
if (resumed)
|
|
88
|
+
return resumed;
|
|
89
|
+
}
|
|
28
90
|
const mediaType = noteAudioMime(input.fileName) ?? "";
|
|
29
91
|
const target = await ask(door, {
|
|
30
92
|
path: "/api/notes/audio/target", method: "POST", label: "notes audio target", timeoutMs: SMALL_DEADLINE_MS,
|
|
@@ -37,6 +99,9 @@ export async function uploadAudioDirect(door, input) {
|
|
|
37
99
|
if (!issued.objectKey || !url) {
|
|
38
100
|
return { ok: false, reason: "target_unreadable", detail: "Tower answered without an upload link." };
|
|
39
101
|
}
|
|
102
|
+
// Kept BEFORE the PUT: a crash after the bytes landed still lets the next try ask `/saved` instead of re-sending.
|
|
103
|
+
if (input.recording)
|
|
104
|
+
await keepObjectKey(door, input.recording, issued.objectKey, input.size);
|
|
40
105
|
writeLine(door.io.stderr, `Uploading ${input.fileName} (${(input.size / 1_000_000).toFixed(1)} MB) straight to storage...`);
|
|
41
106
|
let put;
|
|
42
107
|
try {
|
|
@@ -63,15 +128,5 @@ export async function uploadAudioDirect(door, input) {
|
|
|
63
128
|
: `Storage refused the upload (HTTP ${put.status}). Nothing was saved; try again.` };
|
|
64
129
|
}
|
|
65
130
|
writeLine(door.io.stderr, `${TAG} storage put ok ${JSON.stringify({ bytes: input.size, elapsed_ms: Date.now() - started })}`);
|
|
66
|
-
|
|
67
|
-
path: "/api/notes/audio/saved", method: "POST", label: "notes audio saved", timeoutMs: SMALL_DEADLINE_MS,
|
|
68
|
-
body: {
|
|
69
|
-
object_key: issued.objectKey, file_name: input.fileName,
|
|
70
|
-
...(input.exclude ? { exclusions: input.exclude } : {}),
|
|
71
|
-
...(input.folder ? { folder_path: input.folder } : {}),
|
|
72
|
-
},
|
|
73
|
-
});
|
|
74
|
-
if (!saved.ok)
|
|
75
|
-
return { ok: false, reason: saved.reason, detail: saved.detail };
|
|
76
|
-
return { ok: true, body: saved.body };
|
|
131
|
+
return await confirmSaved(door, input, issued.objectKey);
|
|
77
132
|
}
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
* `MAX_RECORDING_SECONDS` of audio (8 hours, about 922 MB of 16 kHz mono
|
|
23
23
|
* 16-bit, under the 1,000,000,000-byte storage limit), writing `ended.txt`
|
|
24
24
|
* (`max_duration_reached`) and `done`. `status` then shows the session as
|
|
25
|
-
* stopped and waiting for upload.
|
|
25
|
+
* stopped and waiting for upload. BLI-23470: the same stop fires after 8 hours
|
|
26
|
+
* on the wall clock, or once the file nears the storage limit
|
|
27
|
+
* (`max_size_reached`), whichever comes first (`RECORDER_STOP_RULE`).
|
|
26
28
|
*
|
|
27
29
|
* Everything in a session lives in one directory under the collector's state
|
|
28
30
|
* dir. Nothing here touches the network.
|
|
@@ -40,8 +42,35 @@ export const MAX_RECORDING_SECONDS = 28_800;
|
|
|
40
42
|
export const MAX_RECORDING_BYTES = MAX_RECORDING_SECONDS * RECORDING_SAMPLE_RATE * 2;
|
|
41
43
|
/** The marker a recorder writes when it ended by itself, holding one closed reason label. */
|
|
42
44
|
export const ENDED_MARKER = "ended.txt";
|
|
43
|
-
/**
|
|
45
|
+
/** Why a recorder ends by itself: the 8-hour cap (by audio or by the clock). */
|
|
44
46
|
export const MAX_DURATION_REASON = "max_duration_reached";
|
|
47
|
+
/** BLI-23470: or its file came near the storage limit first (only if the audio is not the format asked for). */
|
|
48
|
+
export const MAX_SIZE_REASON = "max_size_reached";
|
|
49
|
+
/** The storage bucket's per-object limit (`AUDIO_DIRECT_MAX_BYTES` on the server). */
|
|
50
|
+
export const AUDIO_UPLOAD_LIMIT_BYTES = 1_000_000_000;
|
|
51
|
+
/**
|
|
52
|
+
* BLI-23470: the macOS recorder stops once `audio.wav` reaches this, 1 MB under the limit: room for the WAV header
|
|
53
|
+
* and the chunks AVAudioRecorder adds, and for what it writes in one 0.2 s poll even at a far higher rate.
|
|
54
|
+
*/
|
|
55
|
+
export const RECORDER_STOP_BYTES = AUDIO_UPLOAD_LIMIT_BYTES - 1_000_000;
|
|
56
|
+
/** BLI-23470: a recorder whose `audio.wav` has not changed for this long is not recording (both platforms write it continuously). */
|
|
57
|
+
export const RECORDER_SILENT_AFTER_MS = 60_000;
|
|
58
|
+
/** Asked again after this pause before calling it silent: a laptop just back from sleep writes again within a second. */
|
|
59
|
+
export const RECORDER_SILENT_RECHECK_MS = 3_000;
|
|
60
|
+
/** A session older than the 8-hour cap plus this has no recorder of ours behind it, whatever its pid says. */
|
|
61
|
+
export const RECORDER_PAST_CAP_MARGIN_SECONDS = 15 * 60;
|
|
62
|
+
/** BLI-23470: the session file keeping the object key Tower issued for its upload (see `notes-audio-direct.ts`). */
|
|
63
|
+
export const UPLOAD_STATE_FILE = "upload.json";
|
|
64
|
+
/**
|
|
65
|
+
* BLI-23470: when the macOS recorder stops by itself, as JavaScript it runs (and the tests run, with a fake clock).
|
|
66
|
+
* Whichever comes first: 8 hours of audio in the file, 8 hours on the wall clock since it started (the old tick
|
|
67
|
+
* count drifted), or the file nearing the storage limit. Returns the closed reason label, or null to keep recording.
|
|
68
|
+
*/
|
|
69
|
+
export const RECORDER_STOP_RULE = `function capReached(startedMs, nowMs, audioSeconds, fileBytes) {
|
|
70
|
+
if (audioSeconds >= ${MAX_RECORDING_SECONDS} || nowMs - startedMs >= ${MAX_RECORDING_SECONDS * 1000}) return '${MAX_DURATION_REASON}';
|
|
71
|
+
if (fileBytes >= ${RECORDER_STOP_BYTES}) return '${MAX_SIZE_REASON}';
|
|
72
|
+
return null;
|
|
73
|
+
}`;
|
|
45
74
|
/** The JXA recorder. `argv[0]` is the session directory. Reasons it writes to `failed.txt`: mic_denied, recorder_failed. */
|
|
46
75
|
export const RECORDER_SCRIPT = `ObjC.import('AVFoundation');
|
|
47
76
|
ObjC.import('Foundation');
|
|
@@ -51,8 +80,19 @@ function writeText(file, text) {
|
|
|
51
80
|
}
|
|
52
81
|
function exists(file) { return $.NSFileManager.defaultManager.fileExistsAtPath(file); }
|
|
53
82
|
function tick(seconds) { $.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(seconds)); }
|
|
83
|
+
function fileBytes(file) {
|
|
84
|
+
// Never throws: a size it cannot read is 0, and the audio and clock checks still stop the recorder.
|
|
85
|
+
try {
|
|
86
|
+
var attrs = $.NSFileManager.defaultManager.attributesOfItemAtPathError(file, null);
|
|
87
|
+
if (!attrs || attrs.isNil()) return 0;
|
|
88
|
+
var size = ObjC.unwrap(attrs.objectForKey('NSFileSize'));
|
|
89
|
+
return typeof size === 'number' ? size : 0;
|
|
90
|
+
} catch (e) { return 0; }
|
|
91
|
+
}
|
|
92
|
+
${RECORDER_STOP_RULE}
|
|
54
93
|
function run(argv) {
|
|
55
94
|
var dir = argv[0];
|
|
95
|
+
var startedMs = Date.now();
|
|
56
96
|
var auth = $.AVCaptureDevice.authorizationStatusForMediaType('soun');
|
|
57
97
|
if (auth === 1 || auth === 2) { writeText(dir + '/failed.txt', 'mic_denied'); $.exit(3); }
|
|
58
98
|
var settings = $.NSDictionary.dictionaryWithObjectsForKeys(
|
|
@@ -69,10 +109,11 @@ function run(argv) {
|
|
|
69
109
|
if (now === 1 || now === 2) { recorder.stop; writeText(dir + '/failed.txt', 'mic_denied'); $.exit(3); }
|
|
70
110
|
if (!announced && now === 3) { announced = true; writeText(dir + '/ready', 'ok'); }
|
|
71
111
|
if (!announced && waited > 120) { recorder.stop; writeText(dir + '/failed.txt', 'mic_permission_unanswered'); $.exit(5); }
|
|
72
|
-
// currentTime is the audio in the file
|
|
73
|
-
|
|
112
|
+
// currentTime is the audio in the file; the wall clock and the file size are the backstops (BLI-23470).
|
|
113
|
+
var capped = capReached(startedMs, Date.now(), recorder.currentTime, fileBytes(dir + '/audio.wav'));
|
|
114
|
+
if (capped) {
|
|
74
115
|
recorder.stop;
|
|
75
|
-
writeText(dir + '/${ENDED_MARKER}',
|
|
116
|
+
writeText(dir + '/${ENDED_MARKER}', capped);
|
|
76
117
|
writeText(dir + '/done', 'ok');
|
|
77
118
|
$.exit(0);
|
|
78
119
|
}
|
|
@@ -130,6 +171,15 @@ export async function fileSize(file) {
|
|
|
130
171
|
return null;
|
|
131
172
|
}
|
|
132
173
|
}
|
|
174
|
+
/** How long ago `file` last changed, by the real clock, or null when it does not exist (BLI-23470). */
|
|
175
|
+
export async function fileQuietMs(file) {
|
|
176
|
+
try {
|
|
177
|
+
return Math.max(0, Date.now() - (await stat(file)).mtimeMs);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
133
183
|
export async function fileExists(file) {
|
|
134
184
|
return (await fileSize(file)) !== null;
|
|
135
185
|
}
|
|
@@ -22,6 +22,14 @@
|
|
|
22
22
|
* refuses by name. Both platforms share one session directory, marker files,
|
|
23
23
|
* JSON answers and WAV format, so the upload and recovery below are one code path.
|
|
24
24
|
*
|
|
25
|
+
* BLI-23470: a session whose pid answers but whose recorder is plainly gone
|
|
26
|
+
* (silent file, past the cap, never opened the microphone) is crashed, so it
|
|
27
|
+
* uploads and `start` works again; an upload names its session and keeps the
|
|
28
|
+
* object key Tower issued (`upload.json`), so a retry makes no second note and
|
|
29
|
+
* sends no byte twice; failed uploads are counted on the session and `status`
|
|
30
|
+
* reports them (`upload_failures`, `last_attempt_age_seconds`) for the app's
|
|
31
|
+
* backoff.
|
|
32
|
+
*
|
|
25
33
|
* Capture is `notes-record-capture.ts` (macOS) and
|
|
26
34
|
* `notes-record-capture-win32.ts` (Windows); the header repair is `notes-record-wav.ts`.
|
|
27
35
|
*/
|
|
@@ -29,7 +37,7 @@ import { link, rm } from "node:fs/promises";
|
|
|
29
37
|
import path from "node:path";
|
|
30
38
|
import { setTimeout as delay } from "node:timers/promises";
|
|
31
39
|
import { writeLine } from "./cli-io.js";
|
|
32
|
-
import { audioPath, fileExists, fileSize, launchRecorder, listSessions, newSessionId, prepareSessionDir, processAlive, readEndedReason, readReason, recordingsRoot, requestStop, sessionDir, uploadPath, writeSession, MAX_RECORDING_SECONDS, RECORDING_SAMPLE_RATE, } from "./notes-record-capture.js";
|
|
40
|
+
import { audioPath, fileExists, fileQuietMs, fileSize, launchRecorder, listSessions, newSessionId, prepareSessionDir, processAlive, readEndedReason, readReason, recordingsRoot, requestStop, sessionDir, uploadPath, writeSession, MAX_RECORDING_SECONDS, RECORDER_PAST_CAP_MARGIN_SECONDS, RECORDER_SILENT_AFTER_MS, RECORDER_SILENT_RECHECK_MS, RECORDING_SAMPLE_RATE, UPLOAD_STATE_FILE, } from "./notes-record-capture.js";
|
|
33
41
|
import { repairWavHeader } from "./notes-record-wav.js";
|
|
34
42
|
const TAG = "[notes record]";
|
|
35
43
|
const READY_DEADLINE_MS = 150_000;
|
|
@@ -47,14 +55,19 @@ export function defaultRecordDeps(io, upload) {
|
|
|
47
55
|
}
|
|
48
56
|
catch { /* already gone */ } },
|
|
49
57
|
upload,
|
|
58
|
+
audioQuietMs: fileQuietMs,
|
|
50
59
|
};
|
|
51
60
|
}
|
|
52
61
|
/**
|
|
53
62
|
* Where a session stands. Order matters: an end the CLI wrote, then an end the recorder wrote (`ended.txt`
|
|
54
63
|
* at the 8-hour cap, or `done`), then a live process. A marker outranks the pid, so a pid the system reused
|
|
55
64
|
* never pins a finished recorder as "recording". `endedReason` is the recorder's closed label, if it wrote one.
|
|
65
|
+
*
|
|
66
|
+
* BLI-23470: a live pid is not proof either. With no marker, a pid the system reused (or one owned by another
|
|
67
|
+
* user, EPERM) would pin a crashed session as "recording" forever: never uploaded, and `start` refused. So a
|
|
68
|
+
* process that looks alive still counts as crashed when its recorder is plainly not recording (`recorderGone`).
|
|
56
69
|
*/
|
|
57
|
-
async function viewOf(root, session, deps) {
|
|
70
|
+
async function viewOf(root, session, deps, io) {
|
|
58
71
|
if (session.endedAt)
|
|
59
72
|
return { state: session.endedBy === "cancel" ? "cancelled" : "stopped", endedReason: null };
|
|
60
73
|
const ended = await readEndedReason(root, session.id);
|
|
@@ -62,11 +75,48 @@ async function viewOf(root, session, deps) {
|
|
|
62
75
|
return { state: "stopped", endedReason: ended };
|
|
63
76
|
if (await fileExists(path.join(sessionDir(root, session.id), "done")))
|
|
64
77
|
return { state: "stopped", endedReason: null };
|
|
65
|
-
|
|
78
|
+
if (!deps.alive(session.pid))
|
|
79
|
+
return { state: "crashed", endedReason: null };
|
|
80
|
+
const gone = await recorderGone(root, session, deps, io);
|
|
81
|
+
return gone ? { state: "crashed", endedReason: gone } : { state: "recording", endedReason: null };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Why a session whose pid answers is not really recording, or null when it is. Judged on the session's own files:
|
|
85
|
+
* - `recorder_past_cap`: started longer ago than the 8-hour cap plus a margin; no recorder of ours runs that long.
|
|
86
|
+
* - `recorder_never_ready`: the microphone never opened (no `ready`) and `start` gave up waiting long ago.
|
|
87
|
+
* - `recorder_silent`: the microphone opened, but `audio.wav` has not changed for `RECORDER_SILENT_AFTER_MS`.
|
|
88
|
+
* Asked twice, `RECORDER_SILENT_RECHECK_MS` apart: right after a laptop wakes from sleep the file is old
|
|
89
|
+
* but the recorder writes again within a second, and that recording must not be cut off.
|
|
90
|
+
*/
|
|
91
|
+
async function recorderGone(root, session, deps, io) {
|
|
92
|
+
const ageMs = deps.now().getTime() - Date.parse(session.startedAt);
|
|
93
|
+
let reason = null;
|
|
94
|
+
if (ageMs > (MAX_RECORDING_SECONDS + RECORDER_PAST_CAP_MARGIN_SECONDS) * 1000)
|
|
95
|
+
reason = "recorder_past_cap";
|
|
96
|
+
else if (!(await fileExists(path.join(sessionDir(root, session.id), "ready")))) {
|
|
97
|
+
if (ageMs > READY_DEADLINE_MS + RECORDER_SILENT_AFTER_MS)
|
|
98
|
+
reason = "recorder_never_ready";
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
const audio = audioPath(root, session.id);
|
|
102
|
+
const quiet = await deps.audioQuietMs(audio);
|
|
103
|
+
if (quiet !== null && quiet > RECORDER_SILENT_AFTER_MS) {
|
|
104
|
+
const before = await fileSize(audio);
|
|
105
|
+
await deps.sleep(RECORDER_SILENT_RECHECK_MS);
|
|
106
|
+
const again = await deps.audioQuietMs(audio);
|
|
107
|
+
// The size too: a file system that is slow to move the modified time still shows the bytes growing.
|
|
108
|
+
if (again !== null && again > RECORDER_SILENT_AFTER_MS && (await fileSize(audio)) === before)
|
|
109
|
+
reason = "recorder_silent";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (reason) {
|
|
113
|
+
writeLine(io.stderr, `${TAG} recorder gone ${JSON.stringify({ session: session.id, pid: session.pid, reason, age_s: Math.round(ageMs / 1000) })}`);
|
|
114
|
+
}
|
|
115
|
+
return reason;
|
|
66
116
|
}
|
|
67
|
-
async function findRecording(root, sessions, deps) {
|
|
117
|
+
async function findRecording(root, sessions, deps, io) {
|
|
68
118
|
for (const session of sessions) {
|
|
69
|
-
if ((await viewOf(root, session, deps)).state === "recording")
|
|
119
|
+
if ((await viewOf(root, session, deps, io)).state === "recording")
|
|
70
120
|
return session;
|
|
71
121
|
}
|
|
72
122
|
return undefined;
|
|
@@ -120,7 +170,7 @@ export async function runNotesRecord(command, io, deps) {
|
|
|
120
170
|
}
|
|
121
171
|
async function startRecording(command, io, deps, root) {
|
|
122
172
|
const sessions = await listSessions(root);
|
|
123
|
-
const running = await findRecording(root, sessions, deps);
|
|
173
|
+
const running = await findRecording(root, sessions, deps, io);
|
|
124
174
|
if (running) {
|
|
125
175
|
return refuse(io, command, "already_recording", `A recording is already running (started ${running.startedAt}). Run \`cockpit notes record stop\` to finish it or \`cockpit notes record cancel\` to keep the file and stop.`);
|
|
126
176
|
}
|
|
@@ -162,7 +212,7 @@ async function closeRecorder(root, session, deps) {
|
|
|
162
212
|
deps.kill(session.pid);
|
|
163
213
|
}
|
|
164
214
|
async function cancelRecording(command, io, deps, root) {
|
|
165
|
-
const running = await findRecording(root, await listSessions(root), deps);
|
|
215
|
+
const running = await findRecording(root, await listSessions(root), deps, io);
|
|
166
216
|
if (!running)
|
|
167
217
|
return refuse(io, command, "not_recording", "No recording is running.");
|
|
168
218
|
await closeRecorder(root, running, deps);
|
|
@@ -213,7 +263,7 @@ async function stopRecording(command, io, deps, root) {
|
|
|
213
263
|
const sessions = await listSessions(root);
|
|
214
264
|
const target = command.sessionId
|
|
215
265
|
? sessions.find((s) => s.id === command.sessionId)
|
|
216
|
-
: await findRecording(root, sessions, deps);
|
|
266
|
+
: await findRecording(root, sessions, deps, io);
|
|
217
267
|
if (!target) {
|
|
218
268
|
const waiting = sessions.filter((s) => !s.submittedAt && !s.nothingToUpload);
|
|
219
269
|
const hint = waiting.length ? ` Waiting for upload: ${waiting.map((s) => s.id).join(", ")} (\`cockpit notes record stop <id>\`).` : "";
|
|
@@ -223,12 +273,21 @@ async function stopRecording(command, io, deps, root) {
|
|
|
223
273
|
return refuse(io, command, "already_submitted", `Recording ${target.id} was already submitted at ${target.submittedAt}.`);
|
|
224
274
|
}
|
|
225
275
|
let session = target;
|
|
226
|
-
const view = await viewOf(root, target, deps);
|
|
276
|
+
const view = await viewOf(root, target, deps, io);
|
|
227
277
|
if (view.state === "recording") {
|
|
228
278
|
await closeRecorder(root, target, deps);
|
|
229
279
|
session = { ...target, endedAt: deps.now().toISOString(), endedBy: "stop" };
|
|
230
280
|
await writeSession(root, session);
|
|
231
281
|
}
|
|
282
|
+
else if (view.endedReason?.startsWith("recorder_")) {
|
|
283
|
+
// BLI-23470: its pid answers but the recorder is gone (`recorderGone`). Ask a recorder that may still be there to
|
|
284
|
+
// close its file, but never kill: that pid may belong to somebody else's process now. Pinned as stopped.
|
|
285
|
+
await requestStop(root, target.id);
|
|
286
|
+
for (let waited = 0; waited < STOP_DEADLINE_MS && deps.alive(target.pid); waited += POLL_MS)
|
|
287
|
+
await deps.sleep(POLL_MS);
|
|
288
|
+
session = { ...target, endedAt: deps.now().toISOString(), endedBy: "stop" };
|
|
289
|
+
await writeSession(root, session);
|
|
290
|
+
}
|
|
232
291
|
const audio = audioPath(root, session.id);
|
|
233
292
|
const reason = await unsendable(root, session, deps);
|
|
234
293
|
if (reason) {
|
|
@@ -240,9 +299,15 @@ async function stopRecording(command, io, deps, root) {
|
|
|
240
299
|
writeLine(io.stderr, `${TAG} uploading original ${JSON.stringify({ session: session.id, bytes, seconds: Math.round(audioSeconds(bytes)), ended_reason: view.endedReason })}`);
|
|
241
300
|
if (!command.json)
|
|
242
301
|
writeLine(io.stderr, `Uploading the whole recording (${clock(audioSeconds(bytes))}, ${megabytes(bytes)})...`);
|
|
243
|
-
|
|
302
|
+
// BLI-23470: the upload names this session, and keeps the object key Tower issued beside the audio, so a retry after
|
|
303
|
+
// `/saved` failed asks Tower again instead of sending the whole file twice (and Tower answers with the same note).
|
|
304
|
+
const recordingUpload = { session: session.id, stateFile: path.join(sessionDir(root, session.id), UPLOAD_STATE_FILE) };
|
|
305
|
+
const code = await deps.upload({ ...command, action: "upload", paths: [sent], recordingUpload }, sent);
|
|
244
306
|
if (code !== 0) {
|
|
245
|
-
|
|
307
|
+
// Counted on the session, so the app's retries back off across restarts (5 min doubling to 1 h, `backoff.rs`).
|
|
308
|
+
const failures = (session.uploadFailures ?? 0) + 1;
|
|
309
|
+
await writeSession(root, { ...session, uploadFailures: failures, lastUploadAttemptAt: deps.now().toISOString() });
|
|
310
|
+
writeLine(io.stderr, `${TAG} upload failed ${JSON.stringify({ session: session.id, exit: code, upload_failures: failures })}`);
|
|
246
311
|
writeLine(io.stderr, `The upload did not finish. The recording is kept; the BLI Notetaker app tries again by itself, or run \`cockpit notes record stop ${session.id}\`.`);
|
|
247
312
|
return code;
|
|
248
313
|
}
|
|
@@ -261,14 +326,19 @@ async function recordingStatus(command, io, deps, root) {
|
|
|
261
326
|
const sessions = (await listSessions(root)).filter((s) => !s.submittedAt && !s.nothingToUpload);
|
|
262
327
|
const rows = [];
|
|
263
328
|
for (const session of sessions) {
|
|
264
|
-
const { state, endedReason } = await viewOf(root, session, deps);
|
|
329
|
+
const { state, endedReason } = await viewOf(root, session, deps, io);
|
|
265
330
|
const bytes = await fileSize(audioPath(root, session.id));
|
|
266
331
|
if (state !== "recording" && bytes === null)
|
|
267
332
|
continue;
|
|
268
|
-
const
|
|
333
|
+
const now = deps.now().getTime();
|
|
334
|
+
const elapsed = state === "recording" ? (now - Date.parse(session.startedAt)) / 1000 : audioSeconds(bytes);
|
|
335
|
+
const lastAttempt = session.lastUploadAttemptAt ? Date.parse(session.lastUploadAttemptAt) : Number.NaN;
|
|
269
336
|
rows.push({
|
|
270
337
|
session: session.id, state, needs_upload: state !== "recording", ended_reason: endedReason,
|
|
271
338
|
started_at: session.startedAt, elapsed_seconds: Math.round(elapsed), bytes, file: audioPath(root, session.id),
|
|
339
|
+
// BLI-23470: what the app's backoff reads. Seconds since the last failed upload, so no clock is compared across processes.
|
|
340
|
+
upload_failures: session.uploadFailures ?? 0,
|
|
341
|
+
last_attempt_age_seconds: Number.isFinite(lastAttempt) ? Math.max(0, Math.round((now - lastAttempt) / 1000)) : null,
|
|
272
342
|
});
|
|
273
343
|
}
|
|
274
344
|
writeLine(io.stderr, `${TAG} status ${JSON.stringify({ sessions: rows.length, needs_upload: rows.filter((row) => row.needs_upload).length })}`);
|
|
@@ -80,9 +80,12 @@ async function uploadOneNote(command, door, filePath) {
|
|
|
80
80
|
* upload route.
|
|
81
81
|
*/
|
|
82
82
|
async function sendNoteFile(command, door, filePath, read) {
|
|
83
|
-
|
|
83
|
+
// BLI-23470: a laptop recording always goes direct, whatever its size, so it names its session to `/saved` and a
|
|
84
|
+
// retry never makes a second note (the small-file route has no session check).
|
|
85
|
+
if (read.direct || (command.recordingUpload && noteAudioMime(read.fileName))) {
|
|
84
86
|
const direct = await uploadAudioDirect(door, {
|
|
85
87
|
filePath, fileName: read.fileName, size: read.size, exclude: command.exclude, folder: command.folder,
|
|
88
|
+
...(command.recordingUpload ? { recording: command.recordingUpload } : {}),
|
|
86
89
|
});
|
|
87
90
|
return direct.ok ? { ok: true, body: direct.body } : { ok: false, reason: direct.reason, detail: direct.detail };
|
|
88
91
|
}
|
|
@@ -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.
|
|
24
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.278");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|