@thehammer/danx-session-telemetry 0.1.14 → 0.1.16

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.
@@ -0,0 +1,227 @@
1
+ /**
2
+ * DX-2963 — the stats-write wire contract, shared by the worker (sender) and
3
+ * the dashboard (the one server writer, `usage-ledger.ts`). DB-free so a
4
+ * DB-less worker can import it.
5
+ *
6
+ * A stats write carries ONLY ledger entries (one per reply: the per-field
7
+ * maximum usage seen so far, the reply's tool-use ids) that are new or changed
8
+ * since the last acknowledged write — never totals. Every total, the cost,
9
+ * the turn count and the tool-call count are derived from the ledger on the
10
+ * server, and the response hands them back.
11
+ */
12
+ import type { LedgerEntry, LedgerUsage } from "./usage-ledger.js";
13
+ /**
14
+ * DX-2963 — cap on entries per stats write. A from-0 backlog (a reattach, a
15
+ * session read from the start) is sent as consecutive capped batches, so one
16
+ * request never carries an unbounded body or holds the dispatch lock for an
17
+ * unbounded time. 500 entries is well under a second of upsert work.
18
+ */
19
+ export declare const STATS_BATCH_CAP = 500;
20
+ /**
21
+ * DX-2954 — the two offsets a collector keeps per `(dispatch, source)`, where
22
+ * `source` is the value the ledger keys by (`main`, or a sub-agent id).
23
+ *
24
+ * - `acked` — the RESUME point: every line before it has had its ledger
25
+ * entry acknowledged (and, once DX-2956 ships, its log line too).
26
+ * - `delivered` — the highest line already handed to transcript subscribers.
27
+ *
28
+ * Lines between them are re-read on a restart and handed on as REPLAYS.
29
+ *
30
+ * "None stored" is the ABSENCE of a source's key, never `{acked: 0}` — 0 is a
31
+ * legitimate stored cursor meaning "start of file", and CP1 forbids a reader
32
+ * defaulting a missing persisted value.
33
+ */
34
+ export interface SourceCursor {
35
+ acked: number;
36
+ delivered: number;
37
+ }
38
+ /** DX-2954 — per-source cursors. An absent key means none stored (never 0). */
39
+ export type SourceCursors = Record<string, SourceCursor>;
40
+ /**
41
+ * DX-2954 — the collector found its stored cursor PAST the end of the file (it
42
+ * was replaced or truncated) and restarted that source at 0. Reported so the
43
+ * server can record a `transcript_cursor_past_eof` event with both numbers.
44
+ *
45
+ * This is the ONE sanctioned backwards move of a stored `acked`: without it the
46
+ * write would be refused as a `cursor_regression` and the source would tail
47
+ * nothing for the rest of the dispatch (the pre-card watcher's `size <=
48
+ * byteOffset` early return, silently).
49
+ */
50
+ export interface PastEofReset {
51
+ storedOffset: number;
52
+ fileSize: number;
53
+ }
54
+ /** DX-2954 — the payload form of a cursor: what a collector REPORTS. */
55
+ export interface SourceCursorReport extends SourceCursor {
56
+ pastEof?: PastEofReset;
57
+ }
58
+ /** DX-2954 — per-source cursor reports, as a stats write carries them. */
59
+ export type SourceCursorReports = Record<string, SourceCursorReport>;
60
+ /**
61
+ * DX-2954 — the running set's two kinds. `agent` is a sub-agent, produced from
62
+ * the TRANSCRIPT: one row per sub-agent transcript file the session-log watcher
63
+ * discovers (`packages/danx-session-telemetry/src/activity-observer.ts`; orchestrator decision, DX-2954
64
+ * comment 8004). `bash` is a background script; nothing produces it yet — the
65
+ * hook-side producer (`PostToolUse` on a backgrounded Bash call) is DX-3284's.
66
+ * No hook event observes a background shell FINISHING (verified against the
67
+ * hooks reference — see the Step 0 note on DX-2954), so an open row of either
68
+ * kind is closed by the dispatch's own terminal write, never by inferring an
69
+ * end from transcript lines.
70
+ */
71
+ export declare const ACTIVITY_KINDS: readonly ["agent", "bash"];
72
+ export type ActivityKind = (typeof ACTIVITY_KINDS)[number];
73
+ /**
74
+ * DX-2954 — one activity's current state, as a stats write reports it. Applied
75
+ * as an idempotent upsert keyed by `(dispatch, kind, activityId)`: a hook that
76
+ * fires twice, or a batch resent after a failed flush, must not duplicate it.
77
+ *
78
+ * `activityId` for a sub-agent is its transcript file's basename
79
+ * (`agent-<hash>`) — the SAME value the ledger credits its tokens under as
80
+ * `source`, which is how per-sub-agent tokens join to the running set without
81
+ * this table carrying a token column. Any future producer (DX-3284's hooks)
82
+ * must derive it the same way — the basename of the hook's `transcript_path`,
83
+ * never the hook's `agent_id` — or it upserts a duplicate row. For a
84
+ * background script it is the Bash call's `tool_use_id`.
85
+ */
86
+ export interface ActivityChange {
87
+ kind: ActivityKind;
88
+ activityId: string;
89
+ /** The `agent_type` hook field; null for a background script. */
90
+ agentType: string | null;
91
+ /** The Bash call's `description`; null for a sub-agent. */
92
+ description: string | null;
93
+ startedAt: number;
94
+ lastActivityAt: number;
95
+ /** Epoch ms when it finished, or null while it is still running. */
96
+ finishedAt: number | null;
97
+ }
98
+ export interface StatsPayload {
99
+ entries: LedgerEntry[];
100
+ /**
101
+ * DX-2963 — the worker applied the end-of-file rule (its own row or its
102
+ * predecessor's is pre-card), so the dispatch inherits pre-card lineage.
103
+ *
104
+ * DX-2954 RETIRED this field (DX-3286 removes it): the cursor start order replaces the rule it
105
+ * carried. It is still ACCEPTED, and ignored, for exactly one release — a
106
+ * deploy restarts the dashboard before it relaunches the workers, so for a
107
+ * window old workers send it to the new server, and refusing it would throw
108
+ * away their terminal status. A payload carrying it records one visible
109
+ * `stats_field_ignored` event per dispatch. The release after this one drops
110
+ * the field, the `usage_pre_card_lineage` column and the two
111
+ * `handleReadDispatch` fields together.
112
+ */
113
+ preCardLineage?: true;
114
+ /**
115
+ * DX-2954 — this collector's cursors for every source it has opened. Optional
116
+ * because an old worker in the deploy window sends none; absent means
117
+ * "nothing to store", never "reset to 0".
118
+ */
119
+ sourceCursors?: SourceCursorReports;
120
+ /**
121
+ * DX-2954 — the running set's changes since the last write. Optional for the
122
+ * same deploy-window reason as {@link StatsPayload.sourceCursors}.
123
+ */
124
+ activities?: ActivityChange[];
125
+ /**
126
+ * DX-2954 — the session's own transcript file path, recorded on the
127
+ * dispatch's `jsonl_path`.
128
+ *
129
+ * **This is the ONE path field the stats wire carries, and the ONE exception
130
+ * to the card's privacy rule** that a heartbeat sends only numbers, ids,
131
+ * model names, the `speed` value and timestamps. It is sent once, in the
132
+ * first stats-bearing write, because an operator session has no other way to
133
+ * tell the server where its transcript lives (a worker's tracker already
134
+ * records it from the watcher). The strict parser below allows a path HERE
135
+ * and nowhere else — no other key may carry one.
136
+ */
137
+ transcriptPath?: string;
138
+ }
139
+ /** The dispatch's flat totals, as derived from its ledger. */
140
+ export interface UsageTotals {
141
+ tokensIn: number;
142
+ tokensOut: number;
143
+ cacheRead: number;
144
+ /** 5-minute + 1-hour (+ any pre-card unsplit baseline) cache writes. */
145
+ cacheWrite: number;
146
+ tokensTotal: number;
147
+ costUsd: number;
148
+ turnCount: number;
149
+ toolCallCount: number;
150
+ }
151
+ /** Per-source (main / sub-agent id) credited usage. */
152
+ export interface SourceUsage extends LedgerUsage {
153
+ costUsd: number;
154
+ }
155
+ export interface StatsResponse {
156
+ totals: UsageTotals;
157
+ bySource: Record<string, SourceUsage>;
158
+ /**
159
+ * Per sent `message_id`, the per-field total now credited for that reply
160
+ * across ALL dispatches — what the worker's ceiling estimate subtracts.
161
+ */
162
+ perMessage: Record<string, LedgerUsage>;
163
+ /**
164
+ * DX-2954 — the dispatch's STORED cursors after this write, per source. This
165
+ * is what a process takes at start to know where to read on from; an absent
166
+ * source key means none stored (distinct from 0).
167
+ */
168
+ sourceCursors: SourceCursors;
169
+ /**
170
+ * DX-2954 — sources whose reported `acked` was LOWER than the stored one and
171
+ * was therefore refused (a `cursor_regression` event was recorded for each).
172
+ * The rest of the write still applied. Empty on the happy path.
173
+ */
174
+ cursorRegressions: string[];
175
+ /** DX-2954 — running (`finished_at IS NULL`) activities after this write. */
176
+ runningActivities: number;
177
+ }
178
+ export declare function chunkEntries(entries: readonly LedgerEntry[], cap?: number): LedgerEntry[][];
179
+ export declare class StatsPayloadError extends Error {
180
+ }
181
+ /**
182
+ * DX-2954 — cap on activity rows per stats write. The running set is bounded by
183
+ * how many sub-agents and background scripts one dispatch has ever started;
184
+ * this keeps a malformed or runaway sender from holding the dispatch lock while
185
+ * it upserts an unbounded list.
186
+ */
187
+ export declare const ACTIVITY_BATCH_CAP = 500;
188
+ /** DX-2954 — cap on sources per stats write, for the same reason. */
189
+ export declare const CURSOR_BATCH_CAP = 500;
190
+ /**
191
+ * DX-2954 — collapse a queue of activity changes to ONE per
192
+ * `(kind, activityId)`, applying the SAME merge the server's upsert applies.
193
+ *
194
+ * Both sides need this rule, so it is stated once, here, beside the wire types:
195
+ * the server merges an incoming row against what it has stored, and a SENDER
196
+ * merges the changes it has queued since its last write against each other. The
197
+ * two are the same operation on the same monotone fields, so a client-side
198
+ * collapse is lossless — the server reaches the identical row either way.
199
+ *
200
+ * Why a sender needs it at all: a producer reports an activity's full current
201
+ * state every time anything about it moves, and a chatty sub-agent moves its
202
+ * `lastActivityAt` continuously. Queuing those raw would grow one entry per
203
+ * change between writes, blow {@link ACTIVITY_BATCH_CAP}, and take the WHOLE
204
+ * stats write down with it — not merely the activity part — for a payload that
205
+ * describes a handful of activities.
206
+ *
207
+ * A plain last-one-wins would be WRONG here, which is why this merges field by
208
+ * field: `finishedAt` is `COALESCE(stored, incoming)` server-side, so a
209
+ * still-running report queued after a finish must not un-finish the row.
210
+ */
211
+ export declare function collapseActivityChanges(changes: readonly ActivityChange[]): ActivityChange[];
212
+ /**
213
+ * Strict parse of a stats body (fail loud — a malformed entry is a worker
214
+ * bug, never coerced). Refuses more than {@link STATS_BATCH_CAP} entries, and
215
+ * (DX-2954) any key outside {@link STATS_PAYLOAD_KEYS}.
216
+ */
217
+ export declare function parseStatsPayload(body: unknown): StatsPayload;
218
+ /**
219
+ * DX-2963 — a terminal dispatch is never written: the server refuses a stats
220
+ * write for one. Defined here (DB-free) so both sinks throw the SAME error —
221
+ * the getPool-direct writer directly, the worker client on the route's 409.
222
+ */
223
+ export declare class DispatchUsageTerminalError extends Error {
224
+ readonly dispatchId: string;
225
+ readonly status: string;
226
+ constructor(dispatchId: string, status: string);
227
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * DX-2963 — cap on entries per stats write. A from-0 backlog (a reattach, a
3
+ * session read from the start) is sent as consecutive capped batches, so one
4
+ * request never carries an unbounded body or holds the dispatch lock for an
5
+ * unbounded time. 500 entries is well under a second of upsert work.
6
+ */
7
+ export const STATS_BATCH_CAP = 500;
8
+ /**
9
+ * DX-2954 — the running set's two kinds. `agent` is a sub-agent, produced from
10
+ * the TRANSCRIPT: one row per sub-agent transcript file the session-log watcher
11
+ * discovers (`packages/danx-session-telemetry/src/activity-observer.ts`; orchestrator decision, DX-2954
12
+ * comment 8004). `bash` is a background script; nothing produces it yet — the
13
+ * hook-side producer (`PostToolUse` on a backgrounded Bash call) is DX-3284's.
14
+ * No hook event observes a background shell FINISHING (verified against the
15
+ * hooks reference — see the Step 0 note on DX-2954), so an open row of either
16
+ * kind is closed by the dispatch's own terminal write, never by inferring an
17
+ * end from transcript lines.
18
+ */
19
+ export const ACTIVITY_KINDS = ["agent", "bash"];
20
+ export function chunkEntries(entries, cap = STATS_BATCH_CAP) {
21
+ const out = [];
22
+ for (let i = 0; i < entries.length; i += cap)
23
+ out.push(entries.slice(i, i + cap));
24
+ return out;
25
+ }
26
+ export class StatsPayloadError extends Error {
27
+ }
28
+ function isNonNegInt(v) {
29
+ return typeof v === "number" && Number.isInteger(v) && v >= 0;
30
+ }
31
+ const USAGE_KEYS = ["input", "output", "cacheRead", "cacheWrite5m", "cacheWrite1h"];
32
+ /**
33
+ * DX-2954 — the ONLY keys a stats body may carry. An unknown key is a 400, so
34
+ * a worker that invents a field (or a renamed one that never reached a
35
+ * consumer) fails loud instead of being silently dropped — the pre-DX-2954
36
+ * parser rebuilt the object field by field and swallowed every extra key.
37
+ *
38
+ * `preCardLineage` is on this list for exactly one release, until DX-3286 (see its doc on
39
+ * {@link StatsPayload}); `transcriptPath` is the ONE key allowed to carry a
40
+ * filesystem path.
41
+ */
42
+ const STATS_PAYLOAD_KEYS = new Set([
43
+ "entries",
44
+ "preCardLineage",
45
+ "sourceCursors",
46
+ "activities",
47
+ "transcriptPath",
48
+ ]);
49
+ /**
50
+ * DX-2954 — cap on activity rows per stats write. The running set is bounded by
51
+ * how many sub-agents and background scripts one dispatch has ever started;
52
+ * this keeps a malformed or runaway sender from holding the dispatch lock while
53
+ * it upserts an unbounded list.
54
+ */
55
+ export const ACTIVITY_BATCH_CAP = 500;
56
+ /** DX-2954 — cap on sources per stats write, for the same reason. */
57
+ export const CURSOR_BATCH_CAP = 500;
58
+ /**
59
+ * DX-2954 — collapse a queue of activity changes to ONE per
60
+ * `(kind, activityId)`, applying the SAME merge the server's upsert applies.
61
+ *
62
+ * Both sides need this rule, so it is stated once, here, beside the wire types:
63
+ * the server merges an incoming row against what it has stored, and a SENDER
64
+ * merges the changes it has queued since its last write against each other. The
65
+ * two are the same operation on the same monotone fields, so a client-side
66
+ * collapse is lossless — the server reaches the identical row either way.
67
+ *
68
+ * Why a sender needs it at all: a producer reports an activity's full current
69
+ * state every time anything about it moves, and a chatty sub-agent moves its
70
+ * `lastActivityAt` continuously. Queuing those raw would grow one entry per
71
+ * change between writes, blow {@link ACTIVITY_BATCH_CAP}, and take the WHOLE
72
+ * stats write down with it — not merely the activity part — for a payload that
73
+ * describes a handful of activities.
74
+ *
75
+ * A plain last-one-wins would be WRONG here, which is why this merges field by
76
+ * field: `finishedAt` is `COALESCE(stored, incoming)` server-side, so a
77
+ * still-running report queued after a finish must not un-finish the row.
78
+ */
79
+ export function collapseActivityChanges(changes) {
80
+ const byKey = new Map();
81
+ for (const c of changes) {
82
+ // NUL separator: neither a kind (a fixed enum) nor an activity id (a
83
+ // transcript basename or a tool-use id) can contain one, so no pair of
84
+ // distinct keys can collide into one string.
85
+ const key = `${c.kind}\u0000${c.activityId}`;
86
+ const prior = byKey.get(key);
87
+ if (prior === undefined) {
88
+ byKey.set(key, { ...c });
89
+ continue;
90
+ }
91
+ byKey.set(key, {
92
+ kind: c.kind,
93
+ activityId: c.activityId,
94
+ // COALESCE(incoming, stored) — a later report may FILL one in, never erase
95
+ // it (a Bash launch knows the description, a sub-agent stop knows the type;
96
+ // neither knows the other's).
97
+ agentType: c.agentType ?? prior.agentType,
98
+ description: c.description ?? prior.description,
99
+ // LEAST — a start never moves later.
100
+ startedAt: Math.min(prior.startedAt, c.startedAt),
101
+ // GREATEST — liveness never moves backwards.
102
+ lastActivityAt: Math.max(prior.lastActivityAt, c.lastActivityAt),
103
+ // COALESCE(stored, incoming) — the FIRST finish wins; a later
104
+ // still-running report must never resurrect a stopped activity.
105
+ finishedAt: prior.finishedAt ?? c.finishedAt,
106
+ });
107
+ }
108
+ return [...byKey.values()];
109
+ }
110
+ const CURSOR_KEYS = new Set(["acked", "delivered", "pastEof"]);
111
+ const PAST_EOF_KEYS = new Set(["storedOffset", "fileSize"]);
112
+ function parsePastEof(raw, where) {
113
+ if (!raw || typeof raw !== "object")
114
+ throw new StatsPayloadError(where);
115
+ const p = raw;
116
+ for (const k of Object.keys(p)) {
117
+ if (!PAST_EOF_KEYS.has(k))
118
+ throw new StatsPayloadError(`${where}.${k} is not a pastEof field`);
119
+ }
120
+ if (!isNonNegInt(p.storedOffset))
121
+ throw new StatsPayloadError(`${where}.storedOffset`);
122
+ if (!isNonNegInt(p.fileSize))
123
+ throw new StatsPayloadError(`${where}.fileSize`);
124
+ return { storedOffset: p.storedOffset, fileSize: p.fileSize };
125
+ }
126
+ function parseSourceCursors(raw) {
127
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
128
+ throw new StatsPayloadError("sourceCursors must be an object");
129
+ }
130
+ const entries = Object.entries(raw);
131
+ if (entries.length > CURSOR_BATCH_CAP) {
132
+ throw new StatsPayloadError(`sourceCursors exceeds the cap of ${CURSOR_BATCH_CAP}`);
133
+ }
134
+ const out = {};
135
+ for (const [source, value] of entries) {
136
+ if (!source)
137
+ throw new StatsPayloadError("sourceCursors key must be a non-empty source");
138
+ if (!value || typeof value !== "object") {
139
+ throw new StatsPayloadError(`sourceCursors[${source}] must be an object`);
140
+ }
141
+ const c = value;
142
+ for (const k of Object.keys(c)) {
143
+ if (!CURSOR_KEYS.has(k)) {
144
+ throw new StatsPayloadError(`sourceCursors[${source}].${k} is not a cursor field`);
145
+ }
146
+ }
147
+ if (!isNonNegInt(c.acked))
148
+ throw new StatsPayloadError(`sourceCursors[${source}].acked`);
149
+ if (!isNonNegInt(c.delivered))
150
+ throw new StatsPayloadError(`sourceCursors[${source}].delivered`);
151
+ // `delivered` is the highest line HANDED ON, `acked` the highest
152
+ // ACKNOWLEDGED — acknowledging a line necessarily means it was handed on
153
+ // first, so delivered < acked is structurally impossible and a sender bug.
154
+ if (c.delivered < c.acked) {
155
+ throw new StatsPayloadError(`sourceCursors[${source}]: delivered (${c.delivered}) is behind acked (${c.acked})`);
156
+ }
157
+ const report = { acked: c.acked, delivered: c.delivered };
158
+ if (c.pastEof !== undefined) {
159
+ report.pastEof = parsePastEof(c.pastEof, `sourceCursors[${source}].pastEof`);
160
+ }
161
+ out[source] = report;
162
+ }
163
+ return out;
164
+ }
165
+ const ACTIVITY_KEYS = new Set([
166
+ "kind",
167
+ "activityId",
168
+ "agentType",
169
+ "description",
170
+ "startedAt",
171
+ "lastActivityAt",
172
+ "finishedAt",
173
+ ]);
174
+ function parseActivities(raw) {
175
+ if (!Array.isArray(raw))
176
+ throw new StatsPayloadError("activities must be an array");
177
+ if (raw.length > ACTIVITY_BATCH_CAP) {
178
+ throw new StatsPayloadError(`activities exceeds the cap of ${ACTIVITY_BATCH_CAP}`);
179
+ }
180
+ return raw.map((entry, i) => {
181
+ const where = `activities[${i}]`;
182
+ if (!entry || typeof entry !== "object")
183
+ throw new StatsPayloadError(where);
184
+ const a = entry;
185
+ for (const k of Object.keys(a)) {
186
+ if (!ACTIVITY_KEYS.has(k))
187
+ throw new StatsPayloadError(`${where}.${k} is not an activity field`);
188
+ }
189
+ if (typeof a.kind !== "string" || !ACTIVITY_KINDS.includes(a.kind)) {
190
+ throw new StatsPayloadError(`${where}.kind must be one of ${ACTIVITY_KINDS.join(", ")}`);
191
+ }
192
+ if (typeof a.activityId !== "string" || !a.activityId) {
193
+ throw new StatsPayloadError(`${where}.activityId`);
194
+ }
195
+ if (a.agentType !== null && typeof a.agentType !== "string") {
196
+ throw new StatsPayloadError(`${where}.agentType`);
197
+ }
198
+ if (a.description !== null && typeof a.description !== "string") {
199
+ throw new StatsPayloadError(`${where}.description`);
200
+ }
201
+ if (!isNonNegInt(a.startedAt))
202
+ throw new StatsPayloadError(`${where}.startedAt`);
203
+ if (!isNonNegInt(a.lastActivityAt))
204
+ throw new StatsPayloadError(`${where}.lastActivityAt`);
205
+ if (a.finishedAt !== null && !isNonNegInt(a.finishedAt)) {
206
+ throw new StatsPayloadError(`${where}.finishedAt`);
207
+ }
208
+ return {
209
+ kind: a.kind,
210
+ activityId: a.activityId,
211
+ agentType: a.agentType,
212
+ description: a.description,
213
+ startedAt: a.startedAt,
214
+ lastActivityAt: a.lastActivityAt,
215
+ finishedAt: a.finishedAt,
216
+ };
217
+ });
218
+ }
219
+ /**
220
+ * Strict parse of a stats body (fail loud — a malformed entry is a worker
221
+ * bug, never coerced). Refuses more than {@link STATS_BATCH_CAP} entries, and
222
+ * (DX-2954) any key outside {@link STATS_PAYLOAD_KEYS}.
223
+ */
224
+ export function parseStatsPayload(body) {
225
+ if (!body || typeof body !== "object")
226
+ throw new StatsPayloadError("body must be an object");
227
+ const b = body;
228
+ const unknown = Object.keys(b).filter((k) => !STATS_PAYLOAD_KEYS.has(k));
229
+ if (unknown.length > 0) {
230
+ throw new StatsPayloadError(`unknown field(s): ${unknown.join(", ")} — allowed: ${[...STATS_PAYLOAD_KEYS].join(", ")}`);
231
+ }
232
+ if (!Array.isArray(b.entries))
233
+ throw new StatsPayloadError("entries must be an array");
234
+ if (b.entries.length > STATS_BATCH_CAP) {
235
+ throw new StatsPayloadError(`entries exceeds the cap of ${STATS_BATCH_CAP}`);
236
+ }
237
+ if (b.preCardLineage !== undefined && b.preCardLineage !== true) {
238
+ throw new StatsPayloadError("preCardLineage must be true when present");
239
+ }
240
+ if (b.transcriptPath !== undefined && (typeof b.transcriptPath !== "string" || !b.transcriptPath)) {
241
+ throw new StatsPayloadError("transcriptPath must be a non-empty string when present");
242
+ }
243
+ const entries = b.entries.map((raw, i) => {
244
+ const e = raw;
245
+ const where = `entries[${i}]`;
246
+ if (typeof e.messageId !== "string" || !e.messageId)
247
+ throw new StatsPayloadError(`${where}.messageId`);
248
+ if (typeof e.source !== "string" || !e.source)
249
+ throw new StatsPayloadError(`${where}.source`);
250
+ if (e.model !== null && typeof e.model !== "string")
251
+ throw new StatsPayloadError(`${where}.model`);
252
+ if (typeof e.speed !== "string" || !e.speed)
253
+ throw new StatsPayloadError(`${where}.speed`);
254
+ if (!isNonNegInt(e.repliedAt))
255
+ throw new StatsPayloadError(`${where}.repliedAt`);
256
+ const u = e.usage;
257
+ if (!u || typeof u !== "object")
258
+ throw new StatsPayloadError(`${where}.usage`);
259
+ for (const k of USAGE_KEYS) {
260
+ if (!isNonNegInt(u[k]))
261
+ throw new StatsPayloadError(`${where}.usage.${k}`);
262
+ }
263
+ if (!Array.isArray(e.toolUseIds) || e.toolUseIds.some((t) => typeof t !== "string")) {
264
+ throw new StatsPayloadError(`${where}.toolUseIds`);
265
+ }
266
+ return {
267
+ messageId: e.messageId,
268
+ source: e.source,
269
+ model: e.model,
270
+ speed: e.speed,
271
+ repliedAt: e.repliedAt,
272
+ usage: {
273
+ input: u.input,
274
+ output: u.output,
275
+ cacheRead: u.cacheRead,
276
+ cacheWrite5m: u.cacheWrite5m,
277
+ cacheWrite1h: u.cacheWrite1h,
278
+ },
279
+ toolUseIds: e.toolUseIds,
280
+ };
281
+ });
282
+ const payload = { entries };
283
+ if (b.preCardLineage === true)
284
+ payload.preCardLineage = true;
285
+ if (b.sourceCursors !== undefined)
286
+ payload.sourceCursors = parseSourceCursors(b.sourceCursors);
287
+ if (b.activities !== undefined)
288
+ payload.activities = parseActivities(b.activities);
289
+ if (b.transcriptPath !== undefined)
290
+ payload.transcriptPath = b.transcriptPath;
291
+ return payload;
292
+ }
293
+ /**
294
+ * DX-2963 — a terminal dispatch is never written: the server refuses a stats
295
+ * write for one. Defined here (DB-free) so both sinks throw the SAME error —
296
+ * the getPool-direct writer directly, the worker client on the route's 409.
297
+ */
298
+ export class DispatchUsageTerminalError extends Error {
299
+ dispatchId;
300
+ status;
301
+ constructor(dispatchId, status) {
302
+ super(`usage ledger: dispatch ${dispatchId} is terminal (${status}) — a terminal dispatch is never written`);
303
+ this.dispatchId = dispatchId;
304
+ this.status = status;
305
+ this.name = "DispatchUsageTerminalError";
306
+ }
307
+ }
@@ -32,7 +32,7 @@ export function isSyntheticMessageKey(key) {
32
32
  * A token count is an integer by definition, so a fractional value here is
33
33
  * itself the anomaly (a malformed/synthetic producer), not a legitimate
34
34
  * reading to preserve verbatim — and the wire contract this feeds
35
- * (`parseStatsPayload`'s `isNonNegInt`, `usage-ledger-wire.ts`) REQUIRES an
35
+ * (`parseStatsPayload`'s `isNonNegInt`, `stats-wire.ts`) REQUIRES an
36
36
  * integer and fails loud on anything else. Before this fix a fractional
37
37
  * value here would sail through the counter, get queued as a changed entry,
38
38
  * then 400 on every single stats-write attempt for that dispatch FOREVER —
@@ -0,0 +1,9 @@
1
+ /**
2
+ * DX-2398 — a per-tracker FIFO for writes that must land in order: the
3
+ * periodic stats flush and the terminal finalize (a tick already in flight
4
+ * when cleanup stops the timer must not commit after the terminal write).
5
+ * DX-2963 also serializes a dispatch's stats batches through it, so two
6
+ * flushes never interleave their batches. The chain is never left rejected:
7
+ * each writer owns its own error handling.
8
+ */
9
+ export declare function createUsageWriteQueue(): <T>(write: () => Promise<T>) => Promise<T>;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * DX-2398 — a per-tracker FIFO for writes that must land in order: the
3
+ * periodic stats flush and the terminal finalize (a tick already in flight
4
+ * when cleanup stops the timer must not commit after the terminal write).
5
+ * DX-2963 also serializes a dispatch's stats batches through it, so two
6
+ * flushes never interleave their batches. The chain is never left rejected:
7
+ * each writer owns its own error handling.
8
+ */
9
+ export function createUsageWriteQueue() {
10
+ let chain = Promise.resolve();
11
+ return (write) => {
12
+ const queued = chain.then(write);
13
+ chain = queued.then(() => undefined, () => undefined);
14
+ return queued;
15
+ };
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thehammer/danx-session-telemetry",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Shared Claude Code transcript collector (DX-2962) — incremental JSONL tailing for the danxbot worker and the danx-dashboard-mcp desktop bridge, so both run identical collector code.",
5
5
  "license": "MIT",
6
6
  "type": "module",