@catalyst-cloud/schema 0.1.45 → 0.1.47
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.47",
|
|
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
|
@@ -791,6 +791,20 @@ export const durableEventTypes = {
|
|
|
791
791
|
wakes: false,
|
|
792
792
|
entity: "ticket",
|
|
793
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
|
+
},
|
|
794
808
|
"phase.merge.park": {
|
|
795
809
|
rationale:
|
|
796
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`.",
|
|
@@ -1568,6 +1582,20 @@ export const durableEventTypes = {
|
|
|
1568
1582
|
wakes: false,
|
|
1569
1583
|
entity: "ticket",
|
|
1570
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
|
+
},
|
|
1571
1599
|
"phase.validate.park": {
|
|
1572
1600
|
rationale:
|
|
1573
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`.",
|
|
@@ -2041,6 +2069,11 @@ export const telemetryEventTypes = {
|
|
|
2041
2069
|
reason:
|
|
2042
2070
|
"A replica read falling back to a secondary path — an internal routing diagnostic, not a ticket/phase coordination fact.",
|
|
2043
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
|
+
},
|
|
2044
2077
|
"catalyst.service.health": { reason: "A periodic service health-check gauge." },
|
|
2045
2078
|
"ci.run.completed": {
|
|
2046
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 });
|