@indigoai-us/hq-cloud 6.14.33 → 6.14.35
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 +14 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +129 -1
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.test.js +181 -5
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.js +76 -14
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +139 -7
- package/dist/cli/share.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-watch-loop.ts +159 -2
- package/src/bin/sync-runner.test.ts +234 -4
- package/src/cli/share.test.ts +161 -7
- package/src/cli/share.ts +73 -14
package/package.json
CHANGED
|
@@ -136,6 +136,46 @@ export function journalEntriesForWatcherDelete(
|
|
|
136
136
|
);
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Bulk-intent quarantine thresholds. Deliberately the same shape and values as
|
|
141
|
+
* the engine's `BULK_ASYMMETRY_*` constants (`src/cli/share.ts`) so operators
|
|
142
|
+
* learn one rule and one knob: a disappearance covering >= 10 journal entries
|
|
143
|
+
* AND >= 10% of the shard is treated as suspect rather than deliberate.
|
|
144
|
+
*/
|
|
145
|
+
const WATCHER_BULK_INTENT_MIN_ABS = 10;
|
|
146
|
+
const WATCHER_BULK_INTENT_RATIO = 0.1;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Should a single `unlinkDir` expansion be quarantined instead of stamping a
|
|
150
|
+
* version-bound delete intent on every descendant journal entry?
|
|
151
|
+
*
|
|
152
|
+
* One filesystem event can cover an arbitrary subtree, so the producer is
|
|
153
|
+
* capable of minting thousands of individually-valid intents from a checkout
|
|
154
|
+
* swap, an unmount, a partial restore or an `rm -rf` — the 2026-07-30 mass
|
|
155
|
+
* deletion did exactly that. Quarantined entries are simply left intent-less,
|
|
156
|
+
* which the engine already refuses (`missing-delete-intent`); nothing is
|
|
157
|
+
* deleted locally or remotely and the next pull restores the tree.
|
|
158
|
+
*
|
|
159
|
+
* Pure so the threshold is unit-testable without a watcher or a journal.
|
|
160
|
+
*/
|
|
161
|
+
export function shouldQuarantineWatcherDeleteIntents(
|
|
162
|
+
expandedEntries: number,
|
|
163
|
+
journalEntries: number,
|
|
164
|
+
): boolean {
|
|
165
|
+
if (expandedEntries < WATCHER_BULK_INTENT_MIN_ABS) return false;
|
|
166
|
+
if (journalEntries <= 0) return false;
|
|
167
|
+
return expandedEntries / journalEntries >= WATCHER_BULK_INTENT_RATIO;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Same operator escape hatch as the engine breaker — one knob for both layers.
|
|
172
|
+
* Set when a genuinely intentional large folder removal must propagate.
|
|
173
|
+
*/
|
|
174
|
+
function isBulkIntentOverride(): boolean {
|
|
175
|
+
const v = (process.env.HQ_SYNC_DELETE_BULK_OVERRIDE ?? "").toLowerCase();
|
|
176
|
+
return v === "1" || v === "true" || v === "yes";
|
|
177
|
+
}
|
|
178
|
+
|
|
139
179
|
function passExitCode(outcome: number | RunnerPassOutcome): number {
|
|
140
180
|
return typeof outcome === "number" ? outcome : outcome.exitCode;
|
|
141
181
|
}
|
|
@@ -428,6 +468,46 @@ export async function runWatchLoop(
|
|
|
428
468
|
}
|
|
429
469
|
};
|
|
430
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
|
+
|
|
431
511
|
const journalCoordinates = (
|
|
432
512
|
relativePath: string,
|
|
433
513
|
): { slug: string; key: string } | null => {
|
|
@@ -490,11 +570,68 @@ export async function runWatchLoop(
|
|
|
490
570
|
// Chokidar emits one unlink per file during rm -rf. Avoid walking the
|
|
491
571
|
// whole shard for every exact-key event; directory unlinks alone need
|
|
492
572
|
// descendant expansion.
|
|
493
|
-
|
|
573
|
+
const covered = journalEntriesForWatcherDelete(
|
|
494
574
|
journal.files,
|
|
495
575
|
coordinates.key,
|
|
496
576
|
kind,
|
|
497
|
-
)
|
|
577
|
+
);
|
|
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
|
+
|
|
602
|
+
// Bulk-intent quarantine: one directory event must not be able to mint
|
|
603
|
+
// intents across a large fraction of the shard. Leaving them intent-less
|
|
604
|
+
// is the safe state — the engine refuses intent-less disappearances and
|
|
605
|
+
// the next pull restores the subtree.
|
|
606
|
+
const stampable = covered.filter(
|
|
607
|
+
([, entry]) =>
|
|
608
|
+
entry?.remoteEtag && entry.kind && entry.removedAt === undefined,
|
|
609
|
+
);
|
|
610
|
+
if (
|
|
611
|
+
kind === "unlinkDir" &&
|
|
612
|
+
!isBulkIntentOverride() &&
|
|
613
|
+
shouldQuarantineWatcherDeleteIntents(
|
|
614
|
+
stampable.length,
|
|
615
|
+
Object.keys(journal.files).length,
|
|
616
|
+
)
|
|
617
|
+
) {
|
|
618
|
+
quarantinedDeleteRoots.add(
|
|
619
|
+
quarantineRootKey(coordinates.slug, coordinates.key),
|
|
620
|
+
);
|
|
621
|
+
const revoked = revokeCoveredDeleteIntents();
|
|
622
|
+
process.stderr.write(
|
|
623
|
+
`hq-sync-runner: refusing to record delete intent for ${stampable.length} ` +
|
|
624
|
+
`of ${Object.keys(journal.files).length} journal entries under ` +
|
|
625
|
+
`"${coordinates.key || "<root>"}" — a single directory removal that large ` +
|
|
626
|
+
`is treated as a possible mirror loss. Nothing was deleted. ` +
|
|
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`,
|
|
630
|
+
);
|
|
631
|
+
return [];
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
for (const [key, entry] of stampable) {
|
|
498
635
|
captureEntry(key, entry);
|
|
499
636
|
}
|
|
500
637
|
|
|
@@ -521,6 +658,21 @@ export async function runWatchLoop(
|
|
|
521
658
|
for (const [absolutePath, change] of changes.entries()) {
|
|
522
659
|
if (change.kind === "unlink" || change.kind === "unlinkDir") {
|
|
523
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
|
+
}
|
|
524
676
|
let absent = false;
|
|
525
677
|
try {
|
|
526
678
|
fs.lstatSync(snapshot.absolutePath);
|
|
@@ -862,6 +1014,11 @@ export async function runWatchLoop(
|
|
|
862
1014
|
const result = shouldRunFull
|
|
863
1015
|
? await runGuarded(passArgv)
|
|
864
1016
|
: await runScopedDrain(pending.batch);
|
|
1017
|
+
// The quarantine is scoped to the delete episode it refused. The batch
|
|
1018
|
+
// carrying that episode has now been prepared and pushed, so a later
|
|
1019
|
+
// removal under the same path is judged fresh — a root that never
|
|
1020
|
+
// expired would silently refuse ordinary deletes from here on.
|
|
1021
|
+
quarantinedDeleteRoots.clear();
|
|
865
1022
|
const exitCode = passExitCode(result);
|
|
866
1023
|
if (exitCode === runtime.authRequiredPassExit) {
|
|
867
1024
|
// The pass already emitted auth-error. Stop the unattended loop but
|
|
@@ -37,11 +37,13 @@ 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 {
|
|
43
44
|
adaptivePollMs,
|
|
44
45
|
journalEntriesForWatcherDelete,
|
|
46
|
+
shouldQuarantineWatcherDeleteIntents,
|
|
45
47
|
} from "./sync-runner-watch-loop.js";
|
|
46
48
|
import {
|
|
47
49
|
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
@@ -7800,6 +7802,32 @@ describe("watcher delete intents without pre-seeded authorization", () => {
|
|
|
7800
7802
|
).toEqual([["knowledge/exact.md", exactEntry]]);
|
|
7801
7803
|
});
|
|
7802
7804
|
|
|
7805
|
+
// Bulk-intent quarantine. A single `unlinkDir` expands to every descendant
|
|
7806
|
+
// journal entry, so without a ceiling one filesystem event — a checkout
|
|
7807
|
+
// swap, an unmount, a partial restore — mints thousands of individually
|
|
7808
|
+
// valid delete intents. Same thresholds as the engine's bulk-asymmetry
|
|
7809
|
+
// breaker so there is one rule and one override knob across both layers.
|
|
7810
|
+
it.each([
|
|
7811
|
+
// [expanded, journalEntries, quarantined, why]
|
|
7812
|
+
[11, 100, true, "11% of the shard, above both thresholds"],
|
|
7813
|
+
[10, 100, true, "exactly at MIN_ABS and RATIO"],
|
|
7814
|
+
[9, 100, false, "below MIN_ABS — ordinary cleanup must propagate"],
|
|
7815
|
+
[9, 9, false, "100% ratio but below MIN_ABS floor"],
|
|
7816
|
+
[10, 1000, false, "at MIN_ABS but only 1% of the shard"],
|
|
7817
|
+
[50, 50, true, "the whole shard vanished at once"],
|
|
7818
|
+
[0, 0, false, "empty journal never quarantines"],
|
|
7819
|
+
])(
|
|
7820
|
+
"bulk-intent quarantine for %i of %i journal entries → %s (%s)",
|
|
7821
|
+
(expanded, journalEntries, quarantined) => {
|
|
7822
|
+
expect(
|
|
7823
|
+
shouldQuarantineWatcherDeleteIntents(
|
|
7824
|
+
expanded as number,
|
|
7825
|
+
journalEntries as number,
|
|
7826
|
+
),
|
|
7827
|
+
).toBe(quarantined);
|
|
7828
|
+
},
|
|
7829
|
+
);
|
|
7830
|
+
|
|
7803
7831
|
it("keeps a deleted top-level personal file in the scoped push delete roots", async () => {
|
|
7804
7832
|
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-personal-delete-"));
|
|
7805
7833
|
const key = "personal-note.md";
|
|
@@ -7974,17 +8002,219 @@ describe("watcher delete intents without pre-seeded authorization", () => {
|
|
|
7974
8002
|
await stopHarness(h);
|
|
7975
8003
|
});
|
|
7976
8004
|
|
|
7977
|
-
|
|
8005
|
+
// Replaces #227's "unlinkDir records every tracked descendant across a
|
|
8006
|
+
// 12-file breaker-sized subtree", which asserted that one directory event
|
|
8007
|
+
// may mint an intent for the entire shard and wipe it remotely. That is the
|
|
8008
|
+
// shape of the 2026-07-30 incident (1,108 files in one push), and the test
|
|
8009
|
+
// name conceded it was breaker-sized. Quarantining is now the contract; the
|
|
8010
|
+
// three tests below cover the refusal, the operator escape hatch, and the
|
|
8011
|
+
// ordinary folder removal that must keep working.
|
|
8012
|
+
it("unlinkDir covering the whole shard is quarantined: no intents, nothing deleted", async () => {
|
|
8013
|
+
const keys = Array.from(
|
|
8014
|
+
{ length: 12 }, (_, i) => `knowledge/moved/f-${i}.md`,
|
|
8015
|
+
);
|
|
8016
|
+
const h = startDeleteHarness(keys);
|
|
8017
|
+
await settle();
|
|
8018
|
+
const subtree = path.join(h.companyRoot, "knowledge/moved");
|
|
8019
|
+
const snapshots = h.capture()(
|
|
8020
|
+
"companies/indigo/knowledge/moved", "unlinkDir",
|
|
8021
|
+
);
|
|
8022
|
+
expect(snapshots).toHaveLength(0);
|
|
8023
|
+
expect(
|
|
8024
|
+
Object.values(readJournal("indigo").files).filter(
|
|
8025
|
+
(entry) => entry.localDeleteIntent,
|
|
8026
|
+
),
|
|
8027
|
+
).toHaveLength(0);
|
|
8028
|
+
fs.rmSync(subtree, { recursive: true, force: true });
|
|
8029
|
+
h.watcher.emit("companies/indigo/knowledge/moved", {
|
|
8030
|
+
paths: new Map([[subtree, "companies/indigo/knowledge/moved"]]),
|
|
8031
|
+
changes: new Map([
|
|
8032
|
+
[subtree, { kind: "unlinkDir", deleteSnapshots: snapshots }],
|
|
8033
|
+
]),
|
|
8034
|
+
} as TreeChangeBatch);
|
|
8035
|
+
h.tick();
|
|
8036
|
+
await settle();
|
|
8037
|
+
expect(h.remote.size).toBe(12);
|
|
8038
|
+
expect(Object.keys(readJournal("indigo").files)).toHaveLength(12);
|
|
8039
|
+
await stopHarness(h);
|
|
8040
|
+
});
|
|
8041
|
+
|
|
8042
|
+
// Chokidar (the Linux backend) reports every removed child through `unlink`
|
|
8043
|
+
// BEFORE the parent `unlinkDir`. Each descendant therefore persists its own
|
|
8044
|
+
// version-bound intent and hands a snapshot to the pending batch before the
|
|
8045
|
+
// directory expansion is ever seen, so a quarantine that only declines the
|
|
8046
|
+
// expansion leaves a whole `rm -rf` intent-backed — and
|
|
8047
|
+
// `HQ_SYNC_DELETE_POLICY=all`, which bypasses the engine breaker entirely,
|
|
8048
|
+
// would still delete the subtree without HQ_SYNC_DELETE_BULK_OVERRIDE.
|
|
8049
|
+
it("unlink-before-unlinkDir: the trip revokes intents descendants already recorded", async () => {
|
|
8050
|
+
const keys = Array.from(
|
|
8051
|
+
{ length: 12 }, (_, i) => `knowledge/moved/f-${i}.md`,
|
|
8052
|
+
);
|
|
8053
|
+
const h = startDeleteHarness(keys);
|
|
8054
|
+
await settle();
|
|
8055
|
+
const subtree = path.join(h.companyRoot, "knowledge/moved");
|
|
8056
|
+
const intentCount = () =>
|
|
8057
|
+
Object.values(readJournal("indigo").files).filter(
|
|
8058
|
+
(entry) => entry.localDeleteIntent,
|
|
8059
|
+
).length;
|
|
8060
|
+
|
|
8061
|
+
const childChanges = new Map<string, TreeChange>();
|
|
8062
|
+
for (const key of keys) {
|
|
8063
|
+
const absolutePath = path.join(h.companyRoot, key);
|
|
8064
|
+
fs.unlinkSync(absolutePath);
|
|
8065
|
+
const snapshots = h.capture()(`companies/indigo/${key}`, "unlink");
|
|
8066
|
+
expect(snapshots).toHaveLength(1);
|
|
8067
|
+
childChanges.set(absolutePath, {
|
|
8068
|
+
kind: "unlink",
|
|
8069
|
+
deleteSnapshots: snapshots,
|
|
8070
|
+
});
|
|
8071
|
+
}
|
|
8072
|
+
expect(intentCount()).toBe(12);
|
|
8073
|
+
|
|
8074
|
+
fs.rmSync(subtree, { recursive: true, force: true });
|
|
8075
|
+
const dirSnapshots = h.capture()(
|
|
8076
|
+
"companies/indigo/knowledge/moved", "unlinkDir",
|
|
8077
|
+
);
|
|
8078
|
+
expect(dirSnapshots).toHaveLength(0);
|
|
8079
|
+
expect(intentCount()).toBe(0);
|
|
8080
|
+
|
|
8081
|
+
h.watcher.emit("companies/indigo/knowledge/moved", {
|
|
8082
|
+
paths: new Map<string, string>([
|
|
8083
|
+
...keys.map(
|
|
8084
|
+
(key) =>
|
|
8085
|
+
[
|
|
8086
|
+
path.join(h.companyRoot, key),
|
|
8087
|
+
`companies/indigo/${key}`,
|
|
8088
|
+
] as [string, string],
|
|
8089
|
+
),
|
|
8090
|
+
[subtree, "companies/indigo/knowledge/moved"],
|
|
8091
|
+
]),
|
|
8092
|
+
changes: new Map<string, TreeChange>([
|
|
8093
|
+
...childChanges,
|
|
8094
|
+
[subtree, { kind: "unlinkDir", deleteSnapshots: dirSnapshots }],
|
|
8095
|
+
]),
|
|
8096
|
+
} as TreeChangeBatch);
|
|
8097
|
+
h.tick();
|
|
8098
|
+
await settle();
|
|
8099
|
+
|
|
8100
|
+
// The batch still carries the descendants' snapshots; preparing it must
|
|
8101
|
+
// not re-stamp the intents the trip revoked.
|
|
8102
|
+
expect(intentCount()).toBe(0);
|
|
8103
|
+
expect(h.remote.size).toBe(12);
|
|
8104
|
+
expect(Object.keys(readJournal("indigo").files)).toHaveLength(12);
|
|
8105
|
+
await stopHarness(h);
|
|
8106
|
+
});
|
|
8107
|
+
|
|
8108
|
+
// The native macOS/Windows backends make no child-before-parent guarantee,
|
|
8109
|
+
// so the refusal has to hold for descendants that arrive after it too.
|
|
8110
|
+
it("a descendant unlink arriving after a refused unlinkDir records no intent", async () => {
|
|
8111
|
+
const keys = Array.from(
|
|
8112
|
+
{ length: 12 }, (_, i) => `knowledge/moved/f-${i}.md`,
|
|
8113
|
+
);
|
|
8114
|
+
const h = startDeleteHarness(keys);
|
|
8115
|
+
await settle();
|
|
8116
|
+
const subtree = path.join(h.companyRoot, "knowledge/moved");
|
|
8117
|
+
fs.rmSync(subtree, { recursive: true, force: true });
|
|
8118
|
+
|
|
8119
|
+
expect(
|
|
8120
|
+
h.capture()("companies/indigo/knowledge/moved", "unlinkDir"),
|
|
8121
|
+
).toHaveLength(0);
|
|
8122
|
+
expect(h.capture()(`companies/indigo/${keys[0]}`, "unlink")).toHaveLength(0);
|
|
8123
|
+
expect(
|
|
8124
|
+
readJournal("indigo").files[keys[0]]?.localDeleteIntent,
|
|
8125
|
+
).toBeUndefined();
|
|
8126
|
+
await stopHarness(h);
|
|
8127
|
+
});
|
|
8128
|
+
|
|
8129
|
+
// The quarantine must not reach past the episode it refused: an unexpired
|
|
8130
|
+
// root would silently swallow every later delete under that path.
|
|
8131
|
+
it("an ordinary delete under a previously refused root still propagates", async () => {
|
|
7978
8132
|
const keys = Array.from(
|
|
7979
8133
|
{ length: 12 }, (_, i) => `knowledge/moved/f-${i}.md`,
|
|
7980
8134
|
);
|
|
7981
8135
|
const h = startDeleteHarness(keys);
|
|
7982
8136
|
await settle();
|
|
7983
8137
|
const subtree = path.join(h.companyRoot, "knowledge/moved");
|
|
8138
|
+
fs.rmSync(subtree, { recursive: true, force: true });
|
|
8139
|
+
const dirSnapshots = h.capture()(
|
|
8140
|
+
"companies/indigo/knowledge/moved", "unlinkDir",
|
|
8141
|
+
);
|
|
8142
|
+
h.watcher.emit("companies/indigo/knowledge/moved", {
|
|
8143
|
+
paths: new Map([[subtree, "companies/indigo/knowledge/moved"]]),
|
|
8144
|
+
changes: new Map([
|
|
8145
|
+
[subtree, { kind: "unlinkDir", deleteSnapshots: dirSnapshots }],
|
|
8146
|
+
]),
|
|
8147
|
+
} as TreeChangeBatch);
|
|
8148
|
+
h.tick();
|
|
8149
|
+
await settle();
|
|
8150
|
+
expect(h.remote.size).toBe(12);
|
|
8151
|
+
|
|
8152
|
+
// Next episode: one ordinary file removal under the same directory.
|
|
8153
|
+
const lonePath = path.join(h.companyRoot, keys[0]);
|
|
8154
|
+
fs.mkdirSync(path.dirname(lonePath), { recursive: true });
|
|
8155
|
+
fs.writeFileSync(lonePath, `synced:${keys[0]}`);
|
|
8156
|
+
fs.unlinkSync(lonePath);
|
|
8157
|
+
const snapshots = h.capture()(`companies/indigo/${keys[0]}`, "unlink");
|
|
8158
|
+
expect(snapshots).toHaveLength(1);
|
|
8159
|
+
h.watcher.emit(`companies/indigo/${keys[0]}`, {
|
|
8160
|
+
paths: new Map([[lonePath, `companies/indigo/${keys[0]}`]]),
|
|
8161
|
+
changes: new Map([
|
|
8162
|
+
[lonePath, { kind: "unlink", deleteSnapshots: snapshots }],
|
|
8163
|
+
]),
|
|
8164
|
+
} as TreeChangeBatch);
|
|
8165
|
+
h.tick();
|
|
8166
|
+
await settle();
|
|
8167
|
+
expect(h.remote.has(keys[0])).toBe(false);
|
|
8168
|
+
expect(h.remote.size).toBe(11);
|
|
8169
|
+
await stopHarness(h);
|
|
8170
|
+
});
|
|
8171
|
+
|
|
8172
|
+
it("HQ_SYNC_DELETE_BULK_OVERRIDE=1 lets a whole-shard unlinkDir propagate", async () => {
|
|
8173
|
+
process.env.HQ_SYNC_DELETE_BULK_OVERRIDE = "1";
|
|
8174
|
+
try {
|
|
8175
|
+
const keys = Array.from(
|
|
8176
|
+
{ length: 12 }, (_, i) => `knowledge/moved/f-${i}.md`,
|
|
8177
|
+
);
|
|
8178
|
+
const h = startDeleteHarness(keys);
|
|
8179
|
+
await settle();
|
|
8180
|
+
const subtree = path.join(h.companyRoot, "knowledge/moved");
|
|
8181
|
+
const snapshots = h.capture()(
|
|
8182
|
+
"companies/indigo/knowledge/moved", "unlinkDir",
|
|
8183
|
+
);
|
|
8184
|
+
expect(snapshots).toHaveLength(12);
|
|
8185
|
+
fs.rmSync(subtree, { recursive: true, force: true });
|
|
8186
|
+
h.watcher.emit("companies/indigo/knowledge/moved", {
|
|
8187
|
+
paths: new Map([[subtree, "companies/indigo/knowledge/moved"]]),
|
|
8188
|
+
changes: new Map([
|
|
8189
|
+
[subtree, { kind: "unlinkDir", deleteSnapshots: snapshots }],
|
|
8190
|
+
]),
|
|
8191
|
+
} as TreeChangeBatch);
|
|
8192
|
+
h.tick();
|
|
8193
|
+
await settle();
|
|
8194
|
+
expect(h.remote.size).toBe(0);
|
|
8195
|
+
expect(Object.keys(readJournal("indigo").files)).toHaveLength(0);
|
|
8196
|
+
await stopHarness(h);
|
|
8197
|
+
} finally {
|
|
8198
|
+
delete process.env.HQ_SYNC_DELETE_BULK_OVERRIDE;
|
|
8199
|
+
}
|
|
8200
|
+
});
|
|
8201
|
+
|
|
8202
|
+
// The quarantine must not touch ordinary folder cleanup — that is the
|
|
8203
|
+
// delete-resurrection bug #227 was written to fix, and it stays fixed.
|
|
8204
|
+
it("an ordinary folder removal (10 of 120 entries) still records intents and propagates", async () => {
|
|
8205
|
+
const removed = Array.from(
|
|
8206
|
+
{ length: 10 }, (_, i) => `knowledge/moved/f-${i}.md`,
|
|
8207
|
+
);
|
|
8208
|
+
const kept = Array.from(
|
|
8209
|
+
{ length: 110 }, (_, i) => `knowledge/keep/k-${i}.md`,
|
|
8210
|
+
);
|
|
8211
|
+
const h = startDeleteHarness([...removed, ...kept]);
|
|
8212
|
+
await settle();
|
|
8213
|
+
const subtree = path.join(h.companyRoot, "knowledge/moved");
|
|
7984
8214
|
const snapshots = h.capture()(
|
|
7985
8215
|
"companies/indigo/knowledge/moved", "unlinkDir",
|
|
7986
8216
|
);
|
|
7987
|
-
expect(snapshots).toHaveLength(
|
|
8217
|
+
expect(snapshots).toHaveLength(10);
|
|
7988
8218
|
fs.rmSync(subtree, { recursive: true, force: true });
|
|
7989
8219
|
h.watcher.emit("companies/indigo/knowledge/moved", {
|
|
7990
8220
|
paths: new Map([[subtree, "companies/indigo/knowledge/moved"]]),
|
|
@@ -7994,8 +8224,8 @@ describe("watcher delete intents without pre-seeded authorization", () => {
|
|
|
7994
8224
|
} as TreeChangeBatch);
|
|
7995
8225
|
h.tick();
|
|
7996
8226
|
await settle();
|
|
7997
|
-
expect(h.remote.
|
|
7998
|
-
expect(
|
|
8227
|
+
for (const key of removed) expect(h.remote.has(key)).toBe(false);
|
|
8228
|
+
expect(h.remote.size).toBe(110);
|
|
7999
8229
|
await stopHarness(h);
|
|
8000
8230
|
});
|
|
8001
8231
|
|