@indigoai-us/hq-cloud 6.14.17 → 6.14.19
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/.github/workflows/ci.yml +35 -0
- package/dist/active-company.d.ts +43 -0
- package/dist/active-company.d.ts.map +1 -0
- package/dist/active-company.js +132 -0
- package/dist/active-company.js.map +1 -0
- package/dist/active-company.test.d.ts +2 -0
- package/dist/active-company.test.d.ts.map +1 -0
- package/dist/active-company.test.js +149 -0
- package/dist/active-company.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts +7 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +21 -1
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-planning.d.ts +27 -1
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +33 -4
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-planning.test.d.ts +2 -0
- package/dist/bin/sync-runner-planning.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-planning.test.js +115 -0
- package/dist/bin/sync-runner-planning.test.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts +10 -2
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +201 -25
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts +1 -1
- package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-routes.js +14 -2
- package/dist/bin/sync-runner-watch-routes.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +39 -9
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +114 -20
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +852 -8
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/rescue-core.js +27 -1
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/rescue-drop-dir-symlink.test.d.ts +2 -0
- package/dist/cli/rescue-drop-dir-symlink.test.d.ts.map +1 -0
- package/dist/cli/rescue-drop-dir-symlink.test.js +206 -0
- package/dist/cli/rescue-drop-dir-symlink.test.js.map +1 -0
- package/dist/cli/share.d.ts +15 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +100 -55
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +119 -6
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +19 -25
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +121 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +3 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +13 -2
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +26 -1
- package/dist/journal.test.js.map +1 -1
- package/dist/manifest-reconcile.d.ts +118 -5
- package/dist/manifest-reconcile.d.ts.map +1 -1
- package/dist/manifest-reconcile.js +319 -62
- package/dist/manifest-reconcile.js.map +1 -1
- package/dist/manifest-reconcile.test.js +824 -2
- package/dist/manifest-reconcile.test.js.map +1 -1
- package/dist/personal-vault.d.ts +12 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +20 -0
- package/dist/personal-vault.js.map +1 -1
- package/dist/qmd-reindex.d.ts +88 -11
- package/dist/qmd-reindex.d.ts.map +1 -1
- package/dist/qmd-reindex.js +354 -65
- package/dist/qmd-reindex.js.map +1 -1
- package/dist/qmd-reindex.test.d.ts +4 -0
- package/dist/qmd-reindex.test.d.ts.map +1 -1
- package/dist/qmd-reindex.test.js +285 -3
- package/dist/qmd-reindex.test.js.map +1 -1
- package/dist/s3.d.ts +13 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +34 -1
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +60 -1
- package/dist/s3.test.js.map +1 -1
- package/dist/telemetry.d.ts +22 -0
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +79 -0
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +117 -1
- package/dist/telemetry.test.js.map +1 -1
- package/dist/watcher.d.ts +26 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +75 -12
- package/dist/watcher.js.map +1 -1
- package/package.json +1 -1
- package/pnpm-workspace.yaml +1 -1
- package/src/active-company.test.ts +188 -0
- package/src/active-company.ts +168 -0
- package/src/bin/sync-runner-company.ts +29 -1
- package/src/bin/sync-runner-planning.test.ts +131 -0
- package/src/bin/sync-runner-planning.ts +60 -7
- package/src/bin/sync-runner-watch-loop.ts +322 -32
- package/src/bin/sync-runner-watch-routes.ts +14 -1
- package/src/bin/sync-runner.test.ts +1021 -16
- package/src/bin/sync-runner.ts +179 -28
- package/src/cli/rescue-core.ts +26 -1
- package/src/cli/rescue-drop-dir-symlink.test.ts +224 -0
- package/src/cli/share.test.ts +150 -6
- package/src/cli/share.ts +143 -48
- package/src/cli/sync.test.ts +150 -1
- package/src/cli/sync.ts +25 -29
- package/src/journal.test.ts +45 -0
- package/src/journal.ts +18 -1
- package/src/manifest-reconcile.test.ts +1019 -3
- package/src/manifest-reconcile.ts +424 -66
- package/src/personal-vault.ts +23 -0
- package/src/qmd-reindex.test.ts +324 -4
- package/src/qmd-reindex.ts +414 -76
- package/src/s3.test.ts +79 -0
- package/src/s3.ts +48 -1
- package/src/telemetry.test.ts +128 -0
- package/src/telemetry.ts +80 -0
- package/src/watcher.ts +111 -14
- package/test/e2e/watcher-real-chokidar.test.ts +62 -2
- package/test/e2e/watcher-recursive-backend.test.ts +67 -1
- package/test/joiner-manifest-reconcile.integration.test.ts +283 -0
|
@@ -33,9 +33,20 @@ import type {
|
|
|
33
33
|
VaultClientSurface,
|
|
34
34
|
WatcherSurface,
|
|
35
35
|
} from "./sync-runner.js";
|
|
36
|
-
import {
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
import {
|
|
37
|
+
FakeClock,
|
|
38
|
+
type LocalDeleteSnapshot,
|
|
39
|
+
type TreeChangeBatch,
|
|
40
|
+
} from "../watcher.js";
|
|
41
|
+
import {
|
|
42
|
+
adaptivePollMs,
|
|
43
|
+
journalEntriesForWatcherDelete,
|
|
44
|
+
} from "./sync-runner-watch-loop.js";
|
|
45
|
+
import {
|
|
46
|
+
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
47
|
+
readJournal,
|
|
48
|
+
writeJournal,
|
|
49
|
+
} from "../journal.js";
|
|
39
50
|
import { HQ_CLOUD_VERSION } from "../version.js";
|
|
40
51
|
import { lockPathFor, OPERATION_LOCKED_EXIT } from "../operation-lock.js";
|
|
41
52
|
import { TRANSIENT_NETWORK_EXIT } from "../lib/net-errors.js";
|
|
@@ -52,6 +63,11 @@ import type {
|
|
|
52
63
|
import { VaultAuthError, VaultNotFoundError } from "../vault-client.js";
|
|
53
64
|
import { CognitoRefreshError } from "../cognito-auth.js";
|
|
54
65
|
import type { PushEvent } from "../sync/push-event.js";
|
|
66
|
+
import {
|
|
67
|
+
reconcileCompanyManifest,
|
|
68
|
+
type ManifestReconcileOptions,
|
|
69
|
+
} from "../manifest-reconcile.js";
|
|
70
|
+
import yaml from "js-yaml";
|
|
55
71
|
|
|
56
72
|
// ---------------------------------------------------------------------------
|
|
57
73
|
// Hermetic journal state — the runner now calls migratePersonalVaultJournal()
|
|
@@ -226,8 +242,13 @@ function makeDeps(overrides: Partial<RunnerDeps> = {}): TestDeps {
|
|
|
226
242
|
updated: true,
|
|
227
243
|
embedded: false,
|
|
228
244
|
pendingDirty: false,
|
|
245
|
+
lockBusy: false,
|
|
246
|
+
timedOut: false,
|
|
247
|
+
corruptionQuarantined: false,
|
|
229
248
|
})),
|
|
230
|
-
reconcileManifest: vi
|
|
249
|
+
reconcileManifest: vi
|
|
250
|
+
.fn()
|
|
251
|
+
.mockResolvedValue({ written: false, added: [], updated: [], skipped: [] }),
|
|
231
252
|
...overrides,
|
|
232
253
|
stdout,
|
|
233
254
|
stderr,
|
|
@@ -279,7 +300,9 @@ describe("argv parsing", () => {
|
|
|
279
300
|
const code = await runRunner(["--companies", "--json"], deps);
|
|
280
301
|
expect(code).toBe(0);
|
|
281
302
|
// Empty memberships → setup-needed, not a parse error
|
|
282
|
-
expect(deps.stdout.events()).toEqual([
|
|
303
|
+
expect(deps.stdout.events()).toEqual([
|
|
304
|
+
{ type: "setup-needed", reason: "no-memberships" },
|
|
305
|
+
]);
|
|
283
306
|
});
|
|
284
307
|
});
|
|
285
308
|
|
|
@@ -618,7 +641,80 @@ describe("claim-dance", () => {
|
|
|
618
641
|
expect(ensureSpy).not.toHaveBeenCalled();
|
|
619
642
|
expect(claimSpy).not.toHaveBeenCalled();
|
|
620
643
|
// No memberships, no invites — truly empty → setup-needed is correct here.
|
|
621
|
-
expect(deps.stdout.events()).toEqual([
|
|
644
|
+
expect(deps.stdout.events()).toEqual([
|
|
645
|
+
{ type: "setup-needed", reason: "no-memberships" },
|
|
646
|
+
]);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
// The michelle@boringecom.com shape: an invite exists, the claim attempt does
|
|
650
|
+
// not convert it into a membership, and the user is told they are solo. The
|
|
651
|
+
// count is what turns "you have no team" into "you have an invite to accept".
|
|
652
|
+
it("US-004: carries pendingInviteCount when invites did not become memberships", async () => {
|
|
653
|
+
const deps = makeDeps({
|
|
654
|
+
createVaultClient: () =>
|
|
655
|
+
makeVaultStub({
|
|
656
|
+
pendingInvites: [{ uid: "inv_1" }, { uid: "inv_2" }] as never,
|
|
657
|
+
ensurePerson: vi
|
|
658
|
+
.fn()
|
|
659
|
+
.mockResolvedValue({ uid: "ent_person" }) as unknown as VaultClientSurface["ensureMyPersonEntity"],
|
|
660
|
+
claim: vi
|
|
661
|
+
.fn()
|
|
662
|
+
.mockResolvedValue(undefined) as unknown as VaultClientSurface["claimPendingInvitesByEmail"],
|
|
663
|
+
}),
|
|
664
|
+
getIdTokenClaims: () => claims,
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
668
|
+
expect(deps.stdout.events()).toEqual([
|
|
669
|
+
{ type: "setup-needed", reason: "no-memberships", pendingInviteCount: 2 },
|
|
670
|
+
]);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it("US-004: omits pendingInviteCount entirely when there were no invites", async () => {
|
|
674
|
+
const deps = makeDeps({
|
|
675
|
+
createVaultClient: () => makeVaultStub({ pendingInvites: [] }),
|
|
676
|
+
getIdTokenClaims: () => claims,
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
680
|
+
const [event] = deps.stdout.events();
|
|
681
|
+
expect(event).toEqual({ type: "setup-needed", reason: "no-memberships" });
|
|
682
|
+
expect("pendingInviteCount" in event).toBe(false);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("US-004: reports claim-dance skipped as a diagnostic, not a bare stderr line", async () => {
|
|
686
|
+
const deps = makeDeps({
|
|
687
|
+
createVaultClient: () =>
|
|
688
|
+
makeVaultStub({
|
|
689
|
+
pendingInvites: [{ uid: "inv_1" }] as never,
|
|
690
|
+
ensurePerson: vi
|
|
691
|
+
.fn()
|
|
692
|
+
.mockRejectedValue(
|
|
693
|
+
new Error("BatchWriteItem denied"),
|
|
694
|
+
) as unknown as VaultClientSurface["ensureMyPersonEntity"],
|
|
695
|
+
}),
|
|
696
|
+
getIdTokenClaims: () => claims,
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
// Non-throwing by contract: the failure never changes the exit code...
|
|
700
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
701
|
+
// ...but it is no longer invisible.
|
|
702
|
+
expect(deps.stderr.raw()).toContain("runner.claim_dance.skipped");
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it("US-004: a claim-dance that cannot identify the caller still reports the pending invites", async () => {
|
|
706
|
+
const deps = makeDeps({
|
|
707
|
+
createVaultClient: () =>
|
|
708
|
+
makeVaultStub({ pendingInvites: [{ uid: "inv_1" }] as never }),
|
|
709
|
+
// No sub/name — the claim cannot proceed, but the invites are still real.
|
|
710
|
+
getIdTokenClaims: () => ({}) as never,
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
714
|
+
expect(deps.stdout.events()).toEqual([
|
|
715
|
+
{ type: "setup-needed", reason: "no-memberships", pendingInviteCount: 1 },
|
|
716
|
+
]);
|
|
717
|
+
expect(deps.stderr.raw()).toContain("runner.claim_dance.skipped");
|
|
622
718
|
});
|
|
623
719
|
|
|
624
720
|
it("skips claim-dance entirely when no idToken claims are available", async () => {
|
|
@@ -696,7 +792,9 @@ describe("target resolution", () => {
|
|
|
696
792
|
const deps = makeDeps();
|
|
697
793
|
const code = await runRunner(["--companies"], deps);
|
|
698
794
|
expect(code).toBe(0);
|
|
699
|
-
expect(deps.stdout.events()).toEqual([
|
|
795
|
+
expect(deps.stdout.events()).toEqual([
|
|
796
|
+
{ type: "setup-needed", reason: "no-memberships" },
|
|
797
|
+
]);
|
|
700
798
|
// sync should NOT have been called — no targets
|
|
701
799
|
expect(deps.sync).not.toHaveBeenCalled();
|
|
702
800
|
});
|
|
@@ -1031,7 +1129,11 @@ describe("fanout-plan", () => {
|
|
|
1031
1129
|
RunnerEvent,
|
|
1032
1130
|
{ type: "fanout-plan" }
|
|
1033
1131
|
>;
|
|
1034
|
-
|
|
1132
|
+
// The surviving target now also carries the plan-time entity snapshot the
|
|
1133
|
+
// manifest reconciler reads instead of re-fetching (US-002).
|
|
1134
|
+
expect(plan.companies).toEqual([
|
|
1135
|
+
{ uid: "cmp_live", slug: "live", entityType: "company", entityStatus: "active" },
|
|
1136
|
+
]);
|
|
1035
1137
|
});
|
|
1036
1138
|
|
|
1037
1139
|
it("reconciles after the personal leg completes", async () => {
|
|
@@ -1066,6 +1168,7 @@ describe("fanout-plan", () => {
|
|
|
1066
1168
|
}),
|
|
1067
1169
|
reconcileManifest: vi.fn().mockImplementation(async () => {
|
|
1068
1170
|
order.push("reconcile");
|
|
1171
|
+
return { written: false, added: [], updated: [], skipped: [] };
|
|
1069
1172
|
}),
|
|
1070
1173
|
});
|
|
1071
1174
|
|
|
@@ -2070,6 +2173,9 @@ describe("all-complete aggregate", () => {
|
|
|
2070
2173
|
updated: true,
|
|
2071
2174
|
embedded: false,
|
|
2072
2175
|
pendingDirty: false,
|
|
2176
|
+
lockBusy: false,
|
|
2177
|
+
timedOut: false,
|
|
2178
|
+
corruptionQuarantined: false,
|
|
2073
2179
|
}));
|
|
2074
2180
|
const deps = makeDeps({
|
|
2075
2181
|
createVaultClient: () =>
|
|
@@ -2723,11 +2829,9 @@ describe("personal slot fanout", () => {
|
|
|
2723
2829
|
// companies/personal company journal.
|
|
2724
2830
|
expect(lastEntry.slug).toBe("personal");
|
|
2725
2831
|
expect(lastEntry.uid).toBe("prs_older");
|
|
2726
|
-
expect(
|
|
2727
|
-
expect(
|
|
2728
|
-
expect(
|
|
2729
|
-
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
2730
|
-
);
|
|
2832
|
+
expect(lastEntry.bucketName).toBe("hq-vault-prs-older");
|
|
2833
|
+
expect(lastEntry.personalMode).toBe(true);
|
|
2834
|
+
expect(lastEntry.journalSlug).toBe(PERSONAL_VAULT_JOURNAL_SLUG);
|
|
2731
2835
|
});
|
|
2732
2836
|
|
|
2733
2837
|
it("B: syncFn invoked with personalMode: true + reserved journalSlug for personal slot", async () => {
|
|
@@ -5490,13 +5594,13 @@ describe("runRunnerWithLoop — operation lock", () => {
|
|
|
5490
5594
|
// pass (events must never announce bytes that are not in S3 yet).
|
|
5491
5595
|
|
|
5492
5596
|
interface BatchWatcherStub extends WatcherSurface {
|
|
5493
|
-
emit(changedRelPath?: string, batch?:
|
|
5597
|
+
emit(changedRelPath?: string, batch?: TreeChangeBatch): void;
|
|
5494
5598
|
disposed: boolean;
|
|
5495
5599
|
}
|
|
5496
5600
|
|
|
5497
5601
|
function makeBatchWatcherStub(): BatchWatcherStub {
|
|
5498
5602
|
const listeners = new Set<
|
|
5499
|
-
(p?: string, b?:
|
|
5603
|
+
(p?: string, b?: TreeChangeBatch) => void
|
|
5500
5604
|
>();
|
|
5501
5605
|
return {
|
|
5502
5606
|
disposed: false,
|
|
@@ -5510,7 +5614,7 @@ function makeBatchWatcherStub(): BatchWatcherStub {
|
|
|
5510
5614
|
this.disposed = true;
|
|
5511
5615
|
listeners.clear();
|
|
5512
5616
|
},
|
|
5513
|
-
emit(changedRelPath?: string, batch?:
|
|
5617
|
+
emit(changedRelPath?: string, batch?: TreeChangeBatch) {
|
|
5514
5618
|
for (const l of [...listeners]) l(changedRelPath, batch);
|
|
5515
5619
|
},
|
|
5516
5620
|
};
|
|
@@ -5687,6 +5791,94 @@ describe("runRunnerWithLoop — Phase 3 event-sync wiring (US-017/018/019)", ()
|
|
|
5687
5791
|
await loop;
|
|
5688
5792
|
});
|
|
5689
5793
|
|
|
5794
|
+
it("does not publish an exit-zero deletion that the scoped push refused", async () => {
|
|
5795
|
+
const publishBatch = vi.fn();
|
|
5796
|
+
const startEventSync = vi.fn().mockResolvedValue({
|
|
5797
|
+
publishBatch,
|
|
5798
|
+
receiver: { start: async () => {}, dispose: async () => {}, connected: true },
|
|
5799
|
+
ownDeviceId: "dev-test",
|
|
5800
|
+
dispose: vi.fn().mockResolvedValue(undefined),
|
|
5801
|
+
});
|
|
5802
|
+
const runPass = vi
|
|
5803
|
+
.fn()
|
|
5804
|
+
.mockResolvedValueOnce(0)
|
|
5805
|
+
.mockResolvedValueOnce({
|
|
5806
|
+
exitCode: 0,
|
|
5807
|
+
result: {
|
|
5808
|
+
pushPathResults: [{
|
|
5809
|
+
relativePath: "companies/indigo/deleted.md",
|
|
5810
|
+
status: "refused",
|
|
5811
|
+
operation: "delete",
|
|
5812
|
+
reason: "missing-delete-intent",
|
|
5813
|
+
}],
|
|
5814
|
+
},
|
|
5815
|
+
})
|
|
5816
|
+
.mockResolvedValue(0);
|
|
5817
|
+
const { loop, watcher, tick, shutdown } = runLoop({
|
|
5818
|
+
claims: ENROLLED_CLAIMS,
|
|
5819
|
+
startEventSync,
|
|
5820
|
+
runPass,
|
|
5821
|
+
});
|
|
5822
|
+
await microtasks();
|
|
5823
|
+
watcher.emit("companies/indigo/deleted.md", {
|
|
5824
|
+
paths: new Map([[
|
|
5825
|
+
"/tmp/hq/companies/indigo/deleted.md",
|
|
5826
|
+
"companies/indigo/deleted.md",
|
|
5827
|
+
]]),
|
|
5828
|
+
});
|
|
5829
|
+
tick();
|
|
5830
|
+
await microtasks();
|
|
5831
|
+
|
|
5832
|
+
expect(publishBatch).not.toHaveBeenCalled();
|
|
5833
|
+
shutdown();
|
|
5834
|
+
await loop;
|
|
5835
|
+
});
|
|
5836
|
+
|
|
5837
|
+
it("does not publish unlinkDir when a covered descendant delete was refused", async () => {
|
|
5838
|
+
const publishBatch = vi.fn();
|
|
5839
|
+
const startEventSync = vi.fn().mockResolvedValue({
|
|
5840
|
+
publishBatch,
|
|
5841
|
+
receiver: { start: async () => {}, dispose: async () => {}, connected: true },
|
|
5842
|
+
ownDeviceId: "dev-test",
|
|
5843
|
+
dispose: vi.fn().mockResolvedValue(undefined),
|
|
5844
|
+
});
|
|
5845
|
+
const runPass = vi
|
|
5846
|
+
.fn()
|
|
5847
|
+
.mockResolvedValueOnce(0)
|
|
5848
|
+
.mockResolvedValueOnce({
|
|
5849
|
+
exitCode: 0,
|
|
5850
|
+
result: {
|
|
5851
|
+
pushPathResults: [{
|
|
5852
|
+
relativePath: "companies/indigo/archive/deleted.md",
|
|
5853
|
+
status: "refused",
|
|
5854
|
+
operation: "delete",
|
|
5855
|
+
reason: "missing-delete-intent",
|
|
5856
|
+
}],
|
|
5857
|
+
},
|
|
5858
|
+
})
|
|
5859
|
+
.mockResolvedValue(0);
|
|
5860
|
+
const { loop, watcher, tick, shutdown } = runLoop({
|
|
5861
|
+
claims: ENROLLED_CLAIMS,
|
|
5862
|
+
startEventSync,
|
|
5863
|
+
runPass,
|
|
5864
|
+
});
|
|
5865
|
+
await microtasks();
|
|
5866
|
+
const absolutePath = "/tmp/hq/companies/indigo/archive";
|
|
5867
|
+
watcher.emit("companies/indigo/archive", {
|
|
5868
|
+
paths: new Map([[
|
|
5869
|
+
absolutePath,
|
|
5870
|
+
"companies/indigo/archive",
|
|
5871
|
+
]]),
|
|
5872
|
+
changes: new Map([[absolutePath, { kind: "unlinkDir" }]]),
|
|
5873
|
+
});
|
|
5874
|
+
tick();
|
|
5875
|
+
await microtasks();
|
|
5876
|
+
|
|
5877
|
+
expect(publishBatch).not.toHaveBeenCalled();
|
|
5878
|
+
shutdown();
|
|
5879
|
+
await loop;
|
|
5880
|
+
});
|
|
5881
|
+
|
|
5690
5882
|
it("F04: mixed-route watcher batches publish only routes that were pushed", async () => {
|
|
5691
5883
|
const publishBatch = vi.fn();
|
|
5692
5884
|
const startEventSync = vi.fn().mockResolvedValue({
|
|
@@ -6581,3 +6773,816 @@ describe("US-004 — --personal for an AGENT machine identity (HARD GATE)", () =
|
|
|
6581
6773
|
expect(cmp(HQ_CLOUD_VERSION, FLOOR)).toBeGreaterThanOrEqual(0);
|
|
6582
6774
|
});
|
|
6583
6775
|
});
|
|
6776
|
+
|
|
6777
|
+
// ---------------------------------------------------------------------------
|
|
6778
|
+
// US-002 — post-fanout manifest reconciliation wiring
|
|
6779
|
+
//
|
|
6780
|
+
// The reconciler itself is unit-tested in manifest-reconcile.test.ts. These
|
|
6781
|
+
// tests own the SEAM: which run modes reach it, which targets cross it, and
|
|
6782
|
+
// what the runner does with the result it hands back.
|
|
6783
|
+
// ---------------------------------------------------------------------------
|
|
6784
|
+
|
|
6785
|
+
describe("US-002 — manifest reconciliation wiring", () => {
|
|
6786
|
+
const PERSON_ENTITY = {
|
|
6787
|
+
uid: "prs_me",
|
|
6788
|
+
slug: "me",
|
|
6789
|
+
type: "person",
|
|
6790
|
+
status: "active",
|
|
6791
|
+
bucketName: "hq-vault-prs-me",
|
|
6792
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
6793
|
+
} as unknown as EntityInfo;
|
|
6794
|
+
|
|
6795
|
+
function noopReconcileResult() {
|
|
6796
|
+
return { written: false, added: [], updated: [], skipped: [] };
|
|
6797
|
+
}
|
|
6798
|
+
|
|
6799
|
+
/** A vault stub whose companies all resolve to `cmp_x` → slug `x`. */
|
|
6800
|
+
function companiesClient(companyUids: string[]): VaultClientSurface {
|
|
6801
|
+
return makeVaultStub({
|
|
6802
|
+
memberships: companyUids.map((companyUid) => ({ companyUid })),
|
|
6803
|
+
entityGet: (uid: string) =>
|
|
6804
|
+
Promise.resolve({
|
|
6805
|
+
uid,
|
|
6806
|
+
slug: uid.replace(/^cmp_/, ""),
|
|
6807
|
+
name: `${uid} Inc`,
|
|
6808
|
+
type: "company",
|
|
6809
|
+
status: "active",
|
|
6810
|
+
bucketName: `bucket-${uid}`,
|
|
6811
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
6812
|
+
} as unknown as EntityInfo),
|
|
6813
|
+
listPersons: () => Promise.resolve([PERSON_ENTITY]),
|
|
6814
|
+
});
|
|
6815
|
+
}
|
|
6816
|
+
|
|
6817
|
+
function reconcileArgs(
|
|
6818
|
+
reconcile: ReturnType<typeof vi.fn>,
|
|
6819
|
+
): ManifestReconcileOptions {
|
|
6820
|
+
return reconcile.mock.calls[0]![0] as ManifestReconcileOptions;
|
|
6821
|
+
}
|
|
6822
|
+
|
|
6823
|
+
/** Diagnostics ride stderr as ndjson; pick out the manifest ones. */
|
|
6824
|
+
function manifestDiagnostics(stderr: CapturingWriter): Record<string, unknown>[] {
|
|
6825
|
+
return stderr
|
|
6826
|
+
.lines()
|
|
6827
|
+
.flatMap((line) => {
|
|
6828
|
+
try {
|
|
6829
|
+
return [JSON.parse(line) as Record<string, unknown>];
|
|
6830
|
+
} catch {
|
|
6831
|
+
return [];
|
|
6832
|
+
}
|
|
6833
|
+
})
|
|
6834
|
+
.filter((entry) => entry.component === "manifest-reconcile");
|
|
6835
|
+
}
|
|
6836
|
+
|
|
6837
|
+
it("AC2: an errored company is not eligible — only the completing company is", async () => {
|
|
6838
|
+
const reconcile = vi.fn().mockResolvedValue(noopReconcileResult());
|
|
6839
|
+
const deps = makeDeps({
|
|
6840
|
+
createVaultClient: () => companiesClient(["cmp_ok", "cmp_bad"]),
|
|
6841
|
+
sync: vi.fn().mockImplementation(async (options: SyncOptions) => {
|
|
6842
|
+
if (options.company === "cmp_bad") throw new Error("pull leg exploded");
|
|
6843
|
+
return defaultSyncResult();
|
|
6844
|
+
}),
|
|
6845
|
+
reconcileManifest: reconcile,
|
|
6846
|
+
});
|
|
6847
|
+
|
|
6848
|
+
await runRunner(["--companies"], deps);
|
|
6849
|
+
|
|
6850
|
+
expect(reconcile).toHaveBeenCalledTimes(1);
|
|
6851
|
+
const args = reconcileArgs(reconcile);
|
|
6852
|
+
// The completion filter is `completedCompanySlugs`; assert the EFFECTIVE
|
|
6853
|
+
// eligible set, which is what actually reaches the manifest.
|
|
6854
|
+
const eligible = args.targets.filter((target) =>
|
|
6855
|
+
args.completedCompanySlugs.has(target.slug),
|
|
6856
|
+
);
|
|
6857
|
+
expect(eligible.map((target) => target.slug)).toEqual(["ok"]);
|
|
6858
|
+
expect(args.completedCompanySlugs.has("bad")).toBe(false);
|
|
6859
|
+
});
|
|
6860
|
+
|
|
6861
|
+
it("AC3: the personal target is never passed to the reconciler", async () => {
|
|
6862
|
+
const reconcile = vi.fn().mockResolvedValue(noopReconcileResult());
|
|
6863
|
+
const deps = makeDeps({
|
|
6864
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
6865
|
+
reconcileManifest: reconcile,
|
|
6866
|
+
});
|
|
6867
|
+
|
|
6868
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
6869
|
+
|
|
6870
|
+
const args = reconcileArgs(reconcile);
|
|
6871
|
+
// The personal leg definitely ran — otherwise this assertion is vacuous.
|
|
6872
|
+
const plan = deps.stdout
|
|
6873
|
+
.events()
|
|
6874
|
+
.find((e) => e.type === "fanout-plan") as Extract<
|
|
6875
|
+
RunnerEvent,
|
|
6876
|
+
{ type: "fanout-plan" }
|
|
6877
|
+
>;
|
|
6878
|
+
expect(plan.companies.map((t) => t.slug)).toContain("personal");
|
|
6879
|
+
|
|
6880
|
+
expect(args.targets.map((target) => target.slug)).toEqual(["acme"]);
|
|
6881
|
+
expect(args.targets.some((target) => target.personalMode === true)).toBe(false);
|
|
6882
|
+
expect(args.targets.some((target) => target.slug === "personal")).toBe(false);
|
|
6883
|
+
});
|
|
6884
|
+
|
|
6885
|
+
it("AC4: --personal mode does not reconcile", async () => {
|
|
6886
|
+
const reconcile = vi.fn().mockResolvedValue(noopReconcileResult());
|
|
6887
|
+
const deps = makeDeps({
|
|
6888
|
+
createVaultClient: () =>
|
|
6889
|
+
makeVaultStub({ listPersons: () => Promise.resolve([PERSON_ENTITY]) }),
|
|
6890
|
+
reconcileManifest: reconcile,
|
|
6891
|
+
});
|
|
6892
|
+
|
|
6893
|
+
expect(await runRunner(["--personal"], deps)).toBe(0);
|
|
6894
|
+
expect(reconcile).not.toHaveBeenCalled();
|
|
6895
|
+
});
|
|
6896
|
+
|
|
6897
|
+
it("AC4: single-company mode (--company <slug>) does not reconcile", async () => {
|
|
6898
|
+
const reconcile = vi.fn().mockResolvedValue(noopReconcileResult());
|
|
6899
|
+
const deps = makeDeps({
|
|
6900
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
6901
|
+
reconcileManifest: reconcile,
|
|
6902
|
+
});
|
|
6903
|
+
|
|
6904
|
+
expect(await runRunner(["--company", "cmp_acme"], deps)).toBe(0);
|
|
6905
|
+
expect(reconcile).not.toHaveBeenCalled();
|
|
6906
|
+
});
|
|
6907
|
+
|
|
6908
|
+
it("AC5: company targets carry the plan-resolved bucketName, so no getEntity is passed", async () => {
|
|
6909
|
+
const reconcile = vi.fn().mockResolvedValue(noopReconcileResult());
|
|
6910
|
+
const deps = makeDeps({
|
|
6911
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
6912
|
+
reconcileManifest: reconcile,
|
|
6913
|
+
});
|
|
6914
|
+
|
|
6915
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
6916
|
+
|
|
6917
|
+
const args = reconcileArgs(reconcile);
|
|
6918
|
+
expect(args.targets[0]).toMatchObject({
|
|
6919
|
+
uid: "cmp_acme",
|
|
6920
|
+
slug: "acme",
|
|
6921
|
+
name: "cmp_acme Inc",
|
|
6922
|
+
bucketName: "bucket-cmp_acme",
|
|
6923
|
+
// Liveness evidence travels with the target. Dropping the round trip
|
|
6924
|
+
// must not drop the guarantee it used to provide.
|
|
6925
|
+
entityType: "company",
|
|
6926
|
+
entityStatus: "active",
|
|
6927
|
+
});
|
|
6928
|
+
// Zero extra API calls: the runner hands over no entity resolver at all.
|
|
6929
|
+
expect(args.getEntity).toBeUndefined();
|
|
6930
|
+
});
|
|
6931
|
+
|
|
6932
|
+
it("END-TO-END: a suspended company completes its sync leg but never reaches the manifest", async () => {
|
|
6933
|
+
// Exercises the REAL reconciler through the runner — no stub — because the
|
|
6934
|
+
// defect this guards was invisible to every stubbed assertion.
|
|
6935
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-manifest-"));
|
|
6936
|
+
try {
|
|
6937
|
+
fs.mkdirSync(path.join(root, "companies", "live"), { recursive: true });
|
|
6938
|
+
fs.mkdirSync(path.join(root, "companies", "susp"), { recursive: true });
|
|
6939
|
+
const manifestPath = path.join(root, "companies", "manifest.yaml");
|
|
6940
|
+
fs.writeFileSync(manifestPath, "companies: {}\n");
|
|
6941
|
+
|
|
6942
|
+
const deps = makeDeps({
|
|
6943
|
+
createVaultClient: () =>
|
|
6944
|
+
makeVaultStub({
|
|
6945
|
+
memberships: [{ companyUid: "cmp_live" }, { companyUid: "cmp_susp" }],
|
|
6946
|
+
entityGet: (uid: string) =>
|
|
6947
|
+
Promise.resolve({
|
|
6948
|
+
uid,
|
|
6949
|
+
slug: uid === "cmp_live" ? "live" : "susp",
|
|
6950
|
+
type: "company",
|
|
6951
|
+
// The suspended company is still a perfectly syncable target.
|
|
6952
|
+
status: uid === "cmp_live" ? "active" : "suspended",
|
|
6953
|
+
bucketName: `bucket-${uid}`,
|
|
6954
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
6955
|
+
} as unknown as EntityInfo),
|
|
6956
|
+
listPersons: () => Promise.resolve([PERSON_ENTITY]),
|
|
6957
|
+
}),
|
|
6958
|
+
// The REAL reconciler, not the makeDeps stub — that stub is exactly
|
|
6959
|
+
// what let this defect hide from every other assertion here.
|
|
6960
|
+
reconcileManifest: reconcileCompanyManifest,
|
|
6961
|
+
});
|
|
6962
|
+
|
|
6963
|
+
expect(await runRunner(["--companies", "--hq-root", root], deps)).toBe(0);
|
|
6964
|
+
|
|
6965
|
+
// Both companies synced (the guard must not have leaked into sync).
|
|
6966
|
+
const syncedCompanies = (deps.sync as ReturnType<typeof vi.fn>).mock.calls
|
|
6967
|
+
.map((call) => (call[0] as SyncOptions).company)
|
|
6968
|
+
.filter(Boolean);
|
|
6969
|
+
expect(syncedCompanies).toContain("cmp_live");
|
|
6970
|
+
expect(syncedCompanies).toContain("cmp_susp");
|
|
6971
|
+
|
|
6972
|
+
// ...but only the active one earned a manifest entry.
|
|
6973
|
+
const written = yaml.load(fs.readFileSync(manifestPath, "utf8")) as {
|
|
6974
|
+
companies?: Record<string, unknown>;
|
|
6975
|
+
};
|
|
6976
|
+
expect(Object.keys(written.companies ?? {})).toEqual(["live"]);
|
|
6977
|
+
} finally {
|
|
6978
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
6979
|
+
}
|
|
6980
|
+
});
|
|
6981
|
+
|
|
6982
|
+
it("US-003: seeds activeCompany from the completed company set", async () => {
|
|
6983
|
+
const seed = vi.fn().mockReturnValue({ written: true, activeCompany: "acme" });
|
|
6984
|
+
const deps = makeDeps({
|
|
6985
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
6986
|
+
seedActiveCompany: seed,
|
|
6987
|
+
});
|
|
6988
|
+
|
|
6989
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
6990
|
+
expect(seed).toHaveBeenCalledTimes(1);
|
|
6991
|
+
expect(seed.mock.calls[0][0]).toMatchObject({ companySlugs: ["acme"] });
|
|
6992
|
+
expect(deps.stderr.raw()).toContain("active-company-seeded");
|
|
6993
|
+
});
|
|
6994
|
+
|
|
6995
|
+
it("US-003: the personal target is never offered as an activeCompany candidate", async () => {
|
|
6996
|
+
const seed = vi.fn().mockReturnValue({ written: false, skippedReason: "already-set" });
|
|
6997
|
+
const deps = makeDeps({
|
|
6998
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
6999
|
+
seedActiveCompany: seed,
|
|
7000
|
+
});
|
|
7001
|
+
|
|
7002
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
7003
|
+
expect(seed.mock.calls[0][0].companySlugs).not.toContain("personal");
|
|
7004
|
+
});
|
|
7005
|
+
|
|
7006
|
+
// The two writes are independent repairs. A joiner whose manifest write fails
|
|
7007
|
+
// should still get routed, and vice versa.
|
|
7008
|
+
it("US-003: a failing manifest reconcile does not prevent activeCompany seeding", async () => {
|
|
7009
|
+
const seed = vi.fn().mockReturnValue({ written: true, activeCompany: "acme" });
|
|
7010
|
+
const deps = makeDeps({
|
|
7011
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
7012
|
+
reconcileManifest: vi.fn().mockRejectedValue(new Error("manifest write blew up")),
|
|
7013
|
+
seedActiveCompany: seed,
|
|
7014
|
+
});
|
|
7015
|
+
|
|
7016
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
7017
|
+
expect(deps.stderr.raw()).toContain("runner.manifest_reconcile.failed");
|
|
7018
|
+
expect(seed).toHaveBeenCalledTimes(1);
|
|
7019
|
+
});
|
|
7020
|
+
|
|
7021
|
+
it("US-003: a throwing seeder still emits all-complete and exits 0", async () => {
|
|
7022
|
+
const deps = makeDeps({
|
|
7023
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
7024
|
+
seedActiveCompany: vi.fn().mockImplementation(() => {
|
|
7025
|
+
throw new Error("config write blew up");
|
|
7026
|
+
}),
|
|
7027
|
+
});
|
|
7028
|
+
|
|
7029
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
7030
|
+
expect(deps.stdout.events().some((e) => e.type === "all-complete")).toBe(true);
|
|
7031
|
+
expect(deps.stderr.raw()).toContain("runner.active_company.failed");
|
|
7032
|
+
});
|
|
7033
|
+
|
|
7034
|
+
it("US-003: a no-op seeding is silent", async () => {
|
|
7035
|
+
const deps = makeDeps({
|
|
7036
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
7037
|
+
seedActiveCompany: vi
|
|
7038
|
+
.fn()
|
|
7039
|
+
.mockReturnValue({ written: false, skippedReason: "already-set" }),
|
|
7040
|
+
});
|
|
7041
|
+
|
|
7042
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
7043
|
+
expect(deps.stderr.raw()).not.toContain("active-company-seeded");
|
|
7044
|
+
});
|
|
7045
|
+
|
|
7046
|
+
it("US-003: single-company mode does not seed activeCompany", async () => {
|
|
7047
|
+
const seed = vi.fn();
|
|
7048
|
+
const deps = makeDeps({
|
|
7049
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
7050
|
+
seedActiveCompany: seed,
|
|
7051
|
+
});
|
|
7052
|
+
|
|
7053
|
+
await runRunner(["--company", "acme"], deps);
|
|
7054
|
+
expect(seed).not.toHaveBeenCalled();
|
|
7055
|
+
});
|
|
7056
|
+
|
|
7057
|
+
it("AC6: a throwing reconciler still emits all-complete and exits 0", async () => {
|
|
7058
|
+
const deps = makeDeps({
|
|
7059
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
7060
|
+
reconcileManifest: vi
|
|
7061
|
+
.fn()
|
|
7062
|
+
.mockRejectedValue(new Error("manifest write blew up")),
|
|
7063
|
+
});
|
|
7064
|
+
|
|
7065
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
7066
|
+
expect(deps.stdout.events().some((e) => e.type === "all-complete")).toBe(true);
|
|
7067
|
+
expect(deps.stderr.raw()).toContain("runner.manifest_reconcile.failed");
|
|
7068
|
+
});
|
|
7069
|
+
|
|
7070
|
+
it("AC7: a write emits a manifest-reconciled diagnostic naming the slugs", async () => {
|
|
7071
|
+
const deps = makeDeps({
|
|
7072
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
7073
|
+
reconcileManifest: vi.fn().mockResolvedValue({
|
|
7074
|
+
written: true,
|
|
7075
|
+
added: ["acme"],
|
|
7076
|
+
updated: ["beta"],
|
|
7077
|
+
skipped: [],
|
|
7078
|
+
}),
|
|
7079
|
+
});
|
|
7080
|
+
|
|
7081
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
7082
|
+
|
|
7083
|
+
const diagnostics = manifestDiagnostics(deps.stderr);
|
|
7084
|
+
expect(diagnostics).toHaveLength(1);
|
|
7085
|
+
expect(diagnostics[0]).toMatchObject({
|
|
7086
|
+
component: "manifest-reconcile",
|
|
7087
|
+
event: "manifest-reconciled",
|
|
7088
|
+
added: ["acme"],
|
|
7089
|
+
updated: ["beta"],
|
|
7090
|
+
});
|
|
7091
|
+
});
|
|
7092
|
+
|
|
7093
|
+
it("AC7: a no-op reconciliation is completely silent (steady-state sync)", async () => {
|
|
7094
|
+
const deps = makeDeps({
|
|
7095
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
7096
|
+
reconcileManifest: vi.fn().mockResolvedValue({
|
|
7097
|
+
written: false,
|
|
7098
|
+
added: [],
|
|
7099
|
+
updated: [],
|
|
7100
|
+
skipped: ["weird-slug"],
|
|
7101
|
+
}),
|
|
7102
|
+
});
|
|
7103
|
+
|
|
7104
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
7105
|
+
expect(manifestDiagnostics(deps.stderr)).toEqual([]);
|
|
7106
|
+
expect(deps.stderr.raw()).not.toContain("manifest-reconciled");
|
|
7107
|
+
});
|
|
7108
|
+
});
|
|
7109
|
+
|
|
7110
|
+
describe("watcher delete intents without pre-seeded authorization", () => {
|
|
7111
|
+
function startDeleteHarness(
|
|
7112
|
+
keys: string[],
|
|
7113
|
+
options: { gateInitialPull?: boolean } = {},
|
|
7114
|
+
) {
|
|
7115
|
+
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-delete-"));
|
|
7116
|
+
const companyRoot = path.join(hqRoot, "companies", "indigo");
|
|
7117
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
7118
|
+
const remote = new Map<string, string>();
|
|
7119
|
+
const files: Record<string, {
|
|
7120
|
+
hash: string; size: number; syncedAt: string; direction: "down";
|
|
7121
|
+
remoteEtag: string; kind: "file";
|
|
7122
|
+
}> = {};
|
|
7123
|
+
for (const key of keys) {
|
|
7124
|
+
const absolutePath = path.join(companyRoot, key);
|
|
7125
|
+
const content = `synced:${key}`;
|
|
7126
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
7127
|
+
fs.writeFileSync(absolutePath, content);
|
|
7128
|
+
remote.set(key, content);
|
|
7129
|
+
files[key] = {
|
|
7130
|
+
hash: `hash:${key}`,
|
|
7131
|
+
size: Buffer.byteLength(content),
|
|
7132
|
+
syncedAt: new Date().toISOString(),
|
|
7133
|
+
direction: "down",
|
|
7134
|
+
remoteEtag: `etag:${key}`,
|
|
7135
|
+
kind: "file",
|
|
7136
|
+
};
|
|
7137
|
+
}
|
|
7138
|
+
writeJournal("indigo", {
|
|
7139
|
+
version: "2", lastSync: new Date().toISOString(), files, pulls: [],
|
|
7140
|
+
});
|
|
7141
|
+
|
|
7142
|
+
const watcher = makeBatchWatcherStub();
|
|
7143
|
+
const sleep = makeSteppableSleep();
|
|
7144
|
+
let capture: ((
|
|
7145
|
+
relativePath: string,
|
|
7146
|
+
kind: "unlink" | "unlinkDir",
|
|
7147
|
+
) => LocalDeleteSnapshot[]) | undefined;
|
|
7148
|
+
let triggerShutdown = () => {};
|
|
7149
|
+
let downloads = 0;
|
|
7150
|
+
let releaseInitialPull = () => {};
|
|
7151
|
+
const initialPullGate = new Promise<void>((resolve) => {
|
|
7152
|
+
releaseInitialPull = resolve;
|
|
7153
|
+
});
|
|
7154
|
+
let initialPullGated = options.gateInitialPull === true;
|
|
7155
|
+
const runPass = vi.fn(async (argv: string[]) => {
|
|
7156
|
+
const direction = argv[argv.indexOf("--direction") + 1];
|
|
7157
|
+
if (direction === "push") {
|
|
7158
|
+
const journal = readJournal("indigo");
|
|
7159
|
+
const pathResults: Array<{
|
|
7160
|
+
relativePath: string; status: "accepted" | "refused";
|
|
7161
|
+
operation: "delete"; reason?: string;
|
|
7162
|
+
}> = [];
|
|
7163
|
+
const scopes = argv.flatMap((value, index) =>
|
|
7164
|
+
argv[index - 1] === "--scope-path" ? [value] : [],
|
|
7165
|
+
);
|
|
7166
|
+
for (const scope of scopes) {
|
|
7167
|
+
for (const key of Object.keys(journal.files)) {
|
|
7168
|
+
if (key !== scope && !key.startsWith(`${scope}/`)) continue;
|
|
7169
|
+
const absolutePath = path.join(companyRoot, key);
|
|
7170
|
+
const entry = journal.files[key];
|
|
7171
|
+
if (fs.existsSync(absolutePath)) {
|
|
7172
|
+
remote.set(key, fs.readFileSync(absolutePath, "utf8"));
|
|
7173
|
+
continue;
|
|
7174
|
+
}
|
|
7175
|
+
const intent = entry.localDeleteIntent;
|
|
7176
|
+
const current =
|
|
7177
|
+
intent?.remoteEtag === entry.remoteEtag &&
|
|
7178
|
+
intent?.localHash === entry.hash &&
|
|
7179
|
+
intent?.localKind === entry.kind;
|
|
7180
|
+
if (current) {
|
|
7181
|
+
remote.delete(key);
|
|
7182
|
+
delete journal.files[key];
|
|
7183
|
+
pathResults.push({
|
|
7184
|
+
relativePath: `companies/indigo/${key}`,
|
|
7185
|
+
status: "accepted",
|
|
7186
|
+
operation: "delete",
|
|
7187
|
+
});
|
|
7188
|
+
} else {
|
|
7189
|
+
pathResults.push({
|
|
7190
|
+
relativePath: `companies/indigo/${key}`,
|
|
7191
|
+
status: "refused",
|
|
7192
|
+
operation: "delete",
|
|
7193
|
+
reason: "missing-delete-intent",
|
|
7194
|
+
});
|
|
7195
|
+
}
|
|
7196
|
+
}
|
|
7197
|
+
const added = path.join(companyRoot, scope);
|
|
7198
|
+
if (fs.existsSync(added) && fs.lstatSync(added).isFile()) {
|
|
7199
|
+
remote.set(scope, fs.readFileSync(added, "utf8"));
|
|
7200
|
+
}
|
|
7201
|
+
}
|
|
7202
|
+
writeJournal("indigo", journal);
|
|
7203
|
+
return { exitCode: 0, result: { pushPathResults: pathResults } };
|
|
7204
|
+
}
|
|
7205
|
+
if (direction === "pull" || direction === "both") {
|
|
7206
|
+
if (initialPullGated) {
|
|
7207
|
+
initialPullGated = false;
|
|
7208
|
+
await initialPullGate;
|
|
7209
|
+
}
|
|
7210
|
+
const journal = readJournal("indigo");
|
|
7211
|
+
for (const [key, content] of remote) {
|
|
7212
|
+
const absolutePath = path.join(companyRoot, key);
|
|
7213
|
+
if (fs.existsSync(absolutePath)) continue;
|
|
7214
|
+
const entry = journal.files[key];
|
|
7215
|
+
const intent = entry?.localDeleteIntent;
|
|
7216
|
+
const currentIntent =
|
|
7217
|
+
intent?.remoteEtag === entry?.remoteEtag &&
|
|
7218
|
+
intent?.localHash === entry?.hash &&
|
|
7219
|
+
intent?.localKind === entry?.kind;
|
|
7220
|
+
if (currentIntent) continue;
|
|
7221
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
7222
|
+
fs.writeFileSync(absolutePath, content);
|
|
7223
|
+
downloads++;
|
|
7224
|
+
}
|
|
7225
|
+
}
|
|
7226
|
+
return 0;
|
|
7227
|
+
});
|
|
7228
|
+
const loop = runRunnerWithLoop(
|
|
7229
|
+
[
|
|
7230
|
+
"--companies", "--watch", "--event-push", "--direction", "both",
|
|
7231
|
+
"--hq-root", hqRoot, "--poll-remote-ms", "60000",
|
|
7232
|
+
],
|
|
7233
|
+
{
|
|
7234
|
+
runPass,
|
|
7235
|
+
createWatcher: (opts) => {
|
|
7236
|
+
capture = opts.captureLocalDeleteSnapshots;
|
|
7237
|
+
return watcher;
|
|
7238
|
+
},
|
|
7239
|
+
sleep: sleep.sleep,
|
|
7240
|
+
onShutdownSignal: (handler) => {
|
|
7241
|
+
triggerShutdown = handler;
|
|
7242
|
+
return () => {};
|
|
7243
|
+
},
|
|
7244
|
+
},
|
|
7245
|
+
);
|
|
7246
|
+
return {
|
|
7247
|
+
hqRoot, companyRoot, remote, watcher, loop, tick: sleep.tick,
|
|
7248
|
+
capture: () => capture!, downloads: () => downloads,
|
|
7249
|
+
releaseInitialPull,
|
|
7250
|
+
shutdown: () => triggerShutdown(),
|
|
7251
|
+
};
|
|
7252
|
+
}
|
|
7253
|
+
|
|
7254
|
+
async function settle(): Promise<void> {
|
|
7255
|
+
await flushLoopMicrotasks(12);
|
|
7256
|
+
}
|
|
7257
|
+
|
|
7258
|
+
async function stopHarness(h: ReturnType<typeof startDeleteHarness>) {
|
|
7259
|
+
h.shutdown();
|
|
7260
|
+
h.tick();
|
|
7261
|
+
await h.loop;
|
|
7262
|
+
fs.rmSync(h.hqRoot, { recursive: true, force: true });
|
|
7263
|
+
}
|
|
7264
|
+
|
|
7265
|
+
it("live unlink binds the pre-delete revision, deletes remote, and survives immediate plus later full pulls", async () => {
|
|
7266
|
+
const h = startDeleteHarness(["knowledge/gone.md"]);
|
|
7267
|
+
await settle();
|
|
7268
|
+
const absolutePath = path.join(h.companyRoot, "knowledge/gone.md");
|
|
7269
|
+
fs.unlinkSync(absolutePath);
|
|
7270
|
+
const snapshots = h.capture()(
|
|
7271
|
+
"companies/indigo/knowledge/gone.md", "unlink",
|
|
7272
|
+
);
|
|
7273
|
+
expect(snapshots).toHaveLength(1);
|
|
7274
|
+
expect(readJournal("indigo").files["knowledge/gone.md"]?.localDeleteIntent)
|
|
7275
|
+
.toMatchObject({
|
|
7276
|
+
remoteEtag: "etag:knowledge/gone.md",
|
|
7277
|
+
localHash: "hash:knowledge/gone.md",
|
|
7278
|
+
localKind: "file",
|
|
7279
|
+
});
|
|
7280
|
+
h.watcher.emit("companies/indigo/knowledge/gone.md", {
|
|
7281
|
+
paths: new Map([[absolutePath, "companies/indigo/knowledge/gone.md"]]),
|
|
7282
|
+
changes: new Map([
|
|
7283
|
+
[absolutePath, { kind: "unlink", deleteSnapshots: snapshots }],
|
|
7284
|
+
]),
|
|
7285
|
+
} as TreeChangeBatch);
|
|
7286
|
+
h.tick();
|
|
7287
|
+
await settle();
|
|
7288
|
+
expect(h.remote.has("knowledge/gone.md")).toBe(false);
|
|
7289
|
+
expect(fs.existsSync(absolutePath)).toBe(false);
|
|
7290
|
+
expect(h.downloads()).toBe(0);
|
|
7291
|
+
|
|
7292
|
+
for (let i = 0; i < 8; i++) {
|
|
7293
|
+
h.tick();
|
|
7294
|
+
await settle();
|
|
7295
|
+
}
|
|
7296
|
+
expect(fs.existsSync(absolutePath)).toBe(false);
|
|
7297
|
+
expect(h.downloads()).toBe(0);
|
|
7298
|
+
await stopHarness(h);
|
|
7299
|
+
});
|
|
7300
|
+
|
|
7301
|
+
it("persists the intent before an in-flight pull plans the missing path", async () => {
|
|
7302
|
+
const h = startDeleteHarness(
|
|
7303
|
+
["knowledge/in-flight.md"],
|
|
7304
|
+
{ gateInitialPull: true },
|
|
7305
|
+
);
|
|
7306
|
+
await settle();
|
|
7307
|
+
const relativePath = "companies/indigo/knowledge/in-flight.md";
|
|
7308
|
+
const absolutePath = path.join(
|
|
7309
|
+
h.companyRoot,
|
|
7310
|
+
"knowledge/in-flight.md",
|
|
7311
|
+
);
|
|
7312
|
+
fs.unlinkSync(absolutePath);
|
|
7313
|
+
const snapshots = h.capture()(relativePath, "unlink");
|
|
7314
|
+
|
|
7315
|
+
expect(snapshots).toHaveLength(1);
|
|
7316
|
+
expect(
|
|
7317
|
+
readJournal("indigo").files["knowledge/in-flight.md"]
|
|
7318
|
+
?.localDeleteIntent,
|
|
7319
|
+
).toBeDefined();
|
|
7320
|
+
|
|
7321
|
+
h.watcher.emit(relativePath, {
|
|
7322
|
+
paths: new Map([[absolutePath, relativePath]]),
|
|
7323
|
+
changes: new Map([
|
|
7324
|
+
[absolutePath, { kind: "unlink", deleteSnapshots: snapshots }],
|
|
7325
|
+
]),
|
|
7326
|
+
} as TreeChangeBatch);
|
|
7327
|
+
h.releaseInitialPull();
|
|
7328
|
+
await settle();
|
|
7329
|
+
|
|
7330
|
+
expect(h.downloads()).toBe(0);
|
|
7331
|
+
expect(fs.existsSync(absolutePath)).toBe(false);
|
|
7332
|
+
|
|
7333
|
+
h.tick();
|
|
7334
|
+
await settle();
|
|
7335
|
+
expect(h.remote.has("knowledge/in-flight.md")).toBe(false);
|
|
7336
|
+
expect(h.downloads()).toBe(0);
|
|
7337
|
+
await stopHarness(h);
|
|
7338
|
+
});
|
|
7339
|
+
|
|
7340
|
+
it("uses direct journal-key lookup for file unlink snapshots", () => {
|
|
7341
|
+
const exactEntry = {
|
|
7342
|
+
hash: "hash",
|
|
7343
|
+
size: 4,
|
|
7344
|
+
syncedAt: new Date().toISOString(),
|
|
7345
|
+
direction: "down" as const,
|
|
7346
|
+
remoteEtag: "etag",
|
|
7347
|
+
kind: "file" as const,
|
|
7348
|
+
};
|
|
7349
|
+
const files = new Proxy(
|
|
7350
|
+
{ "knowledge/exact.md": exactEntry },
|
|
7351
|
+
{
|
|
7352
|
+
ownKeys: () => {
|
|
7353
|
+
throw new Error("file unlink must not enumerate the journal");
|
|
7354
|
+
},
|
|
7355
|
+
},
|
|
7356
|
+
);
|
|
7357
|
+
|
|
7358
|
+
expect(
|
|
7359
|
+
journalEntriesForWatcherDelete(
|
|
7360
|
+
files,
|
|
7361
|
+
"knowledge/exact.md",
|
|
7362
|
+
"unlink",
|
|
7363
|
+
),
|
|
7364
|
+
).toEqual([["knowledge/exact.md", exactEntry]]);
|
|
7365
|
+
});
|
|
7366
|
+
|
|
7367
|
+
it("keeps a deleted top-level personal file in the scoped push delete roots", async () => {
|
|
7368
|
+
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-personal-delete-"));
|
|
7369
|
+
const key = "personal-note.md";
|
|
7370
|
+
const absolutePath = path.join(hqRoot, key);
|
|
7371
|
+
fs.writeFileSync(absolutePath, "personal content");
|
|
7372
|
+
const remote = new Map([[key, "personal content"]]);
|
|
7373
|
+
writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, {
|
|
7374
|
+
version: "2",
|
|
7375
|
+
lastSync: new Date().toISOString(),
|
|
7376
|
+
pulls: [],
|
|
7377
|
+
files: {
|
|
7378
|
+
[key]: {
|
|
7379
|
+
hash: "personal-hash",
|
|
7380
|
+
size: 16,
|
|
7381
|
+
syncedAt: new Date().toISOString(),
|
|
7382
|
+
direction: "down",
|
|
7383
|
+
remoteEtag: "personal-etag",
|
|
7384
|
+
kind: "file",
|
|
7385
|
+
},
|
|
7386
|
+
},
|
|
7387
|
+
});
|
|
7388
|
+
|
|
7389
|
+
const shareSpy = vi.fn(async (options: ShareOptions) => {
|
|
7390
|
+
const journal = readJournal(PERSONAL_VAULT_JOURNAL_SLUG);
|
|
7391
|
+
const entry = journal.files[key];
|
|
7392
|
+
const intent = entry?.localDeleteIntent;
|
|
7393
|
+
const currentIntent =
|
|
7394
|
+
intent?.remoteEtag === entry?.remoteEtag &&
|
|
7395
|
+
intent?.localHash === entry?.hash &&
|
|
7396
|
+
intent?.localKind === entry?.kind;
|
|
7397
|
+
if (
|
|
7398
|
+
!fs.existsSync(absolutePath) &&
|
|
7399
|
+
options.deleteScopeRoots?.includes(key) &&
|
|
7400
|
+
currentIntent
|
|
7401
|
+
) {
|
|
7402
|
+
remote.delete(key);
|
|
7403
|
+
delete journal.files[key];
|
|
7404
|
+
writeJournal(PERSONAL_VAULT_JOURNAL_SLUG, journal);
|
|
7405
|
+
return defaultShareResult({
|
|
7406
|
+
filesDeleted: 1,
|
|
7407
|
+
pathResults: [
|
|
7408
|
+
{ path: key, status: "accepted", operation: "delete" },
|
|
7409
|
+
],
|
|
7410
|
+
});
|
|
7411
|
+
}
|
|
7412
|
+
return defaultShareResult();
|
|
7413
|
+
});
|
|
7414
|
+
let downloads = 0;
|
|
7415
|
+
const syncSpy = vi.fn(async () => {
|
|
7416
|
+
if (remote.has(key) && !fs.existsSync(absolutePath)) {
|
|
7417
|
+
const entry =
|
|
7418
|
+
readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files[key];
|
|
7419
|
+
const intent = entry?.localDeleteIntent;
|
|
7420
|
+
const currentIntent =
|
|
7421
|
+
intent?.remoteEtag === entry?.remoteEtag &&
|
|
7422
|
+
intent?.localHash === entry?.hash &&
|
|
7423
|
+
intent?.localKind === entry?.kind;
|
|
7424
|
+
if (!currentIntent) {
|
|
7425
|
+
fs.writeFileSync(absolutePath, remote.get(key)!);
|
|
7426
|
+
downloads++;
|
|
7427
|
+
}
|
|
7428
|
+
}
|
|
7429
|
+
return defaultSyncResult();
|
|
7430
|
+
});
|
|
7431
|
+
const runnerDeps = makeDeps({
|
|
7432
|
+
share: shareSpy,
|
|
7433
|
+
sync: syncSpy,
|
|
7434
|
+
createVaultClient: () =>
|
|
7435
|
+
makeVaultStub({
|
|
7436
|
+
listPersons: () =>
|
|
7437
|
+
Promise.resolve([
|
|
7438
|
+
{
|
|
7439
|
+
uid: "prs_personal",
|
|
7440
|
+
type: "person",
|
|
7441
|
+
bucketName: "hq-vault-personal",
|
|
7442
|
+
status: "active",
|
|
7443
|
+
} as unknown as EntityInfo,
|
|
7444
|
+
]),
|
|
7445
|
+
}),
|
|
7446
|
+
operationLockAlreadyHeld: true,
|
|
7447
|
+
});
|
|
7448
|
+
const watcher = makeBatchWatcherStub();
|
|
7449
|
+
const sleep = makeSteppableSleep();
|
|
7450
|
+
let capture: ((
|
|
7451
|
+
relativePath: string,
|
|
7452
|
+
kind: "unlink" | "unlinkDir",
|
|
7453
|
+
) => LocalDeleteSnapshot[]) | undefined;
|
|
7454
|
+
let triggerShutdown = () => {};
|
|
7455
|
+
const loop = runRunnerWithLoop(
|
|
7456
|
+
[
|
|
7457
|
+
"--personal",
|
|
7458
|
+
"--watch",
|
|
7459
|
+
"--event-push",
|
|
7460
|
+
"--direction",
|
|
7461
|
+
"both",
|
|
7462
|
+
"--hq-root",
|
|
7463
|
+
hqRoot,
|
|
7464
|
+
"--poll-remote-ms",
|
|
7465
|
+
"60000",
|
|
7466
|
+
],
|
|
7467
|
+
{
|
|
7468
|
+
runPass: (passArgv) => runRunner(passArgv, runnerDeps),
|
|
7469
|
+
createWatcher: (options) => {
|
|
7470
|
+
capture = options.captureLocalDeleteSnapshots;
|
|
7471
|
+
return watcher;
|
|
7472
|
+
},
|
|
7473
|
+
sleep: sleep.sleep,
|
|
7474
|
+
onShutdownSignal: (handler) => {
|
|
7475
|
+
triggerShutdown = handler;
|
|
7476
|
+
return () => {};
|
|
7477
|
+
},
|
|
7478
|
+
},
|
|
7479
|
+
);
|
|
7480
|
+
await vi.waitFor(() => expect(syncSpy).toHaveBeenCalled());
|
|
7481
|
+
await settle();
|
|
7482
|
+
shareSpy.mockClear();
|
|
7483
|
+
|
|
7484
|
+
fs.unlinkSync(absolutePath);
|
|
7485
|
+
const snapshots = capture!(key, "unlink");
|
|
7486
|
+
expect(snapshots).toHaveLength(1);
|
|
7487
|
+
expect(snapshots[0].deleteScopeRoot).toBe(key);
|
|
7488
|
+
expect(
|
|
7489
|
+
readJournal(PERSONAL_VAULT_JOURNAL_SLUG).files[key]
|
|
7490
|
+
?.localDeleteIntent,
|
|
7491
|
+
).toBeDefined();
|
|
7492
|
+
watcher.emit(key, {
|
|
7493
|
+
paths: new Map([[absolutePath, key]]),
|
|
7494
|
+
changes: new Map([
|
|
7495
|
+
[absolutePath, { kind: "unlink", deleteSnapshots: snapshots }],
|
|
7496
|
+
]),
|
|
7497
|
+
} as TreeChangeBatch);
|
|
7498
|
+
sleep.tick();
|
|
7499
|
+
await settle();
|
|
7500
|
+
|
|
7501
|
+
expect(remote.has(key)).toBe(false);
|
|
7502
|
+
expect(downloads).toBe(0);
|
|
7503
|
+
expect(fs.existsSync(absolutePath)).toBe(false);
|
|
7504
|
+
const scopedShareOptions = shareSpy.mock.calls.at(-1)?.[0];
|
|
7505
|
+
expect(scopedShareOptions?.paths).not.toContain(absolutePath);
|
|
7506
|
+
expect(scopedShareOptions?.deleteScopeRoots).toEqual([key]);
|
|
7507
|
+
|
|
7508
|
+
triggerShutdown();
|
|
7509
|
+
await loop;
|
|
7510
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
7511
|
+
});
|
|
7512
|
+
|
|
7513
|
+
it("rename uploads the new path, deletes the old path, and neither resurrects", async () => {
|
|
7514
|
+
const h = startDeleteHarness(["knowledge/old.md"]);
|
|
7515
|
+
await settle();
|
|
7516
|
+
const oldPath = path.join(h.companyRoot, "knowledge/old.md");
|
|
7517
|
+
const newPath = path.join(h.companyRoot, "knowledge/new.md");
|
|
7518
|
+
const snapshots = h.capture()(
|
|
7519
|
+
"companies/indigo/knowledge/old.md", "unlink",
|
|
7520
|
+
);
|
|
7521
|
+
fs.renameSync(oldPath, newPath);
|
|
7522
|
+
h.watcher.emit("companies/indigo/knowledge/old.md", {
|
|
7523
|
+
paths: new Map([
|
|
7524
|
+
[oldPath, "companies/indigo/knowledge/old.md"],
|
|
7525
|
+
[newPath, "companies/indigo/knowledge/new.md"],
|
|
7526
|
+
]),
|
|
7527
|
+
changes: new Map([
|
|
7528
|
+
[oldPath, { kind: "unlink", deleteSnapshots: snapshots }],
|
|
7529
|
+
[newPath, { kind: "add" }],
|
|
7530
|
+
]),
|
|
7531
|
+
} as TreeChangeBatch);
|
|
7532
|
+
h.tick();
|
|
7533
|
+
await settle();
|
|
7534
|
+
expect(h.remote.has("knowledge/old.md")).toBe(false);
|
|
7535
|
+
expect(h.remote.get("knowledge/new.md")).toBe("synced:knowledge/old.md");
|
|
7536
|
+
expect(fs.existsSync(oldPath)).toBe(false);
|
|
7537
|
+
expect(fs.existsSync(newPath)).toBe(true);
|
|
7538
|
+
await stopHarness(h);
|
|
7539
|
+
});
|
|
7540
|
+
|
|
7541
|
+
it("unlinkDir records every tracked descendant across a 12-file breaker-sized subtree", async () => {
|
|
7542
|
+
const keys = Array.from(
|
|
7543
|
+
{ length: 12 }, (_, i) => `knowledge/moved/f-${i}.md`,
|
|
7544
|
+
);
|
|
7545
|
+
const h = startDeleteHarness(keys);
|
|
7546
|
+
await settle();
|
|
7547
|
+
const subtree = path.join(h.companyRoot, "knowledge/moved");
|
|
7548
|
+
const snapshots = h.capture()(
|
|
7549
|
+
"companies/indigo/knowledge/moved", "unlinkDir",
|
|
7550
|
+
);
|
|
7551
|
+
expect(snapshots).toHaveLength(12);
|
|
7552
|
+
fs.rmSync(subtree, { recursive: true, force: true });
|
|
7553
|
+
h.watcher.emit("companies/indigo/knowledge/moved", {
|
|
7554
|
+
paths: new Map([[subtree, "companies/indigo/knowledge/moved"]]),
|
|
7555
|
+
changes: new Map([
|
|
7556
|
+
[subtree, { kind: "unlinkDir", deleteSnapshots: snapshots }],
|
|
7557
|
+
]),
|
|
7558
|
+
} as TreeChangeBatch);
|
|
7559
|
+
h.tick();
|
|
7560
|
+
await settle();
|
|
7561
|
+
expect(h.remote.size).toBe(0);
|
|
7562
|
+
expect(Object.keys(readJournal("indigo").files)).toHaveLength(0);
|
|
7563
|
+
await stopHarness(h);
|
|
7564
|
+
});
|
|
7565
|
+
|
|
7566
|
+
it("concurrent recreation before delete execution invalidates the observation and preserves remote", async () => {
|
|
7567
|
+
const h = startDeleteHarness(["knowledge/race.md"]);
|
|
7568
|
+
await settle();
|
|
7569
|
+
const absolutePath = path.join(h.companyRoot, "knowledge/race.md");
|
|
7570
|
+
fs.unlinkSync(absolutePath);
|
|
7571
|
+
const snapshots = h.capture()(
|
|
7572
|
+
"companies/indigo/knowledge/race.md", "unlink",
|
|
7573
|
+
);
|
|
7574
|
+
h.watcher.emit("companies/indigo/knowledge/race.md", {
|
|
7575
|
+
paths: new Map([[absolutePath, "companies/indigo/knowledge/race.md"]]),
|
|
7576
|
+
changes: new Map([
|
|
7577
|
+
[absolutePath, { kind: "unlink", deleteSnapshots: snapshots }],
|
|
7578
|
+
]),
|
|
7579
|
+
} as TreeChangeBatch);
|
|
7580
|
+
fs.writeFileSync(absolutePath, "concurrent recreation");
|
|
7581
|
+
h.tick();
|
|
7582
|
+
await settle();
|
|
7583
|
+
expect(h.remote.has("knowledge/race.md")).toBe(true);
|
|
7584
|
+
expect(readJournal("indigo").files["knowledge/race.md"]?.localDeleteIntent)
|
|
7585
|
+
.toBeUndefined();
|
|
7586
|
+
await stopHarness(h);
|
|
7587
|
+
});
|
|
7588
|
+
});
|