@bli-cockpit/cli 0.2.5 → 0.2.8
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/README.md +20 -8
- package/dist/adapters/attribution-core.js +152 -31
- package/dist/adapters/claude-attribution.js +14 -7
- package/dist/adapters/codex-attribution.js +8 -2
- package/dist/adapters/raw-evidence.js +33 -12
- package/dist/agent-rules.js +24 -10
- package/dist/autostart.js +460 -20
- package/dist/commands/backfill.js +582 -87
- package/dist/commands/doctor.js +72 -60
- package/dist/commands/local-args.js +19 -2
- package/dist/commands/local.js +418 -241
- package/dist/commands/public-root.js +6 -1
- package/dist/commands/session-sync.js +259 -54
- package/dist/cursors/backfill-cursor.js +169 -1
- package/dist/cursors/raw-evidence-cursor.js +16 -2
- package/dist/evidence-upload-client.js +139 -107
- package/dist/local-state.js +108 -6
- package/dist/onboarding-roots.js +42 -17
- package/dist/process-runner.js +103 -0
- package/dist/raw-evidence-attribution-policy.js +25 -0
- package/dist/repo-identity.js +142 -26
- package/dist/root-normalization.js +29 -16
- package/dist/spool/local-spool.js +335 -58
- package/dist/upload.js +309 -66
- package/package.json +2 -2
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
export function collapseAncestorRoots(roots) {
|
|
2
|
+
export function collapseAncestorRoots(roots, pathApi = path) {
|
|
3
3
|
const collapsed = [];
|
|
4
4
|
for (const root of roots) {
|
|
5
|
-
const resolved =
|
|
6
|
-
if (collapsed.some((existing) => containsPath(existing, resolved)))
|
|
5
|
+
const resolved = pathApi.resolve(root);
|
|
6
|
+
if (collapsed.some((existing) => containsPath(existing, resolved, pathApi))) {
|
|
7
7
|
continue;
|
|
8
|
+
}
|
|
8
9
|
for (let index = collapsed.length - 1; index >= 0; index -= 1) {
|
|
9
|
-
if (containsPath(resolved, collapsed[index])) {
|
|
10
|
+
if (containsPath(resolved, collapsed[index], pathApi)) {
|
|
10
11
|
collapsed.splice(index, 1);
|
|
11
12
|
}
|
|
12
13
|
}
|
|
@@ -14,21 +15,33 @@ export function collapseAncestorRoots(roots) {
|
|
|
14
15
|
}
|
|
15
16
|
return collapsed;
|
|
16
17
|
}
|
|
17
|
-
export function normalizeCollectionRoots(roots) {
|
|
18
|
-
const collapsed = collapseAncestorRoots(roots);
|
|
19
|
-
if (!collapsed.some(isBliWorkspaceRoot))
|
|
18
|
+
export function normalizeCollectionRoots(roots, pathApi = path) {
|
|
19
|
+
const collapsed = collapseAncestorRoots(roots, pathApi);
|
|
20
|
+
if (!collapsed.some((root) => isBliWorkspaceRoot(root, pathApi))) {
|
|
20
21
|
return collapsed;
|
|
21
|
-
|
|
22
|
+
}
|
|
23
|
+
return collapsed.filter((root) => !isCodexWorktreePath(root, pathApi));
|
|
22
24
|
}
|
|
23
|
-
function containsPath(parent, candidate) {
|
|
24
|
-
const relative =
|
|
25
|
+
export function containsPath(parent, candidate, pathApi = path) {
|
|
26
|
+
const relative = pathApi.relative(parent, candidate);
|
|
25
27
|
return (relative === "" ||
|
|
26
|
-
(!!relative &&
|
|
28
|
+
(!!relative &&
|
|
29
|
+
relative !== ".." &&
|
|
30
|
+
!relative.startsWith(`..${pathApi.sep}`) &&
|
|
31
|
+
!pathApi.isAbsolute(relative)));
|
|
32
|
+
}
|
|
33
|
+
export function isSamePath(left, right, pathApi = path) {
|
|
34
|
+
return pathApi.relative(left, right) === "";
|
|
35
|
+
}
|
|
36
|
+
function comparableSegment(value, pathApi) {
|
|
37
|
+
return pathApi.sep === "\\" ? value.toLowerCase() : value;
|
|
27
38
|
}
|
|
28
|
-
function isBliWorkspaceRoot(root) {
|
|
29
|
-
return
|
|
39
|
+
function isBliWorkspaceRoot(root, pathApi) {
|
|
40
|
+
return comparableSegment(pathApi.basename(root), pathApi) ===
|
|
41
|
+
comparableSegment("BLI", pathApi);
|
|
30
42
|
}
|
|
31
|
-
function isCodexWorktreePath(root) {
|
|
32
|
-
const parts = root.split(
|
|
33
|
-
return parts.some((part, index) => part === ".codex" &&
|
|
43
|
+
export function isCodexWorktreePath(root, pathApi = path) {
|
|
44
|
+
const parts = root.split(pathApi.sep).filter(Boolean);
|
|
45
|
+
return parts.some((part, index) => comparableSegment(part, pathApi) === ".codex" &&
|
|
46
|
+
comparableSegment(parts[index + 1] ?? "", pathApi) === "worktrees");
|
|
34
47
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { CodexSessionAttributionSchema, } from "@bli-cockpit/telemetry-core";
|
|
4
5
|
const SPOOL_STATE_FILENAME = "upload-state.json";
|
|
5
6
|
const MAX_PENDING_UPLOADS = 20;
|
|
6
7
|
export function emptyUploadSpoolState() {
|
|
@@ -11,15 +12,33 @@ export function emptyUploadSpoolState() {
|
|
|
11
12
|
last_upload_success_at: null,
|
|
12
13
|
last_upload_failure_reason: null,
|
|
13
14
|
pending_uploads: [],
|
|
15
|
+
pending_source_retries: [],
|
|
16
|
+
pending_session_reports: [],
|
|
14
17
|
};
|
|
15
18
|
}
|
|
16
19
|
export async function readLocalUploadSpoolState(paths) {
|
|
20
|
+
const filePath = uploadSpoolStatePath(paths);
|
|
21
|
+
let serialized;
|
|
22
|
+
try {
|
|
23
|
+
serialized = await fs.readFile(filePath, "utf8");
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (isErrnoException(error, "ENOENT"))
|
|
27
|
+
return emptyUploadSpoolState();
|
|
28
|
+
throw uploadSpoolReadError(filePath, "unreadable", error);
|
|
29
|
+
}
|
|
30
|
+
let raw;
|
|
31
|
+
try {
|
|
32
|
+
raw = JSON.parse(serialized);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
throw uploadSpoolReadError(filePath, "invalid JSON", error);
|
|
36
|
+
}
|
|
17
37
|
try {
|
|
18
|
-
const raw = JSON.parse(await fs.readFile(uploadSpoolStatePath(paths), "utf8"));
|
|
19
38
|
return parseUploadSpoolState(raw);
|
|
20
39
|
}
|
|
21
|
-
catch {
|
|
22
|
-
|
|
40
|
+
catch (error) {
|
|
41
|
+
throw uploadSpoolReadError(filePath, "invalid schema", error);
|
|
23
42
|
}
|
|
24
43
|
}
|
|
25
44
|
export async function summarizeLocalUploadSpool(paths) {
|
|
@@ -28,8 +47,14 @@ export async function summarizeLocalUploadSpool(paths) {
|
|
|
28
47
|
last_upload_attempt_at: state.last_upload_attempt_at,
|
|
29
48
|
last_upload_success_at: state.last_upload_success_at,
|
|
30
49
|
last_upload_failure_reason: state.last_upload_failure_reason,
|
|
31
|
-
pending_upload_count: state.pending_uploads.length
|
|
32
|
-
|
|
50
|
+
pending_upload_count: state.pending_uploads.length +
|
|
51
|
+
state.pending_source_retries.length +
|
|
52
|
+
state.pending_session_reports.length,
|
|
53
|
+
retry_command: state.pending_uploads[0]?.retry_command ??
|
|
54
|
+
(state.pending_source_retries.length > 0 ||
|
|
55
|
+
state.pending_session_reports.length > 0
|
|
56
|
+
? "cockpit sync"
|
|
57
|
+
: null),
|
|
33
58
|
};
|
|
34
59
|
}
|
|
35
60
|
export async function recordUploadBlocked(paths, options) {
|
|
@@ -45,13 +70,19 @@ export async function recordUploadBlocked(paths, options) {
|
|
|
45
70
|
}
|
|
46
71
|
export async function recordUploadSuccess(paths, options) {
|
|
47
72
|
const state = await readLocalUploadSpoolState(paths);
|
|
73
|
+
const pending = options.clearPendingForContext === false
|
|
74
|
+
? state.pending_uploads
|
|
75
|
+
: state.pending_uploads.filter((entry) => entry.work_context_id !== options.workContextId);
|
|
48
76
|
const next = {
|
|
49
77
|
...state,
|
|
50
78
|
updated_at: options.attemptedAt,
|
|
51
79
|
last_upload_attempt_at: options.attemptedAt,
|
|
52
80
|
last_upload_success_at: options.attemptedAt,
|
|
53
|
-
last_upload_failure_reason:
|
|
54
|
-
|
|
81
|
+
last_upload_failure_reason: firstPendingFailureReason({
|
|
82
|
+
...state,
|
|
83
|
+
pending_uploads: pending,
|
|
84
|
+
}),
|
|
85
|
+
pending_uploads: pending,
|
|
55
86
|
};
|
|
56
87
|
await writeUploadSpoolState(paths, next);
|
|
57
88
|
return next;
|
|
@@ -78,69 +109,315 @@ export async function recordUploadFailure(paths, entry) {
|
|
|
78
109
|
});
|
|
79
110
|
return spoolEntry;
|
|
80
111
|
}
|
|
112
|
+
export async function recordSourceRetryFailure(paths, options) {
|
|
113
|
+
const state = await readLocalUploadSpoolState(paths);
|
|
114
|
+
const retryEntry = {
|
|
115
|
+
source: options.source,
|
|
116
|
+
reason: options.reason,
|
|
117
|
+
last_attempt_at: options.attemptedAt,
|
|
118
|
+
};
|
|
119
|
+
const next = {
|
|
120
|
+
...state,
|
|
121
|
+
updated_at: options.attemptedAt,
|
|
122
|
+
last_upload_attempt_at: options.attemptedAt,
|
|
123
|
+
last_upload_failure_reason: options.reason,
|
|
124
|
+
pending_source_retries: [
|
|
125
|
+
retryEntry,
|
|
126
|
+
...state.pending_source_retries.filter((entry) => entry.source !== options.source),
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
await writeUploadSpoolState(paths, next);
|
|
130
|
+
return next;
|
|
131
|
+
}
|
|
132
|
+
export async function clearSourceRetryFailure(paths, source, attemptedAt) {
|
|
133
|
+
const state = await readLocalUploadSpoolState(paths);
|
|
134
|
+
const next = {
|
|
135
|
+
...state,
|
|
136
|
+
updated_at: attemptedAt,
|
|
137
|
+
pending_source_retries: state.pending_source_retries.filter((entry) => entry.source !== source),
|
|
138
|
+
};
|
|
139
|
+
next.last_upload_failure_reason = firstPendingFailureReason(next);
|
|
140
|
+
await writeUploadSpoolState(paths, next);
|
|
141
|
+
return next;
|
|
142
|
+
}
|
|
143
|
+
export async function recordPendingSessionReport(paths, entry) {
|
|
144
|
+
const state = await readLocalUploadSpoolState(paths);
|
|
145
|
+
const existing = state.pending_session_reports.find((candidate) => candidate.work_context_id === entry.work_context_id);
|
|
146
|
+
const safeIncomingSessions = parseMetadataOnlySessionReportRows(entry.sessions, "pending_session_report.sessions");
|
|
147
|
+
const mergedSessions = mergeSessionReportRows(existing?.sessions ?? [], safeIncomingSessions);
|
|
148
|
+
const pending = {
|
|
149
|
+
report_id: existing?.report_id ?? `session-report-${crypto.randomUUID()}`,
|
|
150
|
+
created_at: existing?.created_at ?? entry.attempted_at,
|
|
151
|
+
last_attempt_at: entry.attempted_at,
|
|
152
|
+
dashboard_url: entry.dashboard_url,
|
|
153
|
+
generated_at: entry.generated_at,
|
|
154
|
+
work_context_id: entry.work_context_id,
|
|
155
|
+
repo_label: entry.repo_label,
|
|
156
|
+
branch: entry.branch,
|
|
157
|
+
repo_fingerprint: entry.repo_fingerprint,
|
|
158
|
+
repo_origin_url: entry.repo_origin_url,
|
|
159
|
+
worktree_label: entry.worktree_label,
|
|
160
|
+
worktree_fingerprint: entry.worktree_fingerprint,
|
|
161
|
+
worktree_is_primary: entry.worktree_is_primary,
|
|
162
|
+
sessions: mergedSessions,
|
|
163
|
+
failure_reason: entry.failure_reason ?? "session_report_pending",
|
|
164
|
+
};
|
|
165
|
+
const next = {
|
|
166
|
+
...state,
|
|
167
|
+
updated_at: entry.attempted_at,
|
|
168
|
+
last_upload_attempt_at: entry.attempted_at,
|
|
169
|
+
last_upload_failure_reason: pending.failure_reason,
|
|
170
|
+
pending_session_reports: [
|
|
171
|
+
pending,
|
|
172
|
+
...state.pending_session_reports.filter((candidate) => candidate.report_id !== pending.report_id),
|
|
173
|
+
],
|
|
174
|
+
};
|
|
175
|
+
await writeUploadSpoolState(paths, next);
|
|
176
|
+
return pending;
|
|
177
|
+
}
|
|
178
|
+
export async function recordSessionReportFailure(paths, options) {
|
|
179
|
+
const state = await readLocalUploadSpoolState(paths);
|
|
180
|
+
const next = {
|
|
181
|
+
...state,
|
|
182
|
+
updated_at: options.attemptedAt,
|
|
183
|
+
last_upload_attempt_at: options.attemptedAt,
|
|
184
|
+
last_upload_failure_reason: options.reason,
|
|
185
|
+
pending_session_reports: state.pending_session_reports.map((entry) => entry.report_id === options.reportId
|
|
186
|
+
? {
|
|
187
|
+
...entry,
|
|
188
|
+
last_attempt_at: options.attemptedAt,
|
|
189
|
+
failure_reason: options.reason,
|
|
190
|
+
}
|
|
191
|
+
: entry),
|
|
192
|
+
};
|
|
193
|
+
await writeUploadSpoolState(paths, next);
|
|
194
|
+
return next;
|
|
195
|
+
}
|
|
196
|
+
export async function recordSessionReportSuccess(paths, options) {
|
|
197
|
+
const state = await readLocalUploadSpoolState(paths);
|
|
198
|
+
const next = {
|
|
199
|
+
...state,
|
|
200
|
+
updated_at: options.attemptedAt,
|
|
201
|
+
last_upload_attempt_at: options.attemptedAt,
|
|
202
|
+
last_upload_success_at: options.attemptedAt,
|
|
203
|
+
pending_session_reports: state.pending_session_reports.filter((entry) => entry.report_id !== options.reportId),
|
|
204
|
+
};
|
|
205
|
+
next.last_upload_failure_reason = firstPendingFailureReason(next);
|
|
206
|
+
await writeUploadSpoolState(paths, next);
|
|
207
|
+
return next;
|
|
208
|
+
}
|
|
81
209
|
function parseUploadSpoolState(value) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
210
|
+
const record = requireRecord(value, "root");
|
|
211
|
+
if (record["schema_version"] !== "cockpit-upload-spool.v1") {
|
|
212
|
+
invalidUploadSpoolState("schema_version", 'literal "cockpit-upload-spool.v1"');
|
|
213
|
+
}
|
|
85
214
|
return {
|
|
86
215
|
schema_version: "cockpit-upload-spool.v1",
|
|
87
|
-
updated_at:
|
|
88
|
-
last_upload_attempt_at:
|
|
89
|
-
last_upload_success_at:
|
|
90
|
-
last_upload_failure_reason:
|
|
91
|
-
pending_uploads:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const lastAttemptAt = optionalString(record["last_attempt_at"]);
|
|
105
|
-
const dashboardUrl = optionalString(record["dashboard_url"]);
|
|
106
|
-
const failureReason = optionalString(record["failure_reason"]);
|
|
107
|
-
const retryCommand = optionalString(record["retry_command"]);
|
|
108
|
-
if (!spoolId || !createdAt || !lastAttemptAt || !dashboardUrl || !failureReason) {
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
216
|
+
updated_at: requireNullableString(record["updated_at"], "updated_at"),
|
|
217
|
+
last_upload_attempt_at: requireNullableString(record["last_upload_attempt_at"], "last_upload_attempt_at"),
|
|
218
|
+
last_upload_success_at: requireNullableString(record["last_upload_success_at"], "last_upload_success_at"),
|
|
219
|
+
last_upload_failure_reason: requireNullableString(record["last_upload_failure_reason"], "last_upload_failure_reason"),
|
|
220
|
+
pending_uploads: requireArray(record["pending_uploads"], "pending_uploads").map((entry, index) => parseUploadSpoolEntry(entry, `pending_uploads[${index}]`)),
|
|
221
|
+
// These fields were added without changing the v1 schema version. Missing
|
|
222
|
+
// fields are therefore a valid legacy state, while present malformed fields
|
|
223
|
+
// must fail closed instead of being silently discarded.
|
|
224
|
+
pending_source_retries: optionalArray(record["pending_source_retries"], "pending_source_retries").map((entry, index) => parseSourceRetryEntry(entry, `pending_source_retries[${index}]`)),
|
|
225
|
+
pending_session_reports: optionalArray(record["pending_session_reports"], "pending_session_reports").map((entry, index) => parsePendingSessionReport(entry, `pending_session_reports[${index}]`)),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
function parseUploadSpoolEntry(value, fieldPath) {
|
|
229
|
+
const record = requireRecord(value, fieldPath);
|
|
230
|
+
const retryCommand = record["retry_command"] === undefined
|
|
231
|
+
? "cockpit sync"
|
|
232
|
+
: requireString(record["retry_command"], `${fieldPath}.retry_command`);
|
|
111
233
|
return {
|
|
112
|
-
spool_id:
|
|
113
|
-
created_at:
|
|
114
|
-
last_attempt_at:
|
|
115
|
-
dashboard_url:
|
|
116
|
-
work_context_id:
|
|
117
|
-
ticket_id:
|
|
118
|
-
repo_label:
|
|
119
|
-
branch:
|
|
120
|
-
event_count:
|
|
121
|
-
source_scan_count:
|
|
122
|
-
risk_flag_count:
|
|
123
|
-
raw_evidence_file_count:
|
|
124
|
-
|
|
125
|
-
|
|
234
|
+
spool_id: requireString(record["spool_id"], `${fieldPath}.spool_id`),
|
|
235
|
+
created_at: requireString(record["created_at"], `${fieldPath}.created_at`),
|
|
236
|
+
last_attempt_at: requireString(record["last_attempt_at"], `${fieldPath}.last_attempt_at`),
|
|
237
|
+
dashboard_url: requireString(record["dashboard_url"], `${fieldPath}.dashboard_url`),
|
|
238
|
+
work_context_id: requireNullableString(record["work_context_id"], `${fieldPath}.work_context_id`),
|
|
239
|
+
ticket_id: requireNullableString(record["ticket_id"], `${fieldPath}.ticket_id`),
|
|
240
|
+
repo_label: requireNullableString(record["repo_label"], `${fieldPath}.repo_label`),
|
|
241
|
+
branch: requireNullableString(record["branch"], `${fieldPath}.branch`),
|
|
242
|
+
event_count: requireNonNegativeInteger(record["event_count"], `${fieldPath}.event_count`),
|
|
243
|
+
source_scan_count: requireNonNegativeInteger(record["source_scan_count"], `${fieldPath}.source_scan_count`),
|
|
244
|
+
risk_flag_count: requireNonNegativeInteger(record["risk_flag_count"], `${fieldPath}.risk_flag_count`),
|
|
245
|
+
raw_evidence_file_count: requireNonNegativeInteger(record["raw_evidence_file_count"], `${fieldPath}.raw_evidence_file_count`),
|
|
246
|
+
retry_sources: parseRetrySources(record["retry_sources"], `${fieldPath}.retry_sources`),
|
|
247
|
+
failure_reason: requireString(record["failure_reason"], `${fieldPath}.failure_reason`),
|
|
248
|
+
retry_command: retryCommand,
|
|
126
249
|
};
|
|
127
250
|
}
|
|
128
251
|
async function writeUploadSpoolState(paths, state) {
|
|
129
252
|
const filePath = uploadSpoolStatePath(paths);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
|
|
253
|
+
const directoryPath = path.dirname(filePath);
|
|
254
|
+
const serialized = serializeUploadSpoolState(state);
|
|
255
|
+
const tempPath = path.join(directoryPath, `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
256
|
+
await fs.mkdir(directoryPath, { recursive: true, mode: 0o700 });
|
|
257
|
+
let handle = null;
|
|
258
|
+
let tempCreated = false;
|
|
259
|
+
let renamed = false;
|
|
260
|
+
try {
|
|
261
|
+
handle = await fs.open(tempPath, "wx", 0o600);
|
|
262
|
+
tempCreated = true;
|
|
263
|
+
if (process.platform !== "win32") {
|
|
264
|
+
await handle.chmod(0o600);
|
|
265
|
+
}
|
|
266
|
+
await handle.writeFile(serialized, "utf8");
|
|
267
|
+
await handle.sync();
|
|
268
|
+
await handle.close();
|
|
269
|
+
handle = null;
|
|
270
|
+
await fs.rename(tempPath, filePath);
|
|
271
|
+
renamed = true;
|
|
272
|
+
await fsyncDirectoryBestEffort(directoryPath);
|
|
136
273
|
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
await handle?.close().catch(() => undefined);
|
|
276
|
+
if (tempCreated && !renamed) {
|
|
277
|
+
await fs.unlink(tempPath).catch(() => undefined);
|
|
278
|
+
}
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function serializeUploadSpoolState(state) {
|
|
283
|
+
const json = JSON.stringify(state);
|
|
284
|
+
const canonical = parseUploadSpoolState(JSON.parse(json));
|
|
285
|
+
return `${JSON.stringify(canonical, null, 2)}\n`;
|
|
137
286
|
}
|
|
138
287
|
function uploadSpoolStatePath(paths) {
|
|
139
288
|
return path.join(paths.spool_dir, SPOOL_STATE_FILENAME);
|
|
140
289
|
}
|
|
141
|
-
function
|
|
142
|
-
|
|
290
|
+
function parseRetrySources(value, fieldPath) {
|
|
291
|
+
if (value === undefined)
|
|
292
|
+
return [];
|
|
293
|
+
const values = requireArray(value, fieldPath);
|
|
294
|
+
return [...new Set(values.map((source) => parseRetrySource(source, fieldPath)))];
|
|
295
|
+
}
|
|
296
|
+
function parseSourceRetryEntry(value, fieldPath) {
|
|
297
|
+
const record = requireRecord(value, fieldPath);
|
|
298
|
+
return {
|
|
299
|
+
source: parseRetrySource(record["source"], `${fieldPath}.source`),
|
|
300
|
+
reason: requireString(record["reason"], `${fieldPath}.reason`),
|
|
301
|
+
last_attempt_at: requireString(record["last_attempt_at"], `${fieldPath}.last_attempt_at`),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
function parsePendingSessionReport(value, fieldPath) {
|
|
305
|
+
const record = requireRecord(value, fieldPath);
|
|
306
|
+
const sessions = parseMetadataOnlySessionReportRows(record["sessions"], `${fieldPath}.sessions`);
|
|
307
|
+
return {
|
|
308
|
+
report_id: requireString(record["report_id"], `${fieldPath}.report_id`),
|
|
309
|
+
created_at: requireString(record["created_at"], `${fieldPath}.created_at`),
|
|
310
|
+
last_attempt_at: requireString(record["last_attempt_at"], `${fieldPath}.last_attempt_at`),
|
|
311
|
+
dashboard_url: requireString(record["dashboard_url"], `${fieldPath}.dashboard_url`),
|
|
312
|
+
generated_at: requireString(record["generated_at"], `${fieldPath}.generated_at`),
|
|
313
|
+
work_context_id: requireString(record["work_context_id"], `${fieldPath}.work_context_id`),
|
|
314
|
+
repo_label: requireString(record["repo_label"], `${fieldPath}.repo_label`),
|
|
315
|
+
branch: requireString(record["branch"], `${fieldPath}.branch`),
|
|
316
|
+
repo_fingerprint: requireString(record["repo_fingerprint"], `${fieldPath}.repo_fingerprint`),
|
|
317
|
+
repo_origin_url: record["repo_origin_url"] === undefined
|
|
318
|
+
? null
|
|
319
|
+
: requireNullableString(record["repo_origin_url"], `${fieldPath}.repo_origin_url`),
|
|
320
|
+
worktree_label: requireString(record["worktree_label"], `${fieldPath}.worktree_label`),
|
|
321
|
+
worktree_fingerprint: requireString(record["worktree_fingerprint"], `${fieldPath}.worktree_fingerprint`),
|
|
322
|
+
worktree_is_primary: requireBoolean(record["worktree_is_primary"], `${fieldPath}.worktree_is_primary`),
|
|
323
|
+
sessions,
|
|
324
|
+
failure_reason: requireString(record["failure_reason"], `${fieldPath}.failure_reason`),
|
|
325
|
+
};
|
|
143
326
|
}
|
|
144
|
-
function
|
|
145
|
-
|
|
327
|
+
function parseMetadataOnlySessionReportRows(value, fieldPath) {
|
|
328
|
+
const sessions = requireArray(value, fieldPath).map((session, index) => {
|
|
329
|
+
const parsed = CodexSessionAttributionSchema.safeParse(session);
|
|
330
|
+
if (!parsed.success) {
|
|
331
|
+
invalidUploadSpoolState(`${fieldPath}[${index}]`, "metadata-only Codex session attribution");
|
|
332
|
+
}
|
|
333
|
+
return parsed.data;
|
|
334
|
+
});
|
|
335
|
+
if (sessions.length === 0) {
|
|
336
|
+
invalidUploadSpoolState(fieldPath, "non-empty array");
|
|
337
|
+
}
|
|
338
|
+
return sessions;
|
|
339
|
+
}
|
|
340
|
+
function parseRetrySource(value, fieldPath) {
|
|
341
|
+
if (value === "codex" || value === "claude_code")
|
|
342
|
+
return value;
|
|
343
|
+
return invalidUploadSpoolState(fieldPath, '"codex" or "claude_code"');
|
|
344
|
+
}
|
|
345
|
+
function mergeSessionReportRows(existing, incoming) {
|
|
346
|
+
const bySession = new Map();
|
|
347
|
+
for (const session of [...existing, ...incoming]) {
|
|
348
|
+
bySession.set(`${session.source ?? "codex"}:${session.codex_session_id}`, session);
|
|
349
|
+
}
|
|
350
|
+
return [...bySession.values()];
|
|
351
|
+
}
|
|
352
|
+
function firstPendingFailureReason(state) {
|
|
353
|
+
return (state.pending_uploads[0]?.failure_reason ??
|
|
354
|
+
state.pending_source_retries[0]?.reason ??
|
|
355
|
+
state.pending_session_reports[0]?.failure_reason ??
|
|
356
|
+
null);
|
|
357
|
+
}
|
|
358
|
+
function requireRecord(value, fieldPath) {
|
|
359
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
360
|
+
return value;
|
|
361
|
+
}
|
|
362
|
+
return invalidUploadSpoolState(fieldPath, "object");
|
|
363
|
+
}
|
|
364
|
+
function requireArray(value, fieldPath) {
|
|
365
|
+
if (Array.isArray(value))
|
|
366
|
+
return value;
|
|
367
|
+
return invalidUploadSpoolState(fieldPath, "array");
|
|
368
|
+
}
|
|
369
|
+
function optionalArray(value, fieldPath) {
|
|
370
|
+
return value === undefined ? [] : requireArray(value, fieldPath);
|
|
371
|
+
}
|
|
372
|
+
function requireString(value, fieldPath) {
|
|
373
|
+
if (typeof value === "string" && value.trim())
|
|
374
|
+
return value;
|
|
375
|
+
return invalidUploadSpoolState(fieldPath, "non-empty string");
|
|
376
|
+
}
|
|
377
|
+
function requireNullableString(value, fieldPath) {
|
|
378
|
+
if (value === null)
|
|
379
|
+
return null;
|
|
380
|
+
return requireString(value, fieldPath);
|
|
381
|
+
}
|
|
382
|
+
function requireNonNegativeInteger(value, fieldPath) {
|
|
383
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 0) {
|
|
384
|
+
return value;
|
|
385
|
+
}
|
|
386
|
+
return invalidUploadSpoolState(fieldPath, "non-negative integer");
|
|
387
|
+
}
|
|
388
|
+
function requireBoolean(value, fieldPath) {
|
|
389
|
+
if (typeof value === "boolean")
|
|
390
|
+
return value;
|
|
391
|
+
return invalidUploadSpoolState(fieldPath, "boolean");
|
|
392
|
+
}
|
|
393
|
+
function invalidUploadSpoolState(fieldPath, expected) {
|
|
394
|
+
throw new Error(`Upload spool state schema mismatch at ${fieldPath}; expected ${expected}.`);
|
|
395
|
+
}
|
|
396
|
+
function isErrnoException(error, code) {
|
|
397
|
+
return (error instanceof Error &&
|
|
398
|
+
"code" in error &&
|
|
399
|
+
error.code === code);
|
|
400
|
+
}
|
|
401
|
+
function uploadSpoolReadError(filePath, classification, cause) {
|
|
402
|
+
const detail = cause &&
|
|
403
|
+
typeof cause === "object" &&
|
|
404
|
+
"code" in cause &&
|
|
405
|
+
typeof cause.code === "string"
|
|
406
|
+
? ` (${cause.code})`
|
|
407
|
+
: "";
|
|
408
|
+
return new Error(`Upload spool state at ${filePath} is ${classification}; refusing to treat pending delivery state as empty.${detail}`);
|
|
409
|
+
}
|
|
410
|
+
async function fsyncDirectoryBestEffort(directoryPath) {
|
|
411
|
+
let handle = null;
|
|
412
|
+
try {
|
|
413
|
+
handle = await fs.open(directoryPath, "r");
|
|
414
|
+
await handle.sync();
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
// Directory fsync is not supported by every host/filesystem (notably some
|
|
418
|
+
// Windows versions). The file itself was fsynced before the atomic rename.
|
|
419
|
+
}
|
|
420
|
+
finally {
|
|
421
|
+
await handle?.close().catch(() => undefined);
|
|
422
|
+
}
|
|
146
423
|
}
|