@catalyst-cloud/schema 0.1.44 → 0.1.46
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@catalyst-cloud/schema",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.46",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Typed Drizzle schema = single source of truth for the per-tenant Mirror DO SQLite store (CTC-13 / ADR-0002). Shared by the mirror Worker, the host-sync replica, and the browser OPFS replica.",
|
|
6
6
|
"license": "MIT",
|
package/src/events/registry.ts
CHANGED
|
@@ -76,6 +76,27 @@ export const durableEventTypes = {
|
|
|
76
76
|
ingestVerb: "coordination.publish",
|
|
77
77
|
wakes: false,
|
|
78
78
|
},
|
|
79
|
+
// CTC-838 — a long-lived coordination agent (concierge/steward/worker) announcing which ROLE it
|
|
80
|
+
// holds, so the fleet's own agent_roster (apps/mirror/src/do/agent-roster.ts) can answer "who holds
|
|
81
|
+
// which role, and are they alive" without inventing a heartbeat-shaped table of its own. See
|
|
82
|
+
// ADR-20260903T011701 for why this is observational only.
|
|
83
|
+
"agent.role.started": {
|
|
84
|
+
rationale:
|
|
85
|
+
"A long-lived coordination agent announced the ROLE it holds (concierge / steward / worker) and, when it has one, its parent — a discrete boundary fact, not a sampled reading. The paired terminal fact is agent.role.stopped.",
|
|
86
|
+
ingestVerb: "coordination.publish",
|
|
87
|
+
// ⛔ CTC-838 / ADR-20260903T011701 — a roster row is observational only. Waking a dispatcher on it would
|
|
88
|
+
// make a heartbeat-derived signal load-bearing for scheduling, which this repo's lease and
|
|
89
|
+
// entitlement planes explicitly refuse (lease-verbs.ts:10-13).
|
|
90
|
+
wakes: false,
|
|
91
|
+
entity: "agent",
|
|
92
|
+
},
|
|
93
|
+
"agent.role.stopped": {
|
|
94
|
+
rationale:
|
|
95
|
+
"A coordination agent released its role — the paired terminal fact to agent.role.started, and the only thing that distinguishes 'went away cleanly' from 'stopped pulsing'.",
|
|
96
|
+
ingestVerb: "coordination.publish",
|
|
97
|
+
wakes: false,
|
|
98
|
+
entity: "agent",
|
|
99
|
+
},
|
|
79
100
|
"agent.waiting-on-user": {
|
|
80
101
|
rationale:
|
|
81
102
|
"The agent is now blocked awaiting human input — a coordination fact a host must act on.",
|
|
@@ -770,6 +791,20 @@ export const durableEventTypes = {
|
|
|
770
791
|
wakes: false,
|
|
771
792
|
entity: "ticket",
|
|
772
793
|
},
|
|
794
|
+
"phase.merge.complete": {
|
|
795
|
+
rationale:
|
|
796
|
+
"ADR-0039 durable-fact example ('a phase started, completed, failed, or was preempted'): the phase reported success.",
|
|
797
|
+
ingestVerb: "coordination.publish",
|
|
798
|
+
wakes: false,
|
|
799
|
+
entity: "ticket",
|
|
800
|
+
},
|
|
801
|
+
"phase.merge.failed": {
|
|
802
|
+
rationale:
|
|
803
|
+
"ADR-0039 durable-fact example ('a phase started, completed, failed, or was preempted'): the phase reported failure.",
|
|
804
|
+
ingestVerb: "coordination.publish",
|
|
805
|
+
wakes: false,
|
|
806
|
+
entity: "ticket",
|
|
807
|
+
},
|
|
773
808
|
"phase.merge.park": {
|
|
774
809
|
rationale:
|
|
775
810
|
"ADR-0039 durable-fact example ('a phase started, completed, failed, or was preempted'): the merge phase was PARKED after N consecutive failures (CTC-1270) — a terminal-until-released outcome, distinct from `phase.merge.failed`, which a reader must be able to tell apart. Paired with `phase.merge.revive`.",
|
|
@@ -1141,6 +1176,13 @@ export const durableEventTypes = {
|
|
|
1141
1176
|
wakes: false,
|
|
1142
1177
|
entity: "ticket",
|
|
1143
1178
|
},
|
|
1179
|
+
"phase.remediate.blocked": {
|
|
1180
|
+
rationale:
|
|
1181
|
+
"ADR-0039 durable-fact example: an escalated remediate round's exhaustion (or its own declared block) raised a catalyst-ask decision ticket that BLOCKS the remediated ticket (CTC-1373) — a confirmed, terminal-until-answered designation, distinct from `phase.remediate.escalation-exhausted`, which merely requested it.",
|
|
1182
|
+
ingestVerb: "coordination.publish",
|
|
1183
|
+
wakes: false,
|
|
1184
|
+
entity: "ticket",
|
|
1185
|
+
},
|
|
1144
1186
|
"phase.remediate.boot-resume": {
|
|
1145
1187
|
rationale:
|
|
1146
1188
|
"ADR-0039 durable-fact example ('a phase started, completed, failed, or was preempted'): the daemon restarted and resumed this phase — a real coordination fact about continuity across a restart.",
|
|
@@ -1162,6 +1204,20 @@ export const durableEventTypes = {
|
|
|
1162
1204
|
wakes: false,
|
|
1163
1205
|
entity: "ticket",
|
|
1164
1206
|
},
|
|
1207
|
+
"phase.remediate.escalated": {
|
|
1208
|
+
rationale:
|
|
1209
|
+
"ADR-0039 durable-fact example: a remediate round's cap-exhaustion escalated to a second, broader-purview attempt instead of parking (CTC-1373) — a distinct lifecycle transition from an ordinary `phase.remediate.failed`, and one that creates newly claimable work.",
|
|
1210
|
+
ingestVerb: "coordination.publish",
|
|
1211
|
+
wakes: { consumerClass: "dispatcher" },
|
|
1212
|
+
entity: "ticket",
|
|
1213
|
+
},
|
|
1214
|
+
"phase.remediate.escalation-exhausted": {
|
|
1215
|
+
rationale:
|
|
1216
|
+
"ADR-0039 durable-fact example: the escalated (broader-purview) remediate round also failed, or declared itself blocked (CTC-1373) — the terminal signal that requests a blocking ASK ticket be raised; see the paired `phase.remediate.blocked` once one is confirmed.",
|
|
1217
|
+
ingestVerb: "coordination.publish",
|
|
1218
|
+
wakes: false,
|
|
1219
|
+
entity: "ticket",
|
|
1220
|
+
},
|
|
1165
1221
|
"phase.remediate.failed": {
|
|
1166
1222
|
rationale:
|
|
1167
1223
|
"ADR-0039 durable-fact example ('a phase started, completed, failed, or was preempted'): the phase reported failure.",
|
|
@@ -1211,6 +1267,20 @@ export const durableEventTypes = {
|
|
|
1211
1267
|
wakes: false,
|
|
1212
1268
|
entity: "ticket",
|
|
1213
1269
|
},
|
|
1270
|
+
"phase.remediate.rewind": {
|
|
1271
|
+
rationale:
|
|
1272
|
+
"ADR-0039 durable-fact example: a successful remediate round's own ladder-rewind request was applied (CTC-1373) — earlier relay_phase_state rows were reset to `ready`, creating newly claimable work for the reset span.",
|
|
1273
|
+
ingestVerb: "coordination.publish",
|
|
1274
|
+
wakes: { consumerClass: "dispatcher" },
|
|
1275
|
+
entity: "ticket",
|
|
1276
|
+
},
|
|
1277
|
+
"phase.remediate.rewind-requested": {
|
|
1278
|
+
rationale:
|
|
1279
|
+
"ADR-0039 durable-fact example: a successful remediate round recommended the ladder start over rather than advance (CTC-1373's adjudication) — a request the mirror applies under its own fence and budget; see the paired `phase.remediate.rewind` once applied.",
|
|
1280
|
+
ingestVerb: "coordination.publish",
|
|
1281
|
+
wakes: false,
|
|
1282
|
+
entity: "ticket",
|
|
1283
|
+
},
|
|
1214
1284
|
"phase.rescue.dispatched": {
|
|
1215
1285
|
rationale:
|
|
1216
1286
|
"ADR-0039 durable-fact example ('a phase started, completed, failed, or was preempted'): work became eligible and was dispatched to a worker.",
|
|
@@ -1512,6 +1582,20 @@ export const durableEventTypes = {
|
|
|
1512
1582
|
wakes: false,
|
|
1513
1583
|
entity: "ticket",
|
|
1514
1584
|
},
|
|
1585
|
+
"phase.validate.complete": {
|
|
1586
|
+
rationale:
|
|
1587
|
+
"ADR-0039 durable-fact example ('a phase started, completed, failed, or was preempted'): the phase reported success.",
|
|
1588
|
+
ingestVerb: "coordination.publish",
|
|
1589
|
+
wakes: false,
|
|
1590
|
+
entity: "ticket",
|
|
1591
|
+
},
|
|
1592
|
+
"phase.validate.failed": {
|
|
1593
|
+
rationale:
|
|
1594
|
+
"ADR-0039 durable-fact example ('a phase started, completed, failed, or was preempted'): the phase reported failure. CTC-1541 — absent until now, so the record the runner has always published was dropped at deriveDurableCoordinationEvents's classification filter and never reached phase-failure-trigger.ts; ADR-0069 D3's 'every per-ticket failure drives the trigger' was not true for validate.",
|
|
1595
|
+
ingestVerb: "coordination.publish",
|
|
1596
|
+
wakes: false,
|
|
1597
|
+
entity: "ticket",
|
|
1598
|
+
},
|
|
1515
1599
|
"phase.validate.park": {
|
|
1516
1600
|
rationale:
|
|
1517
1601
|
"ADR-0039 durable-fact example ('a phase started, completed, failed, or was preempted'): the validate phase was PARKED after N consecutive failures (CTC-1270) — a terminal-until-released outcome, distinct from `phase.validate.failed`, which a reader must be able to tell apart. Paired with `phase.validate.revive`.",
|
|
@@ -1861,6 +1945,13 @@ export interface CurrentStateEventSpec {
|
|
|
1861
1945
|
* for zero replay value.
|
|
1862
1946
|
*/
|
|
1863
1947
|
export const currentStateEventTypes = {
|
|
1948
|
+
// CTC-838 — the pulse half of the agent-role pair above: updates the agent_roster row's
|
|
1949
|
+
// last_seen_at in place rather than growing the durable history on every pulse.
|
|
1950
|
+
"agent.role.heartbeat": {
|
|
1951
|
+
rationale:
|
|
1952
|
+
"A coordination agent's role liveness pulse — updates mutable NOW (the agent_roster row's last_seen_at); agent.role.started/stopped are the durable boundary facts.",
|
|
1953
|
+
boundaryEvents: ["agent.role.started", "agent.role.stopped"],
|
|
1954
|
+
},
|
|
1864
1955
|
"broker.daemon.heartbeat": {
|
|
1865
1956
|
rationale:
|
|
1866
1957
|
"The coordination broker daemon's own liveness pulse — updates mutable NOW; startup/shutdown are the durable boundary facts.",
|
|
@@ -1978,6 +2069,11 @@ export const telemetryEventTypes = {
|
|
|
1978
2069
|
reason:
|
|
1979
2070
|
"A replica read falling back to a secondary path — an internal routing diagnostic, not a ticket/phase coordination fact.",
|
|
1980
2071
|
},
|
|
2072
|
+
"catalyst.runner.egress.policy.applied": {
|
|
2073
|
+
reason:
|
|
2074
|
+
"CTC-1501 — one point per RunnerDispatchDO `/ensure` egress-policy application, `outcome` carrying ok (applied) / error (observe-mode swallow, `detail` naming the classified reason) / skip (RUNNER_EGRESS_MODE=off). Gauge/ratio-shaped: the whole value is the rollup of error-over-total, which is the denominator the ticket's own 'how often, versus container starts per hour' question needs — no individual /ensure poll has identity. The durable fact a failed application produces elsewhere is the phase outcome's egress: unpoliced attribute (loop.ts), not this point.",
|
|
2075
|
+
sampling: { kind: "counted" },
|
|
2076
|
+
},
|
|
1981
2077
|
"catalyst.service.health": { reason: "A periodic service health-check gauge." },
|
|
1982
2078
|
"ci.run.completed": {
|
|
1983
2079
|
reason:
|
|
@@ -45,6 +45,21 @@ export interface PhaseLifecycleFields {
|
|
|
45
45
|
artifactKey?: string;
|
|
46
46
|
durationMs?: number;
|
|
47
47
|
reason?: string;
|
|
48
|
+
/** CTC-1417 — the Linear team key. Optional here (an ergonomic caller-facing input, unlike
|
|
49
|
+
* TelemetryPoint's wire shape): when omitted, {@link buildPhaseLifecyclePoint} derives it from
|
|
50
|
+
* `ticket` via {@link teamFromTicket}. An explicit value always wins over the derivation. */
|
|
51
|
+
team?: string;
|
|
52
|
+
/** CTC-1417 — `owner/name`. Optional; a caller with none in scope leaves the point's `repo: null`
|
|
53
|
+
* rather than fabricating one. */
|
|
54
|
+
repo?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The Linear team key is the ticket identifier's prefix by construction (`CTC-1390` → `CTC`) — the
|
|
58
|
+
* same convention apps/mirror/src/do/ci-run-telemetry.ts's TICKET_SHAPE already depends on. Returns
|
|
59
|
+
* null, NEVER a guess, for anything that is not identifier-shaped. */
|
|
60
|
+
export function teamFromTicket(ticket: string): string | null {
|
|
61
|
+
const m = /^([A-Z][A-Z0-9]{1,9})-\d+$/.exec(ticket);
|
|
62
|
+
return m === null ? null : m[1]!;
|
|
48
63
|
}
|
|
49
64
|
|
|
50
65
|
/**
|
|
@@ -75,5 +90,8 @@ export function buildPhaseLifecyclePoint(fields: PhaseLifecycleFields): Telemetr
|
|
|
75
90
|
traceId: `${fields.ticket}:${fields.phase}:${fields.nonce}`,
|
|
76
91
|
durationMs: fields.durationMs ?? null,
|
|
77
92
|
count: 1,
|
|
93
|
+
projectId: null,
|
|
94
|
+
repo: fields.repo ?? null,
|
|
95
|
+
team: fields.team ?? teamFromTicket(fields.ticket),
|
|
78
96
|
};
|
|
79
97
|
}
|
package/src/telemetry/point.ts
CHANGED
|
@@ -18,26 +18,31 @@ export const MAX_POINTS_PER_BATCH = 25;
|
|
|
18
18
|
export const MAX_INDEX_BYTES = 96;
|
|
19
19
|
/** ClickHouse/AE per-blob byte cap. The bounded excerpt (`detail`) is budgeted against this. */
|
|
20
20
|
export const MAX_EXCERPT_BYTES = 1_024;
|
|
21
|
+
/** AE caps each blob at 1 KiB individually and the blob SUM at ~5 KiB. `detail` owns the 1 KiB budget;
|
|
22
|
+
* a dimension blob (`project_id`/`repo`/`team`) gets 256 — far above any real `owner/name` (GitHub:
|
|
23
|
+
* 39 + 100 chars) or Linear team key, and small enough that three of them cannot push a row over the
|
|
24
|
+
* sum cap (CTC-1417 D4). */
|
|
25
|
+
export const MAX_DIMENSION_BYTES = 256;
|
|
21
26
|
|
|
22
27
|
function byteLen(s: string): number {
|
|
23
28
|
return new TextEncoder().encode(s).length;
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
/**
|
|
27
|
-
* Bound a would-be-blob string to the AE per-blob cap, appending a stable elision
|
|
28
|
-
* how many bytes were dropped. Truncation is on a UTF-8 CHARACTER boundary: a naive
|
|
29
|
-
* split a multi-byte sequence, so the kept bytes are decoded leniently and a trailing
|
|
30
|
-
* (U+FFFD) is dropped so the excerpt is always valid text AND stays under the byte budget.
|
|
31
|
-
* here (CTC-962) from apps/mirror/src/event-stream/validate.ts, which now imports this copy
|
|
32
|
-
* of carrying its own — one truncator, not two that could drift.
|
|
32
|
+
* Bound a would-be-blob string to `cap` (default the AE per-blob cap), appending a stable elision
|
|
33
|
+
* marker that names how many bytes were dropped. Truncation is on a UTF-8 CHARACTER boundary: a naive
|
|
34
|
+
* byte slice can split a multi-byte sequence, so the kept bytes are decoded leniently and a trailing
|
|
35
|
+
* partial (U+FFFD) is dropped so the excerpt is always valid text AND stays under the byte budget.
|
|
36
|
+
* Lifted here (CTC-962) from apps/mirror/src/event-stream/validate.ts, which now imports this copy
|
|
37
|
+
* instead of carrying its own — one truncator, not two that could drift.
|
|
33
38
|
*/
|
|
34
|
-
export function boundExcerpt(raw: string): string {
|
|
39
|
+
export function boundExcerpt(raw: string, cap: number = MAX_EXCERPT_BYTES): string {
|
|
35
40
|
const bytes = byteLen(raw);
|
|
36
|
-
if (bytes <=
|
|
41
|
+
if (bytes <= cap) return raw;
|
|
37
42
|
// Reserve marker room using the WORST-CASE omitted count (the whole payload); the real marker is
|
|
38
|
-
// never longer, so the final excerpt is guaranteed ≤
|
|
43
|
+
// never longer, so the final excerpt is guaranteed ≤ cap.
|
|
39
44
|
const markerReserve = byteLen(`…[+${bytes} bytes]`);
|
|
40
|
-
const keepBudget = Math.max(0,
|
|
45
|
+
const keepBudget = Math.max(0, cap - markerReserve);
|
|
41
46
|
const kept = new TextEncoder().encode(raw).slice(0, keepBudget);
|
|
42
47
|
// Lenient decode → a split trailing multi-byte becomes U+FFFD; strip it so output is valid + smaller.
|
|
43
48
|
let decoded = new TextDecoder().decode(kept);
|
|
@@ -46,6 +51,20 @@ export function boundExcerpt(raw: string): string {
|
|
|
46
51
|
return `${decoded}…[+${omitted} bytes]`;
|
|
47
52
|
}
|
|
48
53
|
|
|
54
|
+
/**
|
|
55
|
+
* The ONE builder for an AE `indexes` array in this repo (CTC-1417 D8). Every lane fences on exactly
|
|
56
|
+
* one low-cardinality value — the tenant — so "every stream is indexed by the same account_id" is a
|
|
57
|
+
* property of CONSTRUCTION, not a convention five modules separately honour (which is how
|
|
58
|
+
* catalyst_runner_egress once shipped with no index at all, CTC-1528). THROWS on an over-cap id rather
|
|
59
|
+
* than truncating: a truncated tenant id is a plausible-looking WRONG fence.
|
|
60
|
+
*/
|
|
61
|
+
export function aeAccountIndexes(accountId: string): string[] {
|
|
62
|
+
if (byteLen(accountId) > MAX_INDEX_BYTES) {
|
|
63
|
+
throw new Error(`accountId exceeds ${MAX_INDEX_BYTES}-byte AE index cap`);
|
|
64
|
+
}
|
|
65
|
+
return [accountId];
|
|
66
|
+
}
|
|
67
|
+
|
|
49
68
|
/** Conservative default for a telemetry name with no explicit `sampling` spec — never "unbounded".
|
|
50
69
|
* 70 pre-existing fleet/orchestrator names carry no per-name budget yet (see registry.ts); defaulting
|
|
51
70
|
* them to `always` would import their full volume into the hot tier the moment anything emits them. */
|
|
@@ -76,6 +95,13 @@ export interface TelemetryPoint {
|
|
|
76
95
|
readonly durationMs: number | null;
|
|
77
96
|
/** >1 when this point is a collapsed repeat (see `collapse`). */
|
|
78
97
|
readonly count: number;
|
|
98
|
+
/** ADR-0011 dimensions, promoted to typed fields so BOTH legs carry them (ADR-0048 D3: one
|
|
99
|
+
* vocabulary, two volumes; CTC-1417). `null` — never absent, never the string "null" — when the
|
|
100
|
+
* emitting surface genuinely has no such value. Required-but-nullable ON PURPOSE: an optional field
|
|
101
|
+
* lets a call site forget one; a nullable one makes it STATE that it has none. */
|
|
102
|
+
readonly projectId: string | null;
|
|
103
|
+
readonly repo: string | null;
|
|
104
|
+
readonly team: string | null;
|
|
79
105
|
}
|
|
80
106
|
|
|
81
107
|
/**
|
|
@@ -114,14 +140,26 @@ export interface TelemetryAeDataPoint {
|
|
|
114
140
|
* event-stream/sql-proxy.ts's `doubles:[seq]` already uses. Encoding an unknown duration as `0`
|
|
115
141
|
* (the previous behavior) would have AE and the hot-leg feed report a false zero-latency instead of
|
|
116
142
|
* "no duration recorded", indistinguishable from a real fast operation.
|
|
143
|
+
*
|
|
144
|
+
* Blobs 1-5 (`service, name, outcome, traceId, detail`) are FROZEN — the read proxy is positional.
|
|
145
|
+
* Blobs 6-8 (`project_id, repo, team`, CTC-1417) are APPENDED: an absent dimension emits an empty
|
|
146
|
+
* string, never a dropped slot, so position never shifts.
|
|
117
147
|
*/
|
|
118
148
|
export function toTelemetryDataPoint(p: TelemetryPoint, accountId: string): TelemetryAeDataPoint {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
149
|
+
const dimBlob = (v: string | null): string =>
|
|
150
|
+
v === null ? "" : boundExcerpt(v, MAX_DIMENSION_BYTES);
|
|
122
151
|
return {
|
|
123
|
-
indexes:
|
|
124
|
-
blobs: [
|
|
152
|
+
indexes: aeAccountIndexes(accountId),
|
|
153
|
+
blobs: [
|
|
154
|
+
p.service,
|
|
155
|
+
p.name,
|
|
156
|
+
p.outcome,
|
|
157
|
+
p.traceId,
|
|
158
|
+
boundExcerpt(p.detail),
|
|
159
|
+
dimBlob(p.projectId),
|
|
160
|
+
dimBlob(p.repo),
|
|
161
|
+
dimBlob(p.team),
|
|
162
|
+
],
|
|
125
163
|
doubles: p.durationMs == null ? [p.count] : [p.count, p.durationMs],
|
|
126
164
|
};
|
|
127
165
|
}
|
|
@@ -142,6 +180,9 @@ export interface TelemetryStreamRecord {
|
|
|
142
180
|
readonly detail: string | null;
|
|
143
181
|
readonly duration_ms: number | null;
|
|
144
182
|
readonly count: number;
|
|
183
|
+
readonly project_id: string | null;
|
|
184
|
+
readonly repo: string | null;
|
|
185
|
+
readonly team: string | null;
|
|
145
186
|
readonly [key: string]: unknown;
|
|
146
187
|
}
|
|
147
188
|
|
|
@@ -162,21 +203,29 @@ export function toTelemetryStreamRecord(
|
|
|
162
203
|
detail: p.detail ? boundExcerpt(p.detail) : null,
|
|
163
204
|
duration_ms: p.durationMs,
|
|
164
205
|
count: p.count,
|
|
206
|
+
project_id: p.projectId,
|
|
207
|
+
repo: p.repo,
|
|
208
|
+
team: p.team,
|
|
165
209
|
};
|
|
166
210
|
}
|
|
167
211
|
|
|
168
212
|
/**
|
|
169
|
-
* Fold points sharing an identical (name, outcome)
|
|
170
|
-
* count and the FIRST traceId seen for that
|
|
171
|
-
* fact logged 14,256 times). Order-preserving on first occurrence.
|
|
172
|
-
* sampling class, to BOTH legs (D3/D4) — a `counted` name's
|
|
173
|
-
* comes from this stage, not from `applySampling`.
|
|
213
|
+
* Fold points sharing an identical (name, outcome, projectId, repo, team) tuple into a single point
|
|
214
|
+
* carrying the summed count and the FIRST traceId seen for that tuple — the direct answer to
|
|
215
|
+
* ADR-0039's measurement (one fact logged 14,256 times). Order-preserving on first occurrence.
|
|
216
|
+
* Applies identically regardless of sampling class, to BOTH legs (D3/D4) — a `counted` name's
|
|
217
|
+
* "never stored individually" guarantee comes from this stage, not from `applySampling`.
|
|
218
|
+
*
|
|
219
|
+
* The key is widened beyond (name, outcome) to include projectId/repo/team (CTC-1417): once
|
|
220
|
+
* dimensions live on the point, folding on (name, outcome) alone while spreading the FIRST point
|
|
221
|
+
* would merge two different repos' identically-named/outcome points into one row carrying the first
|
|
222
|
+
* repo's name and the SUMMED count — one repo's volume silently attributed to another.
|
|
174
223
|
*/
|
|
175
224
|
export function collapse(points: readonly TelemetryPoint[]): TelemetryPoint[] {
|
|
176
225
|
const order: string[] = [];
|
|
177
226
|
const byKey = new Map<string, TelemetryPoint>();
|
|
178
227
|
for (const p of points) {
|
|
179
|
-
const key = `${p.name}\u0000${p.outcome}`;
|
|
228
|
+
const key = `${p.name}\u0000${p.outcome}\u0000${p.projectId}\u0000${p.repo}\u0000${p.team}`;
|
|
180
229
|
const existing = byKey.get(key);
|
|
181
230
|
if (existing) {
|
|
182
231
|
byKey.set(key, { ...existing, count: existing.count + p.count });
|