@indigoai-us/hq-cloud 6.14.3 → 6.14.4

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.
@@ -2,9 +2,9 @@
2
2
  * hq reindex — surfaces namespaced skills as Claude Code skill wrappers under
3
3
  * .claude/skills/<ns>:<skill>/ (one symlink per source file), mirrors
4
4
  * personal/{knowledge,policies,workers,settings}/<entry> into core/<type>/,
5
- * prunes orphan wrappers + legacy command symlinks, captures this root's Claude
6
- * Code session logs into workspace/.session-logs, and regenerates the workers
7
- * registry.
5
+ * prunes orphan wrappers + legacy command symlinks, captures this HQ tree's
6
+ * coding-harness session logs (Claude Code / Codex / Grok) into
7
+ * workspace/.session-logs/<harness>/, and regenerates the workers registry.
8
8
  *
9
9
  * The logic historically lived in a bundled bash script (scripts/reindex.sh)
10
10
  * that this module exec'd via `bash`. It is now implemented natively in
@@ -199,46 +199,216 @@ function safeSymlink(target: string, linkPath: string, label: string): boolean {
199
199
  }
200
200
  }
201
201
 
202
- // Copy every file/folder under Claude Code's session-log directory for this HQ
203
- // root (`<claude-config>/projects/<encoded-root>`) into
204
- // `workspace/.session-logs`, so the raw session transcripts are captured inside
202
+ // Capture the raw session transcripts of every supported coding harness for
203
+ // this HQ tree into `workspace/.session-logs/<harness>/`, so they live inside
205
204
  // the HQ tree (where sync/search can see them) on each reindex.
206
205
  //
207
- // Contract (per the feature request):
208
- // - copies ALL files and folders from the projects dir into the dest,
209
- // - OVERWRITES existing dest files (force), and
210
- // - NEVER deletes dest entries that lack a source counterpart — this is a
211
- // one-way overlay, not a mirror, so anything already parked in
212
- // workspace/.session-logs survives even after Claude Code rotates or prunes
213
- // its own projects dir.
206
+ // Scope "this HQ tree" = the HQ root cwd OR any working directory UNDER it
207
+ // (worktrees, repo subdirs). Harnesses key their logs by cwd differently, so
208
+ // each helper re-derives the same scope in its own encoding.
214
209
  //
215
- // Best-effort: any failure warns to stderr and is swallowed so it can never
216
- // fail the reindex. `<claude-config>` honors CLAUDE_CONFIG_DIR, else ~/.claude.
210
+ // Contract (per the feature request), applied by every helper:
211
+ // - copies ALL matching files/folders into `.session-logs/<harness>/…`,
212
+ // - OVERWRITES existing dest files, and
213
+ // - NEVER deletes dest entries that lack a source counterpart — a one-way
214
+ // overlay, not a mirror, so anything already parked in `.session-logs`
215
+ // survives even after a harness rotates or prunes its own store.
216
+ //
217
+ // Best-effort throughout: any failure warns to stderr and is swallowed so it
218
+ // can never fail the reindex. Each harness is independent — one failing must
219
+ // not stop the others. Home dirs honor CLAUDE_CONFIG_DIR / CODEX_HOME /
220
+ // GROK_HOME, else ~/.claude, ~/.codex, ~/.grok.
217
221
  function copySessionLogs(root: string): void {
218
- // Claude Code names a project's log dir by replacing every non-alphanumeric
219
- // character in the project's cwd with '-' (e.g. /home/ec2-user/hq ->
220
- // -home-ec2-user-hq). Re-derive that slug so we target THIS root's logs.
221
- const slug = root.replace(/[^a-zA-Z0-9]/g, "-");
222
- const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
223
- const src = path.join(claudeConfigDir, "projects", slug);
224
- if (!isDir(src)) return; // no session logs for this root yet — nothing to do
222
+ const destBase = path.join(root, "workspace", ".session-logs");
223
+ for (const capture of [
224
+ copyClaudeSessionLogs,
225
+ copyCodexSessionLogs,
226
+ copyGrokSessionLogs,
227
+ ]) {
228
+ try {
229
+ capture(root, destBase);
230
+ } catch (err) {
231
+ warn(`reindex: session-log capture step failed (${(err as Error).message}); skipping.`);
232
+ }
233
+ }
234
+ }
225
235
 
226
- const dest = path.join(root, "workspace", ".session-logs");
227
- if (!safeMkdir(dest, "workspace/.session-logs directory")) return;
236
+ /** `fs.statSync` without throwing (follows symlinks; null when missing). */
237
+ function statOrNull(p: string): fs.Stats | null {
238
+ try {
239
+ return fs.statSync(p);
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
228
244
 
245
+ // Overlay-copy a whole directory tree: mkdir dest, then recursive+force cpSync
246
+ // (overwrite existing, never prune dest-only entries). Best-effort — warns and
247
+ // returns on any failure. Shared by the Claude + Grok per-cwd-folder captures.
248
+ function copyTreeOverlay(src: string, dest: string, label: string): void {
249
+ if (!safeMkdir(dest, `${label} directory`)) return;
229
250
  try {
230
- // recursive: copy the whole tree; force: overwrite existing dest files.
231
- // cpSync copies src INTO dest and never removes dest-only entries — exactly
232
- // the "overwrite, but don't delete extras" contract we want.
233
251
  fs.cpSync(src, dest, { recursive: true, force: true });
234
252
  } catch (err) {
235
253
  warn(
236
- `reindex: could not copy session logs from '${src}' to '${dest}' ` +
254
+ `reindex: could not copy ${label} from '${src}' to '${dest}' ` +
237
255
  `(${(err as NodeJS.ErrnoException).code ?? "error"}: ${(err as Error).message}); skipping.`,
238
256
  );
239
257
  }
240
258
  }
241
259
 
260
+ // Claude Code: `<claude-config>/projects/<encoded-cwd>/…`, where the cwd is
261
+ // encoded by replacing every non-alphanumeric char with '-'. Because that map
262
+ // is per-character, a descendant cwd's dir name is exactly the root's encoded
263
+ // name + '-' + <encoded-subpath> — so "this HQ tree" is the exact-root dir OR
264
+ // any dir whose name starts with `<slug>-`. Copy each into `.session-logs/claude/`.
265
+ function copyClaudeSessionLogs(root: string, destBase: string): void {
266
+ const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
267
+ const projectsDir = path.join(configDir, "projects");
268
+ if (!isDir(projectsDir)) return;
269
+
270
+ const slug = root.replace(/[^a-zA-Z0-9]/g, "-");
271
+ for (const entry of globEntries(projectsDir)) {
272
+ if (entry !== slug && !entry.startsWith(`${slug}-`)) continue;
273
+ const src = path.join(projectsDir, entry);
274
+ if (!isDir(src)) continue;
275
+ copyTreeOverlay(src, path.join(destBase, "claude", entry), "claude session logs");
276
+ }
277
+ }
278
+
279
+ // Grok: `<grok-home>/sessions/<url-encoded-cwd>/<session-uuid>/…`, where the cwd
280
+ // is URL-encoded (encodeURIComponent: '/' -> '%2F'). A descendant cwd's key is
281
+ // the root's key + '%2F' + <encoded-subpath>, so match the exact-root key OR
282
+ // that prefix. Copy each matching cwd folder into `.session-logs/grok/`.
283
+ function copyGrokSessionLogs(root: string, destBase: string): void {
284
+ const grokHome = process.env.GROK_HOME || path.join(os.homedir(), ".grok");
285
+ const sessionsDir = path.join(grokHome, "sessions");
286
+ if (!isDir(sessionsDir)) return;
287
+
288
+ const key = encodeURIComponent(root);
289
+ for (const entry of globEntries(sessionsDir)) {
290
+ if (entry !== key && !entry.startsWith(`${key}%2F`)) continue;
291
+ const src = path.join(sessionsDir, entry);
292
+ if (!isDir(src)) continue;
293
+ copyTreeOverlay(src, path.join(destBase, "grok", entry), "grok session logs");
294
+ }
295
+ }
296
+
297
+ // Codex: `<codex-home>/sessions/YYYY/MM/DD/rollout-*.jsonl` — a DATE tree, not
298
+ // per-project. The working dir is only recorded inside each rollout's first
299
+ // line (`session_meta`, at `payload.cwd`). Walk the tree and capture a rollout
300
+ // iff its recorded cwd is the HQ root or under it, mirroring the date-relative
301
+ // path under `.session-logs/codex/`. A rollout already copied and unchanged is
302
+ // skipped by mtime, so a steady-state reindex only classifies newly-written
303
+ // rollouts — the full-tree cwd scan is a one-time first-run cost.
304
+ function copyCodexSessionLogs(root: string, destBase: string): void {
305
+ const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
306
+ const sessionsDir = path.join(codexHome, "sessions");
307
+ if (!isDir(sessionsDir)) return;
308
+
309
+ const destDir = path.join(destBase, "codex");
310
+ const rootPrefix = root.endsWith(path.sep) ? root : root + path.sep;
311
+
312
+ for (const abs of walkFiles(sessionsDir)) {
313
+ if (!abs.endsWith(".jsonl")) continue;
314
+ const srcStat = statOrNull(abs);
315
+ if (!srcStat) continue;
316
+
317
+ const dest = path.join(destDir, path.relative(sessionsDir, abs));
318
+ // Cheap skip: already captured and unchanged since (dest mtime is the copy
319
+ // time, so it stays >= src mtime until the rollout is appended to again).
320
+ const destStat = statOrNull(dest);
321
+ if (destStat && destStat.mtimeMs >= srcStat.mtimeMs) continue;
322
+
323
+ const cwd = readCodexRolloutCwd(abs);
324
+ if (cwd === null) continue;
325
+ if (cwd !== root && !cwd.startsWith(rootPrefix)) continue;
326
+
327
+ if (!safeMkdir(path.dirname(dest), "codex session-log dir")) continue;
328
+ try {
329
+ fs.copyFileSync(abs, dest);
330
+ } catch (err) {
331
+ warn(
332
+ `reindex: could not copy codex session log '${abs}' -> '${dest}' ` +
333
+ `(${(err as NodeJS.ErrnoException).code ?? "error"}: ${(err as Error).message}); skipping.`,
334
+ );
335
+ }
336
+ }
337
+ }
338
+
339
+ // Recursively collect every regular file under `dir` (absolute paths). Symlinks
340
+ // are not followed (lstat gate) so a stray link can't escape the tree or loop.
341
+ // Missing/unreadable dirs yield []. Best-effort — mirrors globEntries' tolerance.
342
+ function walkFiles(dir: string): string[] {
343
+ const out: string[] = [];
344
+ let names: string[];
345
+ try {
346
+ names = fs.readdirSync(dir);
347
+ } catch {
348
+ return out;
349
+ }
350
+ for (const name of names) {
351
+ const abs = path.join(dir, name);
352
+ const st = lstatOrNull(abs);
353
+ if (!st) continue;
354
+ if (st.isDirectory()) out.push(...walkFiles(abs));
355
+ else if (st.isFile()) out.push(abs);
356
+ }
357
+ return out;
358
+ }
359
+
360
+ // Extract `payload.cwd` (Codex ≥ the session_meta format; falls back to a
361
+ // top-level `cwd`) from a rollout's first line, reading only that line. Codex
362
+ // records cwd early, before the large `base_instructions` blob, but the line
363
+ // can still be tens of KB — so read up to a generous cap and stop at the first
364
+ // newline. Returns null on any parse/read failure (the rollout is then skipped).
365
+ function readCodexRolloutCwd(file: string): string | null {
366
+ const line = readFirstLine(file, 4 * 1024 * 1024);
367
+ if (line === null) return null;
368
+ try {
369
+ const obj = JSON.parse(line) as { cwd?: unknown; payload?: { cwd?: unknown } };
370
+ const cwd = obj?.payload?.cwd ?? obj?.cwd;
371
+ return typeof cwd === "string" ? cwd : null;
372
+ } catch {
373
+ return null;
374
+ }
375
+ }
376
+
377
+ // Read the first line of `file` (up to `capBytes`), returning it WITHOUT the
378
+ // trailing newline, or null on error / empty. Byte-accumulates and decodes once
379
+ // so a multibyte char split across a read boundary can't corrupt the result.
380
+ function readFirstLine(file: string, capBytes: number): string | null {
381
+ let fd: number | null = null;
382
+ try {
383
+ fd = fs.openSync(file, "r");
384
+ const chunks: Buffer[] = [];
385
+ const chunk = Buffer.allocUnsafe(65536);
386
+ let total = 0;
387
+ while (total < capBytes) {
388
+ const n = fs.readSync(fd, chunk, 0, chunk.length, total);
389
+ if (n <= 0) break;
390
+ const nl = chunk.indexOf(0x0a, 0);
391
+ if (nl !== -1 && nl < n) {
392
+ chunks.push(Buffer.from(chunk.subarray(0, nl)));
393
+ return Buffer.concat(chunks).toString("utf8");
394
+ }
395
+ chunks.push(Buffer.from(chunk.subarray(0, n)));
396
+ total += n;
397
+ }
398
+ return chunks.length ? Buffer.concat(chunks).toString("utf8") : null;
399
+ } catch {
400
+ return null;
401
+ } finally {
402
+ if (fd !== null) {
403
+ try {
404
+ fs.closeSync(fd);
405
+ } catch {
406
+ /* best-effort */
407
+ }
408
+ }
409
+ }
410
+ }
411
+
242
412
  // --- legacy `.claude/commands/<ns>/<skill>.md` symlink matcher --------------
243
413
  // Mirrors the bash `case` patterns; `*` matches any chars (including `/`).
244
414
  function isLegacyCommandTarget(t: string): boolean {
@@ -3518,6 +3518,17 @@ describe("reportNewFilesToNotify chunking (server cap = 1000 files/report)", ()
3518
3518
  (fetchMock.mock.calls as Array<[string, RequestInit?]>)
3519
3519
  .filter(([u]) => String(u).includes("/v1/notify/file-added"))
3520
3520
  .map(([, init]) => JSON.parse(String(init!.body)).files.length);
3521
+ const notificationTelemetryEvents = (
3522
+ fetchMock: ReturnType<typeof vi.fn>,
3523
+ ): Array<{ eventName: string; properties?: Record<string, unknown> }> =>
3524
+ (fetchMock.mock.calls as Array<[string, RequestInit?]>)
3525
+ .filter(([u]) => String(u).includes("/v1/telemetry/events"))
3526
+ .flatMap(([, init]) =>
3527
+ JSON.parse(String(init!.body)).events as Array<{
3528
+ eventName: string;
3529
+ properties?: Record<string, unknown>;
3530
+ }>,
3531
+ );
3521
3532
 
3522
3533
  afterEach(() => {
3523
3534
  vi.unstubAllGlobals();
@@ -3580,6 +3591,85 @@ describe("reportNewFilesToNotify chunking (server cap = 1000 files/report)", ()
3580
3591
  expect(notifyBatchSizes(fetchMock)).toEqual([1000, 1000, 1]);
3581
3592
  });
3582
3593
 
3594
+ it("does not emit notification telemetry after a successful report", async () => {
3595
+ const fetchMock = vi.fn().mockImplementation(async (url: string) => {
3596
+ if (url.includes("/v1/notify/file-added")) {
3597
+ return { ok: true, status: 200, text: async () => "" };
3598
+ }
3599
+ return {
3600
+ ok: true,
3601
+ status: 200,
3602
+ text: async () => JSON.stringify({ ok: true, written: 1, skipped: [] }),
3603
+ };
3604
+ });
3605
+ vi.stubGlobal("fetch", fetchMock);
3606
+
3607
+ await reportNewFilesToNotify(cfg, "cmp_X", "acme", mkFiles(1));
3608
+ await new Promise((resolve) => setTimeout(resolve, 0));
3609
+
3610
+ expect(notificationTelemetryEvents(fetchMock)).toEqual([]);
3611
+ });
3612
+
3613
+ it("emits failure telemetry for a non-successful notification response", async () => {
3614
+ const fetchMock = vi.fn().mockImplementation(async (url: string) => {
3615
+ if (url.includes("/v1/notify/file-added")) {
3616
+ return { ok: false, status: 503, text: async () => "unavailable" };
3617
+ }
3618
+ return {
3619
+ ok: true,
3620
+ status: 200,
3621
+ text: async () => JSON.stringify({ ok: true, written: 1, skipped: [] }),
3622
+ };
3623
+ });
3624
+ vi.stubGlobal("fetch", fetchMock);
3625
+
3626
+ await reportNewFilesToNotify(cfg, "cmp_X", "acme", mkFiles(1));
3627
+ await new Promise((resolve) => setTimeout(resolve, 0));
3628
+
3629
+ expect(notificationTelemetryEvents(fetchMock)).toEqual([
3630
+ expect.objectContaining({
3631
+ eventName: "new_files_notification_reported",
3632
+ properties: {
3633
+ status: "failure",
3634
+ fileCount: 1,
3635
+ batchIndex: 1,
3636
+ batchCount: 1,
3637
+ statusCode: 503,
3638
+ },
3639
+ }),
3640
+ ]);
3641
+ });
3642
+
3643
+ it("emits error-class telemetry when notification reporting throws", async () => {
3644
+ const fetchMock = vi.fn().mockImplementation(async (url: string) => {
3645
+ if (url.includes("/v1/notify/file-added")) {
3646
+ throw new TypeError("network down");
3647
+ }
3648
+ return {
3649
+ ok: true,
3650
+ status: 200,
3651
+ text: async () => JSON.stringify({ ok: true, written: 1, skipped: [] }),
3652
+ };
3653
+ });
3654
+ vi.stubGlobal("fetch", fetchMock);
3655
+
3656
+ await reportNewFilesToNotify(cfg, "cmp_X", "acme", mkFiles(1));
3657
+ await new Promise((resolve) => setTimeout(resolve, 0));
3658
+
3659
+ expect(notificationTelemetryEvents(fetchMock)).toEqual([
3660
+ expect.objectContaining({
3661
+ eventName: "new_files_notification_reported",
3662
+ properties: {
3663
+ status: "failure",
3664
+ fileCount: 1,
3665
+ batchIndex: 1,
3666
+ batchCount: 1,
3667
+ errorClass: "TypeError",
3668
+ },
3669
+ }),
3670
+ ]);
3671
+ });
3672
+
3583
3673
  it("no request at all when there are no new files", async () => {
3584
3674
  const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => "" });
3585
3675
  vi.stubGlobal("fetch", fetchMock);
package/src/cli/sync.ts CHANGED
@@ -722,18 +722,20 @@ export async function reportNewFilesToNotify(
722
722
  }),
723
723
  signal: controller.signal,
724
724
  });
725
- void emitCloudTelemetry(telemetryClient, {
726
- eventName: "new_files_notification_reported",
727
- source: "hq-sync",
728
- companyUid,
729
- properties: {
730
- result: response.ok ? "success" : "failure",
731
- fileCount: batch.length,
732
- batchIndex,
733
- batchCount,
734
- statusCode: response.status,
735
- },
736
- }, { claims: telemetryClaims });
725
+ if (!response.ok) {
726
+ void emitCloudTelemetry(telemetryClient, {
727
+ eventName: "new_files_notification_reported",
728
+ source: "hq-sync",
729
+ companyUid,
730
+ properties: {
731
+ status: "failure",
732
+ fileCount: batch.length,
733
+ batchIndex,
734
+ batchCount,
735
+ statusCode: response.status,
736
+ },
737
+ }, { claims: telemetryClaims });
738
+ }
737
739
  } catch (err) {
738
740
  // Best-effort per chunk: never let notification reporting affect the sync
739
741
  // result, and a failed chunk must not abort the remaining chunks.
@@ -743,11 +745,11 @@ export async function reportNewFilesToNotify(
743
745
  source: "hq-sync",
744
746
  companyUid,
745
747
  properties: {
746
- result: "failure",
748
+ status: "failure",
747
749
  fileCount: batch.length,
748
750
  batchIndex,
749
751
  batchCount,
750
- errorCode: safeErrorName(err),
752
+ errorClass: safeErrorName(err),
751
753
  },
752
754
  }, { claims: telemetryClaims });
753
755
  } finally {
@@ -12,6 +12,7 @@ import {
12
12
  import { StaticFlagProvider } from "./sync/feature-flags.js";
13
13
  import type { PushEvent } from "./sync/push-event.js";
14
14
  import type { PushTransport } from "./sync/push-transport.js";
15
+ import type { TelemetryEventsBatch } from "./vault-client.js";
15
16
 
16
17
  /**
17
18
  * US-001 — Phase 1 test harness: watch-triggered push seam + latency assertion.
@@ -736,4 +737,62 @@ describe("PushEventEmitter — directory and delete tombstone handling", () => {
736
737
  expect(published[0]).not.toHaveProperty("contentHash");
737
738
  expect(published[0]).not.toHaveProperty("mtime");
738
739
  });
740
+
741
+ it("emits push telemetry only when publication fails", async () => {
742
+ const posts: TelemetryEventsBatch[] = [];
743
+ const telemetryClient = {
744
+ postTelemetryEvents: vi.fn(async (batch: TelemetryEventsBatch) => {
745
+ posts.push(batch);
746
+ return { ok: true, written: batch.events.length, skipped: [] };
747
+ }),
748
+ };
749
+ const changed = path.join(dir, "changed.md");
750
+ fs.writeFileSync(changed, "changed");
751
+ const success = new PushEventEmitter({
752
+ originTenantId: "tenant-indigo",
753
+ originDeviceId: "device-a",
754
+ transport: {
755
+ start: async () => {},
756
+ dispose: async () => {},
757
+ connected: true,
758
+ publish: async () => {},
759
+ },
760
+ flagProvider: new StaticFlagProvider(["tenant-indigo"]),
761
+ telemetryClient,
762
+ });
763
+
764
+ await success.emitForBatch({ paths: new Map([[changed, "changed.md"]]) });
765
+ await flushImmediate();
766
+ expect(posts).toEqual([]);
767
+
768
+ const failed = new PushEventEmitter({
769
+ originTenantId: "tenant-indigo",
770
+ originDeviceId: "device-a",
771
+ transport: {
772
+ start: async () => {},
773
+ dispose: async () => {},
774
+ connected: true,
775
+ publish: async () => {
776
+ throw new Error("network down");
777
+ },
778
+ },
779
+ flagProvider: new StaticFlagProvider(["tenant-indigo"]),
780
+ telemetryClient,
781
+ onError: vi.fn(),
782
+ });
783
+
784
+ await failed.emitForBatch({ paths: new Map([[changed, "changed.md"]]) });
785
+ await flushImmediate();
786
+ expect(posts.flatMap((post) => post.events)).toEqual([
787
+ expect.objectContaining({
788
+ eventName: "push_event_failed",
789
+ properties: {
790
+ kind: "upsert",
791
+ count: 1,
792
+ status: "failure",
793
+ stage: "publish",
794
+ },
795
+ }),
796
+ ]);
797
+ });
739
798
  });
package/src/watcher.ts CHANGED
@@ -989,7 +989,7 @@ export class PushEventEmitter {
989
989
  this.onError(err instanceof Error ? err : new Error(String(err)), {
990
990
  relativePath,
991
991
  });
992
- this.emitPublishTelemetry("push_event_failed", "unknown", "capture");
992
+ this.emitPublishFailureTelemetry("unknown", "capture");
993
993
  return;
994
994
  }
995
995
 
@@ -1025,31 +1025,29 @@ export class PushEventEmitter {
1025
1025
 
1026
1026
  try {
1027
1027
  await this.transport.publish(event);
1028
- this.emitPublishTelemetry("push_event_published", event.kind, "publish");
1029
1028
  } catch (err) {
1030
1029
  // Push failure (network / non-2xx / timeout). The cadence poll is the
1031
1030
  // safety net — log + continue, never throw.
1032
1031
  this.onError(err instanceof Error ? err : new Error(String(err)), {
1033
1032
  relativePath,
1034
1033
  });
1035
- this.emitPublishTelemetry("push_event_failed", event.kind, "publish");
1034
+ this.emitPublishFailureTelemetry(event.kind, "publish");
1036
1035
  }
1037
1036
  }
1038
1037
 
1039
- private emitPublishTelemetry(
1040
- eventName: "push_event_published" | "push_event_failed",
1038
+ private emitPublishFailureTelemetry(
1041
1039
  kind: string,
1042
- stage: string,
1040
+ stage: "capture" | "publish",
1043
1041
  ): void {
1044
1042
  void emitCloudTelemetry(
1045
1043
  this.telemetryClient,
1046
1044
  {
1047
- eventName,
1045
+ eventName: "push_event_failed",
1048
1046
  source: "watcher",
1049
1047
  properties: {
1050
1048
  kind,
1051
1049
  count: 1,
1052
- result: eventName === "push_event_published" ? "success" : "failure",
1050
+ status: "failure",
1053
1051
  stage,
1054
1052
  },
1055
1053
  },