@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.
- package/dist/bin/sync-runner.d.ts +26 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +90 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +168 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/sync-scope.test.d.ts +22 -0
- package/dist/cli/sync-scope.test.d.ts.map +1 -0
- package/dist/cli/sync-scope.test.js +273 -0
- package/dist/cli/sync-scope.test.js.map +1 -0
- package/dist/cli/sync.d.ts +64 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +152 -4
- package/dist/cli/sync.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/prefix-coalesce.d.ts +29 -0
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +48 -0
- package/dist/prefix-coalesce.js.map +1 -1
- package/dist/prefix-coalesce.test.js +51 -1
- package/dist/prefix-coalesce.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +18 -0
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +28 -0
- package/dist/scope-shrink.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +222 -0
- package/src/bin/sync-runner.ts +108 -0
- package/src/cli/sync-scope.test.ts +307 -0
- package/src/cli/sync.ts +240 -1
- package/src/index.ts +1 -0
- package/src/prefix-coalesce.test.ts +76 -1
- package/src/prefix-coalesce.ts +45 -0
- package/src/scope-shrink.ts +28 -0
|
@@ -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
|
+
});
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -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",
|