@indigoai-us/hq-cloud 6.15.0 → 6.15.2
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-mutation.d.ts +16 -0
- package/dist/bin/sync-mutation.d.ts.map +1 -0
- package/dist/bin/sync-mutation.js +60 -0
- package/dist/bin/sync-mutation.js.map +1 -0
- package/dist/bin/sync-mutation.test.d.ts +2 -0
- package/dist/bin/sync-mutation.test.d.ts.map +1 -0
- package/dist/bin/sync-mutation.test.js +165 -0
- package/dist/bin/sync-mutation.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts +8 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +16 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.d.ts +2 -0
- package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-company.test.js +36 -0
- package/dist/bin/sync-runner-company.test.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +98 -8
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +17 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +109 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/conflict-recovery.test.d.ts +2 -0
- package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
- package/dist/cli/conflict-recovery.test.js +201 -0
- package/dist/cli/conflict-recovery.test.js.map +1 -0
- package/dist/cli/conflict.d.ts +60 -0
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +333 -0
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/sync.d.ts +35 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +100 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +85 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/skill-telemetry.d.ts +6 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +14 -2
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +79 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/candidate-uploader.d.ts +88 -0
- package/dist/sync/candidate-uploader.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.js +212 -0
- package/dist/sync/candidate-uploader.js.map +1 -0
- package/dist/sync/candidate-uploader.test.d.ts +2 -0
- package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.test.js +132 -0
- package/dist/sync/candidate-uploader.test.js.map +1 -0
- package/dist/sync/delta-client.d.ts +73 -0
- package/dist/sync/delta-client.d.ts.map +1 -0
- package/dist/sync/delta-client.js +201 -0
- package/dist/sync/delta-client.js.map +1 -0
- package/dist/sync/delta-client.test.d.ts +2 -0
- package/dist/sync/delta-client.test.d.ts.map +1 -0
- package/dist/sync/delta-client.test.js +97 -0
- package/dist/sync/delta-client.test.js.map +1 -0
- package/dist/sync/durable-apply.d.ts +76 -0
- package/dist/sync/durable-apply.d.ts.map +1 -0
- package/dist/sync/durable-apply.js +530 -0
- package/dist/sync/durable-apply.js.map +1 -0
- package/dist/sync/durable-apply.test.d.ts +2 -0
- package/dist/sync/durable-apply.test.d.ts.map +1 -0
- package/dist/sync/durable-apply.test.js +180 -0
- package/dist/sync/durable-apply.test.js.map +1 -0
- package/dist/sync/event-sync.d.ts +33 -1
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +149 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +142 -1
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/index.d.ts +2 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -0
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/multipart-uploader.d.ts +99 -0
- package/dist/sync/multipart-uploader.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.js +447 -0
- package/dist/sync/multipart-uploader.js.map +1 -0
- package/dist/sync/multipart-uploader.test.d.ts +2 -0
- package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.test.js +119 -0
- package/dist/sync/multipart-uploader.test.js.map +1 -0
- package/dist/sync/mutation-client.d.ts +85 -0
- package/dist/sync/mutation-client.d.ts.map +1 -0
- package/dist/sync/mutation-client.js +245 -0
- package/dist/sync/mutation-client.js.map +1 -0
- package/dist/sync/mutation-client.test.d.ts +2 -0
- package/dist/sync/mutation-client.test.d.ts.map +1 -0
- package/dist/sync/mutation-client.test.js +51 -0
- package/dist/sync/mutation-client.test.js.map +1 -0
- package/dist/sync/push-receiver.d.ts +45 -0
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +101 -0
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +54 -2
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync/scope-inventory-client.d.ts +69 -0
- package/dist/sync/scope-inventory-client.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.js +210 -0
- package/dist/sync/scope-inventory-client.js.map +1 -0
- package/dist/sync/scope-inventory-client.test.d.ts +2 -0
- package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.test.js +94 -0
- package/dist/sync/scope-inventory-client.test.js.map +1 -0
- package/dist/sync/snapshot-client.d.ts +98 -0
- package/dist/sync/snapshot-client.d.ts.map +1 -0
- package/dist/sync/snapshot-client.js +402 -0
- package/dist/sync/snapshot-client.js.map +1 -0
- package/dist/sync/snapshot-client.test.d.ts +2 -0
- package/dist/sync/snapshot-client.test.d.ts.map +1 -0
- package/dist/sync/snapshot-client.test.js +169 -0
- package/dist/sync/snapshot-client.test.js.map +1 -0
- package/dist/sync/uploader-finalization.d.ts +97 -0
- package/dist/sync/uploader-finalization.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.js +273 -0
- package/dist/sync/uploader-finalization.js.map +1 -0
- package/dist/sync/uploader-finalization.test.d.ts +2 -0
- package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.test.js +92 -0
- package/dist/sync/uploader-finalization.test.js.map +1 -0
- package/dist/telemetry.d.ts +11 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +21 -2
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +80 -0
- package/dist/telemetry.test.js.map +1 -1
- package/package.json +6 -1
- package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
- package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
- package/.github/workflows/ci.yml +0 -84
- package/.github/workflows/publish.yml +0 -56
- package/.github/workflows/unreleased-commits-nag.yml +0 -256
- package/eslint.config.js +0 -67
- package/pnpm-workspace.yaml +0 -2
- package/scripts/presign-transport-e2e.mjs +0 -250
- package/scripts/vault-rebaseline.sh +0 -323
- package/scripts/vault-rescue.sh +0 -332
- package/src/active-company.test.ts +0 -188
- package/src/active-company.ts +0 -168
- package/src/agent-codex-instructions.test.ts +0 -332
- package/src/agent-codex-instructions.ts +0 -309
- package/src/auth.ts +0 -146
- package/src/backup-prune.test.ts +0 -98
- package/src/backup-prune.ts +0 -182
- package/src/bin/backup-prune-runner.ts +0 -33
- package/src/bin/rescue-runner.ts +0 -25
- package/src/bin/sync-runner-company.ts +0 -695
- package/src/bin/sync-runner-events.test.ts +0 -143
- package/src/bin/sync-runner-events.ts +0 -55
- package/src/bin/sync-runner-planning.test.ts +0 -311
- package/src/bin/sync-runner-planning.ts +0 -258
- package/src/bin/sync-runner-rollup.test.ts +0 -37
- package/src/bin/sync-runner-rollup.ts +0 -97
- package/src/bin/sync-runner-telemetry.ts +0 -15
- package/src/bin/sync-runner-watch-loop.ts +0 -1235
- package/src/bin/sync-runner-watch-routes.test.ts +0 -71
- package/src/bin/sync-runner-watch-routes.ts +0 -184
- package/src/bin/sync-runner.test.ts +0 -8767
- package/src/bin/sync-runner.ts +0 -2190
- package/src/cli/accept.ts +0 -124
- package/src/cli/conflict.ts +0 -119
- package/src/cli/doctor.test.ts +0 -581
- package/src/cli/doctor.ts +0 -642
- package/src/cli/index.ts +0 -49
- package/src/cli/invite.test.ts +0 -250
- package/src/cli/invite.ts +0 -214
- package/src/cli/promote.ts +0 -157
- package/src/cli/reindex-knowledge.test.ts +0 -307
- package/src/cli/reindex-knowledge.ts +0 -450
- package/src/cli/reindex.test.ts +0 -957
- package/src/cli/reindex.ts +0 -979
- package/src/cli/rescue-classify-ordering.test.ts +0 -548
- package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
- package/src/cli/rescue-core.ts +0 -3011
- package/src/cli/rescue-drift-reconcile.test.ts +0 -179
- package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
- package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
- package/src/cli/rescue-hq-root-guard.test.ts +0 -232
- package/src/cli/rescue-journal-reconcile.test.ts +0 -215
- package/src/cli/rescue-mtime-preserve.test.ts +0 -203
- package/src/cli/rescue-settings-reconcile.test.ts +0 -637
- package/src/cli/rescue-snapshot.test.ts +0 -57
- package/src/cli/rescue-snapshot.ts +0 -51
- package/src/cli/rescue.reindex.test.ts +0 -63
- package/src/cli/rescue.test.ts +0 -131
- package/src/cli/rescue.ts +0 -182
- package/src/cli/share.test.ts +0 -7843
- package/src/cli/share.ts +0 -3663
- package/src/cli/sync-scope.test.ts +0 -652
- package/src/cli/sync.test.ts +0 -5207
- package/src/cli/sync.ts +0 -3470
- package/src/cli/tombstones.ts +0 -106
- package/src/cli/watch-event-push-conflict.test.ts +0 -234
- package/src/client-info.test.ts +0 -214
- package/src/client-info.ts +0 -121
- package/src/cognito-auth.test.ts +0 -712
- package/src/cognito-auth.ts +0 -1422
- package/src/company-resolver.test.ts +0 -618
- package/src/company-resolver.ts +0 -521
- package/src/context.test.ts +0 -583
- package/src/context.ts +0 -378
- package/src/daemon-worker.ts +0 -26
- package/src/daemon.ts +0 -99
- package/src/entity-resolver.test.ts +0 -315
- package/src/entity-resolver.ts +0 -180
- package/src/ignore.test.ts +0 -466
- package/src/ignore.ts +0 -469
- package/src/index.ts +0 -439
- package/src/journal.test.ts +0 -968
- package/src/journal.ts +0 -765
- package/src/lib/cloud-authoritative.test.ts +0 -45
- package/src/lib/cloud-authoritative.ts +0 -59
- package/src/lib/conflict-file.ts +0 -86
- package/src/lib/conflict-index.ts +0 -289
- package/src/lib/conflict.test.ts +0 -348
- package/src/lib/describe-error.test.ts +0 -100
- package/src/lib/describe-error.ts +0 -58
- package/src/lib/exit-codes.ts +0 -24
- package/src/lib/machine-id.test.ts +0 -231
- package/src/lib/machine-id.ts +0 -175
- package/src/lib/net-errors.test.ts +0 -65
- package/src/lib/net-errors.ts +0 -86
- package/src/lib/readlink-safe.test.ts +0 -43
- package/src/lib/readlink-safe.ts +0 -29
- package/src/local-path-codec.test.ts +0 -138
- package/src/local-path-codec.ts +0 -161
- package/src/machine-auth.test.ts +0 -1323
- package/src/manifest-reconcile.test.ts +0 -1123
- package/src/manifest-reconcile.ts +0 -518
- package/src/object-io.test.ts +0 -1221
- package/src/object-io.ts +0 -1306
- package/src/operation-lock.test.ts +0 -484
- package/src/operation-lock.ts +0 -680
- package/src/outcome-telemetry.test.ts +0 -498
- package/src/outcome-telemetry.ts +0 -639
- package/src/personal-vault-exclusions.test.ts +0 -308
- package/src/personal-vault-exclusions.ts +0 -354
- package/src/personal-vault.test.ts +0 -756
- package/src/personal-vault.ts +0 -496
- package/src/prefix-coalesce.test.ts +0 -240
- package/src/prefix-coalesce.ts +0 -273
- package/src/public-surface.test.ts +0 -117
- package/src/qmd-reindex.test.ts +0 -877
- package/src/qmd-reindex.ts +0 -842
- package/src/read-only-state-dir.test.ts +0 -188
- package/src/remote-pull.test.ts +0 -1130
- package/src/remote-pull.ts +0 -618
- package/src/s3.symlink-materialize.test.ts +0 -492
- package/src/s3.test.ts +0 -1789
- package/src/s3.ts +0 -1532
- package/src/schemas/signal-types.test.ts +0 -82
- package/src/schemas/signal-types.ts +0 -38
- package/src/schemas/source-channels.test.ts +0 -82
- package/src/schemas/source-channels.ts +0 -53
- package/src/scope-shrink.test.ts +0 -633
- package/src/scope-shrink.ts +0 -481
- package/src/signals/get.test.ts +0 -310
- package/src/signals/get.ts +0 -75
- package/src/signals/internals.ts +0 -195
- package/src/signals/list.test.ts +0 -420
- package/src/signals/list.ts +0 -79
- package/src/signals/parse.ts +0 -8
- package/src/signals/types.ts +0 -91
- package/src/skill-telemetry.test.ts +0 -1825
- package/src/skill-telemetry.ts +0 -1439
- package/src/sources/get.test.ts +0 -293
- package/src/sources/get.ts +0 -66
- package/src/sources/internals.ts +0 -198
- package/src/sources/list.test.ts +0 -402
- package/src/sources/list.ts +0 -84
- package/src/sources/parse.ts +0 -43
- package/src/sources/types.ts +0 -84
- package/src/sync/event-sync.test.ts +0 -594
- package/src/sync/event-sync.ts +0 -545
- package/src/sync/feature-flags.test.ts +0 -378
- package/src/sync/feature-flags.ts +0 -62
- package/src/sync/index.ts +0 -76
- package/src/sync/lease-client.test.ts +0 -128
- package/src/sync/lease-client.ts +0 -207
- package/src/sync/logger.test.ts +0 -242
- package/src/sync/logger.ts +0 -79
- package/src/sync/metrics.test.ts +0 -462
- package/src/sync/metrics.ts +0 -213
- package/src/sync/pull-scope.ts +0 -265
- package/src/sync/push-event.test.ts +0 -266
- package/src/sync/push-event.ts +0 -224
- package/src/sync/push-receiver.test.ts +0 -566
- package/src/sync/push-receiver.ts +0 -1048
- package/src/sync/push-transport.ts +0 -231
- package/src/sync/realtime-rollout.test.ts +0 -86
- package/src/sync/realtime-rollout.ts +0 -262
- package/src/sync/state-store.test.ts +0 -194
- package/src/sync/state-store.ts +0 -727
- package/src/sync-core.ts +0 -58
- package/src/sync-progress.test.ts +0 -94
- package/src/sync-progress.ts +0 -140
- package/src/telemetry-events.test.ts +0 -88
- package/src/telemetry-events.ts +0 -205
- package/src/telemetry.test.ts +0 -1280
- package/src/telemetry.ts +0 -1109
- package/src/types.ts +0 -314
- package/src/vault-client.test.ts +0 -1380
- package/src/vault-client.ts +0 -1694
- package/src/version.ts +0 -24
- package/src/watch-roots.test.ts +0 -278
- package/src/watch-roots.ts +0 -162
- package/src/watcher-event-gate.test.ts +0 -212
- package/src/watcher.test.ts +0 -1079
- package/src/watcher.ts +0 -1741
- package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
- package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
- package/test/e2e/sync/transient-company-leg.test.ts +0 -384
- package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
- package/test/e2e/watcher-real-chokidar.test.ts +0 -165
- package/test/e2e/watcher-recursive-backend.test.ts +0 -181
- package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
- package/test/invite-flow.integration.test.ts +0 -244
- package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
- package/test/share-sync.integration.test.ts +0 -213
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -22
|
@@ -1,652 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for scope-aware download (US-005 wiring into `sync()`).
|
|
3
|
-
*
|
|
4
|
-
* Covers the contract added when `syncMode` / `prefixSet` were threaded
|
|
5
|
-
* through `computePullPlan` + the scope-shrink pass:
|
|
6
|
-
*
|
|
7
|
-
* - `all` → no filtering (regression guard lives in sync.test.ts).
|
|
8
|
-
* - `shared` → only keys covered by `prefixSet` download; the rest are
|
|
9
|
-
* classified `skip-out-of-scope` (NOT downloaded).
|
|
10
|
-
* - `custom` → same mechanism, driven by the explicit path list.
|
|
11
|
-
* - Idempotency → a second `shared` pull downloads nothing and removes
|
|
12
|
-
* nothing (the PullRecord makes scope-change a no-op).
|
|
13
|
-
* - Scope shrink → narrowing `all → shared` prunes the now-out-of-scope
|
|
14
|
-
* CLEAN local orphan; a DIRTY orphan aborts with
|
|
15
|
-
* `ScopeShrinkBlockedError` unless `forceScopeShrink`.
|
|
16
|
-
*
|
|
17
|
-
* The security contract (this filter is footprint-only, never an authz
|
|
18
|
-
* boundary) is asserted indirectly: out-of-scope keys are still LISTED and
|
|
19
|
-
* accessible — the engine simply chooses not to materialize them.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
23
|
-
import * as fs from "fs";
|
|
24
|
-
import * as path from "path";
|
|
25
|
-
import * as os from "os";
|
|
26
|
-
import { clearContextCache } from "../context.js";
|
|
27
|
-
import type { VaultServiceConfig } from "../types.js";
|
|
28
|
-
|
|
29
|
-
// Mutable remote-file list so each test controls what the vault returns.
|
|
30
|
-
const REMOTE: { current: Array<{ key: string; size: number; lastModified: Date; etag: string }> } = {
|
|
31
|
-
current: [
|
|
32
|
-
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"abc123"' },
|
|
33
|
-
{ key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
|
|
34
|
-
],
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
vi.mock("../s3.js", async (importOriginal) => {
|
|
38
|
-
const actual = await importOriginal<typeof import("../s3.js")>();
|
|
39
|
-
const innerFs = await import("fs");
|
|
40
|
-
const innerPath = await import("path");
|
|
41
|
-
const { vi: innerVi } = await import("vitest");
|
|
42
|
-
return {
|
|
43
|
-
...actual,
|
|
44
|
-
toPosixKey: (key: string) => key.split("\\").join("/"),
|
|
45
|
-
uploadFile: innerVi.fn().mockResolvedValue(undefined),
|
|
46
|
-
downloadFile: innerVi
|
|
47
|
-
.fn()
|
|
48
|
-
.mockImplementation(async (_ctx: unknown, key: string, localPath: string) => {
|
|
49
|
-
const dir = innerPath.dirname(localPath);
|
|
50
|
-
if (!innerFs.existsSync(dir)) innerFs.mkdirSync(dir, { recursive: true });
|
|
51
|
-
// Deterministic per-key body so re-downloads produce a stable hash.
|
|
52
|
-
innerFs.writeFileSync(localPath, `mock:${key}`);
|
|
53
|
-
// Real GETs carry author metadata; surface a fixed uploader sub so
|
|
54
|
-
// journal entries are stamped with a KNOWN author. With no `callerSub`
|
|
55
|
-
// passed (the default for these tests), that author is "foreign", so
|
|
56
|
-
// the scope-shrink prune/block paths behave exactly as pre-guard —
|
|
57
|
-
// only the new own-author retention test passes a matching callerSub.
|
|
58
|
-
return { metadata: { "created-by-sub": "uploader-sub" } };
|
|
59
|
-
}),
|
|
60
|
-
listRemoteFiles: innerVi.fn().mockImplementation(async () => REMOTE.current),
|
|
61
|
-
deleteRemoteFile: innerVi.fn().mockResolvedValue(undefined),
|
|
62
|
-
// HEAD returns metadata (object exists) for any key still in REMOTE,
|
|
63
|
-
// null otherwise — mirrors the real bucket so the tombstone HEAD-verify
|
|
64
|
-
// pass behaves correctly for out-of-scope (still-present) keys.
|
|
65
|
-
headRemoteFile: innerVi.fn().mockImplementation(async (_ctx: unknown, key: string) => {
|
|
66
|
-
const hit = REMOTE.current.find((r) => r.key === key);
|
|
67
|
-
return hit ? { metadata: {}, size: hit.size, etag: hit.etag } : null;
|
|
68
|
-
}),
|
|
69
|
-
primeObjectTransport: innerVi.fn().mockResolvedValue(undefined),
|
|
70
|
-
};
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
import { sync } from "./sync.js";
|
|
74
|
-
import {
|
|
75
|
-
ScopeShrinkBlockedError,
|
|
76
|
-
ScopeShrinkLargePruneError,
|
|
77
|
-
} from "../scope-shrink.js";
|
|
78
|
-
|
|
79
|
-
const mockConfig: VaultServiceConfig = {
|
|
80
|
-
apiUrl: "https://vault-api.test",
|
|
81
|
-
authToken: "test-jwt-token",
|
|
82
|
-
region: "us-east-1",
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
const mockEntity = {
|
|
86
|
-
uid: "cmp_01ABCDEF",
|
|
87
|
-
slug: "acme",
|
|
88
|
-
bucketName: "hq-vault-acme-123",
|
|
89
|
-
status: "active",
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
const mockVendResponse = {
|
|
93
|
-
credentials: {
|
|
94
|
-
accessKeyId: "ASIA_TEST_KEY",
|
|
95
|
-
secretAccessKey: "test-secret",
|
|
96
|
-
sessionToken: "test-session-token",
|
|
97
|
-
expiration: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
|
98
|
-
},
|
|
99
|
-
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
function setupFetchMock() {
|
|
103
|
-
const fetchMock = vi.fn().mockImplementation(async (url: string) => {
|
|
104
|
-
const urlStr = String(url);
|
|
105
|
-
if (urlStr.includes("/entity/check-slug/me")) {
|
|
106
|
-
return {
|
|
107
|
-
ok: true,
|
|
108
|
-
status: 200,
|
|
109
|
-
json: async () => ({ available: false, conflictingCompanyUid: mockEntity.uid }),
|
|
110
|
-
text: async () => "",
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
if (urlStr.includes("/entity/by-slug/") || /\/entity\/cmp_/.test(urlStr)) {
|
|
114
|
-
return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
|
|
115
|
-
}
|
|
116
|
-
if (urlStr.includes("/sts/vend")) {
|
|
117
|
-
return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
|
|
118
|
-
}
|
|
119
|
-
return { ok: false, status: 404, text: async () => "Not found" };
|
|
120
|
-
});
|
|
121
|
-
vi.stubGlobal("fetch", fetchMock);
|
|
122
|
-
return fetchMock;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
describe("sync — scope-aware download (US-005)", () => {
|
|
126
|
-
let tmpDir: string;
|
|
127
|
-
let stateDir: string;
|
|
128
|
-
const companyRel = (p: string) => path.join(tmpDir, "companies", "acme", p);
|
|
129
|
-
|
|
130
|
-
beforeEach(() => {
|
|
131
|
-
clearContextCache();
|
|
132
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-scope-"));
|
|
133
|
-
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-scope-"));
|
|
134
|
-
process.env.HQ_STATE_DIR = stateDir;
|
|
135
|
-
// Reset the remote list every test (a prior test may have mutated it).
|
|
136
|
-
REMOTE.current = [
|
|
137
|
-
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"abc123"' },
|
|
138
|
-
{ key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
|
|
139
|
-
];
|
|
140
|
-
setupFetchMock();
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
afterEach(() => {
|
|
144
|
-
vi.unstubAllGlobals();
|
|
145
|
-
vi.clearAllMocks();
|
|
146
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
147
|
-
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
148
|
-
delete process.env.HQ_STATE_DIR;
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it("shared mode downloads only keys covered by prefixSet; rest are skip-out-of-scope", async () => {
|
|
152
|
-
const result = await sync({
|
|
153
|
-
company: "acme",
|
|
154
|
-
vaultConfig: mockConfig,
|
|
155
|
-
hqRoot: tmpDir,
|
|
156
|
-
syncMode: "shared",
|
|
157
|
-
prefixSet: ["knowledge/"],
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
expect(result.filesDownloaded).toBe(1);
|
|
161
|
-
expect(result.filesOutOfScope).toBe(1);
|
|
162
|
-
expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
|
|
163
|
-
// docs/handoff.md is out of scope — never materialized.
|
|
164
|
-
expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(false);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it("custom mode behaves like shared, driven by the explicit prefix list", async () => {
|
|
168
|
-
const result = await sync({
|
|
169
|
-
company: "acme",
|
|
170
|
-
vaultConfig: mockConfig,
|
|
171
|
-
hqRoot: tmpDir,
|
|
172
|
-
syncMode: "custom",
|
|
173
|
-
prefixSet: ["docs/"],
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
expect(result.filesDownloaded).toBe(1);
|
|
177
|
-
expect(result.filesOutOfScope).toBe(1);
|
|
178
|
-
expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
|
|
179
|
-
expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(false);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it("shared mode with empty prefixSet downloads nothing", async () => {
|
|
183
|
-
const result = await sync({
|
|
184
|
-
company: "acme",
|
|
185
|
-
vaultConfig: mockConfig,
|
|
186
|
-
hqRoot: tmpDir,
|
|
187
|
-
syncMode: "shared",
|
|
188
|
-
prefixSet: [],
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
expect(result.filesDownloaded).toBe(0);
|
|
192
|
-
expect(result.filesOutOfScope).toBe(2);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
// FILE_TOMBSTONE fetch is COMPANY-scoped (GET /v1/files/tombstones?company=…).
|
|
196
|
-
// For the personal vault the target uid is a personUid (prs_…), which the
|
|
197
|
-
// server correctly 403s ("No active membership for caller in company prs_…"),
|
|
198
|
-
// spamming hq-pro's Sentry with a per-user no-membership cluster. The pull
|
|
199
|
-
// must SKIP the fetch for the personal target (delete-resync was never a
|
|
200
|
-
// committed personal-vault feature). Differential: company-mode fetches,
|
|
201
|
-
// personal-mode does not.
|
|
202
|
-
const hitTombstones = (m: ReturnType<typeof setupFetchMock>) =>
|
|
203
|
-
m.mock.calls.some((c: unknown[]) =>
|
|
204
|
-
String(c[0]).includes("/v1/files/tombstones"),
|
|
205
|
-
);
|
|
206
|
-
|
|
207
|
-
it("company-mode pull fetches FILE_TOMBSTONEs (baseline for the personal guard)", async () => {
|
|
208
|
-
const fetchMock = setupFetchMock();
|
|
209
|
-
await sync({
|
|
210
|
-
company: "acme",
|
|
211
|
-
vaultConfig: mockConfig,
|
|
212
|
-
hqRoot: tmpDir,
|
|
213
|
-
syncMode: "all",
|
|
214
|
-
prefixSet: [],
|
|
215
|
-
});
|
|
216
|
-
expect(hitTombstones(fetchMock)).toBe(true);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it("personal-vault pull SKIPS the company-scoped tombstone fetch (no 403 → no Sentry no-membership spam)", async () => {
|
|
220
|
-
const fetchMock = setupFetchMock();
|
|
221
|
-
await sync({
|
|
222
|
-
company: "acme",
|
|
223
|
-
vaultConfig: mockConfig,
|
|
224
|
-
hqRoot: tmpDir,
|
|
225
|
-
syncMode: "all",
|
|
226
|
-
prefixSet: [],
|
|
227
|
-
personalMode: true,
|
|
228
|
-
});
|
|
229
|
-
expect(hitTombstones(fetchMock)).toBe(false);
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it("is idempotent: a second shared pull downloads and removes nothing", async () => {
|
|
233
|
-
const opts = {
|
|
234
|
-
company: "acme",
|
|
235
|
-
vaultConfig: mockConfig,
|
|
236
|
-
hqRoot: tmpDir,
|
|
237
|
-
syncMode: "shared" as const,
|
|
238
|
-
prefixSet: ["knowledge/"],
|
|
239
|
-
};
|
|
240
|
-
const first = await sync(opts);
|
|
241
|
-
expect(first.filesDownloaded).toBe(1);
|
|
242
|
-
|
|
243
|
-
const second = await sync(opts);
|
|
244
|
-
expect(second.filesDownloaded).toBe(0);
|
|
245
|
-
expect(second.scopeOrphansRemoved).toBe(0);
|
|
246
|
-
expect(second.scopeOrphansBlocked).toBe(0);
|
|
247
|
-
// knowledge file still present; nothing churned.
|
|
248
|
-
expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
it("scope shrink (all → shared) prunes the clean out-of-scope orphan", async () => {
|
|
252
|
-
// First pull EVERYTHING under all-mode.
|
|
253
|
-
const all = await sync({
|
|
254
|
-
company: "acme",
|
|
255
|
-
vaultConfig: mockConfig,
|
|
256
|
-
hqRoot: tmpDir,
|
|
257
|
-
syncMode: "all",
|
|
258
|
-
});
|
|
259
|
-
expect(all.filesDownloaded).toBe(2);
|
|
260
|
-
expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
|
|
261
|
-
|
|
262
|
-
// Narrow to shared/knowledge → docs/handoff.md is now a clean orphan.
|
|
263
|
-
const shared = await sync({
|
|
264
|
-
company: "acme",
|
|
265
|
-
vaultConfig: mockConfig,
|
|
266
|
-
hqRoot: tmpDir,
|
|
267
|
-
syncMode: "shared",
|
|
268
|
-
prefixSet: ["knowledge/"],
|
|
269
|
-
});
|
|
270
|
-
expect(shared.scopeOrphansRemoved).toBe(1);
|
|
271
|
-
expect(shared.scopeOrphansBlocked).toBe(0);
|
|
272
|
-
// The clean orphan was pruned; the in-scope file stays.
|
|
273
|
-
expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(false);
|
|
274
|
-
expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
it("scope shrink (all → shared) RETAINS the owner's own out-of-scope file", async () => {
|
|
278
|
-
// Corey's exact scenario: the caller authored everything (the mock stamps
|
|
279
|
-
// `created-by-sub: "uploader-sub"`), so passing the matching `callerSub`
|
|
280
|
-
// means narrowing to `shared` must NOT prune their own work — mode only
|
|
281
|
-
// governs mirroring OTHER people's files.
|
|
282
|
-
const all = await sync({
|
|
283
|
-
company: "acme",
|
|
284
|
-
vaultConfig: mockConfig,
|
|
285
|
-
hqRoot: tmpDir,
|
|
286
|
-
syncMode: "all",
|
|
287
|
-
callerSub: "uploader-sub",
|
|
288
|
-
});
|
|
289
|
-
expect(all.filesDownloaded).toBe(2);
|
|
290
|
-
|
|
291
|
-
const shared = await sync({
|
|
292
|
-
company: "acme",
|
|
293
|
-
vaultConfig: mockConfig,
|
|
294
|
-
hqRoot: tmpDir,
|
|
295
|
-
syncMode: "shared",
|
|
296
|
-
prefixSet: ["knowledge/"],
|
|
297
|
-
callerSub: "uploader-sub",
|
|
298
|
-
});
|
|
299
|
-
// Out of scope, but authored by the caller → retained, not pruned.
|
|
300
|
-
expect(shared.scopeOrphansRemoved).toBe(0);
|
|
301
|
-
expect(shared.scopeOrphansBlocked).toBe(0);
|
|
302
|
-
expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
|
|
303
|
-
expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
it("refuses a bulk auto-prune over the safety cap, then proceeds when forced", async () => {
|
|
307
|
-
// Pull both files under all-mode, then narrow to a scope covering neither
|
|
308
|
-
// → 2 clean orphans. With the cap set to 1, the auto-prune is refused.
|
|
309
|
-
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, syncMode: "all" });
|
|
310
|
-
|
|
311
|
-
// Narrow to a scope covering neither file → 2 clean orphans; cap at 1.
|
|
312
|
-
process.env.HQ_SYNC_MAX_AUTO_PRUNE = "1";
|
|
313
|
-
try {
|
|
314
|
-
await expect(
|
|
315
|
-
sync({
|
|
316
|
-
company: "acme",
|
|
317
|
-
vaultConfig: mockConfig,
|
|
318
|
-
hqRoot: tmpDir,
|
|
319
|
-
syncMode: "shared",
|
|
320
|
-
prefixSet: ["nonexistent/"],
|
|
321
|
-
}),
|
|
322
|
-
).rejects.toBeInstanceOf(ScopeShrinkLargePruneError);
|
|
323
|
-
// Nothing deleted on the refused run.
|
|
324
|
-
expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
|
|
325
|
-
expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
|
|
326
|
-
|
|
327
|
-
// Forced: the prune proceeds despite the cap.
|
|
328
|
-
const forced = await sync({
|
|
329
|
-
company: "acme",
|
|
330
|
-
vaultConfig: mockConfig,
|
|
331
|
-
hqRoot: tmpDir,
|
|
332
|
-
syncMode: "shared",
|
|
333
|
-
prefixSet: ["nonexistent/"],
|
|
334
|
-
forceScopeShrink: true,
|
|
335
|
-
});
|
|
336
|
-
expect(forced.scopeOrphansRemoved).toBe(2);
|
|
337
|
-
expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(false);
|
|
338
|
-
expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(false);
|
|
339
|
-
} finally {
|
|
340
|
-
delete process.env.HQ_SYNC_MAX_AUTO_PRUNE;
|
|
341
|
-
}
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
it("scope shrink aborts on a DIRTY orphan unless forceScopeShrink is set", async () => {
|
|
345
|
-
await sync({
|
|
346
|
-
company: "acme",
|
|
347
|
-
vaultConfig: mockConfig,
|
|
348
|
-
hqRoot: tmpDir,
|
|
349
|
-
syncMode: "all",
|
|
350
|
-
});
|
|
351
|
-
// Locally modify the soon-to-be-orphan so it's dirty (hash mismatch).
|
|
352
|
-
fs.writeFileSync(companyRel("docs/handoff.md"), "LOCAL EDIT — do not delete");
|
|
353
|
-
|
|
354
|
-
// Default: abort with the structured error; the dirty file is untouched.
|
|
355
|
-
await expect(
|
|
356
|
-
sync({
|
|
357
|
-
company: "acme",
|
|
358
|
-
vaultConfig: mockConfig,
|
|
359
|
-
hqRoot: tmpDir,
|
|
360
|
-
syncMode: "shared",
|
|
361
|
-
prefixSet: ["knowledge/"],
|
|
362
|
-
}),
|
|
363
|
-
).rejects.toBeInstanceOf(ScopeShrinkBlockedError);
|
|
364
|
-
expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
|
|
365
|
-
expect(fs.readFileSync(companyRel("docs/handoff.md"), "utf-8")).toBe(
|
|
366
|
-
"LOCAL EDIT — do not delete",
|
|
367
|
-
);
|
|
368
|
-
|
|
369
|
-
// With force: the leg proceeds, the dirty file is LEFT ON DISK, only its
|
|
370
|
-
// journal entry is tombstoned.
|
|
371
|
-
const forced = await sync({
|
|
372
|
-
company: "acme",
|
|
373
|
-
vaultConfig: mockConfig,
|
|
374
|
-
hqRoot: tmpDir,
|
|
375
|
-
syncMode: "shared",
|
|
376
|
-
prefixSet: ["knowledge/"],
|
|
377
|
-
forceScopeShrink: true,
|
|
378
|
-
});
|
|
379
|
-
expect(forced.scopeOrphansBlocked).toBe(1);
|
|
380
|
-
expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
// DEV-1768 fix #3: a scope shrink must QUARANTINE clean orphans (recoverable),
|
|
384
|
-
// never silently delete them. The file leaves its working-tree path but is
|
|
385
|
-
// recoverable under `.hq/scope-quarantine/<slug>/`.
|
|
386
|
-
it("scope shrink QUARANTINES a clean orphan (recoverable), never silent-deletes", async () => {
|
|
387
|
-
const quarantined = path.join(
|
|
388
|
-
tmpDir,
|
|
389
|
-
".hq",
|
|
390
|
-
"scope-quarantine",
|
|
391
|
-
"acme",
|
|
392
|
-
"docs",
|
|
393
|
-
"handoff.md",
|
|
394
|
-
);
|
|
395
|
-
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, syncMode: "all" });
|
|
396
|
-
const original = companyRel("docs/handoff.md");
|
|
397
|
-
const bodyBefore = fs.readFileSync(original, "utf-8");
|
|
398
|
-
|
|
399
|
-
const shared = await sync({
|
|
400
|
-
company: "acme",
|
|
401
|
-
vaultConfig: mockConfig,
|
|
402
|
-
hqRoot: tmpDir,
|
|
403
|
-
syncMode: "shared",
|
|
404
|
-
prefixSet: ["knowledge/"],
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
expect(shared.scopeOrphansRemoved).toBe(1);
|
|
408
|
-
// Gone from the working tree...
|
|
409
|
-
expect(fs.existsSync(original)).toBe(false);
|
|
410
|
-
// ...but recoverable in quarantine, byte-for-byte.
|
|
411
|
-
expect(fs.existsSync(quarantined)).toBe(true);
|
|
412
|
-
expect(fs.readFileSync(quarantined, "utf-8")).toBe(bodyBefore);
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
// DEV-1768 fix #1 (recovery) + #2: the background runner path
|
|
416
|
-
// (`scopeShrinkPolicy: "auto-recover"`) must NEVER throw on a scope shrink —
|
|
417
|
-
// it self-heals. This reproduces the EXACT wedge: a buggy `all`-mode pull
|
|
418
|
-
// seeds the journal (all-mode PullRecord + both files), one out-of-scope file
|
|
419
|
-
// is edited locally (dirty), then the real `shared` pull arrives. Under the
|
|
420
|
-
// old code this threw ScopeShrinkBlockedError(all→shared) → exit 2 forever.
|
|
421
|
-
it("auto-recover policy clears an all→shared wedge: dirty kept on disk, clean quarantined, no throw", async () => {
|
|
422
|
-
// 1. Buggy all-mode CLI seed: journals everything + stamps an all-mode record.
|
|
423
|
-
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, syncMode: "all" });
|
|
424
|
-
// 2. User edits an out-of-scope file → it becomes a DIRTY orphan.
|
|
425
|
-
fs.writeFileSync(companyRel("docs/handoff.md"), "LOCAL EDIT — keep me");
|
|
426
|
-
const quarantinedClean = path.join(
|
|
427
|
-
tmpDir, ".hq", "scope-quarantine", "acme", "knowledge", "readme.md",
|
|
428
|
-
);
|
|
429
|
-
|
|
430
|
-
// 3. The menubar runner's real shared pull. prefixSet covers NEITHER file
|
|
431
|
-
// (docs is dirty-orphan, knowledge becomes a clean orphan) so we exercise
|
|
432
|
-
// both dispositions. Must resolve without throwing.
|
|
433
|
-
const recovered = await sync({
|
|
434
|
-
company: "acme",
|
|
435
|
-
vaultConfig: mockConfig,
|
|
436
|
-
hqRoot: tmpDir,
|
|
437
|
-
syncMode: "shared",
|
|
438
|
-
prefixSet: ["projects/"],
|
|
439
|
-
scopeShrinkPolicy: "auto-recover",
|
|
440
|
-
});
|
|
441
|
-
expect(recovered.aborted).toBe(false);
|
|
442
|
-
// Dirty file: KEPT on disk (un-tracked), counted as blocked-but-kept.
|
|
443
|
-
expect(recovered.scopeOrphansBlocked).toBe(1);
|
|
444
|
-
expect(fs.existsSync(companyRel("docs/handoff.md"))).toBe(true);
|
|
445
|
-
expect(fs.readFileSync(companyRel("docs/handoff.md"), "utf-8")).toBe(
|
|
446
|
-
"LOCAL EDIT — keep me",
|
|
447
|
-
);
|
|
448
|
-
// Clean file: quarantined (recoverable), removed from the working tree.
|
|
449
|
-
expect(recovered.scopeOrphansRemoved).toBe(1);
|
|
450
|
-
expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(false);
|
|
451
|
-
expect(fs.existsSync(quarantinedClean)).toBe(true);
|
|
452
|
-
|
|
453
|
-
// 4. Idempotent: the next auto-recover sync re-flags NOTHING (the orphans
|
|
454
|
-
// were tombstoned, so the wedge does not recur).
|
|
455
|
-
const second = await sync({
|
|
456
|
-
company: "acme",
|
|
457
|
-
vaultConfig: mockConfig,
|
|
458
|
-
hqRoot: tmpDir,
|
|
459
|
-
syncMode: "shared",
|
|
460
|
-
prefixSet: ["projects/"],
|
|
461
|
-
scopeShrinkPolicy: "auto-recover",
|
|
462
|
-
});
|
|
463
|
-
expect(second.scopeOrphansRemoved).toBe(0);
|
|
464
|
-
expect(second.scopeOrphansBlocked).toBe(0);
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
// ── US-006: sessions prefix push-only pull-scope exclusion ─────────────────
|
|
468
|
-
// The sessions prefix is PUSH-ONLY: pushed into the vault but never
|
|
469
|
-
// auto-pulled onto grantee laptops in ANY mode (including `all`, which
|
|
470
|
-
// otherwise pulls the whole bucket). The engine expresses this as
|
|
471
|
-
// `excludePrefixes`, applied to the DOWNLOAD filter only — never to
|
|
472
|
-
// scope-shrink, so an authored/pinned session is never orphaned.
|
|
473
|
-
describe("sessions push-only exclusion (US-006)", () => {
|
|
474
|
-
const withSession = () => {
|
|
475
|
-
REMOTE.current = [
|
|
476
|
-
{ key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
|
|
477
|
-
{ key: "sessions/prs_1/2026-07-11T10-00-00.json", size: 50, lastModified: new Date(), etag: '"sess1"' },
|
|
478
|
-
];
|
|
479
|
-
};
|
|
480
|
-
const sessionPath = "sessions/prs_1/2026-07-11T10-00-00.json";
|
|
481
|
-
|
|
482
|
-
it("AC4: `all` mode subtracts excludePrefixes — a sessions key is skip-out-of-scope; non-session downloads", async () => {
|
|
483
|
-
withSession();
|
|
484
|
-
const result = await sync({
|
|
485
|
-
company: "acme",
|
|
486
|
-
vaultConfig: mockConfig,
|
|
487
|
-
hqRoot: tmpDir,
|
|
488
|
-
syncMode: "all",
|
|
489
|
-
excludePrefixes: ["sessions/"],
|
|
490
|
-
});
|
|
491
|
-
// `all` normally pulls everything; the exclusion carves sessions out.
|
|
492
|
-
expect(result.filesDownloaded).toBe(1);
|
|
493
|
-
expect(result.filesOutOfScope).toBe(1);
|
|
494
|
-
expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
|
|
495
|
-
expect(fs.existsSync(companyRel(sessionPath))).toBe(false);
|
|
496
|
-
});
|
|
497
|
-
|
|
498
|
-
it("AC4: excludePrefixes is NOT fed to scope-shrink — a previously-pulled session file is never orphaned", async () => {
|
|
499
|
-
withSession();
|
|
500
|
-
// 1. Full pull WITHOUT the exclusion: the session materializes and journals
|
|
501
|
-
// as a pulled (direction:"down") entry under an `all`-mode PullRecord.
|
|
502
|
-
const first = await sync({
|
|
503
|
-
company: "acme",
|
|
504
|
-
vaultConfig: mockConfig,
|
|
505
|
-
hqRoot: tmpDir,
|
|
506
|
-
syncMode: "all",
|
|
507
|
-
});
|
|
508
|
-
expect(first.filesDownloaded).toBe(2);
|
|
509
|
-
expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
|
|
510
|
-
|
|
511
|
-
// 2. Full pull WITH the sessions exclusion. The session is now
|
|
512
|
-
// skip-out-of-scope (not re-pulled) but MUST NOT be pruned: the exclude
|
|
513
|
-
// set never reaches scope-shrink, whose currentPrefixSet stays `[""]`
|
|
514
|
-
// (covers everything). If it WERE fed in, the file would be orphaned.
|
|
515
|
-
const second = await sync({
|
|
516
|
-
company: "acme",
|
|
517
|
-
vaultConfig: mockConfig,
|
|
518
|
-
hqRoot: tmpDir,
|
|
519
|
-
syncMode: "all",
|
|
520
|
-
excludePrefixes: ["sessions/"],
|
|
521
|
-
});
|
|
522
|
-
expect(second.filesOutOfScope).toBe(1);
|
|
523
|
-
expect(second.scopeOrphansRemoved).toBe(0);
|
|
524
|
-
expect(second.scopeOrphansBlocked).toBe(0);
|
|
525
|
-
expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
|
|
526
|
-
});
|
|
527
|
-
|
|
528
|
-
it("AC6: a pinned/in-scope session survives scope-shrink even though excludePrefixes skips its re-pull", async () => {
|
|
529
|
-
withSession();
|
|
530
|
-
// Materialize both under all-mode (knowledge sync + an on-demand session get).
|
|
531
|
-
await sync({ company: "acme", vaultConfig: mockConfig, hqRoot: tmpDir, syncMode: "all" });
|
|
532
|
-
expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
|
|
533
|
-
|
|
534
|
-
// A scoped shared pull whose inclusion prefixSet includes the pinned
|
|
535
|
-
// session key (as resolvePullScope would after `hq files get` pins it),
|
|
536
|
-
// plus the push-only sessions exclusion. The session is NOT re-pulled
|
|
537
|
-
// (exclusion), but the pin keeps it in scope-shrink's currentPrefixSet, so
|
|
538
|
-
// it is retained rather than orphaned.
|
|
539
|
-
const scoped = await sync({
|
|
540
|
-
company: "acme",
|
|
541
|
-
vaultConfig: mockConfig,
|
|
542
|
-
hqRoot: tmpDir,
|
|
543
|
-
syncMode: "shared",
|
|
544
|
-
prefixSet: ["knowledge/", sessionPath],
|
|
545
|
-
excludePrefixes: ["sessions/"],
|
|
546
|
-
});
|
|
547
|
-
expect(scoped.scopeOrphansRemoved).toBe(0);
|
|
548
|
-
expect(scoped.scopeOrphansBlocked).toBe(0);
|
|
549
|
-
// Session is not re-downloaded (excluded) but survives on disk...
|
|
550
|
-
expect(scoped.filesOutOfScope).toBe(1);
|
|
551
|
-
expect(fs.existsSync(companyRel(sessionPath))).toBe(true);
|
|
552
|
-
// ...and the in-scope knowledge file is retained too.
|
|
553
|
-
expect(fs.existsSync(companyRel("knowledge/readme.md"))).toBe(true);
|
|
554
|
-
});
|
|
555
|
-
|
|
556
|
-
// ── feedback_d2082110 follow-up ────────────────────────────────────────
|
|
557
|
-
// `skip-out-of-scope` carries TWO causes with OPPOSITE remedies:
|
|
558
|
-
// membership scope (fixed by raising the access level to `all`) and the
|
|
559
|
-
// push-only sessions exclusion (NOT fixed by that — sessions are withheld
|
|
560
|
-
// in every mode, `all` included, and materialize only via `hq files get`).
|
|
561
|
-
// The `scope-materialization-gap` advisory names the shared→all lever, so
|
|
562
|
-
// it must count and sample ONLY membership-withheld keys. Counting session
|
|
563
|
-
// keys inflates the gap, can fill the sample list with keys the lever will
|
|
564
|
-
// never deliver, and can fire the whole advisory when the membership scope
|
|
565
|
-
// withheld nothing — sending the reporter back into exactly the
|
|
566
|
-
// flip-everything-to-`all`-and-still-miss-files loop the event exists to end.
|
|
567
|
-
describe("materialization gap excludes push-only keys (feedback_d2082110)", () => {
|
|
568
|
-
const collectEvents = () => {
|
|
569
|
-
const events: Array<Record<string, unknown>> = [];
|
|
570
|
-
return {
|
|
571
|
-
events,
|
|
572
|
-
onEvent: (e: unknown) => events.push(e as Record<string, unknown>),
|
|
573
|
-
};
|
|
574
|
-
};
|
|
575
|
-
const gapOf = (events: Array<Record<string, unknown>>) =>
|
|
576
|
-
events.find((e) => e.type === "scope-materialization-gap");
|
|
577
|
-
|
|
578
|
-
it("a leg withholding ONLY push-only session keys emits no gap — the shared→all lever would not materialize them", async () => {
|
|
579
|
-
withSession();
|
|
580
|
-
const { events, onEvent } = collectEvents();
|
|
581
|
-
const result = await sync({
|
|
582
|
-
company: "acme",
|
|
583
|
-
vaultConfig: mockConfig,
|
|
584
|
-
hqRoot: tmpDir,
|
|
585
|
-
syncMode: "shared",
|
|
586
|
-
prefixSet: ["knowledge/"],
|
|
587
|
-
excludePrefixes: ["sessions/"],
|
|
588
|
-
onEvent,
|
|
589
|
-
});
|
|
590
|
-
|
|
591
|
-
// The session key is still withheld and still counted on the
|
|
592
|
-
// both-causes `filesOutOfScope` axis — that contract is unchanged.
|
|
593
|
-
expect(result.filesDownloaded).toBe(1);
|
|
594
|
-
expect(result.filesOutOfScope).toBe(1);
|
|
595
|
-
expect(fs.existsSync(companyRel(sessionPath))).toBe(false);
|
|
596
|
-
// But the membership scope withheld nothing, so there is no
|
|
597
|
-
// shared-vs-all gap to report and no `all` lever to advertise.
|
|
598
|
-
expect(gapOf(events)).toBeUndefined();
|
|
599
|
-
});
|
|
600
|
-
|
|
601
|
-
it("counts and samples only membership-withheld keys when both causes fire on one leg", async () => {
|
|
602
|
-
REMOTE.current = [
|
|
603
|
-
{ key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
|
|
604
|
-
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"abc123"' },
|
|
605
|
-
{ key: sessionPath, size: 50, lastModified: new Date(), etag: '"sess1"' },
|
|
606
|
-
];
|
|
607
|
-
const { events, onEvent } = collectEvents();
|
|
608
|
-
const result = await sync({
|
|
609
|
-
company: "acme",
|
|
610
|
-
vaultConfig: mockConfig,
|
|
611
|
-
hqRoot: tmpDir,
|
|
612
|
-
syncMode: "shared",
|
|
613
|
-
prefixSet: ["knowledge/"],
|
|
614
|
-
excludePrefixes: ["sessions/"],
|
|
615
|
-
onEvent,
|
|
616
|
-
});
|
|
617
|
-
|
|
618
|
-
// Two keys withheld overall: one by the membership scope, one push-only.
|
|
619
|
-
expect(result.filesDownloaded).toBe(1);
|
|
620
|
-
expect(result.filesOutOfScope).toBe(2);
|
|
621
|
-
|
|
622
|
-
// The advisory reports ONLY the one the `all` lever would deliver.
|
|
623
|
-
const gap = gapOf(events);
|
|
624
|
-
expect(gap).toBeDefined();
|
|
625
|
-
expect(gap!.count).toBe(1);
|
|
626
|
-
expect(gap!.syncMode).toBe("shared");
|
|
627
|
-
expect(gap!.samplePaths).toEqual(["docs/handoff.md"]);
|
|
628
|
-
});
|
|
629
|
-
|
|
630
|
-
it("a pinned session key withheld on the push-only axis alone is not a membership gap", async () => {
|
|
631
|
-
// Exercises the OTHER classification branch: the key PASSES the
|
|
632
|
-
// inclusion check (it is pinned into prefixSet, as resolvePullScope
|
|
633
|
-
// does after `hq files get`) and is withheld by the exclusion filter.
|
|
634
|
-
withSession();
|
|
635
|
-
const { events, onEvent } = collectEvents();
|
|
636
|
-
const result = await sync({
|
|
637
|
-
company: "acme",
|
|
638
|
-
vaultConfig: mockConfig,
|
|
639
|
-
hqRoot: tmpDir,
|
|
640
|
-
syncMode: "shared",
|
|
641
|
-
prefixSet: ["knowledge/", sessionPath],
|
|
642
|
-
excludePrefixes: ["sessions/"],
|
|
643
|
-
onEvent,
|
|
644
|
-
});
|
|
645
|
-
|
|
646
|
-
expect(result.filesDownloaded).toBe(1);
|
|
647
|
-
expect(result.filesOutOfScope).toBe(1);
|
|
648
|
-
expect(gapOf(events)).toBeUndefined();
|
|
649
|
-
});
|
|
650
|
-
});
|
|
651
|
-
});
|
|
652
|
-
});
|