@thehammer/danx-session-telemetry 0.1.14 → 0.1.15
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/activity-observer.d.ts +77 -0
- package/dist/activity-observer.js +73 -0
- package/dist/dispatch-usage.d.ts +209 -0
- package/dist/dispatch-usage.js +430 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +11 -1
- package/dist/internal-logger.js +5 -2
- package/dist/session-log-watcher.d.ts +230 -35
- package/dist/session-log-watcher.js +300 -45
- package/dist/stats-wire.d.ts +227 -0
- package/dist/stats-wire.js +307 -0
- package/dist/usage-ledger.js +1 -1
- package/dist/write-queue.d.ts +9 -0
- package/dist/write-queue.js +16 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/usage-ledger.js
CHANGED
|
@@ -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`, `
|
|
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.
|
|
3
|
+
"version": "0.1.15",
|
|
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",
|