@indigoai-us/hq-cloud 6.11.14 → 6.11.15

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.
@@ -107,6 +107,7 @@ import { collectAndSendSkillTelemetry } from "../skill-telemetry.js";
107
107
  import { reindexAfterSync } from "../qmd-reindex.js";
108
108
  import { pruneConflictIndex } from "../lib/conflict-index.js";
109
109
  import { getOrCreateMachineId } from "../lib/machine-id.js";
110
+ import { describeError } from "../lib/describe-error.js";
110
111
  import type { Clock, TreeChangeBatch } from "../watcher.js";
111
112
  import type { PushReceiver, SyncEngineFn } from "../sync/push-receiver.js";
112
113
  import {
@@ -404,6 +405,53 @@ export interface VaultClientSurface {
404
405
  listMyExplicitGrants?: (companyUid: string) => Promise<ExplicitGrant[]>;
405
406
  }
406
407
 
408
+ interface RunnerDiagnostic {
409
+ component: string;
410
+ event: string;
411
+ message: string;
412
+ err?: unknown;
413
+ context?: Record<string, unknown>;
414
+ }
415
+
416
+ type RunnerDiagnosticReporter = (diagnostic: RunnerDiagnostic) => void;
417
+
418
+ function errorDetails(err: unknown): Record<string, unknown> {
419
+ const details: Record<string, unknown> = { message: describeError(err) };
420
+ if (err instanceof Error) {
421
+ if (err.name) details.name = err.name;
422
+ const code = (err as NodeJS.ErrnoException).code;
423
+ if (code) details.code = code;
424
+ }
425
+ return details;
426
+ }
427
+
428
+ function createRunnerDiagnosticReporter(
429
+ stderr: { write: (chunk: string) => boolean | void },
430
+ ): RunnerDiagnosticReporter {
431
+ return (diagnostic) => {
432
+ try {
433
+ const payload: Record<string, unknown> = {
434
+ type: "error",
435
+ diagnostic: true,
436
+ component: diagnostic.component,
437
+ event: diagnostic.event,
438
+ path: `(${diagnostic.component})`,
439
+ message:
440
+ diagnostic.err === undefined
441
+ ? diagnostic.message
442
+ : `${diagnostic.message}: ${describeError(diagnostic.err)}`,
443
+ ...(diagnostic.context ?? {}),
444
+ };
445
+ if (diagnostic.err !== undefined) {
446
+ payload.err = errorDetails(diagnostic.err);
447
+ }
448
+ stderr.write(`${JSON.stringify(payload)}\n`);
449
+ } catch {
450
+ /* diagnostics must never affect runner control flow */
451
+ }
452
+ };
453
+ }
454
+
407
455
  // `resolvePullScope`, `readPinnedPrefixes`, and the `PullScope` type now live
408
456
  // in `../sync/pull-scope.ts` so the menubar runner and `hq sync pull|now`
409
457
  // (hq-cli) share ONE scope resolver — the drift between them was the root
@@ -769,6 +817,7 @@ export async function defaultCollectTelemetry(
769
817
  client: VaultClientSurface,
770
818
  clientIsStub: boolean,
771
819
  hqRoot: string,
820
+ reportDiagnostic: RunnerDiagnosticReporter = () => undefined,
772
821
  ): Promise<void> {
773
822
  if (clientIsStub) return;
774
823
 
@@ -797,7 +846,14 @@ export async function defaultCollectTelemetry(
797
846
  try {
798
847
  const persons = await client.entity.listByType("person");
799
848
  if (pickCanonicalPersonEntity(persons) === null) return;
800
- } catch {
849
+ } catch (err) {
850
+ reportDiagnostic({
851
+ component: "telemetry",
852
+ event: "runner.telemetry.person_probe_failed",
853
+ message: "telemetry person probe failed; continuing with telemetry",
854
+ err,
855
+ context: { hqRoot },
856
+ });
801
857
  // Probe failed — preserve prior behavior and let telemetry run.
802
858
  }
803
859
 
@@ -820,7 +876,14 @@ export async function defaultCollectTelemetry(
820
876
  try {
821
877
  machineId = getOrCreateMachineId(hqRoot);
822
878
  installerVersion = process.env.HQ_INSTALLER_VERSION ?? "hq-cloud";
823
- } catch {
879
+ } catch (err) {
880
+ reportDiagnostic({
881
+ component: "telemetry",
882
+ event: "runner.telemetry.identity_failed",
883
+ message: "telemetry identity resolution failed; skipping telemetry",
884
+ err,
885
+ context: { hqRoot },
886
+ });
824
887
  return;
825
888
  }
826
889
 
@@ -836,8 +899,22 @@ export async function defaultCollectTelemetry(
836
899
  // manifest.yaml and stamp companyUid (US-002). Unmatched cwds stay
837
900
  // unattributed (field omitted).
838
901
  hqRoot,
902
+ log: (message) =>
903
+ reportDiagnostic({
904
+ component: "telemetry",
905
+ event: "runner.telemetry.usage_nonfatal",
906
+ message,
907
+ context: { hqRoot },
908
+ }),
909
+ });
910
+ } catch (err) {
911
+ reportDiagnostic({
912
+ component: "telemetry",
913
+ event: "runner.telemetry.usage_failed",
914
+ message: "usage telemetry collector threw",
915
+ err,
916
+ context: { hqRoot },
839
917
  });
840
- } catch {
841
918
  // Fire-and-forget; nothing escapes the boundary.
842
919
  }
843
920
 
@@ -851,8 +928,22 @@ export async function defaultCollectTelemetry(
851
928
  // Scope skill capture to the HQ project — only invocations whose cwd is
852
929
  // the HQ root are emitted; skill usage in unrelated repos is excluded.
853
930
  hqRoot,
931
+ log: (message) =>
932
+ reportDiagnostic({
933
+ component: "telemetry",
934
+ event: "runner.telemetry.skill_nonfatal",
935
+ message,
936
+ context: { hqRoot },
937
+ }),
938
+ });
939
+ } catch (err) {
940
+ reportDiagnostic({
941
+ component: "telemetry",
942
+ event: "runner.telemetry.skill_failed",
943
+ message: "skill telemetry collector threw",
944
+ err,
945
+ context: { hqRoot },
854
946
  });
855
- } catch {
856
947
  // Fire-and-forget; nothing escapes the boundary.
857
948
  }
858
949
  }
@@ -867,6 +958,7 @@ export async function runRunner(
867
958
  ): Promise<number> {
868
959
  const stdout = deps.stdout ?? process.stdout;
869
960
  const stderr = deps.stderr ?? process.stderr;
961
+ const reportDiagnostic = createRunnerDiagnosticReporter(stderr);
870
962
 
871
963
  // ---- emit ---------------------------------------------------------------
872
964
  // Error-class events go to stderr; everything else to stdout.
@@ -1068,7 +1160,16 @@ export async function runRunner(
1068
1160
  client,
1069
1161
  deps.createVaultClient !== undefined,
1070
1162
  parsed.hqRoot,
1163
+ reportDiagnostic,
1071
1164
  ),
1165
+ onError: (err) =>
1166
+ reportDiagnostic({
1167
+ component: "telemetry",
1168
+ event: "runner.telemetry.collector_failed",
1169
+ message: "telemetry collector failed",
1170
+ err,
1171
+ context: { hqRoot: parsed.hqRoot },
1172
+ }),
1072
1173
  });
1073
1174
 
1074
1175
  emit({
@@ -1095,8 +1196,24 @@ export async function runRunner(
1095
1196
  process.env.HQ_QMD_REINDEX_ON_SYNC !== "0"
1096
1197
  ) {
1097
1198
  try {
1098
- reindexAfterSync(parsed.hqRoot);
1099
- } catch {
1199
+ reindexAfterSync(parsed.hqRoot, {
1200
+ log: ({ event, message, err, context }) =>
1201
+ reportDiagnostic({
1202
+ component: "qmd-reindex",
1203
+ event,
1204
+ message,
1205
+ err,
1206
+ context: { hqRoot: parsed.hqRoot, ...(context ?? {}) },
1207
+ }),
1208
+ });
1209
+ } catch (err) {
1210
+ reportDiagnostic({
1211
+ component: "qmd-reindex",
1212
+ event: "runner.qmd_reindex.failed",
1213
+ message: "post-sync qmd reindex threw",
1214
+ err,
1215
+ context: { hqRoot: parsed.hqRoot },
1216
+ });
1100
1217
  // Defensive: reindexAfterSync already swallows internally.
1101
1218
  }
1102
1219
  }
@@ -1113,7 +1230,14 @@ export async function runRunner(
1113
1230
  if (process.env.HQ_CONFLICT_PRUNE_ON_SYNC !== "0") {
1114
1231
  try {
1115
1232
  pruneConflictIndex(parsed.hqRoot);
1116
- } catch {
1233
+ } catch (err) {
1234
+ reportDiagnostic({
1235
+ component: "conflict-index",
1236
+ event: "runner.conflict_index.prune_failed",
1237
+ message: "post-sync conflict index prune failed",
1238
+ err,
1239
+ context: { hqRoot: parsed.hqRoot },
1240
+ });
1117
1241
  // Defensive: a prune failure must never affect the sync outcome.
1118
1242
  }
1119
1243
  }
package/src/cli/share.ts CHANGED
@@ -888,15 +888,6 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
888
888
  let propagateDeletePolicy: DeletePolicy =
889
889
  options.propagateDeletePolicy ?? "owned-only";
890
890
  const baseEmit = options.onEvent ?? defaultConsoleLogger;
891
- // Mirror push progress into the shared cross-process snapshot (see sync.ts).
892
- const recordProgress = createSyncProgressRecorder({
893
- company: company ?? null,
894
- phase: "push",
895
- });
896
- const emit: ShareEmit = (event) => {
897
- baseEmit(event);
898
- recordProgress(event);
899
- };
900
891
 
901
892
  if (vaultConfig && entityContext) {
902
893
  throw new Error(
@@ -928,6 +919,21 @@ async function createPushRunContext(options: ShareOptions): Promise<PushRunConte
928
919
  propagateDeletePolicy = "currency-gated";
929
920
  }
930
921
 
922
+ // Mirror push progress into the shared cross-process snapshot (see sync.ts).
923
+ // Record the friendly company slug, or null for the personal vault so the
924
+ // menubar shows "Personal" — never the raw prs_/cmp_ UID.
925
+ const recordProgress = createSyncProgressRecorder({
926
+ company:
927
+ ctx.uid.startsWith("prs_") || options.personalMode === true
928
+ ? null
929
+ : ctx.slug,
930
+ phase: "push",
931
+ });
932
+ const emit: ShareEmit = (event) => {
933
+ baseEmit(event);
934
+ recordProgress(event);
935
+ };
936
+
931
937
  const syncRoot = options.personalMode === true
932
938
  ? hqRoot
933
939
  : path.join(hqRoot, "companies", ctx.slug);
@@ -46,6 +46,7 @@ vi.mock("./reindex.js", () => ({
46
46
  import { sync, reportNewFilesToNotify } from "./sync.js";
47
47
  import * as s3Module from "../s3.js";
48
48
  import { reindex } from "./reindex.js";
49
+ import { readSyncProgress } from "../sync-progress.js";
49
50
 
50
51
  const mockConfig: VaultServiceConfig = {
51
52
  apiUrl: "https://vault-api.test",
@@ -144,6 +145,22 @@ describe("sync", () => {
144
145
  expect(reindex).toHaveBeenCalledWith({ repoRoot: tmpDir, skipLock: true });
145
146
  });
146
147
 
148
+ it("records the company slug, not the input UID, in the shared progress snapshot", async () => {
149
+ // sync() is called with the company UID, but the menubar popover must show
150
+ // the friendly slug — so the producer records ctx.slug, never the raw UID.
151
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
152
+ { key: "docs/x.md", size: 10, lastModified: new Date(), etag: '"x"' },
153
+ ]);
154
+ await sync({
155
+ company: "cmp_01ABCDEF",
156
+ vaultConfig: mockConfig,
157
+ hqRoot: tmpDir,
158
+ });
159
+ const snap = readSyncProgress();
160
+ expect(snap).not.toBeNull();
161
+ expect(snap!.company).toBe("acme"); // ctx.slug, not "cmp_01ABCDEF"
162
+ });
163
+
147
164
  it("F15: public sync entrypoint refuses an already-held operation lock", async () => {
148
165
  process.env.HQ_OP_LOCK_TIMEOUT = "0";
149
166
  const lockPath = lockPathFor(tmpDir);
package/src/cli/sync.ts CHANGED
@@ -658,6 +658,23 @@ function logNotifyFailure(err: unknown): void {
658
658
  }
659
659
  }
660
660
 
661
+ /** Log a non-fatal post-sync maintenance failure without changing sync state. */
662
+ function logPostSyncMaintenanceFailure(
663
+ task: string,
664
+ hqRoot: string,
665
+ err: unknown,
666
+ ): void {
667
+ try {
668
+ console.error(
669
+ `[hq-sync] ${task} failed (non-fatal; hqRoot=${hqRoot}): ${
670
+ err instanceof Error ? err.message : String(err)
671
+ }`,
672
+ );
673
+ } catch {
674
+ // swallow — logging must never break sync
675
+ }
676
+ }
677
+
661
678
  /**
662
679
  * Sync (pull) all allowed files from the entity vault.
663
680
  */
@@ -710,17 +727,6 @@ async function syncWithOperationLockHeld(
710
727
  async function buildPullContext(options: SyncOptions): Promise<PullRunContext> {
711
728
  const { company, vaultConfig, hqRoot } = options;
712
729
  const baseEmit = options.onEvent ?? defaultConsoleLogger;
713
- // Mirror every progress event into the shared cross-process snapshot so the
714
- // menubar shows live progress for THIS sync regardless of who launched it
715
- // (auto-sync / Sync Now / CLI). Best-effort; never affects the sync.
716
- const recordProgress = createSyncProgressRecorder({
717
- company: company ?? null,
718
- phase: "pull",
719
- });
720
- const emit: SyncEventEmitter = (event) => {
721
- baseEmit(event);
722
- recordProgress(event);
723
- };
724
730
 
725
731
  const companyRef = company ?? resolveActiveCompany(hqRoot);
726
732
  if (!companyRef) {
@@ -738,6 +744,20 @@ async function buildPullContext(options: SyncOptions): Promise<PullRunContext> {
738
744
  const journalSlug = options.journalSlug ?? ctx.slug;
739
745
  const startedAt = new Date().toISOString();
740
746
 
747
+ // Mirror every progress event into the shared cross-process snapshot so the
748
+ // menubar shows live progress for THIS sync regardless of who launched it
749
+ // (auto-sync / Sync Now / CLI). Record the friendly company slug (e.g.
750
+ // "indigo") — or null for the personal vault so the menubar shows "Personal"
751
+ // — never the raw prs_/cmp_ UID. Best-effort; never affects the sync.
752
+ const recordProgress = createSyncProgressRecorder({
753
+ company: options.personalMode === true ? null : ctx.slug,
754
+ phase: "pull",
755
+ });
756
+ const emit: SyncEventEmitter = (event) => {
757
+ baseEmit(event);
758
+ recordProgress(event);
759
+ };
760
+
741
761
  if (journalSlug === PERSONAL_VAULT_JOURNAL_SLUG) migratePersonalVaultJournal();
742
762
  const journal = migrateToV2(readJournal(journalSlug));
743
763
  gcTombstones(journal, Date.now());
@@ -1466,7 +1486,8 @@ function finalizePullRun(
1466
1486
  if (!run.options.skipReindex && changedOnDisk) {
1467
1487
  try {
1468
1488
  reindex({ repoRoot: run.hqRoot, skipLock: true });
1469
- } catch {
1489
+ } catch (err) {
1490
+ logPostSyncMaintenanceFailure("post-sync reindex", run.hqRoot, err);
1470
1491
  // best-effort: a post-sync refresh failure never fails the sync
1471
1492
  }
1472
1493
  }
@@ -56,6 +56,13 @@ export interface ReindexOptions {
56
56
  readCompanies?: (companiesDir: string) => string[];
57
57
  /** Returns true if the knowledge dir has at least one indexable .md file. */
58
58
  hasIndexableMarkdown?: (knowledgeDir: string) => boolean;
59
+ /** Optional diagnostic sink for unexpected swallowed failures. */
60
+ log?: (diagnostic: {
61
+ event: string;
62
+ message: string;
63
+ err: unknown;
64
+ context?: Record<string, unknown>;
65
+ }) => void;
59
66
  }
60
67
 
61
68
  /**
@@ -110,7 +117,17 @@ export function reindexAfterSync(
110
117
  const embed = exec(["embed"]);
111
118
  result.embedded = embed.status === 0;
112
119
  }
113
- } catch {
120
+ } catch (err) {
121
+ try {
122
+ opts.log?.({
123
+ event: "runner.qmd_reindex.internal_failed",
124
+ message: "post-sync qmd reindex failed",
125
+ err,
126
+ context: { hqRoot },
127
+ });
128
+ } catch {
129
+ /* diagnostics must never affect sync */
130
+ }
114
131
  // Reindex is best-effort; the sync result is authoritative. Swallow.
115
132
  }
116
133