@indigoai-us/hq-cloud 6.10.2 → 6.11.1

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.
@@ -21,6 +21,7 @@ import {
21
21
  buildTargetedPushArgv,
22
22
  resolvePullScope,
23
23
  readPinnedPrefixes,
24
+ defaultCollectTelemetry,
24
25
  } from "./sync-runner.js";
25
26
  import type {
26
27
  RunnerEvent,
@@ -3987,3 +3988,115 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
3987
3988
  }
3988
3989
  });
3989
3990
  });
3991
+
3992
+ // ---------------------------------------------------------------------------
3993
+ // defaultCollectTelemetry — person-entity gate (Sentry HQ-4N regression)
3994
+ //
3995
+ // The telemetry passes reject SERVER-side with a captured `no-person-entity`
3996
+ // 4xx (and a Sentry warning) when the caller has no person entity. The gate
3997
+ // skips telemetry for unprovisioned callers so the premature `/v1/usage/*`
3998
+ // (and `/v1/skill-invocations`) calls — and therefore the warnings — stop.
3999
+ // `getTelemetryOptIn()` is the first call of BOTH passes, so its call count is
4000
+ // the differential signal for "did telemetry run".
4001
+ // ---------------------------------------------------------------------------
4002
+
4003
+ describe("defaultCollectTelemetry person-entity gate", () => {
4004
+ function makeTelemetryClient(persons: EntityInfo[]) {
4005
+ const calls = {
4006
+ listByType: 0,
4007
+ getTelemetryOptIn: 0,
4008
+ postUsage: 0,
4009
+ postSkillInvocations: 0,
4010
+ };
4011
+ const client = {
4012
+ entity: {
4013
+ get: async () => ({}) as unknown as EntityInfo,
4014
+ listByType: async (_type: string) => {
4015
+ calls.listByType++;
4016
+ return persons;
4017
+ },
4018
+ },
4019
+ // Telemetry surface (cast-to internally by defaultCollectTelemetry). With
4020
+ // opt-in disabled, the collectors return right after this one probe, so
4021
+ // postUsage/postSkillInvocations are never reached even when the gate
4022
+ // lets telemetry run — getTelemetryOptIn is the clean call-ran signal.
4023
+ getTelemetryOptIn: async () => {
4024
+ calls.getTelemetryOptIn++;
4025
+ return { enabled: false };
4026
+ },
4027
+ postUsage: async () => {
4028
+ calls.postUsage++;
4029
+ return { ok: true } as unknown;
4030
+ },
4031
+ postSkillInvocations: async () => {
4032
+ calls.postSkillInvocations++;
4033
+ return { written: 0, skipped: [] } as unknown;
4034
+ },
4035
+ };
4036
+ return { client, calls };
4037
+ }
4038
+
4039
+ const PERSON: EntityInfo = {
4040
+ uid: "prs_abc123",
4041
+ type: "person",
4042
+ slug: "me",
4043
+ status: "active",
4044
+ bucketName: "hq-vault-prs_abc123",
4045
+ createdAt: "2026-01-01T00:00:00Z",
4046
+ } as unknown as EntityInfo;
4047
+
4048
+ it("skips both telemetry passes when the caller owns no person entity (no premature /v1/usage call)", async () => {
4049
+ const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-tele-noperson-"));
4050
+ try {
4051
+ const { client, calls } = makeTelemetryClient([]); // unprovisioned caller
4052
+ await defaultCollectTelemetry(
4053
+ client as unknown as VaultClientSurface,
4054
+ false,
4055
+ hqRoot,
4056
+ );
4057
+ expect(calls.listByType).toBe(1); // orphan-safe probe ran
4058
+ expect(calls.getTelemetryOptIn).toBe(0); // collectors never ran
4059
+ expect(calls.postUsage).toBe(0);
4060
+ expect(calls.postSkillInvocations).toBe(0);
4061
+ } finally {
4062
+ fs.rmSync(hqRoot, { recursive: true, force: true });
4063
+ }
4064
+ });
4065
+
4066
+ it("runs telemetry when a canonical person entity exists (behaviour preserved for provisioned callers)", async () => {
4067
+ const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-tele-person-"));
4068
+ try {
4069
+ const { client, calls } = makeTelemetryClient([PERSON]);
4070
+ await defaultCollectTelemetry(
4071
+ client as unknown as VaultClientSurface,
4072
+ false,
4073
+ hqRoot,
4074
+ );
4075
+ expect(calls.listByType).toBe(1);
4076
+ // Both passes reach their opt-in probe → telemetry ran as before.
4077
+ expect(calls.getTelemetryOptIn).toBeGreaterThan(0);
4078
+ } finally {
4079
+ fs.rmSync(hqRoot, { recursive: true, force: true });
4080
+ }
4081
+ });
4082
+
4083
+ it("fails open: a thrown person probe still lets telemetry run (no provisioned-user regression)", async () => {
4084
+ const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-tele-throw-"));
4085
+ try {
4086
+ const { client, calls } = makeTelemetryClient([]);
4087
+ client.entity.listByType = async () => {
4088
+ calls.listByType++;
4089
+ throw new Error("transient network blip");
4090
+ };
4091
+ await defaultCollectTelemetry(
4092
+ client as unknown as VaultClientSurface,
4093
+ false,
4094
+ hqRoot,
4095
+ );
4096
+ expect(calls.listByType).toBe(1);
4097
+ expect(calls.getTelemetryOptIn).toBeGreaterThan(0); // fell through to telemetry
4098
+ } finally {
4099
+ fs.rmSync(hqRoot, { recursive: true, force: true });
4100
+ }
4101
+ });
4102
+ });
@@ -764,13 +764,42 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
764
764
  // swallowed — telemetry must never abort or delay sync.
765
765
  // ---------------------------------------------------------------------------
766
766
 
767
- async function defaultCollectTelemetry(
767
+ export async function defaultCollectTelemetry(
768
768
  client: VaultClientSurface,
769
769
  clientIsStub: boolean,
770
770
  hqRoot: string,
771
771
  ): Promise<void> {
772
772
  if (clientIsStub) return;
773
773
 
774
+ // Person-entity gate (the "onboarding gate" the telemetry call-site must
775
+ // sit behind). Both telemetry passes below resolve the caller's `personUid`
776
+ // SERVER-side from the JWT and reject with a 4xx when the caller has not yet
777
+ // been provisioned a person entity: `getTelemetryOptIn()` (the first call of
778
+ // each pass) hits `GET /v1/usage/opt-in`, which 404s `no-person-entity` for
779
+ // an unprovisioned caller, and the skill pass's `POST /v1/skill-invocations`
780
+ // does the same. hq-pro logs every such reject as a Sentry *warning*, so an
781
+ // unprovisioned-but-signed-in identity that runs telemetry on a loop — e.g.
782
+ // a machine/daemon/outpost identity, or a single-company sync that fabricates
783
+ // its membership and never runs the onboarding gate — emits a steady stream
784
+ // of benign `no-person-entity` warnings (Sentry HQ-4N). The reject itself is
785
+ // harmless (telemetry is best-effort and the response is swallowed), but the
786
+ // noise is not. Probe the caller's OWN person entity with the orphan-safe
787
+ // `entity.listByType("person")` — `GET /entity/by-type/person` returns `200`
788
+ // with an empty list for an unprovisioned caller (no reject, no warning),
789
+ // and a non-empty result is the EXACT predicate for "the server will resolve
790
+ // a personUid" (same caller-owned-person union the usage handler's
791
+ // `getEffectivePersonUid` uses). Skip both passes when there is none. The
792
+ // probe is the same canonical-person check the runner already uses to pick
793
+ // the personal-vault target. Fail OPEN: a thrown probe (transient/network)
794
+ // falls through to run telemetry as before, so a provisioned user's
795
+ // telemetry is never dropped by a flaky probe.
796
+ try {
797
+ const persons = await client.entity.listByType("person");
798
+ if (pickCanonicalPersonEntity(persons) === null) return;
799
+ } catch {
800
+ // Probe failed — preserve prior behavior and let telemetry run.
801
+ }
802
+
774
803
  // machineId: hq-cloud owns provisioning via `<hqRoot>/.hq/machine-id`
775
804
  // (see `src/lib/machine-id.ts`). The resolver migrates forward from
776
805
  // any legacy `~/.hq/menubar.json` value on first call, then becomes
@@ -69,9 +69,25 @@ const mockVendResponse = {
69
69
  expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
70
70
  };
71
71
 
72
- function setupFetchMock() {
72
+ function setupFetchMock(
73
+ opts: { tombstones?: Array<{ key: string; deletedAt: string }> } = {},
74
+ ) {
73
75
  const fetchMock = vi.fn().mockImplementation(async (url: string) => {
74
76
  const urlStr = String(url);
77
+ // FILE_TOMBSTONE read surface (delete-resync). Defaults to an empty list so
78
+ // every legacy test exercises the no-suppression path; tests that need
79
+ // suppression pass `{ tombstones }`.
80
+ if (urlStr.includes("/v1/files/tombstones")) {
81
+ return {
82
+ ok: true,
83
+ status: 200,
84
+ json: async () => ({
85
+ companyUid: mockEntity.uid,
86
+ tombstones: opts.tombstones ?? [],
87
+ }),
88
+ text: async () => "",
89
+ };
90
+ }
75
91
  // New per-user-namespace slug resolver (hq-pro PR 67). Returns the
76
92
  // mockEntity's uid as the in-namespace match; the caller then
77
93
  // re-fetches it via `/entity/{uid}`, which is matched by the
@@ -1049,6 +1065,230 @@ describe("sync", () => {
1049
1065
  expect(journal.files["docs/really-deleted.md"]).toBeUndefined();
1050
1066
  });
1051
1067
 
1068
+ // ── delete-resync: FILE_TOMBSTONE suppression (issue [6]) ──────────────────
1069
+ // These four cover the brief's regression contract: a scoped-delete tombstone
1070
+ // makes a delete STICK across a pull even when the object is still in the
1071
+ // remote LIST (a peer re-pushed it / the delete-marker hasn't propagated),
1072
+ // while a genuine re-create still syncs and a post-tombstone local edit is
1073
+ // never silently destroyed.
1074
+
1075
+ it("delete-resync: a tombstoned key present in the remote LIST is removed locally and NOT re-downloaded", async () => {
1076
+ // The core stays-deleted assertion. A peer ran `hq files delete docs/` (the
1077
+ // scoped-delete path), which wrote a FILE_TOMBSTONE. This machine still
1078
+ // holds the file and the object is still visible in the remote LIST (a peer
1079
+ // re-push, or an un-propagated delete-marker). Pre-fix the planner saw
1080
+ // "remote present, local present, unchanged" and KEPT it; worse, on a fresh
1081
+ // machine "remote present, local absent" re-DOWNLOADED it — the resurrection
1082
+ // loop. Now the FILE_TOMBSTONE (newer than the remote object) is
1083
+ // authoritative: delete locally, drop the journal entry, never download.
1084
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1085
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
1086
+ const sharedPath = path.join(companyRoot, "docs", "shared.md");
1087
+ fs.writeFileSync(sharedPath, "shared content");
1088
+ const crypto = await import("node:crypto");
1089
+ const baseline = crypto
1090
+ .createHash("sha256")
1091
+ .update("shared content")
1092
+ .digest("hex");
1093
+ const now = Date.now();
1094
+ fs.writeFileSync(
1095
+ journalPath,
1096
+ JSON.stringify({
1097
+ version: "1",
1098
+ lastSync: new Date(now - 120_000).toISOString(),
1099
+ files: {
1100
+ "docs/shared.md": {
1101
+ hash: baseline,
1102
+ size: 14,
1103
+ syncedAt: new Date(now - 120_000).toISOString(),
1104
+ direction: "down",
1105
+ remoteEtag: "remote-old",
1106
+ },
1107
+ },
1108
+ }),
1109
+ );
1110
+ // Remote object is the STALE deleted version: lastModified BEFORE the
1111
+ // tombstone, still present in the LIST.
1112
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1113
+ {
1114
+ key: "docs/shared.md",
1115
+ size: 14,
1116
+ lastModified: new Date(now - 60_000),
1117
+ etag: '"remote-old"',
1118
+ },
1119
+ ]);
1120
+ // FILE_TOMBSTONE deleted AFTER the remote object's lastModified.
1121
+ setupFetchMock({
1122
+ tombstones: [
1123
+ { key: "docs/shared.md", deletedAt: new Date(now).toISOString() },
1124
+ ],
1125
+ });
1126
+
1127
+ const result = await sync({
1128
+ company: "acme",
1129
+ vaultConfig: mockConfig,
1130
+ hqRoot: tmpDir,
1131
+ });
1132
+
1133
+ // Stays deleted locally …
1134
+ expect(fs.existsSync(sharedPath)).toBe(false);
1135
+ // … and was NEVER re-downloaded (no download, no conflict-probe download).
1136
+ expect(s3Module.downloadFile).not.toHaveBeenCalled();
1137
+ expect(result.filesDownloaded).toBe(0);
1138
+ expect(result.filesTombstoned).toBeGreaterThanOrEqual(1);
1139
+ // Journal entry dropped so it converges (no re-tombstone next run).
1140
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
1141
+ expect(journal.files["docs/shared.md"]).toBeUndefined();
1142
+ });
1143
+
1144
+ it("delete-resync: a re-created object NEWER than the tombstone DOES sync (tombstone is not permanent)", async () => {
1145
+ // The tombstone must not permanently suppress a key. After a delete, a user
1146
+ // writes a brand-new object at the same path; its lastModified post-dates
1147
+ // the tombstone, so it is a genuine re-create and must download normally.
1148
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1149
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
1150
+ const recreatedPath = path.join(companyRoot, "docs", "recreated.md");
1151
+ const now = Date.now();
1152
+ // Empty journal + absent local: the file was deleted earlier; this is a
1153
+ // fresh re-create arriving from the vault.
1154
+ fs.writeFileSync(
1155
+ journalPath,
1156
+ JSON.stringify({ version: "1", lastSync: new Date(now).toISOString(), files: {} }),
1157
+ );
1158
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1159
+ {
1160
+ key: "docs/recreated.md",
1161
+ size: 17,
1162
+ lastModified: new Date(now), // newer than the tombstone below
1163
+ etag: '"remote-new"',
1164
+ },
1165
+ ]);
1166
+ setupFetchMock({
1167
+ tombstones: [
1168
+ {
1169
+ key: "docs/recreated.md",
1170
+ deletedAt: new Date(now - 3_600_000).toISOString(), // an hour ago
1171
+ },
1172
+ ],
1173
+ });
1174
+
1175
+ const result = await sync({
1176
+ company: "acme",
1177
+ vaultConfig: mockConfig,
1178
+ hqRoot: tmpDir,
1179
+ });
1180
+
1181
+ // Re-create wins: downloaded, present locally, not tombstoned.
1182
+ expect(s3Module.downloadFile).toHaveBeenCalled();
1183
+ expect(fs.existsSync(recreatedPath)).toBe(true);
1184
+ expect(result.filesDownloaded).toBeGreaterThanOrEqual(1);
1185
+ expect(result.filesTombstoned).toBe(0);
1186
+ });
1187
+
1188
+ it("delete-resync: a local edit made after the tombstone surfaces a CONFLICT, not a silent delete", async () => {
1189
+ // Safety guard: the authoritative delete must never destroy unsynced local
1190
+ // work. This machine edited the file locally (diverged from the synced
1191
+ // baseline) and only then learned of the tombstone — surface a conflict and
1192
+ // PRESERVE local, rather than unlinking it.
1193
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1194
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
1195
+ const editedPath = path.join(companyRoot, "docs", "edited.md");
1196
+ fs.writeFileSync(editedPath, "my local edits");
1197
+ const crypto = await import("node:crypto");
1198
+ const baseline = crypto
1199
+ .createHash("sha256")
1200
+ .update("original synced content")
1201
+ .digest("hex");
1202
+ const now = Date.now();
1203
+ fs.writeFileSync(
1204
+ journalPath,
1205
+ JSON.stringify({
1206
+ version: "1",
1207
+ lastSync: new Date(now - 120_000).toISOString(),
1208
+ files: {
1209
+ "docs/edited.md": {
1210
+ hash: baseline, // local has since diverged → localChanged
1211
+ size: 23,
1212
+ syncedAt: new Date(now - 120_000).toISOString(),
1213
+ direction: "down",
1214
+ remoteEtag: "remote-old",
1215
+ },
1216
+ },
1217
+ }),
1218
+ );
1219
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1220
+ {
1221
+ key: "docs/edited.md",
1222
+ size: 23,
1223
+ lastModified: new Date(now - 60_000),
1224
+ etag: '"remote-old"',
1225
+ },
1226
+ ]);
1227
+ setupFetchMock({
1228
+ tombstones: [
1229
+ { key: "docs/edited.md", deletedAt: new Date(now).toISOString() },
1230
+ ],
1231
+ });
1232
+
1233
+ const result = await sync({
1234
+ company: "acme",
1235
+ onConflict: "keep",
1236
+ vaultConfig: mockConfig,
1237
+ hqRoot: tmpDir,
1238
+ });
1239
+
1240
+ // Conflict surfaced; local edits preserved; nothing tombstoned away.
1241
+ expect(result.conflicts).toBeGreaterThanOrEqual(1);
1242
+ expect(result.conflictPaths).toContain("docs/edited.md");
1243
+ expect(fs.existsSync(editedPath)).toBe(true);
1244
+ expect(fs.readFileSync(editedPath, "utf-8")).toBe("my local edits");
1245
+ expect(result.filesTombstoned).toBe(0);
1246
+ });
1247
+
1248
+ it("delete-resync: suppression is per-key and the fetch is company-scoped (no cross-tenant bleed)", async () => {
1249
+ // Two guards in one: (a) a tombstone for `docs/a.md` must NOT suppress a
1250
+ // different key `docs/b.md` — suppression is keyed exactly; (b) the
1251
+ // tombstone fetch is scoped to the ACTIVE company's uid, so a sync for one
1252
+ // company never consults another company's tombstones (the client half of
1253
+ // the cross-tenant isolation the server enforces via the
1254
+ // FILE_TOMBSTONE#<companyUid># begins_with query).
1255
+ const companyRoot = path.join(tmpDir, "companies", "acme");
1256
+ fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
1257
+ const now = Date.now();
1258
+ fs.writeFileSync(
1259
+ journalPath,
1260
+ JSON.stringify({ version: "1", lastSync: new Date(now).toISOString(), files: {} }),
1261
+ );
1262
+ // a.md = stale tombstoned version (suppressed); b.md = a normal new file.
1263
+ vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
1264
+ { key: "docs/a.md", size: 10, lastModified: new Date(now - 60_000), etag: '"a-old"' },
1265
+ { key: "docs/b.md", size: 10, lastModified: new Date(now), etag: '"b-new"' },
1266
+ ]);
1267
+ const fetchMock = setupFetchMock({
1268
+ tombstones: [
1269
+ { key: "docs/a.md", deletedAt: new Date(now).toISOString() },
1270
+ ],
1271
+ });
1272
+
1273
+ const result = await sync({
1274
+ company: "acme",
1275
+ vaultConfig: mockConfig,
1276
+ hqRoot: tmpDir,
1277
+ });
1278
+
1279
+ // a.md suppressed (never downloaded); b.md downloaded normally.
1280
+ expect(fs.existsSync(path.join(companyRoot, "docs", "a.md"))).toBe(false);
1281
+ expect(fs.existsSync(path.join(companyRoot, "docs", "b.md"))).toBe(true);
1282
+ expect(result.filesDownloaded).toBe(1);
1283
+
1284
+ // The tombstone read was scoped to THIS company's uid.
1285
+ const tombstoneCall = fetchMock.mock.calls.find((c) =>
1286
+ String(c[0]).includes("/v1/files/tombstones"),
1287
+ );
1288
+ expect(tombstoneCall).toBeDefined();
1289
+ expect(String(tombstoneCall![0])).toContain(`company=${mockEntity.uid}`);
1290
+ });
1291
+
1052
1292
  it("does NOT tombstone keys when HEAD returns AccessDenied (Codex P1 — defensive STS skip)", async () => {
1053
1293
  // Same Codex P1, AccessDenied branch: a guest STS session may deny
1054
1294
  // both LIST and HEAD on out-of-scope keys. The tombstone planner