@indigoai-us/hq-cloud 6.14.34 → 6.14.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +87 -1
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.test.js +186 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-watch-loop.ts +100 -1
- package/src/bin/sync-runner.test.ts +234 -2
package/package.json
CHANGED
|
@@ -468,6 +468,46 @@ export async function runWatchLoop(
|
|
|
468
468
|
}
|
|
469
469
|
};
|
|
470
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Journal roots the bulk-intent quarantine has refused for the current
|
|
473
|
+
* delete episode, keyed `slug\0journalKey`.
|
|
474
|
+
*
|
|
475
|
+
* Chokidar (the Linux backend) reports every removed child through `unlink`
|
|
476
|
+
* BEFORE the parent `unlinkDir`, so by the time the directory expansion is
|
|
477
|
+
* refused each descendant has already persisted its own version-bound intent
|
|
478
|
+
* and handed a snapshot to the pending batch. Declining the expansion alone
|
|
479
|
+
* leaves those intents standing and `prepareWatcherChanges` re-stamps them
|
|
480
|
+
* from the snapshots, so a large `rm -rf` still propagates in full under
|
|
481
|
+
* `HQ_SYNC_DELETE_POLICY=all` — the path this producer-side layer exists to
|
|
482
|
+
* cover, because it bypasses the engine breaker entirely. The trip therefore
|
|
483
|
+
* revokes the descendants' intents as well, and this set keeps the refusal
|
|
484
|
+
* in force for the rest of the episode (the native macOS/Windows backends
|
|
485
|
+
* make no child-before-parent guarantee, so descendants can also arrive
|
|
486
|
+
* after the directory event).
|
|
487
|
+
*/
|
|
488
|
+
const quarantinedDeleteRoots = new Set<string>();
|
|
489
|
+
const QUARANTINE_KEY_SEPARATOR = "\u0000";
|
|
490
|
+
const quarantineRootKey = (slug: string, journalKey: string): string =>
|
|
491
|
+
`${slug}${QUARANTINE_KEY_SEPARATOR}${journalKey}`;
|
|
492
|
+
const quarantineRootFor = (
|
|
493
|
+
slug: string,
|
|
494
|
+
journalKey: string,
|
|
495
|
+
): string | null => {
|
|
496
|
+
for (const root of quarantinedDeleteRoots) {
|
|
497
|
+
const separator = root.indexOf(QUARANTINE_KEY_SEPARATOR);
|
|
498
|
+
if (root.slice(0, separator) !== slug) continue;
|
|
499
|
+
const rootKey = root.slice(separator + 1);
|
|
500
|
+
if (
|
|
501
|
+
rootKey === "" ||
|
|
502
|
+
journalKey === rootKey ||
|
|
503
|
+
journalKey.startsWith(`${rootKey}/`)
|
|
504
|
+
) {
|
|
505
|
+
return root;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return null;
|
|
509
|
+
};
|
|
510
|
+
|
|
471
511
|
const journalCoordinates = (
|
|
472
512
|
relativePath: string,
|
|
473
513
|
): { slug: string; key: string } | null => {
|
|
@@ -536,6 +576,29 @@ export async function runWatchLoop(
|
|
|
536
576
|
kind,
|
|
537
577
|
);
|
|
538
578
|
|
|
579
|
+
/**
|
|
580
|
+
* Revoke every delete intent under this event, including ones a descendant
|
|
581
|
+
* `unlink` already persisted before the parent `unlinkDir` arrived. An
|
|
582
|
+
* intent-less disappearance is the safe state: the engine refuses it
|
|
583
|
+
* (`missing-delete-intent`) under every delete policy, nothing is deleted
|
|
584
|
+
* locally or remotely, and the next pull restores the subtree.
|
|
585
|
+
*/
|
|
586
|
+
const revokeCoveredDeleteIntents = (): number => {
|
|
587
|
+
let revoked = 0;
|
|
588
|
+
for (const [key] of covered) {
|
|
589
|
+
if (clearLocalDeleteIntent(journal, key)) revoked += 1;
|
|
590
|
+
}
|
|
591
|
+
if (revoked > 0) writeJournal(coordinates.slug, journal);
|
|
592
|
+
return revoked;
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
// A refused directory removal covers its descendants for the rest of the
|
|
596
|
+
// episode, whichever order the backend reports them in.
|
|
597
|
+
if (quarantineRootFor(coordinates.slug, coordinates.key)) {
|
|
598
|
+
revokeCoveredDeleteIntents();
|
|
599
|
+
return [];
|
|
600
|
+
}
|
|
601
|
+
|
|
539
602
|
// Bulk-intent quarantine: one directory event must not be able to mint
|
|
540
603
|
// intents across a large fraction of the shard. Leaving them intent-less
|
|
541
604
|
// is the safe state — the engine refuses intent-less disappearances and
|
|
@@ -552,12 +615,18 @@ export async function runWatchLoop(
|
|
|
552
615
|
Object.keys(journal.files).length,
|
|
553
616
|
)
|
|
554
617
|
) {
|
|
618
|
+
quarantinedDeleteRoots.add(
|
|
619
|
+
quarantineRootKey(coordinates.slug, coordinates.key),
|
|
620
|
+
);
|
|
621
|
+
const revoked = revokeCoveredDeleteIntents();
|
|
555
622
|
process.stderr.write(
|
|
556
623
|
`hq-sync-runner: refusing to record delete intent for ${stampable.length} ` +
|
|
557
624
|
`of ${Object.keys(journal.files).length} journal entries under ` +
|
|
558
625
|
`"${coordinates.key || "<root>"}" — a single directory removal that large ` +
|
|
559
626
|
`is treated as a possible mirror loss. Nothing was deleted. ` +
|
|
560
|
-
`
|
|
627
|
+
`Revoked ${revoked} intent(s) already recorded by descendant unlink ` +
|
|
628
|
+
`events. Set HQ_SYNC_DELETE_BULK_OVERRIDE=1 to propagate it ` +
|
|
629
|
+
`deliberately.\n`,
|
|
561
630
|
);
|
|
562
631
|
return [];
|
|
563
632
|
}
|
|
@@ -589,6 +658,21 @@ export async function runWatchLoop(
|
|
|
589
658
|
for (const [absolutePath, change] of changes.entries()) {
|
|
590
659
|
if (change.kind === "unlink" || change.kind === "unlinkDir") {
|
|
591
660
|
for (const snapshot of change.deleteSnapshots ?? []) {
|
|
661
|
+
// Snapshots captured by descendant unlinks before the directory
|
|
662
|
+
// expansion was refused are part of the same quarantined episode.
|
|
663
|
+
// Re-stamping them here would hand the engine exactly the bulk
|
|
664
|
+
// delete the quarantine declined.
|
|
665
|
+
if (quarantineRootFor(snapshot.journalSlug, snapshot.journalPath)) {
|
|
666
|
+
if (
|
|
667
|
+
clearLocalDeleteIntent(
|
|
668
|
+
journalFor(snapshot.journalSlug),
|
|
669
|
+
snapshot.journalPath,
|
|
670
|
+
)
|
|
671
|
+
) {
|
|
672
|
+
dirty.add(snapshot.journalSlug);
|
|
673
|
+
}
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
592
676
|
let absent = false;
|
|
593
677
|
try {
|
|
594
678
|
fs.lstatSync(snapshot.absolutePath);
|
|
@@ -922,6 +1006,15 @@ export async function runWatchLoop(
|
|
|
922
1006
|
while (!stopped) {
|
|
923
1007
|
pollTick++;
|
|
924
1008
|
const pending = takePendingWatcherChange();
|
|
1009
|
+
// The quarantine is scoped to the delete episode it refused, and only
|
|
1010
|
+
// the roots already refused when this batch was taken belong to it.
|
|
1011
|
+
// Snapshot them here rather than clearing the whole set after the pass:
|
|
1012
|
+
// the pass is awaited, so a bulk removal observed while it runs adds its
|
|
1013
|
+
// root afterwards and its snapshots ride the NEXT batch. Clearing
|
|
1014
|
+
// unconditionally would drop that root before `prepareWatcherChanges`
|
|
1015
|
+
// ever sees the batch, re-stamping the revoked descendant intents and
|
|
1016
|
+
// handing the engine exactly the bulk delete the quarantine refused.
|
|
1017
|
+
const drainedQuarantineRoots = new Set(quarantinedDeleteRoots);
|
|
925
1018
|
const shouldRunFull =
|
|
926
1019
|
pollTick === 1 ||
|
|
927
1020
|
pending.bare ||
|
|
@@ -930,6 +1023,12 @@ export async function runWatchLoop(
|
|
|
930
1023
|
const result = shouldRunFull
|
|
931
1024
|
? await runGuarded(passArgv)
|
|
932
1025
|
: await runScopedDrain(pending.batch);
|
|
1026
|
+
// This batch's episode has now been prepared and pushed, so a later
|
|
1027
|
+
// removal under the same path is judged fresh — a root that never
|
|
1028
|
+
// expired would silently refuse ordinary deletes from here on.
|
|
1029
|
+
for (const root of drainedQuarantineRoots) {
|
|
1030
|
+
quarantinedDeleteRoots.delete(root);
|
|
1031
|
+
}
|
|
933
1032
|
const exitCode = passExitCode(result);
|
|
934
1033
|
if (exitCode === runtime.authRequiredPassExit) {
|
|
935
1034
|
// The pass already emitted auth-error. Stop the unattended loop but
|
|
@@ -37,6 +37,7 @@ import type {
|
|
|
37
37
|
import {
|
|
38
38
|
FakeClock,
|
|
39
39
|
type LocalDeleteSnapshot,
|
|
40
|
+
type TreeChange,
|
|
40
41
|
type TreeChangeBatch,
|
|
41
42
|
} from "../watcher.js";
|
|
42
43
|
import {
|
|
@@ -7547,7 +7548,11 @@ describe("US-002 — manifest reconciliation wiring", () => {
|
|
|
7547
7548
|
describe("watcher delete intents without pre-seeded authorization", () => {
|
|
7548
7549
|
function startDeleteHarness(
|
|
7549
7550
|
keys: string[],
|
|
7550
|
-
options: {
|
|
7551
|
+
options: {
|
|
7552
|
+
gateInitialPull?: boolean;
|
|
7553
|
+
onPass?: (argv: string[]) => Promise<void> | void;
|
|
7554
|
+
direction?: "both" | "push";
|
|
7555
|
+
} = {},
|
|
7551
7556
|
) {
|
|
7552
7557
|
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-delete-"));
|
|
7553
7558
|
const companyRoot = path.join(hqRoot, "companies", "indigo");
|
|
@@ -7590,6 +7595,9 @@ describe("watcher delete intents without pre-seeded authorization", () => {
|
|
|
7590
7595
|
});
|
|
7591
7596
|
let initialPullGated = options.gateInitialPull === true;
|
|
7592
7597
|
const runPass = vi.fn(async (argv: string[]) => {
|
|
7598
|
+
// Lets a test hold a pass mid-flight and act while the loop is awaiting
|
|
7599
|
+
// it, which is where producer-side state that outlives a pass is decided.
|
|
7600
|
+
await options.onPass?.(argv);
|
|
7593
7601
|
const direction = argv[argv.indexOf("--direction") + 1];
|
|
7594
7602
|
if (direction === "push") {
|
|
7595
7603
|
const journal = readJournal("indigo");
|
|
@@ -7664,7 +7672,8 @@ describe("watcher delete intents without pre-seeded authorization", () => {
|
|
|
7664
7672
|
});
|
|
7665
7673
|
const loop = runRunnerWithLoop(
|
|
7666
7674
|
[
|
|
7667
|
-
"--companies", "--watch", "--event-push",
|
|
7675
|
+
"--companies", "--watch", "--event-push",
|
|
7676
|
+
"--direction", options.direction ?? "both",
|
|
7668
7677
|
"--hq-root", hqRoot, "--poll-remote-ms", "60000",
|
|
7669
7678
|
],
|
|
7670
7679
|
{
|
|
@@ -8038,6 +8047,229 @@ describe("watcher delete intents without pre-seeded authorization", () => {
|
|
|
8038
8047
|
await stopHarness(h);
|
|
8039
8048
|
});
|
|
8040
8049
|
|
|
8050
|
+
// Chokidar (the Linux backend) reports every removed child through `unlink`
|
|
8051
|
+
// BEFORE the parent `unlinkDir`. Each descendant therefore persists its own
|
|
8052
|
+
// version-bound intent and hands a snapshot to the pending batch before the
|
|
8053
|
+
// directory expansion is ever seen, so a quarantine that only declines the
|
|
8054
|
+
// expansion leaves a whole `rm -rf` intent-backed — and
|
|
8055
|
+
// `HQ_SYNC_DELETE_POLICY=all`, which bypasses the engine breaker entirely,
|
|
8056
|
+
// would still delete the subtree without HQ_SYNC_DELETE_BULK_OVERRIDE.
|
|
8057
|
+
it("unlink-before-unlinkDir: the trip revokes intents descendants already recorded", async () => {
|
|
8058
|
+
const keys = Array.from(
|
|
8059
|
+
{ length: 12 }, (_, i) => `knowledge/moved/f-${i}.md`,
|
|
8060
|
+
);
|
|
8061
|
+
const h = startDeleteHarness(keys);
|
|
8062
|
+
await settle();
|
|
8063
|
+
const subtree = path.join(h.companyRoot, "knowledge/moved");
|
|
8064
|
+
const intentCount = () =>
|
|
8065
|
+
Object.values(readJournal("indigo").files).filter(
|
|
8066
|
+
(entry) => entry.localDeleteIntent,
|
|
8067
|
+
).length;
|
|
8068
|
+
|
|
8069
|
+
const childChanges = new Map<string, TreeChange>();
|
|
8070
|
+
for (const key of keys) {
|
|
8071
|
+
const absolutePath = path.join(h.companyRoot, key);
|
|
8072
|
+
fs.unlinkSync(absolutePath);
|
|
8073
|
+
const snapshots = h.capture()(`companies/indigo/${key}`, "unlink");
|
|
8074
|
+
expect(snapshots).toHaveLength(1);
|
|
8075
|
+
childChanges.set(absolutePath, {
|
|
8076
|
+
kind: "unlink",
|
|
8077
|
+
deleteSnapshots: snapshots,
|
|
8078
|
+
});
|
|
8079
|
+
}
|
|
8080
|
+
expect(intentCount()).toBe(12);
|
|
8081
|
+
|
|
8082
|
+
fs.rmSync(subtree, { recursive: true, force: true });
|
|
8083
|
+
const dirSnapshots = h.capture()(
|
|
8084
|
+
"companies/indigo/knowledge/moved", "unlinkDir",
|
|
8085
|
+
);
|
|
8086
|
+
expect(dirSnapshots).toHaveLength(0);
|
|
8087
|
+
expect(intentCount()).toBe(0);
|
|
8088
|
+
|
|
8089
|
+
h.watcher.emit("companies/indigo/knowledge/moved", {
|
|
8090
|
+
paths: new Map<string, string>([
|
|
8091
|
+
...keys.map(
|
|
8092
|
+
(key) =>
|
|
8093
|
+
[
|
|
8094
|
+
path.join(h.companyRoot, key),
|
|
8095
|
+
`companies/indigo/${key}`,
|
|
8096
|
+
] as [string, string],
|
|
8097
|
+
),
|
|
8098
|
+
[subtree, "companies/indigo/knowledge/moved"],
|
|
8099
|
+
]),
|
|
8100
|
+
changes: new Map<string, TreeChange>([
|
|
8101
|
+
...childChanges,
|
|
8102
|
+
[subtree, { kind: "unlinkDir", deleteSnapshots: dirSnapshots }],
|
|
8103
|
+
]),
|
|
8104
|
+
} as TreeChangeBatch);
|
|
8105
|
+
h.tick();
|
|
8106
|
+
await settle();
|
|
8107
|
+
|
|
8108
|
+
// The batch still carries the descendants' snapshots; preparing it must
|
|
8109
|
+
// not re-stamp the intents the trip revoked.
|
|
8110
|
+
expect(intentCount()).toBe(0);
|
|
8111
|
+
expect(h.remote.size).toBe(12);
|
|
8112
|
+
expect(Object.keys(readJournal("indigo").files)).toHaveLength(12);
|
|
8113
|
+
await stopHarness(h);
|
|
8114
|
+
});
|
|
8115
|
+
|
|
8116
|
+
// The native macOS/Windows backends make no child-before-parent guarantee,
|
|
8117
|
+
// so the refusal has to hold for descendants that arrive after it too.
|
|
8118
|
+
it("a descendant unlink arriving after a refused unlinkDir records no intent", async () => {
|
|
8119
|
+
const keys = Array.from(
|
|
8120
|
+
{ length: 12 }, (_, i) => `knowledge/moved/f-${i}.md`,
|
|
8121
|
+
);
|
|
8122
|
+
const h = startDeleteHarness(keys);
|
|
8123
|
+
await settle();
|
|
8124
|
+
const subtree = path.join(h.companyRoot, "knowledge/moved");
|
|
8125
|
+
fs.rmSync(subtree, { recursive: true, force: true });
|
|
8126
|
+
|
|
8127
|
+
expect(
|
|
8128
|
+
h.capture()("companies/indigo/knowledge/moved", "unlinkDir"),
|
|
8129
|
+
).toHaveLength(0);
|
|
8130
|
+
expect(h.capture()(`companies/indigo/${keys[0]}`, "unlink")).toHaveLength(0);
|
|
8131
|
+
expect(
|
|
8132
|
+
readJournal("indigo").files[keys[0]]?.localDeleteIntent,
|
|
8133
|
+
).toBeUndefined();
|
|
8134
|
+
await stopHarness(h);
|
|
8135
|
+
});
|
|
8136
|
+
|
|
8137
|
+
// The quarantine must not reach past the episode it refused: an unexpired
|
|
8138
|
+
// root would silently swallow every later delete under that path.
|
|
8139
|
+
it("an ordinary delete under a previously refused root still propagates", async () => {
|
|
8140
|
+
const keys = Array.from(
|
|
8141
|
+
{ length: 12 }, (_, i) => `knowledge/moved/f-${i}.md`,
|
|
8142
|
+
);
|
|
8143
|
+
const h = startDeleteHarness(keys);
|
|
8144
|
+
await settle();
|
|
8145
|
+
const subtree = path.join(h.companyRoot, "knowledge/moved");
|
|
8146
|
+
fs.rmSync(subtree, { recursive: true, force: true });
|
|
8147
|
+
const dirSnapshots = h.capture()(
|
|
8148
|
+
"companies/indigo/knowledge/moved", "unlinkDir",
|
|
8149
|
+
);
|
|
8150
|
+
h.watcher.emit("companies/indigo/knowledge/moved", {
|
|
8151
|
+
paths: new Map([[subtree, "companies/indigo/knowledge/moved"]]),
|
|
8152
|
+
changes: new Map([
|
|
8153
|
+
[subtree, { kind: "unlinkDir", deleteSnapshots: dirSnapshots }],
|
|
8154
|
+
]),
|
|
8155
|
+
} as TreeChangeBatch);
|
|
8156
|
+
h.tick();
|
|
8157
|
+
await settle();
|
|
8158
|
+
expect(h.remote.size).toBe(12);
|
|
8159
|
+
|
|
8160
|
+
// Next episode: one ordinary file removal under the same directory.
|
|
8161
|
+
const lonePath = path.join(h.companyRoot, keys[0]);
|
|
8162
|
+
fs.mkdirSync(path.dirname(lonePath), { recursive: true });
|
|
8163
|
+
fs.writeFileSync(lonePath, `synced:${keys[0]}`);
|
|
8164
|
+
fs.unlinkSync(lonePath);
|
|
8165
|
+
const snapshots = h.capture()(`companies/indigo/${keys[0]}`, "unlink");
|
|
8166
|
+
expect(snapshots).toHaveLength(1);
|
|
8167
|
+
h.watcher.emit(`companies/indigo/${keys[0]}`, {
|
|
8168
|
+
paths: new Map([[lonePath, `companies/indigo/${keys[0]}`]]),
|
|
8169
|
+
changes: new Map([
|
|
8170
|
+
[lonePath, { kind: "unlink", deleteSnapshots: snapshots }],
|
|
8171
|
+
]),
|
|
8172
|
+
} as TreeChangeBatch);
|
|
8173
|
+
h.tick();
|
|
8174
|
+
await settle();
|
|
8175
|
+
expect(h.remote.has(keys[0])).toBe(false);
|
|
8176
|
+
expect(h.remote.size).toBe(11);
|
|
8177
|
+
await stopHarness(h);
|
|
8178
|
+
});
|
|
8179
|
+
|
|
8180
|
+
// A pass is awaited, so a bulk removal observed while one is in flight
|
|
8181
|
+
// registers its quarantine root only after that pass's batch was taken — its
|
|
8182
|
+
// own snapshots ride the NEXT batch. Expiring the whole set once the pass
|
|
8183
|
+
// returns would drop the root before `prepareWatcherChanges` ever sees that
|
|
8184
|
+
// batch, re-stamping the revoked descendant intents and handing the engine
|
|
8185
|
+
// the bulk delete the quarantine refused (remotely deleting the subtree under
|
|
8186
|
+
// HQ_SYNC_DELETE_POLICY=all).
|
|
8187
|
+
it("a bulk removal observed during an in-flight pass keeps its quarantine", async () => {
|
|
8188
|
+
const keys = Array.from(
|
|
8189
|
+
{ length: 12 }, (_, i) => `knowledge/moved/f-${i}.md`,
|
|
8190
|
+
);
|
|
8191
|
+
let releaseGatedPush = () => {};
|
|
8192
|
+
const gatedPush = new Promise<void>((resolve) => {
|
|
8193
|
+
releaseGatedPush = resolve;
|
|
8194
|
+
});
|
|
8195
|
+
let gateArmed = false;
|
|
8196
|
+
let gateReached = false;
|
|
8197
|
+
// Push-only: a pull would restore the quarantined subtree (the documented
|
|
8198
|
+
// recovery for an intent-less disappearance) before the batch carrying the
|
|
8199
|
+
// episode is prepared, masking whether the refusal itself survived.
|
|
8200
|
+
const h = startDeleteHarness([...keys, "knowledge/other.md"], {
|
|
8201
|
+
direction: "push",
|
|
8202
|
+
onPass: async (_argv) => {
|
|
8203
|
+
if (!gateArmed) return;
|
|
8204
|
+
gateArmed = false;
|
|
8205
|
+
gateReached = true;
|
|
8206
|
+
await gatedPush;
|
|
8207
|
+
},
|
|
8208
|
+
});
|
|
8209
|
+
await settle();
|
|
8210
|
+
const intentCount = () =>
|
|
8211
|
+
Object.values(readJournal("indigo").files).filter(
|
|
8212
|
+
(entry) => entry.localDeleteIntent,
|
|
8213
|
+
).length;
|
|
8214
|
+
|
|
8215
|
+
// An unrelated edit drains as a scoped push, which then blocks on the gate
|
|
8216
|
+
// with its batch already taken from the pending buffer.
|
|
8217
|
+
gateArmed = true;
|
|
8218
|
+
const otherPath = path.join(h.companyRoot, "knowledge/other.md");
|
|
8219
|
+
fs.writeFileSync(otherPath, "edited");
|
|
8220
|
+
h.watcher.emit("companies/indigo/knowledge/other.md", {
|
|
8221
|
+
paths: new Map([[otherPath, "companies/indigo/knowledge/other.md"]]),
|
|
8222
|
+
} as TreeChangeBatch);
|
|
8223
|
+
h.tick();
|
|
8224
|
+
await settle();
|
|
8225
|
+
expect(gateReached).toBe(true);
|
|
8226
|
+
|
|
8227
|
+
// The whole subtree disappears while that pass is still in flight.
|
|
8228
|
+
const childChanges = new Map<string, TreeChange>();
|
|
8229
|
+
for (const key of keys) {
|
|
8230
|
+
const absolutePath = path.join(h.companyRoot, key);
|
|
8231
|
+
fs.unlinkSync(absolutePath);
|
|
8232
|
+
childChanges.set(absolutePath, {
|
|
8233
|
+
kind: "unlink",
|
|
8234
|
+
deleteSnapshots: h.capture()(`companies/indigo/${key}`, "unlink"),
|
|
8235
|
+
});
|
|
8236
|
+
}
|
|
8237
|
+
const subtree = path.join(h.companyRoot, "knowledge/moved");
|
|
8238
|
+
fs.rmSync(subtree, { recursive: true, force: true });
|
|
8239
|
+
const dirSnapshots = h.capture()(
|
|
8240
|
+
"companies/indigo/knowledge/moved", "unlinkDir",
|
|
8241
|
+
);
|
|
8242
|
+
expect(dirSnapshots).toHaveLength(0);
|
|
8243
|
+
expect(intentCount()).toBe(0);
|
|
8244
|
+
h.watcher.emit("companies/indigo/knowledge/moved", {
|
|
8245
|
+
paths: new Map<string, string>([
|
|
8246
|
+
...keys.map(
|
|
8247
|
+
(key) =>
|
|
8248
|
+
[
|
|
8249
|
+
path.join(h.companyRoot, key),
|
|
8250
|
+
`companies/indigo/${key}`,
|
|
8251
|
+
] as [string, string],
|
|
8252
|
+
),
|
|
8253
|
+
[subtree, "companies/indigo/knowledge/moved"],
|
|
8254
|
+
]),
|
|
8255
|
+
changes: new Map<string, TreeChange>([
|
|
8256
|
+
...childChanges,
|
|
8257
|
+
[subtree, { kind: "unlinkDir", deleteSnapshots: dirSnapshots }],
|
|
8258
|
+
]),
|
|
8259
|
+
} as TreeChangeBatch);
|
|
8260
|
+
|
|
8261
|
+
// Completing the overlapped pass must not expire the refusal recorded
|
|
8262
|
+
// during it: the next drain prepares the batch that carries the episode.
|
|
8263
|
+
releaseGatedPush();
|
|
8264
|
+
await settle();
|
|
8265
|
+
h.tick();
|
|
8266
|
+
await settle();
|
|
8267
|
+
|
|
8268
|
+
expect(intentCount()).toBe(0);
|
|
8269
|
+
for (const key of keys) expect(h.remote.has(key)).toBe(true);
|
|
8270
|
+
await stopHarness(h);
|
|
8271
|
+
});
|
|
8272
|
+
|
|
8041
8273
|
it("HQ_SYNC_DELETE_BULK_OVERRIDE=1 lets a whole-shard unlinkDir propagate", async () => {
|
|
8042
8274
|
process.env.HQ_SYNC_DELETE_BULK_OVERRIDE = "1";
|
|
8043
8275
|
try {
|