@bli-cockpit/telemetry-core 0.1.24 → 0.1.26

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,75 @@
1
+ /**
2
+ * The proof that a collector is alive, and what it is allowed to look at.
3
+ *
4
+ * BLI-3551. Until now the ONLY writer of
5
+ * `ambient_collector_devices.last_seen_at` was the ambient ingest path
6
+ * (`apps/dashboard/src/lib/ambient/ingest.ts`), which runs when there is an
7
+ * envelope to send. A machine whose operator works entirely outside the
8
+ * approved roots sends no envelope, so it goes quiet — and a quiet machine and
9
+ * a dead machine look exactly the same from the dashboard. One device ticked
10
+ * 377 times in 38 hours and the last-seen column did not move once.
11
+ *
12
+ * The heartbeat closes that. It is deliberately NOT an ambient envelope: the
13
+ * envelope requires a work context, a session reference and at least one event,
14
+ * and inventing all three per tick would create a work-session row every 15
15
+ * minutes for a machine that collected nothing. A separate door with its own
16
+ * tiny schema costs less and says only what it means.
17
+ *
18
+ * The approved roots ride along as LABELS and never as paths. A root is
19
+ * `{ basename, path_sha256 }`: the basename is the folder name a person would
20
+ * recognise (`BLI`, `repos`), the hash is the stable identity of the normalized
21
+ * absolute path, so two machines pointing at the same folder are comparable
22
+ * without either path leaving the device. This is the same rule the session
23
+ * rows already follow with `cwd_basename` / `cwd_hash`.
24
+ */
25
+ import { z } from "zod";
26
+ export declare const COLLECTOR_HEARTBEAT_SCHEMA_VERSION = "collector-heartbeat.v1";
27
+ /** The most roots one machine may declare. Well above any real operator. */
28
+ export declare const COLLECTOR_HEARTBEAT_MAX_ROOTS = 40;
29
+ export declare const CollectorRootLabelSchema: z.ZodObject<{
30
+ basename: z.ZodString;
31
+ path_sha256: z.ZodString;
32
+ }, z.core.$strict>;
33
+ export type CollectorRootLabel = z.infer<typeof CollectorRootLabelSchema>;
34
+ /** What the last collection tick decided, in the collector's own vocabulary. */
35
+ export declare const CollectorHeartbeatSyncStatusSchema: z.ZodEnum<{
36
+ skipped: "skipped";
37
+ ok: "ok";
38
+ fail: "fail";
39
+ not_run: "not_run";
40
+ }>;
41
+ export declare const CollectorHeartbeatSchema: z.ZodObject<{
42
+ schema_version: z.ZodLiteral<"collector-heartbeat.v1">;
43
+ generated_at: z.ZodString;
44
+ collector_version: z.ZodString;
45
+ os_platform: z.ZodOptional<z.ZodString>;
46
+ roots: z.ZodArray<z.ZodObject<{
47
+ basename: z.ZodString;
48
+ path_sha256: z.ZodString;
49
+ }, z.core.$strict>>;
50
+ last_sync_status: z.ZodDefault<z.ZodEnum<{
51
+ skipped: "skipped";
52
+ ok: "ok";
53
+ fail: "fail";
54
+ not_run: "not_run";
55
+ }>>;
56
+ last_sync_reason: z.ZodOptional<z.ZodString>;
57
+ sessions_observed: z.ZodOptional<z.ZodNumber>;
58
+ sessions_outside_root: z.ZodOptional<z.ZodNumber>;
59
+ }, z.core.$strict>;
60
+ export type CollectorHeartbeat = z.infer<typeof CollectorHeartbeatSchema>;
61
+ /**
62
+ * What the device row's `metadata.roots` holds after a heartbeat lands.
63
+ *
64
+ * Stored as its own key rather than merged into the row's top level so an
65
+ * older reader of `metadata` is unaffected, and so "which folders is this
66
+ * machine allowed to see, and when did it last say so" is one lookup.
67
+ */
68
+ export declare const CollectorDeviceRootsMetadataSchema: z.ZodObject<{
69
+ observed_at: z.ZodString;
70
+ labels: z.ZodArray<z.ZodObject<{
71
+ basename: z.ZodString;
72
+ path_sha256: z.ZodString;
73
+ }, z.core.$strict>>;
74
+ }, z.core.$strict>;
75
+ export type CollectorDeviceRootsMetadata = z.infer<typeof CollectorDeviceRootsMetadataSchema>;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The proof that a collector is alive, and what it is allowed to look at.
3
+ *
4
+ * BLI-3551. Until now the ONLY writer of
5
+ * `ambient_collector_devices.last_seen_at` was the ambient ingest path
6
+ * (`apps/dashboard/src/lib/ambient/ingest.ts`), which runs when there is an
7
+ * envelope to send. A machine whose operator works entirely outside the
8
+ * approved roots sends no envelope, so it goes quiet — and a quiet machine and
9
+ * a dead machine look exactly the same from the dashboard. One device ticked
10
+ * 377 times in 38 hours and the last-seen column did not move once.
11
+ *
12
+ * The heartbeat closes that. It is deliberately NOT an ambient envelope: the
13
+ * envelope requires a work context, a session reference and at least one event,
14
+ * and inventing all three per tick would create a work-session row every 15
15
+ * minutes for a machine that collected nothing. A separate door with its own
16
+ * tiny schema costs less and says only what it means.
17
+ *
18
+ * The approved roots ride along as LABELS and never as paths. A root is
19
+ * `{ basename, path_sha256 }`: the basename is the folder name a person would
20
+ * recognise (`BLI`, `repos`), the hash is the stable identity of the normalized
21
+ * absolute path, so two machines pointing at the same folder are comparable
22
+ * without either path leaving the device. This is the same rule the session
23
+ * rows already follow with `cwd_basename` / `cwd_hash`.
24
+ */
25
+ import { z } from "zod";
26
+ import { IsoDateTimeSchema, Sha256Schema, VersionStringSchema } from "./common.js";
27
+ export const COLLECTOR_HEARTBEAT_SCHEMA_VERSION = "collector-heartbeat.v1";
28
+ /** The most roots one machine may declare. Well above any real operator. */
29
+ export const COLLECTOR_HEARTBEAT_MAX_ROOTS = 40;
30
+ const RootBasenameSchema = z
31
+ .string()
32
+ .trim()
33
+ .min(1)
34
+ .max(160)
35
+ // A basename, never a path: a separator here would mean somebody sent the
36
+ // whole location of someone's work folder.
37
+ .regex(/^[^/\\]+$/u);
38
+ export const CollectorRootLabelSchema = z
39
+ .object({
40
+ basename: RootBasenameSchema,
41
+ /** sha256 of the normalized absolute path. The path itself never travels. */
42
+ path_sha256: Sha256Schema,
43
+ })
44
+ .strict();
45
+ /** What the last collection tick decided, in the collector's own vocabulary. */
46
+ export const CollectorHeartbeatSyncStatusSchema = z.enum([
47
+ "ok",
48
+ "fail",
49
+ "skipped",
50
+ "not_run",
51
+ ]);
52
+ export const CollectorHeartbeatSchema = z
53
+ .object({
54
+ schema_version: z.literal(COLLECTOR_HEARTBEAT_SCHEMA_VERSION),
55
+ generated_at: IsoDateTimeSchema,
56
+ collector_version: VersionStringSchema,
57
+ /** The DEVICE's platform (darwin/win32); the server's would be the runtime's. */
58
+ os_platform: z
59
+ .string()
60
+ .trim()
61
+ .min(1)
62
+ .max(32)
63
+ .regex(/^[a-z0-9_-]+$/iu)
64
+ .optional(),
65
+ roots: z.array(CollectorRootLabelSchema).max(COLLECTOR_HEARTBEAT_MAX_ROOTS),
66
+ /**
67
+ * How the tick that produced this heartbeat went, so a machine that is
68
+ * alive AND failing is distinguishable from one that is alive and idle
69
+ * without waiting for the install-events outbox to drain.
70
+ */
71
+ last_sync_status: CollectorHeartbeatSyncStatusSchema.default("not_run"),
72
+ /**
73
+ * A reason label, never prose: `nothing_in_root:47`, `auth_failed`,
74
+ * `sync_already_running`. Bounded and charset-restricted so nothing
75
+ * content-bearing can ride in on it.
76
+ */
77
+ last_sync_reason: z
78
+ .string()
79
+ .trim()
80
+ .min(1)
81
+ .max(120)
82
+ .regex(/^[a-z0-9_:.-]+$/iu)
83
+ .optional(),
84
+ /** Counts only — how many sessions the tick saw, and how many were outside. */
85
+ sessions_observed: z.number().int().nonnegative().max(1_000_000).optional(),
86
+ sessions_outside_root: z
87
+ .number()
88
+ .int()
89
+ .nonnegative()
90
+ .max(1_000_000)
91
+ .optional(),
92
+ })
93
+ .strict();
94
+ /**
95
+ * What the device row's `metadata.roots` holds after a heartbeat lands.
96
+ *
97
+ * Stored as its own key rather than merged into the row's top level so an
98
+ * older reader of `metadata` is unaffected, and so "which folders is this
99
+ * machine allowed to see, and when did it last say so" is one lookup.
100
+ */
101
+ export const CollectorDeviceRootsMetadataSchema = z
102
+ .object({
103
+ observed_at: IsoDateTimeSchema,
104
+ labels: z.array(CollectorRootLabelSchema).max(COLLECTOR_HEARTBEAT_MAX_ROOTS),
105
+ })
106
+ .strict();
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./agent-artifacts.js";
2
+ export * from "./collector-heartbeat.js";
2
3
  export * from "./common.js";
3
4
  export * from "./decision-event.js";
4
5
  export * from "./decision-event-id.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./agent-artifacts.js";
2
+ export * from "./collector-heartbeat.js";
2
3
  export * from "./common.js";
3
4
  export * from "./decision-event.js";
4
5
  export * from "./decision-event-id.js";
@@ -92,6 +92,54 @@ export declare const DELIVERY_BACKOFF_HOLDING = "delivery_backoff_holding";
92
92
  * retry.
93
93
  */
94
94
  export declare const NOT_UPLOADABLE_ATTRIBUTION_STATE = "not_uploadable_attribution_state";
95
+ /**
96
+ * The four answers `begin` gives when an object key is already spoken for.
97
+ *
98
+ * They are the route's own strings
99
+ * (`apps/dashboard/src/app/api/ambient/evidence/upload/begin/route.ts`), named
100
+ * here so the label the server writes and the label the collector classifies
101
+ * are the same string by construction — the same rule as
102
+ * `STORAGE_REJECTED_OBJECT_TOO_LARGE`.
103
+ *
104
+ * All four are `deterministic`: `begin` compared what is already durable
105
+ * against what this sync is offering and said no. The identical offer gets the
106
+ * identical no, on every tick, forever — which is exactly what four machines
107
+ * did for 38 hours (BLI-3552: ian 157 ticks, veetesh 196, rushik 45, viet 30).
108
+ * They were `unknown` before, and `unknown` is retried.
109
+ *
110
+ * They are NOT interchangeable in what resolves them, and
111
+ * `isRekeyableUploadConflict` is where that difference lives.
112
+ */
113
+ export declare const HASH_MISMATCH_COMMITTED_OBJECT = "hash_mismatch_committed_object";
114
+ export declare const BYTE_SIZE_MISMATCH_COMMITTED_OBJECT = "byte_size_mismatch_committed_object";
115
+ export declare const COMMITTED_OBJECT_RECEIPT_MISMATCH = "committed_object_receipt_mismatch";
116
+ export declare const OBJECT_KEY_OWNED_BY_OTHER_OPERATOR = "object_key_owned_by_other_operator";
117
+ /**
118
+ * Whether a different NAME would settle this conflict.
119
+ *
120
+ * True for exactly one reason. `hash_mismatch_committed_object` means the key
121
+ * holds other bytes that are already durable and these bytes have never been
122
+ * stored anywhere: nobody is wrong, the key was simply not specific enough to
123
+ * tell the two apart. Re-keying to a name that carries this content's own hash
124
+ * stores it beside the old object without touching it — and a committed object
125
+ * is never deleted to make room, in this or any other path.
126
+ *
127
+ * False for the other three, and each for its own reason:
128
+ * - `byte_size_mismatch_committed_object` — same hash, different size. One of
129
+ * the two receipts is lying about bytes nobody can re-derive; a second copy
130
+ * under a second name would not say which.
131
+ * - `committed_object_receipt_mismatch` — the ledger and Storage disagree about
132
+ * an object that is already there. That is a server-side repair (the drain),
133
+ * not a client-side rename.
134
+ * - `object_key_owned_by_other_operator` — a key inside another person's
135
+ * namespace. Renaming around an ownership boundary is precisely what the
136
+ * boundary exists to stop.
137
+ *
138
+ * Those three are terminal for this object: the collector records the reason
139
+ * and stops offering it, which is what makes the failure visible instead of
140
+ * infinite.
141
+ */
142
+ export declare function isRekeyableUploadConflict(reason: string | null | undefined): boolean;
95
143
  /**
96
144
  * Compose the withheld-by-attribution reason for one session.
97
145
  *
@@ -98,6 +98,58 @@ export const DELIVERY_BACKOFF_HOLDING = "delivery_backoff_holding";
98
98
  * retry.
99
99
  */
100
100
  export const NOT_UPLOADABLE_ATTRIBUTION_STATE = "not_uploadable_attribution_state";
101
+ /**
102
+ * The four answers `begin` gives when an object key is already spoken for.
103
+ *
104
+ * They are the route's own strings
105
+ * (`apps/dashboard/src/app/api/ambient/evidence/upload/begin/route.ts`), named
106
+ * here so the label the server writes and the label the collector classifies
107
+ * are the same string by construction — the same rule as
108
+ * `STORAGE_REJECTED_OBJECT_TOO_LARGE`.
109
+ *
110
+ * All four are `deterministic`: `begin` compared what is already durable
111
+ * against what this sync is offering and said no. The identical offer gets the
112
+ * identical no, on every tick, forever — which is exactly what four machines
113
+ * did for 38 hours (BLI-3552: ian 157 ticks, veetesh 196, rushik 45, viet 30).
114
+ * They were `unknown` before, and `unknown` is retried.
115
+ *
116
+ * They are NOT interchangeable in what resolves them, and
117
+ * `isRekeyableUploadConflict` is where that difference lives.
118
+ */
119
+ export const HASH_MISMATCH_COMMITTED_OBJECT = "hash_mismatch_committed_object";
120
+ export const BYTE_SIZE_MISMATCH_COMMITTED_OBJECT = "byte_size_mismatch_committed_object";
121
+ export const COMMITTED_OBJECT_RECEIPT_MISMATCH = "committed_object_receipt_mismatch";
122
+ export const OBJECT_KEY_OWNED_BY_OTHER_OPERATOR = "object_key_owned_by_other_operator";
123
+ /**
124
+ * Whether a different NAME would settle this conflict.
125
+ *
126
+ * True for exactly one reason. `hash_mismatch_committed_object` means the key
127
+ * holds other bytes that are already durable and these bytes have never been
128
+ * stored anywhere: nobody is wrong, the key was simply not specific enough to
129
+ * tell the two apart. Re-keying to a name that carries this content's own hash
130
+ * stores it beside the old object without touching it — and a committed object
131
+ * is never deleted to make room, in this or any other path.
132
+ *
133
+ * False for the other three, and each for its own reason:
134
+ * - `byte_size_mismatch_committed_object` — same hash, different size. One of
135
+ * the two receipts is lying about bytes nobody can re-derive; a second copy
136
+ * under a second name would not say which.
137
+ * - `committed_object_receipt_mismatch` — the ledger and Storage disagree about
138
+ * an object that is already there. That is a server-side repair (the drain),
139
+ * not a client-side rename.
140
+ * - `object_key_owned_by_other_operator` — a key inside another person's
141
+ * namespace. Renaming around an ownership boundary is precisely what the
142
+ * boundary exists to stop.
143
+ *
144
+ * Those three are terminal for this object: the collector records the reason
145
+ * and stops offering it, which is what makes the failure visible instead of
146
+ * infinite.
147
+ */
148
+ export function isRekeyableUploadConflict(reason) {
149
+ if (!reason)
150
+ return false;
151
+ return baseUploadFailureReason(reason) === HASH_MISMATCH_COMMITTED_OBJECT;
152
+ }
101
153
  /** Longest label the ambient envelope's `SafeLabelSchema` accepts. */
102
154
  const MAX_UPLOAD_REASON_LENGTH = 120;
103
155
  /**
@@ -149,6 +201,17 @@ const UPLOAD_FAILURE_CLASSES = {
149
201
  // The file itself is the problem, and it will be the same size and the same
150
202
  // shape on the next pass. Retrying is a promise nobody can keep.
151
203
  file_too_large: "deterministic",
204
+ // A declared image above IMAGE_EVIDENCE_MAX_VALIDATION_BYTES (BLI-3067). The
205
+ // commit route deliberately KEEPS the staged chunks for these so that raising
206
+ // the bound turns the identical row into a commit that works — and
207
+ // `docs/runbooks/cockpit-stuck-evidence-uploads.md` has always documented it
208
+ // as deterministic. The entry was missing here, so it classified `unknown`,
209
+ // `isPermanentUploadFailure` answered false, and `begin`'s terminal-failure
210
+ // guard fell through to the reset that deletes those very chunk receipts —
211
+ // destroying the retained staging on the next sync and re-uploading the same
212
+ // oversized image forever. That is the BLI-2528 loop reappearing inside the
213
+ // code written to end it (BLI-3408).
214
+ image_too_large_to_validate: "deterministic",
152
215
  // Storage looked at the finished object and said no. The bytes are already
153
216
  // final and content-addressed, so the next attempt presents the identical
154
217
  // object to the identical rule (BLI-2528). Retrying these is what let 76
@@ -158,6 +221,22 @@ const UPLOAD_FAILURE_CLASSES = {
158
221
  // The content-addressed key already holds different bytes. One of the two is
159
222
  // wrong and repeating the write cannot decide which; a human has to look.
160
223
  object_conflict: "deterministic",
224
+ // `begin`'s four committed-object conflicts (BLI-3552). Deterministic for the
225
+ // same reason as `object_conflict` above — the route looked at both sides and
226
+ // answered no — and the difference between them is not the class but the
227
+ // remedy, which `isRekeyableUploadConflict` holds. Before this entry existed
228
+ // they classified `unknown`, `retryableFailedOutcomes` counted them as
229
+ // rescuable, and four machines re-offered the identical bytes every 15
230
+ // minutes while every one of those syncs was marked failed.
231
+ [HASH_MISMATCH_COMMITTED_OBJECT]: "deterministic",
232
+ [BYTE_SIZE_MISMATCH_COMMITTED_OBJECT]: "deterministic",
233
+ [COMMITTED_OBJECT_RECEIPT_MISMATCH]: "deterministic",
234
+ [OBJECT_KEY_OWNED_BY_OTHER_OPERATOR]: "deterministic",
235
+ // The drain's terminal answer for a stranded row whose key is already held by
236
+ // a different durable object (BLI-3552). Nothing on the server can finish it:
237
+ // the final write uses `upsert: false` on purpose, so the row would loop as
238
+ // `skipped_storage_error` for as long as anyone kept running the pass.
239
+ object_key_holds_other_content: "deterministic",
161
240
  // Attribution never produced a target, so there is nowhere to put the object.
162
241
  // Widening discovery can change this; running the same scan again cannot.
163
242
  cwd_outside_scanned_worktrees: "deterministic",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/telemetry-core",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",