@indigoai-us/hq-cli 5.106.2 → 5.107.0

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,106 @@
1
+ // src/unhandled-rejection-boundary.ts
2
+ //
3
+ // A process-level unhandledRejection boundary for the CLI entrypoint.
4
+ //
5
+ // @sentry/node's onUnhandledRejection integration defaults to mode 'warn': it
6
+ // CAPTURES a floated rejection but does NOT rethrow it. hq-cli installs no
7
+ // rejection handler of its own, so without this boundary ANY floated rejection
8
+ // (the class that produced Sentry HQ-CLI-W) leaves process.exitCode unset, the
9
+ // event loop drains, and Node exits 0 — a silent success in which the command
10
+ // never ran. index.ts's own comment ("Rejections still become unhandled and
11
+ // preserve a genuine non-zero failure") described the pre-Sentry world and is
12
+ // no longer true; this boundary restores it.
13
+ //
14
+ // The logic is factored out of index.ts so it is unit-testable: importing
15
+ // index.ts for a test would trigger the real command dispatch.
16
+ import { isEpipe } from "./utils/epipe.js";
17
+ // Sticky once a non-EPIPE floated rejection has been handled. `process.exitCode`
18
+ // alone is not enough: a command path that later calls `process.exit(0)` — e.g.
19
+ // `hq run`'s child-`close` handler after a child that exited 0 — would erase the
20
+ // failure. Exit paths that propagate their own status consult this to force a
21
+ // non-zero code instead (see src/commands/run.ts).
22
+ let fatalRejectionSeen = false;
23
+ export function hadFatalRejection() {
24
+ return fatalRejectionSeen;
25
+ }
26
+ // Rejections a scoped handler (e.g. resolveEnvValuesOrThrow) has already claimed
27
+ // and will surface through the normal command path. The process-wide boundary
28
+ // defers to the claimer so one failure yields exactly one diagnostic rather than
29
+ // both a scoped throw AND a boundary report of the same rejection.
30
+ const claimedRejections = new Set();
31
+ export function claimRejection(reason) {
32
+ claimedRejections.add(reason);
33
+ }
34
+ export function releaseRejection(reason) {
35
+ claimedRejections.delete(reason);
36
+ }
37
+ /** Reset the process-level rejection state. Test-only. */
38
+ export function __resetRejectionStateForTests() {
39
+ fatalRejectionSeen = false;
40
+ claimedRejections.clear();
41
+ }
42
+ /**
43
+ * Handle one floated rejection. Synchronously fixes the exit code — a broken
44
+ * pipe (EPIPE) is a clean close (0), anything else is a failure (1) — WITHOUT
45
+ * downgrading a non-zero code a command path already set, then kicks off a
46
+ * best-effort actionable report. Never calls process.exit, so the command's
47
+ * finally block (release-health session end + bounded Sentry flush) still runs.
48
+ */
49
+ export function handleFatalRejection(reason, deps) {
50
+ // A scoped handler already owns this exact rejection and will surface it
51
+ // through the normal command path; do not exit-code or report it twice.
52
+ if (claimedRejections.has(reason))
53
+ return;
54
+ if (isEpipe(reason)) {
55
+ // A clean downstream close. Only assert 0 when nothing has already failed.
56
+ if (deps.getExitCode() === undefined)
57
+ deps.setExitCode(0);
58
+ }
59
+ else {
60
+ // A genuine fatal rejection: mark it sticky, and mark the run failed unless
61
+ // a command already set a non-zero code (e.g. a child's exit status).
62
+ fatalRejectionSeen = true;
63
+ if (!deps.getExitCode())
64
+ deps.setExitCode(1);
65
+ }
66
+ deps.report(reason);
67
+ }
68
+ let installed = false;
69
+ /**
70
+ * Register the boundary on `process`, once, before index.ts dispatches into the
71
+ * command graph. Idempotent so repeated imports/installs add a single listener.
72
+ * The actionable report routes through main.ts's existing handleTopLevelError
73
+ * with a NO-OP capture: @sentry/node's integration has already captured the
74
+ * rejection, so this only adds the classified `hq:` line (and its EPIPE / typed
75
+ * carve-outs) without a second Sentry event.
76
+ */
77
+ export function installProcessRejectionBoundary() {
78
+ if (installed)
79
+ return;
80
+ installed = true;
81
+ process.on("unhandledRejection", (reason) => {
82
+ handleFatalRejection(reason, {
83
+ getExitCode: () => process.exitCode,
84
+ setExitCode: (code) => {
85
+ process.exitCode = code;
86
+ },
87
+ report: (r) => {
88
+ void import("./main.js")
89
+ .then(({ handleTopLevelError }) => handleTopLevelError(r, {
90
+ sentry: { captureException: () => undefined },
91
+ stderr: process.stderr,
92
+ // Print-only. handleFatalRejection already set the authoritative,
93
+ // downgrade-guarded exit code synchronously; letting the async
94
+ // report re-set it would let handleTopLevelError's EPIPE branch
95
+ // (setExitCode(0)) reset an earlier failure to success.
96
+ setExitCode: () => { },
97
+ }))
98
+ .catch(() => {
99
+ // main.js could not be loaded (a rejection on a path that never
100
+ // imported it). The synchronous exit code above still stands.
101
+ });
102
+ },
103
+ });
104
+ });
105
+ }
106
+ //# sourceMappingURL=unhandled-rejection-boundary.js.map
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Client-health wire contract (client-sync-health-control-plane US-000).
3
+ *
4
+ * ONE versioned contract shared in spirit across four adapters:
5
+ *
6
+ * - hq-pro src/sync/server/client-health-contract.ts (reference)
7
+ * - hq-desktop-app crates/hq-desktop-core/src/client_health.rs
8
+ * - hq-cli src/utils/client-health-contract.ts (this file)
9
+ * - indigo-gtm-hq src/lib/client-health.ts
10
+ *
11
+ * Every adapter uses the SAME field names and the SAME enum values, verified
12
+ * per-repo against byte-equivalent canonical fixtures (here embedded in
13
+ * src/utils/client-health-contract.test.ts). The interface, not repository
14
+ * internals, is the shared test surface.
15
+ *
16
+ * Design rules (PRD notes + securityNotes):
17
+ *
18
+ * - ADDITIVE + tolerant of older clients: every non-identity field is
19
+ * optional-friendly, and unknown EXTRA fields are ignored (a newer client
20
+ * talking to an older server must not fail). Absence of `updaterState`
21
+ * means "an older client that never reported it"; the closed value
22
+ * "unchecked" means "the updater has not run yet" — the two are distinct
23
+ * and must survive the wire.
24
+ * - FAIL CLOSED on values: enums are closed sets. Free-form shell text,
25
+ * customer file paths, secret-shaped values, raw logs, and unknown repair
26
+ * kinds are rejected with a typed {@link ClientHealthContractError}.
27
+ * - Device identity: clients send only the stable random `installationId`.
28
+ * HMAC'ing of device identifiers happens server-side (US-001, modeled on
29
+ * scope-sync-mode-repository.ts) and never crosses this wire contract.
30
+ * - Monotonic `sequence`: the server keeps the highest sequence seen per
31
+ * installation and drops older/replayed heartbeats
32
+ * ({@link shouldApplyHeartbeat}).
33
+ */
34
+ export declare const CLIENT_HEALTH_CONTRACT_VERSION = 1;
35
+ export declare const CLIENT_HEALTH_PLATFORMS: readonly ["macos", "windows", "linux"];
36
+ export type ClientHealthPlatform = (typeof CLIENT_HEALTH_PLATFORMS)[number];
37
+ export declare const CLIENT_HEALTH_ARCHS: readonly ["x64", "arm64"];
38
+ export type ClientHealthArch = (typeof CLIENT_HEALTH_ARCHS)[number];
39
+ /** Which client produced the heartbeat. Desktop is always-on; CLI contributes per invocation. */
40
+ export declare const CLIENT_HEALTH_SOURCES: readonly ["desktop", "cli"];
41
+ export type ClientHealthSource = (typeof CLIENT_HEALTH_SOURCES)[number];
42
+ /**
43
+ * Current sync state of the installation. `paused` and `conflict_blocked` are
44
+ * first-class states (not failures folded into `error`) because support treats
45
+ * them differently: pause may be intentional and conflicts are user-owned.
46
+ */
47
+ export declare const CLIENT_HEALTH_SYNC_STATES: readonly ["idle", "syncing", "paused", "conflict_blocked", "error", "never_synced"];
48
+ export type ClientHealthSyncState = (typeof CLIENT_HEALTH_SYNC_STATES)[number];
49
+ /**
50
+ * Updater state. ABSENCE of the field means the client is too old to report
51
+ * it; `"unchecked"` means the updater exists but has not checked yet. Do not
52
+ * collapse the two (US-000 acceptance: Unchecked vs Absent must survive).
53
+ */
54
+ export declare const CLIENT_HEALTH_UPDATER_STATES: readonly ["unchecked", "up_to_date", "update_available", "update_downloading", "update_ready", "update_failed", "unsupported"];
55
+ export type ClientHealthUpdaterState = (typeof CLIENT_HEALTH_UPDATER_STATES)[number];
56
+ /** Closed failure/blocker reason codes — the ONLY reasons that cross the wire. */
57
+ export declare const CLIENT_HEALTH_FAILURE_REASONS: readonly ["SYNC_PAUSED", "CONFLICT_BLOCKED", "DESKTOP_OUTDATED", "CLI_OUTDATED", "CORE_OUTDATED", "AUTH_EXPIRED", "UPDATE_FAILED", "RUNNER_FAILED", "PERMISSION_DENIED", "DISK_FULL", "HEARTBEAT_STALE"];
58
+ export type ClientHealthFailureReason = (typeof CLIENT_HEALTH_FAILURE_REASONS)[number];
59
+ /**
60
+ * Repair/diagnostic command allowlist (US-006/US-009 consume these shapes).
61
+ * A desired-state interface, never a remote shell — any kind outside this set
62
+ * fails closed.
63
+ */
64
+ export declare const CLIENT_HEALTH_REPAIR_KINDS: readonly ["CHECK_NOW", "RETRY_SYNC", "RESUME_SYNC", "REPAIR_CLI", "UPDATE_CORE", "APPLY_DESKTOP_UPDATE", "RESTART_APP"];
65
+ export type ClientHealthRepairKind = (typeof CLIENT_HEALTH_REPAIR_KINDS)[number];
66
+ /** Command/receipt lifecycle states (queued → acknowledged → running → terminal). */
67
+ export declare const CLIENT_HEALTH_COMMAND_STATES: readonly ["queued", "acknowledged", "running", "succeeded", "failed", "expired"];
68
+ export type ClientHealthCommandState = (typeof CLIENT_HEALTH_COMMAND_STATES)[number];
69
+ /** Closed diagnostic probe identifiers (US-007). */
70
+ export declare const CLIENT_HEALTH_DIAGNOSTIC_CHECKS: readonly ["auth", "runner", "cli", "core", "updater", "sync", "conflicts", "storage", "permissions"];
71
+ export type ClientHealthDiagnosticCheck = (typeof CLIENT_HEALTH_DIAGNOSTIC_CHECKS)[number];
72
+ export declare const CLIENT_HEALTH_CHECK_STATUSES: readonly ["pass", "fail", "skip"];
73
+ export type ClientHealthCheckStatus = (typeof CLIENT_HEALTH_CHECK_STATUSES)[number];
74
+ export declare const CLIENT_HEALTH_MAX_STRING_LENGTH = 64;
75
+ export declare const CLIENT_HEALTH_MAX_CONSECUTIVE_FAILURES = 100000;
76
+ export declare const CLIENT_HEALTH_MAX_CONFLICT_COUNT = 100000;
77
+ export declare const CLIENT_HEALTH_MAX_CHECKS = 16;
78
+ /**
79
+ * The four client versions. All optional: a CLI-only installation has no
80
+ * desktop/syncRunner version, and older clients may omit any of them.
81
+ */
82
+ export interface ClientHealthVersions {
83
+ desktop?: string;
84
+ cli?: string;
85
+ core?: string;
86
+ syncRunner?: string;
87
+ }
88
+ export interface ClientHealthHeartbeat {
89
+ contractVersion: number;
90
+ /** Stable random installation identity — NOT a hardware fingerprint. */
91
+ installationId: string;
92
+ source: ClientHealthSource;
93
+ platform: ClientHealthPlatform;
94
+ arch: ClientHealthArch;
95
+ /** Client-side emit time (liveness); the server also stamps receive time. */
96
+ sentAt: string;
97
+ /** Monotonic per-installation sequence — older/replayed values never overwrite newer state. */
98
+ sequence: number;
99
+ versions: ClientHealthVersions;
100
+ syncState: ClientHealthSyncState;
101
+ /** Last time a sync RUN started — distinct from success. */
102
+ lastSyncAttemptAt?: string;
103
+ /** Advances only on genuine success (including no-change runs). */
104
+ lastSyncSuccessAt?: string;
105
+ consecutiveFailures: number;
106
+ conflictCount?: number;
107
+ updaterState?: ClientHealthUpdaterState;
108
+ failureReason?: ClientHealthFailureReason;
109
+ }
110
+ export interface ClientHealthCheckResult {
111
+ check: ClientHealthDiagnosticCheck;
112
+ status: ClientHealthCheckStatus;
113
+ /** Present only on `fail` — a closed reason code, never prose. */
114
+ reason?: ClientHealthFailureReason;
115
+ }
116
+ /**
117
+ * Receipt for a diagnostic or repair command (US-006+ store these). `checks`
118
+ * is only meaningful for `CHECK_NOW`.
119
+ */
120
+ export interface ClientHealthCommandReceipt {
121
+ contractVersion: number;
122
+ commandId: string;
123
+ installationId: string;
124
+ kind: ClientHealthRepairKind;
125
+ state: ClientHealthCommandState;
126
+ /** Monotonic per-command revision — out-of-order receipt updates fail closed downstream. */
127
+ revision: number;
128
+ occurredAt: string;
129
+ checks?: ClientHealthCheckResult[];
130
+ failureReason?: ClientHealthFailureReason;
131
+ }
132
+ export type ClientHealthContractErrorCode = "MISSING_FIELD" | "INVALID_TYPE" | "UNKNOWN_ENUM_VALUE" | "UNSAFE_VALUE" | "OUT_OF_BOUNDS" | "UNSUPPORTED_CONTRACT_VERSION";
133
+ export declare class ClientHealthContractError extends Error {
134
+ readonly code: ClientHealthContractErrorCode;
135
+ readonly field: string;
136
+ constructor(code: ClientHealthContractErrorCode, field: string, detail?: string);
137
+ }
138
+ /**
139
+ * Parse + validate one heartbeat. Unknown extra fields are ignored (additive
140
+ * tolerance); every consumed value fails closed on unsafe content.
141
+ */
142
+ export declare function parseClientHealthHeartbeat(input: unknown): ClientHealthHeartbeat;
143
+ /** Parse + validate one diagnostic/repair command receipt. Unknown kinds fail closed. */
144
+ export declare function parseClientHealthCommandReceipt(input: unknown): ClientHealthCommandReceipt;
145
+ /**
146
+ * True when an incoming heartbeat sequence may replace the stored snapshot.
147
+ * Equal or older sequences are late deliveries/replays: drop them (the server
148
+ * answers idempotently and the current snapshot remains unchanged).
149
+ */
150
+ export declare function shouldApplyHeartbeat(storedSequence: number | undefined, incomingSequence: number): boolean;
151
+ //# sourceMappingURL=client-health-contract.d.ts.map
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Client-health wire contract (client-sync-health-control-plane US-000).
3
+ *
4
+ * ONE versioned contract shared in spirit across four adapters:
5
+ *
6
+ * - hq-pro src/sync/server/client-health-contract.ts (reference)
7
+ * - hq-desktop-app crates/hq-desktop-core/src/client_health.rs
8
+ * - hq-cli src/utils/client-health-contract.ts (this file)
9
+ * - indigo-gtm-hq src/lib/client-health.ts
10
+ *
11
+ * Every adapter uses the SAME field names and the SAME enum values, verified
12
+ * per-repo against byte-equivalent canonical fixtures (here embedded in
13
+ * src/utils/client-health-contract.test.ts). The interface, not repository
14
+ * internals, is the shared test surface.
15
+ *
16
+ * Design rules (PRD notes + securityNotes):
17
+ *
18
+ * - ADDITIVE + tolerant of older clients: every non-identity field is
19
+ * optional-friendly, and unknown EXTRA fields are ignored (a newer client
20
+ * talking to an older server must not fail). Absence of `updaterState`
21
+ * means "an older client that never reported it"; the closed value
22
+ * "unchecked" means "the updater has not run yet" — the two are distinct
23
+ * and must survive the wire.
24
+ * - FAIL CLOSED on values: enums are closed sets. Free-form shell text,
25
+ * customer file paths, secret-shaped values, raw logs, and unknown repair
26
+ * kinds are rejected with a typed {@link ClientHealthContractError}.
27
+ * - Device identity: clients send only the stable random `installationId`.
28
+ * HMAC'ing of device identifiers happens server-side (US-001, modeled on
29
+ * scope-sync-mode-repository.ts) and never crosses this wire contract.
30
+ * - Monotonic `sequence`: the server keeps the highest sequence seen per
31
+ * installation and drops older/replayed heartbeats
32
+ * ({@link shouldApplyHeartbeat}).
33
+ */
34
+ // ─── Contract version ────────────────────────────────────────────────────────
35
+ export const CLIENT_HEALTH_CONTRACT_VERSION = 1;
36
+ // ─── Closed enums ────────────────────────────────────────────────────────────
37
+ export const CLIENT_HEALTH_PLATFORMS = ["macos", "windows", "linux"];
38
+ export const CLIENT_HEALTH_ARCHS = ["x64", "arm64"];
39
+ /** Which client produced the heartbeat. Desktop is always-on; CLI contributes per invocation. */
40
+ export const CLIENT_HEALTH_SOURCES = ["desktop", "cli"];
41
+ /**
42
+ * Current sync state of the installation. `paused` and `conflict_blocked` are
43
+ * first-class states (not failures folded into `error`) because support treats
44
+ * them differently: pause may be intentional and conflicts are user-owned.
45
+ */
46
+ export const CLIENT_HEALTH_SYNC_STATES = [
47
+ "idle",
48
+ "syncing",
49
+ "paused",
50
+ "conflict_blocked",
51
+ "error",
52
+ "never_synced",
53
+ ];
54
+ /**
55
+ * Updater state. ABSENCE of the field means the client is too old to report
56
+ * it; `"unchecked"` means the updater exists but has not checked yet. Do not
57
+ * collapse the two (US-000 acceptance: Unchecked vs Absent must survive).
58
+ */
59
+ export const CLIENT_HEALTH_UPDATER_STATES = [
60
+ "unchecked",
61
+ "up_to_date",
62
+ "update_available",
63
+ "update_downloading",
64
+ "update_ready",
65
+ "update_failed",
66
+ "unsupported",
67
+ ];
68
+ /** Closed failure/blocker reason codes — the ONLY reasons that cross the wire. */
69
+ export const CLIENT_HEALTH_FAILURE_REASONS = [
70
+ "SYNC_PAUSED",
71
+ "CONFLICT_BLOCKED",
72
+ "DESKTOP_OUTDATED",
73
+ "CLI_OUTDATED",
74
+ "CORE_OUTDATED",
75
+ "AUTH_EXPIRED",
76
+ "UPDATE_FAILED",
77
+ "RUNNER_FAILED",
78
+ "PERMISSION_DENIED",
79
+ "DISK_FULL",
80
+ "HEARTBEAT_STALE",
81
+ ];
82
+ /**
83
+ * Repair/diagnostic command allowlist (US-006/US-009 consume these shapes).
84
+ * A desired-state interface, never a remote shell — any kind outside this set
85
+ * fails closed.
86
+ */
87
+ export const CLIENT_HEALTH_REPAIR_KINDS = [
88
+ "CHECK_NOW",
89
+ "RETRY_SYNC",
90
+ "RESUME_SYNC",
91
+ "REPAIR_CLI",
92
+ "UPDATE_CORE",
93
+ "APPLY_DESKTOP_UPDATE",
94
+ "RESTART_APP",
95
+ ];
96
+ /** Command/receipt lifecycle states (queued → acknowledged → running → terminal). */
97
+ export const CLIENT_HEALTH_COMMAND_STATES = [
98
+ "queued",
99
+ "acknowledged",
100
+ "running",
101
+ "succeeded",
102
+ "failed",
103
+ "expired",
104
+ ];
105
+ /** Closed diagnostic probe identifiers (US-007). */
106
+ export const CLIENT_HEALTH_DIAGNOSTIC_CHECKS = [
107
+ "auth",
108
+ "runner",
109
+ "cli",
110
+ "core",
111
+ "updater",
112
+ "sync",
113
+ "conflicts",
114
+ "storage",
115
+ "permissions",
116
+ ];
117
+ export const CLIENT_HEALTH_CHECK_STATUSES = ["pass", "fail", "skip"];
118
+ // ─── Bounds ──────────────────────────────────────────────────────────────────
119
+ export const CLIENT_HEALTH_MAX_STRING_LENGTH = 64;
120
+ export const CLIENT_HEALTH_MAX_CONSECUTIVE_FAILURES = 100_000;
121
+ export const CLIENT_HEALTH_MAX_CONFLICT_COUNT = 100_000;
122
+ export const CLIENT_HEALTH_MAX_CHECKS = 16;
123
+ export class ClientHealthContractError extends Error {
124
+ code;
125
+ field;
126
+ constructor(code, field, detail) {
127
+ super(`client-health contract violation [${code}] at ${field}${detail ? `: ${detail}` : ""}`);
128
+ this.name = "ClientHealthContractError";
129
+ this.code = code;
130
+ this.field = field;
131
+ }
132
+ }
133
+ // ─── Value validators (fail closed) ──────────────────────────────────────────
134
+ const INSTALLATION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{7,63}$/;
135
+ const COMMAND_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{7,63}$/;
136
+ /** Same shape sync-auth-middleware.ts enforces on x-hq-*-version headers. */
137
+ const SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
138
+ const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
139
+ /**
140
+ * Secret-shaped prefixes rejected outright even when the charset is otherwise
141
+ * legal (JWTs, cloud keys, PATs, bot tokens fit in 64 bounded chars).
142
+ */
143
+ const SECRET_PREFIXES = ["AKIA", "ASIA", "ghp_", "gho_", "github_pat_", "xox", "sk-", "eyJ", "-----BEGIN"];
144
+ function assertSafeBoundedString(field, value) {
145
+ if (value === undefined || value === null)
146
+ throw new ClientHealthContractError("MISSING_FIELD", field);
147
+ if (typeof value !== "string")
148
+ throw new ClientHealthContractError("INVALID_TYPE", field, "expected string");
149
+ if (value.length === 0 || value.length > CLIENT_HEALTH_MAX_STRING_LENGTH) {
150
+ throw new ClientHealthContractError("OUT_OF_BOUNDS", field, `length must be 1..${CLIENT_HEALTH_MAX_STRING_LENGTH}`);
151
+ }
152
+ // Raw logs / free-form shell text: any whitespace, newline, or shell metacharacter.
153
+ if (/[\s;|&$<>`'"(){}*?!#=,]/.test(value)) {
154
+ throw new ClientHealthContractError("UNSAFE_VALUE", field, "free-form text is not a contract value");
155
+ }
156
+ // Customer paths: separators, home refs, drive letters.
157
+ if (value.includes("/") || value.includes("\\") || value.startsWith("~") || /^[A-Za-z]:/.test(value)) {
158
+ throw new ClientHealthContractError("UNSAFE_VALUE", field, "path-shaped value rejected");
159
+ }
160
+ for (const prefix of SECRET_PREFIXES) {
161
+ if (value.startsWith(prefix)) {
162
+ throw new ClientHealthContractError("UNSAFE_VALUE", field, "secret-shaped value rejected");
163
+ }
164
+ }
165
+ return value;
166
+ }
167
+ function assertVersion(field, value) {
168
+ const safe = assertSafeBoundedString(field, value);
169
+ if (!SEMVER.test(safe))
170
+ throw new ClientHealthContractError("UNSAFE_VALUE", field, "expected a SemVer version");
171
+ return safe;
172
+ }
173
+ function assertIsoUtc(field, value) {
174
+ const safe = assertSafeBoundedString(field, value);
175
+ if (!ISO_UTC.test(safe) || !Number.isFinite(Date.parse(safe))) {
176
+ throw new ClientHealthContractError("UNSAFE_VALUE", field, "expected ISO-8601 UTC timestamp");
177
+ }
178
+ return safe;
179
+ }
180
+ function assertEnum(field, value, allowed) {
181
+ const safe = assertSafeBoundedString(field, value);
182
+ if (!allowed.includes(safe)) {
183
+ throw new ClientHealthContractError("UNKNOWN_ENUM_VALUE", field, `"${safe.slice(0, 32)}" is not allowed`);
184
+ }
185
+ return safe;
186
+ }
187
+ function assertBoundedInt(field, value, max) {
188
+ if (value === undefined || value === null)
189
+ throw new ClientHealthContractError("MISSING_FIELD", field);
190
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) {
191
+ throw new ClientHealthContractError("INVALID_TYPE", field, "expected integer");
192
+ }
193
+ if (value < 0 || value > max)
194
+ throw new ClientHealthContractError("OUT_OF_BOUNDS", field);
195
+ return value;
196
+ }
197
+ function asRecord(field, value) {
198
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
199
+ throw new ClientHealthContractError("INVALID_TYPE", field, "expected object");
200
+ }
201
+ return value;
202
+ }
203
+ // ─── Parsers ─────────────────────────────────────────────────────────────────
204
+ /**
205
+ * Parse + validate one heartbeat. Unknown extra fields are ignored (additive
206
+ * tolerance); every consumed value fails closed on unsafe content.
207
+ */
208
+ export function parseClientHealthHeartbeat(input) {
209
+ const raw = asRecord("heartbeat", input);
210
+ const contractVersion = assertBoundedInt("contractVersion", raw.contractVersion, 1_000);
211
+ if (contractVersion < 1 || contractVersion > CLIENT_HEALTH_CONTRACT_VERSION) {
212
+ throw new ClientHealthContractError("UNSUPPORTED_CONTRACT_VERSION", "contractVersion");
213
+ }
214
+ const installationId = assertSafeBoundedString("installationId", raw.installationId);
215
+ if (!INSTALLATION_ID.test(installationId)) {
216
+ throw new ClientHealthContractError("UNSAFE_VALUE", "installationId");
217
+ }
218
+ const rawVersions = asRecord("versions", raw.versions);
219
+ const versions = {};
220
+ for (const key of ["desktop", "cli", "core", "syncRunner"]) {
221
+ if (rawVersions[key] !== undefined)
222
+ versions[key] = assertVersion(`versions.${key}`, rawVersions[key]);
223
+ }
224
+ const heartbeat = {
225
+ contractVersion,
226
+ installationId,
227
+ source: assertEnum("source", raw.source, CLIENT_HEALTH_SOURCES),
228
+ platform: assertEnum("platform", raw.platform, CLIENT_HEALTH_PLATFORMS),
229
+ arch: assertEnum("arch", raw.arch, CLIENT_HEALTH_ARCHS),
230
+ sentAt: assertIsoUtc("sentAt", raw.sentAt),
231
+ sequence: assertBoundedInt("sequence", raw.sequence, Number.MAX_SAFE_INTEGER),
232
+ versions,
233
+ syncState: assertEnum("syncState", raw.syncState, CLIENT_HEALTH_SYNC_STATES),
234
+ consecutiveFailures: assertBoundedInt("consecutiveFailures", raw.consecutiveFailures, CLIENT_HEALTH_MAX_CONSECUTIVE_FAILURES),
235
+ };
236
+ if (raw.lastSyncAttemptAt !== undefined)
237
+ heartbeat.lastSyncAttemptAt = assertIsoUtc("lastSyncAttemptAt", raw.lastSyncAttemptAt);
238
+ if (raw.lastSyncSuccessAt !== undefined)
239
+ heartbeat.lastSyncSuccessAt = assertIsoUtc("lastSyncSuccessAt", raw.lastSyncSuccessAt);
240
+ if (raw.conflictCount !== undefined) {
241
+ heartbeat.conflictCount = assertBoundedInt("conflictCount", raw.conflictCount, CLIENT_HEALTH_MAX_CONFLICT_COUNT);
242
+ }
243
+ if (raw.updaterState !== undefined) {
244
+ heartbeat.updaterState = assertEnum("updaterState", raw.updaterState, CLIENT_HEALTH_UPDATER_STATES);
245
+ }
246
+ if (raw.failureReason !== undefined) {
247
+ heartbeat.failureReason = assertEnum("failureReason", raw.failureReason, CLIENT_HEALTH_FAILURE_REASONS);
248
+ }
249
+ return heartbeat;
250
+ }
251
+ /** Parse + validate one diagnostic/repair command receipt. Unknown kinds fail closed. */
252
+ export function parseClientHealthCommandReceipt(input) {
253
+ const raw = asRecord("receipt", input);
254
+ const contractVersion = assertBoundedInt("contractVersion", raw.contractVersion, 1_000);
255
+ if (contractVersion < 1 || contractVersion > CLIENT_HEALTH_CONTRACT_VERSION) {
256
+ throw new ClientHealthContractError("UNSUPPORTED_CONTRACT_VERSION", "contractVersion");
257
+ }
258
+ const commandId = assertSafeBoundedString("commandId", raw.commandId);
259
+ if (!COMMAND_ID.test(commandId))
260
+ throw new ClientHealthContractError("UNSAFE_VALUE", "commandId");
261
+ const installationId = assertSafeBoundedString("installationId", raw.installationId);
262
+ if (!INSTALLATION_ID.test(installationId))
263
+ throw new ClientHealthContractError("UNSAFE_VALUE", "installationId");
264
+ const receipt = {
265
+ contractVersion,
266
+ commandId,
267
+ installationId,
268
+ kind: assertEnum("kind", raw.kind, CLIENT_HEALTH_REPAIR_KINDS),
269
+ state: assertEnum("state", raw.state, CLIENT_HEALTH_COMMAND_STATES),
270
+ revision: assertBoundedInt("revision", raw.revision, Number.MAX_SAFE_INTEGER),
271
+ occurredAt: assertIsoUtc("occurredAt", raw.occurredAt),
272
+ };
273
+ if (raw.failureReason !== undefined) {
274
+ receipt.failureReason = assertEnum("failureReason", raw.failureReason, CLIENT_HEALTH_FAILURE_REASONS);
275
+ }
276
+ if (raw.checks !== undefined) {
277
+ if (!Array.isArray(raw.checks))
278
+ throw new ClientHealthContractError("INVALID_TYPE", "checks", "expected array");
279
+ if (raw.checks.length > CLIENT_HEALTH_MAX_CHECKS)
280
+ throw new ClientHealthContractError("OUT_OF_BOUNDS", "checks");
281
+ receipt.checks = raw.checks.map((entry, index) => {
282
+ const check = asRecord(`checks[${index}]`, entry);
283
+ const result = {
284
+ check: assertEnum(`checks[${index}].check`, check.check, CLIENT_HEALTH_DIAGNOSTIC_CHECKS),
285
+ status: assertEnum(`checks[${index}].status`, check.status, CLIENT_HEALTH_CHECK_STATUSES),
286
+ };
287
+ if (check.reason !== undefined) {
288
+ result.reason = assertEnum(`checks[${index}].reason`, check.reason, CLIENT_HEALTH_FAILURE_REASONS);
289
+ }
290
+ return result;
291
+ });
292
+ }
293
+ return receipt;
294
+ }
295
+ // ─── Sequence discipline ─────────────────────────────────────────────────────
296
+ /**
297
+ * True when an incoming heartbeat sequence may replace the stored snapshot.
298
+ * Equal or older sequences are late deliveries/replays: drop them (the server
299
+ * answers idempotently and the current snapshot remains unchanged).
300
+ */
301
+ export function shouldApplyHeartbeat(storedSequence, incomingSequence) {
302
+ if (storedSequence === undefined)
303
+ return true;
304
+ return incomingSequence > storedSequence;
305
+ }
306
+ //# sourceMappingURL=client-health-contract.js.map