@indigoai-us/hq-cli 5.106.3 → 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,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
@@ -0,0 +1,134 @@
1
+ /**
2
+ * CLI client-health contribution (client-sync-health-control-plane US-003).
3
+ *
4
+ * Sends best-effort heartbeats to the authenticated control-plane endpoint
5
+ * `POST /v1/client-health/heartbeat` (US-001, hq-pro) using the US-000 wire
6
+ * contract adapter (`client-health-contract.ts` — never redefined here), so
7
+ * CLI-only users and version mismatches surface in the same installation
8
+ * health model the desktop app feeds.
9
+ *
10
+ * Discipline (mirrors `cli-telemetry.ts` `emitCliSessionStarted`):
11
+ * - Uses only an ALREADY-CACHED session: never refreshes tokens, never
12
+ * opens a browser.
13
+ * - Bounded by a short timeout ({@link CLIENT_HEALTH_TIMEOUT_MS}); every
14
+ * failure is swallowed. A heartbeat can never delay or change a command's
15
+ * result or exit code.
16
+ * - Machine identities send their ID token (custom:entityType claims), so
17
+ * the server attributes the snapshot to the machine principal, not a
18
+ * human. The CLI never sends identity fields in the body — the server
19
+ * resolves the owning principal from the verified token (US-001 rejects
20
+ * caller-supplied identity outright).
21
+ * - Sync state is read through the engine's `listJournals()` — the ONLY
22
+ * correct enumeration of per-scope journals (single-path reconstruction
23
+ * regressed before: feedback_9fbf1f82 / feedback_46288b7b).
24
+ *
25
+ * Local state (installation identity + monotonic sequence + sync outcome
26
+ * counters) lives at `{stateDir}/cli-client-health.json` next to the sync
27
+ * journals. The installation ID is a stable RANDOM identifier — never a
28
+ * hardware fingerprint; HMAC'ing happens server-side (US-001).
29
+ */
30
+ import { isExpiring, loadCachedTokens } from "./cognito-session.js";
31
+ import { type VersionInfo } from "./feedback-versions.js";
32
+ import { type ClientHealthArch, type ClientHealthHeartbeat, type ClientHealthPlatform } from "./client-health-contract.js";
33
+ /** Same bound as CLI telemetry: a heartbeat may never stall a command. */
34
+ export declare const CLIENT_HEALTH_TIMEOUT_MS = 1200;
35
+ export declare const CLIENT_HEALTH_STATE_FILE = "cli-client-health.json";
36
+ export interface CliClientHealthState {
37
+ /** Stable random installation identity — regenerated only if invalid. */
38
+ installationId: string;
39
+ /** Highest heartbeat sequence this installation has emitted. */
40
+ sequence: number;
41
+ /** Consecutive failed CLI sync commands since the last success. */
42
+ consecutiveFailures: number;
43
+ lastSyncAttemptAt?: string;
44
+ lastSyncSuccessAt?: string;
45
+ }
46
+ export declare function newInstallationId(): string;
47
+ /**
48
+ * Load (or initialize) the persisted installation state. Any read/parse
49
+ * problem or invalid field degrades to a fresh value — health capture must
50
+ * never break a CLI command.
51
+ */
52
+ export declare function loadClientHealthState(stateDir: string): CliClientHealthState;
53
+ /** Best-effort persist — a read-only disk must never fail a command. */
54
+ export declare function persistClientHealthState(stateDir: string, state: CliClientHealthState): void;
55
+ /**
56
+ * Strictly-increasing sequence: wall-clock ms when it is ahead (keeps
57
+ * separate processes ordered without locking), else last + 1.
58
+ */
59
+ export declare function nextHeartbeatSequence(state: CliClientHealthState, nowMs: number): number;
60
+ export declare function detectClientHealthPlatform(platform?: NodeJS.Platform): ClientHealthPlatform | null;
61
+ export declare function detectClientHealthArch(arch?: string): ClientHealthArch | null;
62
+ interface JournalLike {
63
+ journal: {
64
+ lastSync?: string | null;
65
+ };
66
+ }
67
+ /**
68
+ * Latest engine-recorded sync time across ALL journal shards, normalized to
69
+ * contract ISO-UTC. Null when nothing has ever synced.
70
+ */
71
+ export declare function latestJournalSyncTime(journals: readonly JournalLike[]): string | null;
72
+ export type ClientHealthEventKind = "invocation" | "sync_attempt" | "sync_success" | "sync_failure";
73
+ export interface BuildCliHeartbeatInput {
74
+ kind: ClientHealthEventKind;
75
+ state: CliClientHealthState;
76
+ sequence: number;
77
+ versions: VersionInfo;
78
+ /** Latest engine journal sync time (see {@link latestJournalSyncTime}). */
79
+ journalLastSyncAt: string | null;
80
+ now: Date;
81
+ platform?: ClientHealthPlatform | null;
82
+ arch?: ClientHealthArch | null;
83
+ }
84
+ /**
85
+ * Build one contract-valid CLI heartbeat, or null when this environment
86
+ * cannot be represented in the closed contract enums (unknown platform/arch)
87
+ * — in which case nothing is sent, per fail-closed design.
88
+ */
89
+ export declare function buildCliHeartbeat(input: BuildCliHeartbeatInput): ClientHealthHeartbeat | null;
90
+ export type HeartbeatPoster = (heartbeat: ClientHealthHeartbeat, token: string, signal: AbortSignal) => Promise<unknown>;
91
+ export interface ClientHealthDeps {
92
+ now?: () => Date;
93
+ stateDir?: () => string;
94
+ loadTokens?: typeof loadCachedTokens;
95
+ machineIdentity?: () => boolean;
96
+ expiring?: typeof isExpiring;
97
+ versions?: () => VersionInfo;
98
+ journals?: () => readonly JournalLike[];
99
+ post?: HeartbeatPoster;
100
+ timeoutMs?: number;
101
+ }
102
+ /**
103
+ * Apply one health event: update local installation state, then — only when a
104
+ * healthy cached session exists — emit one bounded, fully-swallowed heartbeat.
105
+ * NEVER throws and never refreshes auth.
106
+ *
107
+ * The ENTIRE async contribution — not just the POST — is bounded by
108
+ * `deps.timeoutMs` (default {@link CLIENT_HEALTH_TIMEOUT_MS}): the commander
109
+ * preAction hook and the post-sync `succeeded()`/`failed()` calls AWAIT this
110
+ * promise on the command path, so a hung dependency (e.g. a poster that
111
+ * ignores its abort signal) must never delay a command past the bound. On
112
+ * timeout the pending work is abandoned silently.
113
+ */
114
+ export declare function contributeClientHealth(kind: ClientHealthEventKind, deps?: ClientHealthDeps): Promise<void>;
115
+ /**
116
+ * Fire-once-per-process invocation heartbeat (commander preAction seam, next
117
+ * to `emitCliSessionStarted`). Reports the real CLI + Core versions, platform,
118
+ * stable installation ID, and the invocation timestamp.
119
+ */
120
+ export declare function reportCliClientHealthInvocation(deps?: ClientHealthDeps): Promise<void>;
121
+ export interface SyncHealthReport {
122
+ /** Report the terminal outcome. Bounded, swallowed, safe to await. */
123
+ succeeded(): Promise<void>;
124
+ failed(): Promise<void>;
125
+ }
126
+ /**
127
+ * Report a CLI sync command run: an attempt heartbeat now (fire-and-forget)
128
+ * and a final success/failure heartbeat when the caller resolves the outcome.
129
+ * This is DIRECT reporting from the sync command — never reconstructed from
130
+ * cli_session_started analytics.
131
+ */
132
+ export declare function beginSyncHealthReport(deps?: ClientHealthDeps): SyncHealthReport;
133
+ export {};
134
+ //# sourceMappingURL=client-health.d.ts.map