@bli-cockpit/cli 0.2.56 → 0.2.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/agent-door.js +85 -0
- package/dist/commands/docs.js +227 -0
- package/dist/commands/issue-contracts.js +99 -0
- package/dist/commands/issue-write.js +129 -0
- package/dist/commands/issue.js +189 -0
- package/dist/commands/local-args-tower-docs-msg.js +126 -0
- package/dist/commands/local-args-tower-work.js +178 -0
- package/dist/commands/local-args-tower.js +7 -1
- package/dist/commands/local-args.js +10 -2
- package/dist/commands/local-help.js +70 -0
- package/dist/commands/local.js +12 -0
- package/dist/commands/mcp-bin-resolve.js +102 -0
- package/dist/commands/memory-install-claude.js +13 -5
- package/dist/commands/memory-install-config.js +140 -0
- package/dist/commands/memory-install-report.js +89 -0
- package/dist/commands/memory-install.js +51 -362
- package/dist/commands/msg.js +188 -0
- package/dist/commands/notes-door.js +120 -0
- package/dist/commands/notes-reads.js +134 -0
- package/dist/commands/notes-writes.js +208 -0
- package/dist/commands/notes.js +16 -442
- package/dist/commands/ops-render.js +18 -2
- package/dist/commands/ops.js +9 -2
- package/dist/commands/project.js +38 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/tower-mcp-claude.js +30 -0
- package/dist/commands/tower-mcp-codex.js +100 -0
- package/dist/commands/tower-mcp-contract.js +39 -0
- package/dist/commands/tower-mcp-install.js +75 -0
- package/dist/repo-identity-fingerprint.js +88 -0
- package/dist/repo-identity-git.js +76 -0
- package/dist/repo-identity-linked-worktrees.js +81 -0
- package/dist/repo-identity.js +5 -222
- package/dist/upload-envelope-build.js +240 -0
- package/dist/upload-envelope-event.js +198 -0
- package/dist/upload-envelope.js +16 -427
- package/dist/upload-ingest-receipt.js +121 -0
- package/dist/upload-session-reports-queue.js +156 -0
- package/dist/upload-session-reports-wire.js +275 -0
- package/dist/upload-session-reports.js +14 -425
- package/dist/upload-sync.js +291 -0
- package/dist/upload.js +24 -396
- package/package.json +6 -5
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describeError } from "./health-detail.js";
|
|
2
|
+
import { isNonEmptyString, readResponseJson, serverFailureDetail, } from "./upload-http.js";
|
|
3
|
+
import { SyncDeliveryError } from "./upload-failure-reason.js";
|
|
4
|
+
export async function postEnvelopeToIngest(options) {
|
|
5
|
+
let response;
|
|
6
|
+
try {
|
|
7
|
+
response = await options.fetchImpl(`${options.built.dashboard_url}/api/ambient/ingest`, {
|
|
8
|
+
method: "POST",
|
|
9
|
+
headers: {
|
|
10
|
+
"Authorization": `Bearer ${options.built.device_token}`,
|
|
11
|
+
"Content-Type": "application/json",
|
|
12
|
+
},
|
|
13
|
+
body: JSON.stringify(options.envelope),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
// No status came back at all, so no server-side reason exists to read. The
|
|
18
|
+
// error's own name and code (`TypeError` / `ENOTFOUND` / `ECONNREFUSED`)
|
|
19
|
+
// are the whole answer, and they are what separates "this machine is
|
|
20
|
+
// offline" from "the dashboard refused us" — two words that used to be the
|
|
21
|
+
// same spool row.
|
|
22
|
+
const described = describeError(error);
|
|
23
|
+
console.error("[cockpit-sync] ingest request failed before a status came back", JSON.stringify({ reason: "ingest_transport_error", ...described }));
|
|
24
|
+
throw new SyncDeliveryError("ingest_transport_error", {
|
|
25
|
+
detail: [
|
|
26
|
+
described.error_name,
|
|
27
|
+
described.error_code,
|
|
28
|
+
described.cause_code ?? described.cause_name,
|
|
29
|
+
described.error_detail,
|
|
30
|
+
]
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.join(" "),
|
|
33
|
+
cause: error,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const responseBody = await readResponseJson(response);
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
const serverReason = serverFailureDetail(responseBody);
|
|
39
|
+
console.error("[cockpit-sync] ingest refused the envelope", JSON.stringify({
|
|
40
|
+
reason: "ingest_rejected",
|
|
41
|
+
http_status: response.status,
|
|
42
|
+
server_reason: serverReason ?? "none",
|
|
43
|
+
event_count: options.envelope.events.length,
|
|
44
|
+
}));
|
|
45
|
+
throw new SyncDeliveryError("ingest_rejected", {
|
|
46
|
+
httpStatus: response.status,
|
|
47
|
+
detail: `http ${response.status}; server reason ${serverReason ?? "none"}`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
assertAmbientIngestReceipt(response, responseBody, options.envelope);
|
|
51
|
+
return response;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The receipt that proves ingest persisted exactly what was submitted.
|
|
55
|
+
*
|
|
56
|
+
* Anything short of a 202 whose counts match the envelope's own totals is
|
|
57
|
+
* treated as no receipt at all — a proxy's 200, a truncated body, or a
|
|
58
|
+
* dashboard that accepted fewer rows than were sent all land here. They used to
|
|
59
|
+
* land here under the SAME sentence, which is the BLI-3483 complaint: a
|
|
60
|
+
* network appliance answering on the dashboard's behalf and a dashboard that
|
|
61
|
+
* genuinely persisted three of four rows are opposite repairs. They are now two
|
|
62
|
+
* reasons, and the second one names the field that disagreed and by how much.
|
|
63
|
+
*/
|
|
64
|
+
function assertAmbientIngestReceipt(response, value, envelope) {
|
|
65
|
+
if (response.status !== 202 || !value || typeof value !== "object") {
|
|
66
|
+
const detail = response.status !== 202
|
|
67
|
+
? `http ${response.status}; the ingest route answers 202, so something in front of it replied`
|
|
68
|
+
: "202 with a body that is not an object";
|
|
69
|
+
console.error("[cockpit-sync] ingest answered without a durable receipt", JSON.stringify({
|
|
70
|
+
reason: "ingest_receipt_not_202",
|
|
71
|
+
http_status: response.status,
|
|
72
|
+
body_is_object: Boolean(value) && typeof value === "object",
|
|
73
|
+
}));
|
|
74
|
+
throw new SyncDeliveryError("ingest_receipt_not_202", {
|
|
75
|
+
httpStatus: response.status,
|
|
76
|
+
detail,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const record = value;
|
|
80
|
+
const ingest = record["ingest"] && typeof record["ingest"] === "object"
|
|
81
|
+
? record["ingest"]
|
|
82
|
+
: null;
|
|
83
|
+
const expectedFactCount = envelope.events.length;
|
|
84
|
+
const expectedRiskFlagCount = envelope.events.reduce((total, event) => total + event.risk_flags.length, 0);
|
|
85
|
+
const expectedEvidenceRefCount = envelope.events.reduce((total, event) => total + event.raw_evidence_pointers.length, 0);
|
|
86
|
+
const mismatches = [];
|
|
87
|
+
if (record["ok"] !== true)
|
|
88
|
+
mismatches.push("ok not true");
|
|
89
|
+
if (!ingest)
|
|
90
|
+
mismatches.push("no ingest block");
|
|
91
|
+
if (ingest && !isNonEmptyString(ingest["work_session_id"])) {
|
|
92
|
+
mismatches.push("blank work_session_id");
|
|
93
|
+
}
|
|
94
|
+
if (ingest) {
|
|
95
|
+
mismatches.push(...countMismatch("fact_count", ingest["fact_count"], expectedFactCount), ...countMismatch("risk_flag_count", ingest["risk_flag_count"], expectedRiskFlagCount), ...countMismatch("evidence_ref_count", ingest["evidence_ref_count"], expectedEvidenceRefCount));
|
|
96
|
+
}
|
|
97
|
+
if (mismatches.length === 0)
|
|
98
|
+
return;
|
|
99
|
+
console.error("[cockpit-sync] ingest receipt does not match what was submitted", JSON.stringify({
|
|
100
|
+
reason: "ingest_receipt_incomplete",
|
|
101
|
+
http_status: response.status,
|
|
102
|
+
mismatch_count: mismatches.length,
|
|
103
|
+
mismatches,
|
|
104
|
+
}));
|
|
105
|
+
throw new SyncDeliveryError("ingest_receipt_incomplete", {
|
|
106
|
+
httpStatus: response.status,
|
|
107
|
+
detail: mismatches.join("; "),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* `submitted 4, receipt 3` — the sentence that tells partial persistence from a
|
|
112
|
+
* receipt that never carried the field at all. Counts are numbers this
|
|
113
|
+
* collector computed and numbers the server echoed; neither is content.
|
|
114
|
+
*/
|
|
115
|
+
function countMismatch(field, received, expected) {
|
|
116
|
+
if (received === expected)
|
|
117
|
+
return [];
|
|
118
|
+
return [
|
|
119
|
+
`${field} submitted ${expected}, receipt ${typeof received === "number" ? received : "absent"}`,
|
|
120
|
+
];
|
|
121
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorSessionFile, readLocalSessionReference, } from "./local-state.js";
|
|
2
|
+
import { readLocalUploadSpoolState, recordPendingSessionReport, recordSessionReportFailure, recordSessionReportSuccess, } from "./spool/local-spool.js";
|
|
3
|
+
import { safeRepoLabel } from "./upload-envelope.js";
|
|
4
|
+
import { normalizeDashboardUrl } from "./upload-http.js";
|
|
5
|
+
import { describeError } from "./health-detail.js";
|
|
6
|
+
import { emptyCodexSessionReportResult, reportCodexSessionAttributions, } from "./upload-session-reports-wire.js";
|
|
7
|
+
/**
|
|
8
|
+
* Queues the safe session-attribution rows before the network request. The
|
|
9
|
+
* queue is merged per work context, so an endpoint outage retains every
|
|
10
|
+
* observed session without growing one duplicate report per scheduler pass.
|
|
11
|
+
*/
|
|
12
|
+
export async function queueCodexSessionReport(options) {
|
|
13
|
+
if (options.sessions.length === 0)
|
|
14
|
+
return null;
|
|
15
|
+
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
16
|
+
return await recordPendingSessionReport(paths, {
|
|
17
|
+
attempted_at: options.generatedAt,
|
|
18
|
+
dashboard_url: normalizeDashboardUrl(options.dashboardUrl),
|
|
19
|
+
generated_at: options.generatedAt,
|
|
20
|
+
work_context_id: options.workContextId,
|
|
21
|
+
repo_label: safeRepoLabel(options.repoLabel),
|
|
22
|
+
branch: options.branch,
|
|
23
|
+
repo_fingerprint: options.repoFingerprint,
|
|
24
|
+
repo_origin_url: options.repoOriginUrl,
|
|
25
|
+
worktree_label: options.worktreeLabel,
|
|
26
|
+
worktree_fingerprint: options.worktreeFingerprint,
|
|
27
|
+
worktree_is_primary: options.worktreeIsPrimary,
|
|
28
|
+
sessions: options.sessions,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Flushes every durable session-attribution report using current credentials.
|
|
33
|
+
* Report payloads do not depend on the source files still being inside the live
|
|
34
|
+
* scan window, so a transient endpoint failure cannot silently age them out.
|
|
35
|
+
*/
|
|
36
|
+
export async function flushPendingCodexSessionReports(options) {
|
|
37
|
+
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
38
|
+
const state = await readLocalUploadSpoolState(paths);
|
|
39
|
+
if (state.pending_session_reports.length === 0) {
|
|
40
|
+
return emptyCodexSessionReportResult("no_pending_session_reports");
|
|
41
|
+
}
|
|
42
|
+
const attemptedAt = (options.now ?? new Date()).toISOString();
|
|
43
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
44
|
+
if (!fetchImpl) {
|
|
45
|
+
throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
|
|
46
|
+
}
|
|
47
|
+
let sessionFile;
|
|
48
|
+
let session;
|
|
49
|
+
try {
|
|
50
|
+
[sessionFile, session] = await Promise.all([
|
|
51
|
+
readLocalCollectorSessionFile(paths),
|
|
52
|
+
readLocalSessionReference(paths),
|
|
53
|
+
]);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
// This marks every queued report as failed. The count is what makes it
|
|
57
|
+
// worth a line: an unreadable session file here strands N reports at once
|
|
58
|
+
// and the only visible trace is a spool that stops draining (BLI-3238).
|
|
59
|
+
console.error("[session-reports] session file unreadable, failing the pending reports", JSON.stringify({
|
|
60
|
+
reason: "collector_not_ready",
|
|
61
|
+
pending_report_count: state.pending_session_reports.length,
|
|
62
|
+
...describeError(error),
|
|
63
|
+
}));
|
|
64
|
+
return await failPendingSessionReports(paths, state.pending_session_reports, attemptedAt, "collector_not_ready");
|
|
65
|
+
}
|
|
66
|
+
if (session.session_state !== "valid") {
|
|
67
|
+
return await failPendingSessionReports(paths, state.pending_session_reports, attemptedAt, "collector_not_paired");
|
|
68
|
+
}
|
|
69
|
+
const results = [];
|
|
70
|
+
for (const pending of state.pending_session_reports) {
|
|
71
|
+
const result = await reportCodexSessionAttributions({
|
|
72
|
+
fetchImpl,
|
|
73
|
+
dashboardUrl: normalizeDashboardUrl(pending.dashboard_url || sessionFile.dashboard_url),
|
|
74
|
+
deviceToken: sessionFile.device_token,
|
|
75
|
+
provenance: provenanceForPendingReport(pending, session),
|
|
76
|
+
generatedAt: pending.generated_at,
|
|
77
|
+
sessions: pending.sessions,
|
|
78
|
+
maxAttemptsPerRequest: options.maxAttemptsPerRequest,
|
|
79
|
+
sleep: options.sleep,
|
|
80
|
+
});
|
|
81
|
+
results.push(result);
|
|
82
|
+
if (result.posted) {
|
|
83
|
+
await recordSessionReportSuccess(paths, {
|
|
84
|
+
reportId: pending.report_id,
|
|
85
|
+
attemptedAt,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
await recordSessionReportFailure(paths, {
|
|
90
|
+
reportId: pending.report_id,
|
|
91
|
+
attemptedAt,
|
|
92
|
+
reason: result.reason,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return combineCodexSessionReportResults(results);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* A queued report carries its own repo and worktree identity, captured when the
|
|
100
|
+
* sessions were observed. Only the operator and device session come from the
|
|
101
|
+
* credentials in use now — the report may be days old and from another branch.
|
|
102
|
+
*/
|
|
103
|
+
function provenanceForPendingReport(pending, session) {
|
|
104
|
+
return {
|
|
105
|
+
capture_source: "collector_runtime",
|
|
106
|
+
capture_adapter_version: LOCAL_COLLECTOR_VERSION,
|
|
107
|
+
collector_version: LOCAL_COLLECTOR_VERSION,
|
|
108
|
+
repo: pending.repo_label,
|
|
109
|
+
branch: pending.branch,
|
|
110
|
+
repo_label: pending.repo_label,
|
|
111
|
+
repo_fingerprint: pending.repo_fingerprint,
|
|
112
|
+
...(pending.repo_origin_url
|
|
113
|
+
? { repo_origin_url: pending.repo_origin_url }
|
|
114
|
+
: {}),
|
|
115
|
+
worktree_label: pending.worktree_label,
|
|
116
|
+
worktree_fingerprint: pending.worktree_fingerprint,
|
|
117
|
+
worktree_is_primary: pending.worktree_is_primary,
|
|
118
|
+
operator_id: session.operator_id,
|
|
119
|
+
session_id: session.session_id,
|
|
120
|
+
work_context_id: pending.work_context_id,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
async function failPendingSessionReports(paths, pendingReports, attemptedAt, reason) {
|
|
124
|
+
for (const pending of pendingReports) {
|
|
125
|
+
await recordSessionReportFailure(paths, {
|
|
126
|
+
reportId: pending.report_id,
|
|
127
|
+
attemptedAt,
|
|
128
|
+
reason,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
posted: false,
|
|
133
|
+
reason,
|
|
134
|
+
chunk_count: 0,
|
|
135
|
+
recorded_count: 0,
|
|
136
|
+
failed_count: pendingReports.length,
|
|
137
|
+
chunks: [],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function combineCodexSessionReportResults(results) {
|
|
141
|
+
if (results.length === 0) {
|
|
142
|
+
return emptyCodexSessionReportResult("no_pending_session_reports");
|
|
143
|
+
}
|
|
144
|
+
const failed = results.filter((result) => !result.posted);
|
|
145
|
+
return {
|
|
146
|
+
posted: failed.length === 0,
|
|
147
|
+
reason: failed[0]?.reason ??
|
|
148
|
+
(results.every((result) => result.reason === "recorded")
|
|
149
|
+
? "recorded"
|
|
150
|
+
: results[0]?.reason ?? "recorded"),
|
|
151
|
+
chunk_count: results.reduce((total, result) => total + result.chunk_count, 0),
|
|
152
|
+
recorded_count: results.reduce((total, result) => total + result.recorded_count, 0),
|
|
153
|
+
failed_count: results.reduce((total, result) => total + result.failed_count, 0),
|
|
154
|
+
chunks: results.flatMap((result) => result.chunks),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire: posting Codex session attribution rows and reading back a receipt.
|
|
3
|
+
*
|
|
4
|
+
* - `postCodexSessionReport` — the one-shot path, for a harvest that already
|
|
5
|
+
* has a repo in hand; builds its own provenance and posts immediately.
|
|
6
|
+
* - `reportCodexSessionAttributions` — chunk at the endpoint's session cap,
|
|
7
|
+
* then attempt each chunk under `postCodexSessionAttributionChunk`'s
|
|
8
|
+
* decision table of "final answer" versus "worth another attempt".
|
|
9
|
+
*
|
|
10
|
+
* `upload-session-reports-queue.ts` calls `reportCodexSessionAttributions`
|
|
11
|
+
* once per queued report; nothing here touches the durable queue.
|
|
12
|
+
*/
|
|
13
|
+
import { CODEX_SESSION_REPORT_MAX_SESSIONS, CodexSessionAttributionReportResponseSchema, } from "@bli-cockpit/telemetry-core";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, } from "./local-state.js";
|
|
16
|
+
import { makeCollectorProvenance, makeUploadWorkContext, safeRepoLabel, } from "./upload-envelope.js";
|
|
17
|
+
import { normalizeDashboardUrl, readResponseJson } from "./upload-http.js";
|
|
18
|
+
import { describeError } from "./health-detail.js";
|
|
19
|
+
/** Attempts per chunk, and the linear 250ms-per-attempt backoff between them. */
|
|
20
|
+
const DEFAULT_REPORT_ATTEMPTS = 3;
|
|
21
|
+
const REPORT_RETRY_BACKOFF_STEP_MS = 250;
|
|
22
|
+
/**
|
|
23
|
+
* Posts a Codex session attribution report using the paired collector
|
|
24
|
+
* credentials and the work context of a representative repo. Failures come
|
|
25
|
+
* back as reason labels so the harvest never hard-fails on reporting.
|
|
26
|
+
*/
|
|
27
|
+
export async function postCodexSessionReport(options) {
|
|
28
|
+
if (options.sessions.length === 0) {
|
|
29
|
+
return emptyCodexSessionReportResult("no_sessions_observed");
|
|
30
|
+
}
|
|
31
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
32
|
+
if (!fetchImpl) {
|
|
33
|
+
throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
37
|
+
const config = await readLocalCollectorConfig(paths);
|
|
38
|
+
const sessionFile = await readLocalCollectorSessionFile(paths);
|
|
39
|
+
const session = await readLocalSessionReference(paths);
|
|
40
|
+
if (session.session_state !== "valid") {
|
|
41
|
+
return emptyCodexSessionReportResult("collector_not_paired");
|
|
42
|
+
}
|
|
43
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
44
|
+
const activeContext = await readLocalWorkContextForRepo(paths, repoRoot);
|
|
45
|
+
const repoLabel = safeRepoLabel(activeContext.repo_label ?? repoRoot);
|
|
46
|
+
const provenance = makeCollectorProvenance({
|
|
47
|
+
context: makeUploadWorkContext({
|
|
48
|
+
activeContext,
|
|
49
|
+
session,
|
|
50
|
+
repoLabel,
|
|
51
|
+
now: options.now ?? new Date(),
|
|
52
|
+
}),
|
|
53
|
+
session,
|
|
54
|
+
repoLabel,
|
|
55
|
+
});
|
|
56
|
+
return await reportCodexSessionAttributions({
|
|
57
|
+
fetchImpl,
|
|
58
|
+
dashboardUrl: normalizeDashboardUrl(options.dashboardUrl ?? sessionFile.dashboard_url ?? config.dashboard_url),
|
|
59
|
+
deviceToken: sessionFile.device_token,
|
|
60
|
+
provenance,
|
|
61
|
+
generatedAt: (options.now ?? new Date()).toISOString(),
|
|
62
|
+
sessions: options.sessions,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
// `collector_not_ready` covers this whole block — config read, session
|
|
67
|
+
// read, work-context read AND the report request itself. The label is
|
|
68
|
+
// kept (it is what the receipt contract carries) but it is broader than
|
|
69
|
+
// its name suggests: a network failure inside `reportCodexSessionAttributions`
|
|
70
|
+
// also lands here and reads as "this machine is not set up". The detail is
|
|
71
|
+
// now the only way to tell those apart. Flagged, not reclassified —
|
|
72
|
+
// narrowing the label is a contract change (BLI-3238).
|
|
73
|
+
console.error("[session-reports] could not report session attributions", JSON.stringify({
|
|
74
|
+
reason: "collector_not_ready",
|
|
75
|
+
session_count: options.sessions.length,
|
|
76
|
+
...describeError(error),
|
|
77
|
+
}));
|
|
78
|
+
return emptyCodexSessionReportResult("collector_not_ready");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Reports Codex session attribution outcomes after sync. Non-fatal by design:
|
|
83
|
+
* older dashboards without the endpoint must not fail the harvest, so the
|
|
84
|
+
* caller receives a posted/skipped label instead of an exception.
|
|
85
|
+
*/
|
|
86
|
+
export async function reportCodexSessionAttributions(options) {
|
|
87
|
+
if (options.sessions.length === 0) {
|
|
88
|
+
return emptyCodexSessionReportResult("no_sessions_observed");
|
|
89
|
+
}
|
|
90
|
+
const chunks = [];
|
|
91
|
+
const sessionChunks = chunkArray(options.sessions, CODEX_SESSION_REPORT_MAX_SESSIONS);
|
|
92
|
+
for (const [index, sessions] of sessionChunks.entries()) {
|
|
93
|
+
const chunk = await postCodexSessionAttributionChunk({
|
|
94
|
+
...options,
|
|
95
|
+
sessions,
|
|
96
|
+
batchIndex: index + 1,
|
|
97
|
+
});
|
|
98
|
+
chunks.push(chunk);
|
|
99
|
+
}
|
|
100
|
+
const failedCount = chunks.filter((chunk) => !chunk.posted).length;
|
|
101
|
+
const recordedCount = chunks.reduce((sum, chunk) => sum + chunk.recorded_count, 0);
|
|
102
|
+
return {
|
|
103
|
+
posted: failedCount === 0,
|
|
104
|
+
reason: failedCount === 0
|
|
105
|
+
? "recorded"
|
|
106
|
+
: chunks.find((chunk) => !chunk.posted)?.reason ?? "report_failed",
|
|
107
|
+
chunk_count: chunks.length,
|
|
108
|
+
recorded_count: recordedCount,
|
|
109
|
+
failed_count: failedCount,
|
|
110
|
+
chunks,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* One chunk, up to three attempts, as a decision table on what came back:
|
|
115
|
+
*
|
|
116
|
+
* | what came back | verdict |
|
|
117
|
+
* |-------------------------------------|----------------------------------------|
|
|
118
|
+
* | 404 | this dashboard has no endpoint; stop |
|
|
119
|
+
* | 2xx with a readable, full receipt | recorded; stop |
|
|
120
|
+
* | 2xx malformed or short of the count | keep the reason, try again |
|
|
121
|
+
* | 4xx other than 429 | the server's final answer; stop |
|
|
122
|
+
* | 429 or 5xx | keep the reason, try again |
|
|
123
|
+
* | transport error | keep the reason, try again, no status |
|
|
124
|
+
*
|
|
125
|
+
* The row that looks wrong is the third one, and it is the important one: a 2xx
|
|
126
|
+
* we cannot read is retried because the server may have died between persisting
|
|
127
|
+
* the rows and answering. Only a schema-valid receipt that accounts for every
|
|
128
|
+
* distinct session is safe to delete a durable pending report against.
|
|
129
|
+
*/
|
|
130
|
+
async function postCodexSessionAttributionChunk(options) {
|
|
131
|
+
const maxAttempts = options.maxAttemptsPerRequest ?? DEFAULT_REPORT_ATTEMPTS;
|
|
132
|
+
const sleep = options.sleep ?? defaultReportSleep;
|
|
133
|
+
const requiredAcknowledgements = requiredCodexSessionAcknowledgementCount(options.sessions);
|
|
134
|
+
let lastStatus = null;
|
|
135
|
+
let lastFailureReason = null;
|
|
136
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
137
|
+
try {
|
|
138
|
+
const response = await postSessionAttributionRequest(options);
|
|
139
|
+
lastStatus = response.status;
|
|
140
|
+
const body = await readResponseJson(response);
|
|
141
|
+
if (response.status === 404) {
|
|
142
|
+
return codexSessionReportChunkResult(options, {
|
|
143
|
+
posted: false,
|
|
144
|
+
reason: "codex_session_api_unavailable",
|
|
145
|
+
httpStatus: response.status,
|
|
146
|
+
recordedCount: 0,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if (response.ok) {
|
|
150
|
+
const receipt = readSessionReportReceipt(body, requiredAcknowledgements);
|
|
151
|
+
if (receipt.accepted) {
|
|
152
|
+
return codexSessionReportChunkResult(options, {
|
|
153
|
+
posted: true,
|
|
154
|
+
reason: "recorded",
|
|
155
|
+
httpStatus: response.status,
|
|
156
|
+
recordedCount: receipt.recorded_count,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
lastFailureReason = receipt.reason;
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
lastFailureReason = `report_failed_http_${response.status}`;
|
|
163
|
+
if (isFinalHttpFailure(response.status)) {
|
|
164
|
+
return codexSessionReportChunkResult(options, {
|
|
165
|
+
posted: false,
|
|
166
|
+
reason: lastFailureReason,
|
|
167
|
+
httpStatus: response.status,
|
|
168
|
+
recordedCount: 0,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
// Logged per attempt, on purpose: the interesting failure is the one
|
|
175
|
+
// that repeats. Three identical ECONNRESETs and one DNS failure followed
|
|
176
|
+
// by two resets are different stories, and `report_network_error` alone
|
|
177
|
+
// tells neither (BLI-3238).
|
|
178
|
+
console.error("[session-reports] report attempt failed before a status came back", JSON.stringify({
|
|
179
|
+
reason: "report_network_error",
|
|
180
|
+
attempt,
|
|
181
|
+
max_attempts: maxAttempts,
|
|
182
|
+
batch_index: options.batchIndex,
|
|
183
|
+
session_count: options.sessions.length,
|
|
184
|
+
...describeError(error),
|
|
185
|
+
}));
|
|
186
|
+
lastStatus = null;
|
|
187
|
+
lastFailureReason = "report_network_error";
|
|
188
|
+
}
|
|
189
|
+
if (attempt < maxAttempts) {
|
|
190
|
+
await sleep(REPORT_RETRY_BACKOFF_STEP_MS * attempt);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return codexSessionReportChunkResult(options, {
|
|
194
|
+
posted: false,
|
|
195
|
+
reason: lastFailureReason ??
|
|
196
|
+
(lastStatus === null
|
|
197
|
+
? "report_network_error"
|
|
198
|
+
: `report_failed_http_${lastStatus}`),
|
|
199
|
+
httpStatus: lastStatus,
|
|
200
|
+
recordedCount: 0,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
function postSessionAttributionRequest(options) {
|
|
204
|
+
return options.fetchImpl(`${options.dashboardUrl}/api/ambient/codex-sessions`, {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: {
|
|
207
|
+
"Authorization": `Bearer ${options.deviceToken}`,
|
|
208
|
+
"Content-Type": "application/json",
|
|
209
|
+
},
|
|
210
|
+
body: JSON.stringify({
|
|
211
|
+
schema_version: "ambient-codex-session-attributions.v1",
|
|
212
|
+
generated_at: options.generatedAt,
|
|
213
|
+
provenance: options.provenance,
|
|
214
|
+
sessions: options.sessions,
|
|
215
|
+
}),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/** Is this status the server's final answer, or worth another attempt? */
|
|
219
|
+
function isFinalHttpFailure(status) {
|
|
220
|
+
return status < 500 && status !== 429;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Can we take this 2xx body as proof the rows are durable?
|
|
224
|
+
*
|
|
225
|
+
* Only if it parses as the receipt schema AND accounts for every distinct
|
|
226
|
+
* session in the chunk. Anything less is treated as no receipt at all.
|
|
227
|
+
*/
|
|
228
|
+
function readSessionReportReceipt(body, requiredAcknowledgements) {
|
|
229
|
+
const parsed = CodexSessionAttributionReportResponseSchema.safeParse(body);
|
|
230
|
+
if (!parsed.success) {
|
|
231
|
+
return { accepted: false, reason: "report_invalid_response" };
|
|
232
|
+
}
|
|
233
|
+
if (parsed.data.recorded_count < requiredAcknowledgements) {
|
|
234
|
+
return { accepted: false, reason: "report_incomplete_acknowledgement" };
|
|
235
|
+
}
|
|
236
|
+
return { accepted: true, recorded_count: parsed.data.recorded_count };
|
|
237
|
+
}
|
|
238
|
+
function codexSessionReportChunkResult(options, result) {
|
|
239
|
+
return {
|
|
240
|
+
batch_index: options.batchIndex,
|
|
241
|
+
session_count: options.sessions.length,
|
|
242
|
+
posted: result.posted,
|
|
243
|
+
reason: result.reason,
|
|
244
|
+
http_status: result.httpStatus,
|
|
245
|
+
recorded_count: result.recordedCount,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
export function emptyCodexSessionReportResult(reason) {
|
|
249
|
+
return {
|
|
250
|
+
posted: false,
|
|
251
|
+
reason,
|
|
252
|
+
chunk_count: 0,
|
|
253
|
+
recorded_count: 0,
|
|
254
|
+
failed_count: 0,
|
|
255
|
+
chunks: [],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function chunkArray(items, size) {
|
|
259
|
+
const chunks = [];
|
|
260
|
+
for (let offset = 0; offset < items.length; offset += size) {
|
|
261
|
+
chunks.push(items.slice(offset, offset + size));
|
|
262
|
+
}
|
|
263
|
+
return chunks;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* How many rows the server must acknowledge, counted per distinct session
|
|
267
|
+
* rather than per row — the same session observed twice in one chunk is still
|
|
268
|
+
* one row on the far side.
|
|
269
|
+
*/
|
|
270
|
+
function requiredCodexSessionAcknowledgementCount(sessions) {
|
|
271
|
+
return new Set(sessions.map((session) => `${session.source ?? "codex"}:${session.codex_session_id}`)).size;
|
|
272
|
+
}
|
|
273
|
+
function defaultReportSleep(milliseconds) {
|
|
274
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
275
|
+
}
|