@bli-cockpit/cli 0.2.53 → 0.2.55
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/attribution-core-fallbacks.js +247 -0
- package/dist/adapters/attribution-core-paths.js +182 -0
- package/dist/adapters/attribution-core-score.js +159 -0
- package/dist/adapters/attribution-core-types.js +13 -0
- package/dist/adapters/attribution-core.js +13 -565
- package/dist/adapters/claude-attribution-discovery.js +186 -0
- package/dist/adapters/claude-attribution-score.js +204 -0
- package/dist/adapters/claude-attribution-signals.js +180 -0
- package/dist/adapters/claude-attribution-types.js +25 -0
- package/dist/adapters/claude-attribution.js +14 -569
- package/dist/commands/doctor-access.js +129 -0
- package/dist/commands/doctor-pipeline.js +326 -0
- package/dist/commands/doctor-registration.js +105 -0
- package/dist/commands/doctor-report.js +111 -0
- package/dist/commands/doctor-update.js +120 -0
- package/dist/commands/doctor.js +8 -753
- package/dist/commands/heartbeat.js +8 -0
- package/dist/commands/jarvis-contracts.js +8 -0
- package/dist/commands/jarvis-render.js +413 -0
- package/dist/commands/jarvis-turn.js +305 -0
- package/dist/commands/jarvis.js +23 -698
- package/dist/commands/local-args-collector-setup.js +250 -0
- package/dist/commands/local-args-collector-status.js +227 -0
- package/dist/commands/local-args-collector-work.js +175 -0
- package/dist/commands/local-args-collector.js +19 -624
- package/dist/commands/local-args-tower-admin.js +456 -0
- package/dist/commands/local-args-tower-chat.js +194 -0
- package/dist/commands/local-args-tower-pages.js +314 -0
- package/dist/commands/local-args-tower.js +13 -880
- package/dist/commands/local-help.js +10 -2
- package/dist/commands/onboard-completion.js +136 -0
- package/dist/commands/onboard-flows.js +165 -0
- package/dist/commands/onboard-setup.js +102 -0
- package/dist/commands/onboard.js +5 -392
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync-counters.js +55 -0
- package/dist/commands/session-sync-health.js +8 -1
- package/dist/commands/session-sync-plan.js +47 -7
- package/dist/commands/session-sync-scan.js +4 -4
- package/dist/commands/session-sync.js +6 -0
- package/dist/commands/settings-render.js +27 -0
- package/dist/commands/sync-followups.js +5 -1
- package/dist/commands/sync.js +5 -1
- package/dist/commands/team-device-reasons.js +16 -0
- package/dist/commands/team.js +87 -7
- package/dist/evidence-upload-client.js +14 -763
- package/dist/evidence-upload-object.js +181 -0
- package/dist/evidence-upload-plan.js +233 -0
- package/dist/evidence-upload-terminal.js +309 -0
- package/dist/evidence-upload-transport.js +104 -0
- package/dist/spool/local-spool-io.js +122 -0
- package/dist/spool/local-spool-mutations.js +174 -0
- package/dist/spool/local-spool-parse.js +143 -0
- package/dist/spool/local-spool-types.js +22 -0
- package/dist/spool/local-spool.js +20 -426
- package/dist/upload-evidence-delivery-offer.js +144 -0
- package/dist/upload-evidence-delivery-reconcile.js +134 -0
- package/dist/upload-evidence-delivery-summary.js +205 -0
- package/dist/upload-evidence-delivery.js +12 -482
- package/package.json +3 -3
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { COMMIT_CRASHED_PLATFORM, RawEvidenceUploadCommitResponseSchema, isPermanentUploadFailure, } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import { isNonJsonResponseBody, requestJson, safeFailureDetail, sha256, } from "./evidence-upload-transport.js";
|
|
3
|
+
/**
|
|
4
|
+
* The `chunk` and `commit` stages for one already-`begin`-accepted object:
|
|
5
|
+
* send every chunk the server does not already have, then commit and turn
|
|
6
|
+
* the receipt into a `ChunkedUploadFileOutcome`.
|
|
7
|
+
*/
|
|
8
|
+
export async function uploadOneObject(options, entry, disposition, chunkSizeBytes) {
|
|
9
|
+
const objectKey = entry.file.pointer.object_key ?? "";
|
|
10
|
+
if (disposition.disposition === "already_committed") {
|
|
11
|
+
if (!disposition.upload_id) {
|
|
12
|
+
return failedOutcome(entry.file, "begin_committed_receipt_unavailable");
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
if (disposition.disposition === "conflict" || !disposition.upload_id) {
|
|
16
|
+
return failedOutcome(entry.file, disposition.reason ?? "upload_conflict");
|
|
17
|
+
}
|
|
18
|
+
const chunked = await uploadObjectChunks(options, entry, disposition, chunkSizeBytes, objectKey);
|
|
19
|
+
if (!chunked.ok)
|
|
20
|
+
return chunked.outcome;
|
|
21
|
+
return commitUploadedObject(options, entry, disposition, objectKey, chunked.uploadedChunks);
|
|
22
|
+
}
|
|
23
|
+
async function uploadObjectChunks(options, entry, disposition, chunkSizeBytes, objectKey) {
|
|
24
|
+
const received = new Set(disposition.received_chunk_indexes);
|
|
25
|
+
let uploadedChunks = 0;
|
|
26
|
+
if (disposition.disposition === "already_committed" || disposition.commit_ready) {
|
|
27
|
+
return { ok: true, uploadedChunks };
|
|
28
|
+
}
|
|
29
|
+
for (let index = 0; index < entry.chunkCount; index += 1) {
|
|
30
|
+
if (received.has(index))
|
|
31
|
+
continue;
|
|
32
|
+
const chunk = entry.bytes.subarray(index * chunkSizeBytes, Math.min((index + 1) * chunkSizeBytes, entry.bytes.byteLength));
|
|
33
|
+
const chunkResponse = await requestJson(options, "/api/ambient/evidence/upload/chunk", {
|
|
34
|
+
schema_version: "ambient-raw-evidence-upload-chunk.v1",
|
|
35
|
+
generated_at: options.generatedAt,
|
|
36
|
+
provenance: options.provenance,
|
|
37
|
+
upload_id: disposition.upload_id,
|
|
38
|
+
object_key: objectKey,
|
|
39
|
+
chunk_index: index,
|
|
40
|
+
chunk_count: entry.chunkCount,
|
|
41
|
+
chunk_hash_sha256: sha256(chunk),
|
|
42
|
+
content_base64: chunk.toString("base64"),
|
|
43
|
+
});
|
|
44
|
+
if (!chunkResponse.ok) {
|
|
45
|
+
// Same treatment the commit path has had since BLI-2528: the server's
|
|
46
|
+
// own reason rides on the label, so `chunk_3_failed_http_413` becomes
|
|
47
|
+
// `chunk_3_failed_http_413_object_too_large` and the ledger row names
|
|
48
|
+
// the cause instead of the transport (BLI-3483).
|
|
49
|
+
const chunkDetail = safeFailureDetail(chunkResponse.body);
|
|
50
|
+
console.error("[evidence-upload] chunk rejected", JSON.stringify({
|
|
51
|
+
reason: "chunk_rejected",
|
|
52
|
+
upload_id: disposition.upload_id,
|
|
53
|
+
http_status: chunkResponse.status,
|
|
54
|
+
server_reason: chunkDetail ?? "none",
|
|
55
|
+
chunk_index: index,
|
|
56
|
+
chunk_count: entry.chunkCount,
|
|
57
|
+
uploaded_chunk_count: uploadedChunks,
|
|
58
|
+
}));
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
outcome: failedOutcome(entry.file, `chunk_${index}_failed_http_${chunkResponse.status}${chunkDetail ? `_${chunkDetail}` : ""}`, uploadedChunks),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
uploadedChunks += 1;
|
|
65
|
+
}
|
|
66
|
+
return { ok: true, uploadedChunks };
|
|
67
|
+
}
|
|
68
|
+
async function commitUploadedObject(options, entry, disposition, objectKey, uploadedChunks) {
|
|
69
|
+
const commit = await requestJson(options, "/api/ambient/evidence/upload/commit", {
|
|
70
|
+
schema_version: "ambient-raw-evidence-upload-commit.v1",
|
|
71
|
+
generated_at: options.generatedAt,
|
|
72
|
+
provenance: options.provenance,
|
|
73
|
+
upload_id: disposition.upload_id,
|
|
74
|
+
object_key: objectKey,
|
|
75
|
+
});
|
|
76
|
+
if (!commit.ok) {
|
|
77
|
+
// The server can tell us this object will be refused again. Take its reason
|
|
78
|
+
// verbatim so the label names the cause ("storage rejected 116 MB") instead
|
|
79
|
+
// of the transport ("commit_failed_http_500"), which is all the fleet could
|
|
80
|
+
// say for the 57 days of BLI-2528.
|
|
81
|
+
const permanent = permanentCommitRejection(commit.body);
|
|
82
|
+
if (permanent) {
|
|
83
|
+
return failedOutcome(entry.file, permanent, uploadedChunks);
|
|
84
|
+
}
|
|
85
|
+
// A 5xx whose body is not JSON did not come from the route. The commit
|
|
86
|
+
// handler always answers `{ code, message, ... }`; an HTML error page means
|
|
87
|
+
// the serverless process was killed — an out-of-memory on a large
|
|
88
|
+
// assembly, or a hard timeout — so nothing server-side ran a catch, wrote a
|
|
89
|
+
// ledger reason, or logged a line. Naming it separately is the only way an
|
|
90
|
+
// operator reading upload reasons can tell "the server refused these bytes"
|
|
91
|
+
// from "the server never survived them".
|
|
92
|
+
//
|
|
93
|
+
// The stem comes from telemetry-core so the label the collector writes and
|
|
94
|
+
// the label `classifyUploadFailure` reads are the same string by
|
|
95
|
+
// construction; the status rides on the end so a 502 gateway timeout can
|
|
96
|
+
// still be told from a 500 process kill, and core strips it back off.
|
|
97
|
+
if (commit.status >= 500 && isNonJsonResponseBody(commit.body)) {
|
|
98
|
+
console.error("[evidence-commit] the server died before it could answer; the object is still pending", JSON.stringify({
|
|
99
|
+
upload_id: disposition.upload_id,
|
|
100
|
+
http_status: commit.status,
|
|
101
|
+
byte_size: entry.bytes.byteLength,
|
|
102
|
+
chunk_count: entry.chunkCount,
|
|
103
|
+
uploaded_chunk_count: uploadedChunks,
|
|
104
|
+
reason: COMMIT_CRASHED_PLATFORM,
|
|
105
|
+
}));
|
|
106
|
+
return failedOutcome(entry.file, `${COMMIT_CRASHED_PLATFORM}_http_${commit.status}`, uploadedChunks);
|
|
107
|
+
}
|
|
108
|
+
const detail = safeFailureDetail(commit.body);
|
|
109
|
+
return failedOutcome(entry.file, `commit_failed_http_${commit.status}${detail ? `_${detail}` : ""}`, uploadedChunks);
|
|
110
|
+
}
|
|
111
|
+
// "already_committed" means a concurrent or earlier sync made these bytes
|
|
112
|
+
// durable; this sync does not own them, so they must be reported as reuse —
|
|
113
|
+
// a later ingest failure here must not clean up an object another sync's
|
|
114
|
+
// indexed refs already point at.
|
|
115
|
+
const parsedCommit = RawEvidenceUploadCommitResponseSchema.safeParse(commit.body);
|
|
116
|
+
if (!parsedCommit.success) {
|
|
117
|
+
return failedOutcome(entry.file, "commit_invalid_response", uploadedChunks);
|
|
118
|
+
}
|
|
119
|
+
if (!chunkCommitReceiptMatchesPointer(entry.file.pointer, parsedCommit.data)) {
|
|
120
|
+
return failedOutcome(entry.file, "commit_receipt_mismatch", uploadedChunks);
|
|
121
|
+
}
|
|
122
|
+
const committedPointer = entry.file.pointer;
|
|
123
|
+
if (parsedCommit.data.status === "already_committed") {
|
|
124
|
+
return {
|
|
125
|
+
pointer: committedPointer,
|
|
126
|
+
object_key: objectKey,
|
|
127
|
+
codex_session_id: entry.file.codex_session_id ?? null,
|
|
128
|
+
kind: entry.file.kind ?? "unknown",
|
|
129
|
+
artifact_metadata: entry.file.artifact_metadata,
|
|
130
|
+
upload_state: "reused_existing",
|
|
131
|
+
reason: "already_committed",
|
|
132
|
+
uploaded_chunk_count: uploadedChunks,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
pointer: committedPointer,
|
|
137
|
+
object_key: objectKey,
|
|
138
|
+
codex_session_id: entry.file.codex_session_id ?? null,
|
|
139
|
+
kind: entry.file.kind ?? "unknown",
|
|
140
|
+
artifact_metadata: entry.file.artifact_metadata,
|
|
141
|
+
upload_state: "uploaded",
|
|
142
|
+
reason: null,
|
|
143
|
+
uploaded_chunk_count: uploadedChunks,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The server's verdict that repeating this commit is pointless, or null.
|
|
148
|
+
*
|
|
149
|
+
* Two conditions, both required: the response says `retryable: false`, and the
|
|
150
|
+
* reason is one this collector has classified as permanent. The second check is
|
|
151
|
+
* the important one. A newer dashboard could declare a reason this CLI has never
|
|
152
|
+
* heard of, and quietly abandoning an object on a word we cannot interpret is
|
|
153
|
+
* exactly the silent drop the fleet contract forbids — so an unclassified reason
|
|
154
|
+
* falls through to the ordinary retry path and stays visible.
|
|
155
|
+
*/
|
|
156
|
+
function permanentCommitRejection(body) {
|
|
157
|
+
if (!body || typeof body !== "object")
|
|
158
|
+
return null;
|
|
159
|
+
if (body.retryable !== false)
|
|
160
|
+
return null;
|
|
161
|
+
const reason = safeFailureDetail(body);
|
|
162
|
+
return reason && isPermanentUploadFailure(reason) ? reason : null;
|
|
163
|
+
}
|
|
164
|
+
function chunkCommitReceiptMatchesPointer(pointer, receipt) {
|
|
165
|
+
return (receipt.object_key === pointer.object_key &&
|
|
166
|
+
receipt.content_hash_sha256 === pointer.content_hash_sha256 &&
|
|
167
|
+
receipt.byte_size === pointer.byte_size &&
|
|
168
|
+
receipt.redaction === undefined);
|
|
169
|
+
}
|
|
170
|
+
export function failedOutcome(file, reason, uploadedChunks = 0) {
|
|
171
|
+
return {
|
|
172
|
+
pointer: file.pointer,
|
|
173
|
+
object_key: file.pointer.object_key ?? "",
|
|
174
|
+
codex_session_id: file.codex_session_id ?? null,
|
|
175
|
+
kind: file.kind ?? "unknown",
|
|
176
|
+
artifact_metadata: file.artifact_metadata,
|
|
177
|
+
upload_state: "upload_failed",
|
|
178
|
+
reason,
|
|
179
|
+
uploaded_chunk_count: uploadedChunks,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, RawEvidenceUploadBeginResponseSchema, } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import { requestJson, safeFailureDetail } from "./evidence-upload-transport.js";
|
|
4
|
+
import { failedOutcome, uploadOneObject } from "./evidence-upload-object.js";
|
|
5
|
+
import { duplicateOutcome, rekeyConflictedEntry, reportAbandonedUpload, } from "./evidence-upload-terminal.js";
|
|
6
|
+
import { describeError } from "./health-detail.js";
|
|
7
|
+
export async function loadPlannedEntries(options, chunkSizeBytes, outcomes) {
|
|
8
|
+
const loaded = [];
|
|
9
|
+
const loadedByObjectKey = new Map();
|
|
10
|
+
for (const file of options.files) {
|
|
11
|
+
let stat;
|
|
12
|
+
try {
|
|
13
|
+
stat = await fs.stat(file.local_path);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
// `file_read_failed` is a durable ledger label and stays. Beside it: a
|
|
17
|
+
// staged object that vanished (the GC won a race) and one the process
|
|
18
|
+
// has no permission to open are the same word and opposite repairs
|
|
19
|
+
// (BLI-3238).
|
|
20
|
+
console.error("[evidence-upload] staged object could not be stat'd", JSON.stringify({
|
|
21
|
+
reason: "file_read_failed",
|
|
22
|
+
stage: "stat",
|
|
23
|
+
...describeError(error),
|
|
24
|
+
}));
|
|
25
|
+
outcomes.push(failedOutcome(file, "file_read_failed"));
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (stat.size === 0) {
|
|
29
|
+
outcomes.push(failedOutcome(file, "empty_file"));
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (stat.size > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
|
|
33
|
+
outcomes.push(failedOutcome(file, "file_too_large"));
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
let bytes;
|
|
37
|
+
try {
|
|
38
|
+
bytes = await fs.readFile(file.local_path);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
// Stat succeeded and the read did not — narrower than the stat failure
|
|
42
|
+
// above and worth telling apart, so it carries its own stage label.
|
|
43
|
+
console.error("[evidence-upload] staged object could not be read after a successful stat", JSON.stringify({
|
|
44
|
+
reason: "file_read_failed",
|
|
45
|
+
stage: "read",
|
|
46
|
+
byte_size: stat.size,
|
|
47
|
+
...describeError(error),
|
|
48
|
+
}));
|
|
49
|
+
outcomes.push(failedOutcome(file, "file_read_failed"));
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (bytes.byteLength === 0) {
|
|
53
|
+
outcomes.push(failedOutcome(file, "empty_file"));
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (bytes.byteLength > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
|
|
57
|
+
outcomes.push(failedOutcome(file, "file_too_large"));
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
// Content-addressed keys collide for byte-identical files (e.g. a resumed
|
|
61
|
+
// session copied twice); upload once and share the outcome instead of
|
|
62
|
+
// racing a second upload against a committed ledger row.
|
|
63
|
+
const existing = file.pointer.object_key
|
|
64
|
+
? loadedByObjectKey.get(file.pointer.object_key)
|
|
65
|
+
: undefined;
|
|
66
|
+
if (existing) {
|
|
67
|
+
existing.duplicates.push(file);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const entry = {
|
|
71
|
+
file,
|
|
72
|
+
bytes,
|
|
73
|
+
chunkCount: Math.ceil(bytes.byteLength / chunkSizeBytes),
|
|
74
|
+
duplicates: [],
|
|
75
|
+
};
|
|
76
|
+
loaded.push(entry);
|
|
77
|
+
if (file.pointer.object_key) {
|
|
78
|
+
loadedByObjectKey.set(file.pointer.object_key, entry);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return loaded;
|
|
82
|
+
}
|
|
83
|
+
export function batches(items, size) {
|
|
84
|
+
const out = [];
|
|
85
|
+
for (let offset = 0; offset < items.length; offset += size) {
|
|
86
|
+
out.push(items.slice(offset, offset + size));
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* One `begin` round-trip for one batch, then delivery of everything it
|
|
92
|
+
* accepted. Returns `true` when the route itself cannot be used (old
|
|
93
|
+
* dashboard, or a ledger migration not yet applied) so the caller can fall
|
|
94
|
+
* back to the legacy single-shot route for what is left unresolved.
|
|
95
|
+
*
|
|
96
|
+
* A decision table over `begin`'s possible answers, in the order they are
|
|
97
|
+
* checked — transport unavailable, refused, unparseable, then delivered —
|
|
98
|
+
* so each branch stays a guard clause rather than nested conditionals.
|
|
99
|
+
*/
|
|
100
|
+
export async function resolveBeginBatch(options, totalLoadedCount, batch, chunkSizeBytes, outcomes, resolvedEntries) {
|
|
101
|
+
const begin = await requestJson(options, "/api/ambient/evidence/upload/begin", {
|
|
102
|
+
schema_version: "ambient-raw-evidence-upload-begin.v1",
|
|
103
|
+
generated_at: options.generatedAt,
|
|
104
|
+
provenance: options.provenance,
|
|
105
|
+
objects: batch.map((entry) => ({
|
|
106
|
+
pointer: entry.file.pointer,
|
|
107
|
+
chunk_size_bytes: chunkSizeBytes,
|
|
108
|
+
chunk_count: entry.chunkCount,
|
|
109
|
+
})),
|
|
110
|
+
});
|
|
111
|
+
// 404 means an old dashboard without the chunk routes; a persistent 5xx
|
|
112
|
+
// (after retries) covers a new dashboard whose ledger migration has not
|
|
113
|
+
// been applied yet. Both still serve the legacy v1 route.
|
|
114
|
+
if (begin.status === 404 || begin.status >= 500) {
|
|
115
|
+
// Until BLI-3483 this downgrade abandoned the entire chunked path
|
|
116
|
+
// without a word, and the comment above named two causes that the fleet
|
|
117
|
+
// had no way to tell apart — an old dashboard versus an unapplied ledger
|
|
118
|
+
// migration. This is the BLI-2528 shape exactly: the code knew, the
|
|
119
|
+
// operator did not. Once per run, because `break` leaves the loop.
|
|
120
|
+
console.error("[evidence-upload] chunked upload unavailable; falling back to the legacy single-shot route", JSON.stringify({
|
|
121
|
+
reason: begin.status === 404
|
|
122
|
+
? "begin_route_absent"
|
|
123
|
+
: "begin_server_error_after_retries",
|
|
124
|
+
http_status: begin.status,
|
|
125
|
+
server_reason: safeFailureDetail(begin.body) ?? "none",
|
|
126
|
+
objects_in_batch: batch.length,
|
|
127
|
+
objects_unresolved: totalLoadedCount - resolvedEntries.size,
|
|
128
|
+
}));
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
if (!begin.ok) {
|
|
132
|
+
// The server's own `{ reason }` sat unread in this body while the ledger
|
|
133
|
+
// recorded the transport status and nothing else (BLI-3483); the commit
|
|
134
|
+
// path has read it since BLI-2528 and this one now does the same.
|
|
135
|
+
const beginDetail = safeFailureDetail(begin.body);
|
|
136
|
+
const beginReason = `begin_failed_http_${begin.status}${beginDetail ? `_${beginDetail}` : ""}`;
|
|
137
|
+
console.error("[evidence-upload] begin refused these objects", JSON.stringify({
|
|
138
|
+
reason: "begin_rejected",
|
|
139
|
+
http_status: begin.status,
|
|
140
|
+
server_reason: beginDetail ?? "none",
|
|
141
|
+
objects_in_batch: batch.length,
|
|
142
|
+
}));
|
|
143
|
+
for (const entry of batch) {
|
|
144
|
+
outcomes.push(failedOutcome(entry.file, beginReason));
|
|
145
|
+
for (const duplicate of entry.duplicates) {
|
|
146
|
+
outcomes.push(failedOutcome(duplicate, beginReason));
|
|
147
|
+
}
|
|
148
|
+
resolvedEntries.add(entry);
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
const parsedBegin = RawEvidenceUploadBeginResponseSchema.safeParse(begin.body);
|
|
153
|
+
if (!parsedBegin.success) {
|
|
154
|
+
for (const entry of batch) {
|
|
155
|
+
outcomes.push(failedOutcome(entry.file, "begin_invalid_response"));
|
|
156
|
+
for (const duplicate of entry.duplicates) {
|
|
157
|
+
outcomes.push(failedOutcome(duplicate, "begin_invalid_response"));
|
|
158
|
+
}
|
|
159
|
+
resolvedEntries.add(entry);
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
const dispositions = readBeginDispositions(parsedBegin.data);
|
|
164
|
+
if (!dispositions) {
|
|
165
|
+
for (const entry of batch) {
|
|
166
|
+
outcomes.push(failedOutcome(entry.file, "begin_invalid_response"));
|
|
167
|
+
for (const duplicate of entry.duplicates) {
|
|
168
|
+
outcomes.push(failedOutcome(duplicate, "begin_invalid_response"));
|
|
169
|
+
}
|
|
170
|
+
resolvedEntries.add(entry);
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
for (const entry of batch) {
|
|
175
|
+
await deliverBatchEntry(options, entry, dispositions, chunkSizeBytes, outcomes);
|
|
176
|
+
resolvedEntries.add(entry);
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* One accepted object: settle a re-keyable conflict if `begin` reported one
|
|
182
|
+
* (BLI-3552), then chunk/commit it, then tell the server if the attempt was
|
|
183
|
+
* abandoned, then record the outcome for it and every byte-identical
|
|
184
|
+
* duplicate that rode along.
|
|
185
|
+
*/
|
|
186
|
+
async function deliverBatchEntry(options, entry, dispositions, chunkSizeBytes, outcomes) {
|
|
187
|
+
const objectKey = entry.file.pointer.object_key ?? "";
|
|
188
|
+
const disposition = dispositions.get(objectKey);
|
|
189
|
+
if (!disposition) {
|
|
190
|
+
outcomes.push(failedOutcome(entry.file, "begin_missing_disposition"));
|
|
191
|
+
for (const duplicate of entry.duplicates) {
|
|
192
|
+
outcomes.push(failedOutcome(duplicate, "begin_missing_disposition"));
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (disposition.raw_evidence_pointer_id !== entry.file.pointer.raw_evidence_pointer_id) {
|
|
197
|
+
outcomes.push(failedOutcome(entry.file, "begin_disposition_mismatch"));
|
|
198
|
+
for (const duplicate of entry.duplicates) {
|
|
199
|
+
outcomes.push(failedOutcome(duplicate, "begin_disposition_mismatch"));
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// A conflict a different name can settle gets one, here, before the
|
|
204
|
+
// upload is attempted (BLI-3552). Anything else comes back unchanged and
|
|
205
|
+
// fails on its own reason inside `uploadOneObject`.
|
|
206
|
+
const resolved = await rekeyConflictedEntry(options, entry, disposition, chunkSizeBytes);
|
|
207
|
+
const outcome = await uploadOneObject(options, resolved.entry, resolved.disposition, chunkSizeBytes);
|
|
208
|
+
await reportAbandonedUpload(options, resolved.disposition, outcome);
|
|
209
|
+
outcomes.push(outcome);
|
|
210
|
+
// The duplicates of a re-keyed primary were re-keyed with it: a duplicate
|
|
211
|
+
// is byte-identical and shared the primary's key, so it shares the new
|
|
212
|
+
// one too. Leaving them on the old key would point their evidence refs at
|
|
213
|
+
// somebody else's durable object.
|
|
214
|
+
for (const duplicate of resolved.entry.duplicates) {
|
|
215
|
+
outcomes.push(duplicateOutcome(outcome, duplicate));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
export function readBeginDispositions(body) {
|
|
219
|
+
const map = new Map();
|
|
220
|
+
for (const entry of body.objects) {
|
|
221
|
+
if (map.has(entry.object_key))
|
|
222
|
+
return null;
|
|
223
|
+
map.set(entry.object_key, {
|
|
224
|
+
disposition: entry.disposition,
|
|
225
|
+
upload_id: entry.upload_id ?? null,
|
|
226
|
+
received_chunk_indexes: entry.received_chunk_indexes,
|
|
227
|
+
commit_ready: entry.commit_ready === true,
|
|
228
|
+
reason: entry.reason ?? null,
|
|
229
|
+
raw_evidence_pointer_id: entry.raw_evidence_pointer_id,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return map;
|
|
233
|
+
}
|