@indigoai-us/hq-cloud 5.41.0 → 5.42.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.
@@ -18,6 +18,7 @@ import {
18
18
  resolveSkipPersonal,
19
19
  routeChangeToTarget,
20
20
  buildTargetedPushArgv,
21
+ resolvePullScope,
21
22
  } from "./sync-runner.js";
22
23
  import type {
23
24
  RunnerEvent,
@@ -100,6 +101,9 @@ function defaultSyncResult(overrides: Partial<SyncResult> = {}): SyncResult {
100
101
  newFilesCount: 0,
101
102
  filesExcludedByPolicy: 0,
102
103
  filesTombstoned: 0,
104
+ filesOutOfScope: 0,
105
+ scopeOrphansRemoved: 0,
106
+ scopeOrphansBlocked: 0,
103
107
  ...overrides,
104
108
  };
105
109
  }
@@ -1075,6 +1079,9 @@ describe("per-company fanout", () => {
1075
1079
  filesExcludedByPolicy: 0,
1076
1080
  newFiles: result.newFiles,
1077
1081
  newFilesCount: result.newFilesCount,
1082
+ filesOutOfScope: result.filesOutOfScope,
1083
+ scopeOrphansRemoved: result.scopeOrphansRemoved,
1084
+ scopeOrphansBlocked: result.scopeOrphansBlocked,
1078
1085
  });
1079
1086
  });
1080
1087
 
@@ -3117,3 +3124,218 @@ describe("resolveSkipPersonal", () => {
3117
3124
  },
3118
3125
  );
3119
3126
  });
3127
+
3128
+ // ---------------------------------------------------------------------------
3129
+ // resolvePullScope (US-005) — effective download scope per company leg
3130
+ // ---------------------------------------------------------------------------
3131
+
3132
+ describe("resolvePullScope", () => {
3133
+ function stubClient(
3134
+ overrides: Partial<VaultClientSurface>,
3135
+ ): VaultClientSurface {
3136
+ return {
3137
+ listMyMemberships: async () => [],
3138
+ listMyPendingInvitesByEmail: async () => [],
3139
+ claimPendingInvitesByEmail: async () => {},
3140
+ ensureMyPersonEntity: async () => ({ uid: "p", slug: "p" }) as never,
3141
+ entity: {
3142
+ get: async (uid: string) => ({ uid, slug: uid }) as never,
3143
+ listByType: async () => [],
3144
+ },
3145
+ ...overrides,
3146
+ };
3147
+ }
3148
+
3149
+ const membership = (companyUid: string, membershipKey: string) =>
3150
+ ({
3151
+ membershipKey,
3152
+ personUid: "prs_1",
3153
+ companyUid,
3154
+ role: "member",
3155
+ status: "active",
3156
+ invitedBy: "prs_0",
3157
+ invitedAt: "2026-01-01T00:00:00.000Z",
3158
+ createdAt: "2026-01-01T00:00:00.000Z",
3159
+ updatedAt: "2026-01-01T00:00:00.000Z",
3160
+ }) as never;
3161
+
3162
+ it("returns all when the client has no getMembershipSyncConfig", async () => {
3163
+ const scope = await resolvePullScope(stubClient({}), "cmp_a", "acme");
3164
+ expect(scope).toEqual({ syncMode: "all" });
3165
+ });
3166
+
3167
+ it("returns all for an all-mode membership (no prefixSet)", async () => {
3168
+ const scope = await resolvePullScope(
3169
+ stubClient({
3170
+ listMyMemberships: async () => [membership("cmp_a", "mk_a")],
3171
+ getMembershipSyncConfig: async () => ({
3172
+ membershipId: "mk_a",
3173
+ syncMode: "all",
3174
+ isDefault: true,
3175
+ }),
3176
+ }),
3177
+ "cmp_a",
3178
+ "acme",
3179
+ );
3180
+ expect(scope).toEqual({ syncMode: "all" });
3181
+ });
3182
+
3183
+ it("coalesces explicit grants for a shared-mode membership", async () => {
3184
+ const scope = await resolvePullScope(
3185
+ stubClient({
3186
+ listMyMemberships: async () => [membership("cmp_a", "mk_a")],
3187
+ getMembershipSyncConfig: async () => ({
3188
+ membershipId: "mk_a",
3189
+ syncMode: "shared",
3190
+ isDefault: false,
3191
+ }),
3192
+ listMyExplicitGrants: async () => [
3193
+ { companyUid: "cmp_a", path: "knowledge/", permission: "read", source: "person" },
3194
+ { companyUid: "cmp_a", path: "knowledge/sub/", permission: "read", source: "group" },
3195
+ { companyUid: "cmp_a", path: "shared/", permission: "read", source: "open" },
3196
+ ] as never,
3197
+ }),
3198
+ "cmp_a",
3199
+ "acme",
3200
+ );
3201
+ // knowledge/sub/ is covered by knowledge/ → coalesced away.
3202
+ expect(scope.syncMode).toBe("shared");
3203
+ expect(scope.prefixSet).toEqual(["knowledge/", "shared/"]);
3204
+ });
3205
+
3206
+ it("normalizes real-world mixed/glob grant paths into company-relative prefixes", async () => {
3207
+ // The exact shapes observed in the live hq-pro vault for `indigo`:
3208
+ // bare glob, full-anchored + /*, full-anchored exact file, slug-anchored
3209
+ // + /*, company-relative + /*, and a company-relative exact file.
3210
+ const scope = await resolvePullScope(
3211
+ stubClient({
3212
+ listMyMemberships: async () => [membership("cmp_indigo", "mk_i")],
3213
+ getMembershipSyncConfig: async () => ({
3214
+ membershipId: "mk_i",
3215
+ syncMode: "shared",
3216
+ isDefault: false,
3217
+ }),
3218
+ listMyExplicitGrants: async () =>
3219
+ [
3220
+ { companyUid: "cmp_indigo", path: "companies/indigo/design-pack/*", permission: "write", source: "person" },
3221
+ { companyUid: "cmp_indigo", path: "companies/indigo/knowledge/README.md", permission: "write", source: "person" },
3222
+ { companyUid: "cmp_indigo", path: "indigo/data/vyg/old-meetings/*", permission: "write", source: "person" },
3223
+ { companyUid: "cmp_indigo", path: "data/vyg/*", permission: "write", source: "person" },
3224
+ { companyUid: "cmp_indigo", path: "company.yaml", permission: "read", source: "open" },
3225
+ ] as never,
3226
+ }),
3227
+ "cmp_indigo",
3228
+ "indigo",
3229
+ );
3230
+ expect(scope.syncMode).toBe("shared");
3231
+ // All anchors stripped, globs folded to startsWith-prefixes, sorted —
3232
+ // and `data/vyg/old-meetings/` is subsumed by the broader `data/vyg/`.
3233
+ expect(scope.prefixSet).toEqual([
3234
+ "company.yaml",
3235
+ "data/vyg/",
3236
+ "design-pack/",
3237
+ "knowledge/README.md",
3238
+ ]);
3239
+ });
3240
+
3241
+ it("a bare '*' grant resolves to everything (empty-string prefix)", async () => {
3242
+ const scope = await resolvePullScope(
3243
+ stubClient({
3244
+ listMyMemberships: async () => [membership("cmp_a", "mk_a")],
3245
+ getMembershipSyncConfig: async () => ({
3246
+ membershipId: "mk_a",
3247
+ syncMode: "shared",
3248
+ isDefault: false,
3249
+ }),
3250
+ listMyExplicitGrants: async () =>
3251
+ [{ companyUid: "cmp_a", path: "*", permission: "admin", source: "group" }] as never,
3252
+ }),
3253
+ "cmp_a",
3254
+ "acme",
3255
+ );
3256
+ // A `*` grant = everything. Because coalescePrefixes drops the empty
3257
+ // prefix (which would otherwise collapse to "nothing" and prune the
3258
+ // tree), an everything-scope resolves to full-access `all`.
3259
+ expect(scope).toEqual({ syncMode: "all" });
3260
+ });
3261
+
3262
+ it("uses customPaths for a custom-mode membership", async () => {
3263
+ const scope = await resolvePullScope(
3264
+ stubClient({
3265
+ listMyMemberships: async () => [membership("cmp_a", "mk_a")],
3266
+ getMembershipSyncConfig: async () => ({
3267
+ membershipId: "mk_a",
3268
+ syncMode: "custom",
3269
+ customPaths: ["projects/x/", "projects/x/deep/"],
3270
+ isDefault: false,
3271
+ }),
3272
+ }),
3273
+ "cmp_a",
3274
+ "acme",
3275
+ );
3276
+ expect(scope.syncMode).toBe("custom");
3277
+ expect(scope.prefixSet).toEqual(["projects/x/"]);
3278
+ });
3279
+
3280
+ it("degrades to all for shared mode when listMyExplicitGrants is unavailable (never empty-prune)", async () => {
3281
+ const scope = await resolvePullScope(
3282
+ stubClient({
3283
+ listMyMemberships: async () => [membership("cmp_a", "mk_a")],
3284
+ getMembershipSyncConfig: async () => ({
3285
+ membershipId: "mk_a",
3286
+ syncMode: "shared",
3287
+ isDefault: false,
3288
+ }),
3289
+ // listMyExplicitGrants intentionally absent.
3290
+ }),
3291
+ "cmp_a",
3292
+ "acme",
3293
+ );
3294
+ expect(scope).toEqual({ syncMode: "all" });
3295
+ });
3296
+
3297
+ it("allows a genuinely-empty shared scope (grants method present, returns [])", async () => {
3298
+ const scope = await resolvePullScope(
3299
+ stubClient({
3300
+ listMyMemberships: async () => [membership("cmp_a", "mk_a")],
3301
+ getMembershipSyncConfig: async () => ({
3302
+ membershipId: "mk_a",
3303
+ syncMode: "shared",
3304
+ isDefault: false,
3305
+ }),
3306
+ listMyExplicitGrants: async () => [],
3307
+ }),
3308
+ "cmp_a",
3309
+ "acme",
3310
+ );
3311
+ expect(scope).toEqual({ syncMode: "shared", prefixSet: [] });
3312
+ });
3313
+
3314
+ it("degrades to all when the membership is not found", async () => {
3315
+ const scope = await resolvePullScope(
3316
+ stubClient({
3317
+ listMyMemberships: async () => [membership("cmp_other", "mk_o")],
3318
+ getMembershipSyncConfig: async () => {
3319
+ throw new Error("should not be called");
3320
+ },
3321
+ }),
3322
+ "cmp_a",
3323
+ "acme",
3324
+ );
3325
+ expect(scope).toEqual({ syncMode: "all" });
3326
+ });
3327
+
3328
+ it("degrades to all when sync-config resolution throws (never prune on error)", async () => {
3329
+ const scope = await resolvePullScope(
3330
+ stubClient({
3331
+ listMyMemberships: async () => [membership("cmp_a", "mk_a")],
3332
+ getMembershipSyncConfig: async () => {
3333
+ throw new Error("network blip");
3334
+ },
3335
+ }),
3336
+ "cmp_a",
3337
+ "acme",
3338
+ );
3339
+ expect(scope).toEqual({ syncMode: "all" });
3340
+ });
3341
+ });
@@ -71,8 +71,12 @@ import {
71
71
  type Membership,
72
72
  type EntityInfo,
73
73
  type PendingInviteByEmail,
74
+ type SyncMode,
75
+ type MembershipSyncConfig,
76
+ type ExplicitGrant,
74
77
  } from "../index.js";
75
78
  import { pickCanonicalPersonEntity } from "../vault-client.js";
79
+ import { coalescePrefixes, grantPathToPrefix } from "../prefix-coalesce.js";
76
80
  import {
77
81
  PERSONAL_VAULT_EXCLUDED_TOP_LEVEL,
78
82
  computePersonalVaultPaths,
@@ -339,6 +343,84 @@ export interface VaultClientSurface {
339
343
  get: (uid: string) => Promise<EntityInfo>;
340
344
  listByType: (type: string) => Promise<EntityInfo[]>;
341
345
  };
346
+ // US-005 scope resolution. Optional so older test stubs (and any
347
+ // VaultClientSurface impl that predates sync-config) still satisfy the
348
+ // interface; when absent, `resolvePullScope` degrades to `all`.
349
+ getMembershipSyncConfig?: (membershipId: string) => Promise<MembershipSyncConfig>;
350
+ listMyExplicitGrants?: (companyUid: string) => Promise<ExplicitGrant[]>;
351
+ }
352
+
353
+ /**
354
+ * Effective download scope for one company leg (US-005). Resolved per company
355
+ * just before its pull, then handed to `sync()` as `{ syncMode, prefixSet }`.
356
+ */
357
+ export interface PullScope {
358
+ syncMode: SyncMode;
359
+ /** Coalesced company-relative prefixes; omitted/undefined for `all`. */
360
+ prefixSet?: string[];
361
+ }
362
+
363
+ /**
364
+ * Resolve the effective download scope for a company target.
365
+ *
366
+ * - `all` → no prefix set; full-bucket pull (legacy behavior).
367
+ * - `shared` → coalesced caller explicit grants (company-relative paths,
368
+ * same namespace as `RemoteFile.key`).
369
+ * - `custom` → coalesced `customPaths` from the sync-config row.
370
+ *
371
+ * DEGRADE-TO-`all` CONTRACT: any failure (missing client method, membership
372
+ * not found, network error, grant fetch error) returns `{ syncMode: "all" }`.
373
+ * A transient failure must NEVER silently narrow scope — that would prune the
374
+ * local tree. Mirrors the CLI's `resolvePerCompanyPullPlan` degrade behavior.
375
+ */
376
+ export async function resolvePullScope(
377
+ client: VaultClientSurface,
378
+ companyUid: string,
379
+ // Company slug — required to normalize grant paths (which may be anchored
380
+ // at `companies/<slug>/` or `<slug>/`) into the company-relative namespace.
381
+ slug: string,
382
+ ): Promise<PullScope> {
383
+ if (!client.getMembershipSyncConfig) return { syncMode: "all" };
384
+ try {
385
+ const memberships = await client.listMyMemberships();
386
+ const m = memberships.find((x) => x.companyUid === companyUid);
387
+ if (!m) return { syncMode: "all" };
388
+ const cfg = await client.getMembershipSyncConfig(m.membershipKey);
389
+ if (cfg.syncMode === "all") return { syncMode: "all" };
390
+ if (cfg.syncMode === "custom") {
391
+ const customPrefixes = (cfg.customPaths ?? []).map((p) =>
392
+ grantPathToPrefix(p, slug),
393
+ );
394
+ // A bare-everything entry ("" — e.g. a `*` path) collapses under
395
+ // `coalescePrefixes` (which drops empties) to "nothing", which would
396
+ // prune the whole tree. An everything-scope is semantically `all`.
397
+ if (customPrefixes.some((p) => p === "")) return { syncMode: "all" };
398
+ return { syncMode: "custom", prefixSet: coalescePrefixes(customPrefixes) };
399
+ }
400
+ // shared: scope to the caller's explicit grants. Real grant paths are
401
+ // inconsistent — full (`companies/<slug>/x/*`), slug-anchored
402
+ // (`<slug>/x/*`), company-relative (`x/*`), bare globs (`*`), and exact
403
+ // files all coexist in production — so each is normalized via
404
+ // `grantPathToPrefix` into a company-relative, startsWith-friendly prefix
405
+ // (the namespace the engine's `RemoteFile.key`s live in) before coalescing.
406
+ //
407
+ // SAFETY: if the client can't fetch grants, we must NOT fall through to an
408
+ // empty `shared` scope — that would tell the engine "nothing is in scope"
409
+ // and scope-shrink would prune every clean local file. Degrade to `all`
410
+ // instead. A genuinely-empty grant list (the method exists and returns
411
+ // []) is a real "nothing shared with me" and is allowed to narrow.
412
+ if (!client.listMyExplicitGrants) return { syncMode: "all" };
413
+ const grants = await client.listMyExplicitGrants(companyUid);
414
+ const sharedPrefixes = grants.map((g) => grantPathToPrefix(g.path, slug));
415
+ // A wildcard grant (`*`) normalizes to "" = everything. Since
416
+ // `coalescePrefixes` drops empties (collapsing "everything" to "nothing"),
417
+ // treat any such grant as full-access `all` rather than risk pruning.
418
+ if (sharedPrefixes.some((p) => p === "")) return { syncMode: "all" };
419
+ return { syncMode: "shared", prefixSet: coalescePrefixes(sharedPrefixes) };
420
+ } catch {
421
+ // Degrade to `all` — never prune on a resolution failure.
422
+ return { syncMode: "all" };
423
+ }
342
424
  }
343
425
 
344
426
  /**
@@ -1066,6 +1148,9 @@ export async function runRunner(
1066
1148
  newFilesCount: 0,
1067
1149
  filesExcludedByPolicy: 0,
1068
1150
  filesTombstoned: 0,
1151
+ filesOutOfScope: 0,
1152
+ scopeOrphansRemoved: 0,
1153
+ scopeOrphansBlocked: 0,
1069
1154
  };
1070
1155
 
1071
1156
  // Push first so a subsequent pull doesn't overwrite files we were about
@@ -1166,11 +1251,24 @@ export async function runRunner(
1166
1251
  // whichever side `--on-conflict abort` just protected.
1167
1252
  if (doPull && !pushResult.aborted) {
1168
1253
  activePhase = "pull";
1254
+ // US-005: resolve the membership's effective download scope so the
1255
+ // pull only materializes in-scope keys (and prunes clean orphans when
1256
+ // scope shrank). Personal-vault legs have no membership sync-config —
1257
+ // they stay full-scope (`all`). Degrades to `all` on any error so a
1258
+ // transient failure can't silently prune the tree.
1259
+ const pullScope: PullScope =
1260
+ target.personalMode === true
1261
+ ? { syncMode: "all" }
1262
+ : await resolvePullScope(client, target.uid, target.slug);
1169
1263
  pullResult = await syncFn({
1170
1264
  company: target.uid,
1171
1265
  vaultConfig,
1172
1266
  hqRoot: parsed.hqRoot,
1173
1267
  onConflict: parsed.onConflict,
1268
+ syncMode: pullScope.syncMode,
1269
+ ...(pullScope.prefixSet !== undefined
1270
+ ? { prefixSet: pullScope.prefixSet }
1271
+ : {}),
1174
1272
  ...(target.personalMode !== undefined ? { personalMode: target.personalMode } : {}),
1175
1273
  ...(target.journalSlug !== undefined ? { journalSlug: target.journalSlug } : {}),
1176
1274
  // Symmetric to the push side: for the personal slot, tell sync()
@@ -1275,6 +1373,11 @@ export async function runRunner(
1275
1373
  aborted,
1276
1374
  newFiles: pullResult.newFiles,
1277
1375
  newFilesCount: pullResult.newFilesCount,
1376
+ // Scope-aware download counters (US-005). Pull-only — the push leg
1377
+ // has no scope concept — so they pass through from `pullResult`.
1378
+ filesOutOfScope: pullResult.filesOutOfScope,
1379
+ scopeOrphansRemoved: pullResult.scopeOrphansRemoved,
1380
+ scopeOrphansBlocked: pullResult.scopeOrphansBlocked,
1278
1381
  });
1279
1382
  for (const p of pullResult.conflictPaths) {
1280
1383
  allConflicts.push({ company: companyLabel, path: p, direction: "pull" });
@@ -1316,6 +1419,11 @@ export async function runRunner(
1316
1419
  aborted: true,
1317
1420
  newFiles: [],
1318
1421
  newFilesCount: 0,
1422
+ // Mid-flight throw: no clean scope counts to report. 0 keeps the
1423
+ // event shape stable (US-005).
1424
+ filesOutOfScope: 0,
1425
+ scopeOrphansRemoved: 0,
1426
+ scopeOrphansBlocked: 0,
1319
1427
  });
1320
1428
  emit({
1321
1429
  type: "error",