@indigoai-us/hq-cloud 6.14.34 → 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.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +77 -1
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.test.js +101 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-watch-loop.ts +90 -1
- package/src/bin/sync-runner.test.ts +131 -0
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);
|
|
@@ -930,6 +1014,11 @@ export async function runWatchLoop(
|
|
|
930
1014
|
const result = shouldRunFull
|
|
931
1015
|
? await runGuarded(passArgv)
|
|
932
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();
|
|
933
1022
|
const exitCode = passExitCode(result);
|
|
934
1023
|
if (exitCode === runtime.authRequiredPassExit) {
|
|
935
1024
|
// 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 {
|
|
@@ -8038,6 +8039,136 @@ describe("watcher delete intents without pre-seeded authorization", () => {
|
|
|
8038
8039
|
await stopHarness(h);
|
|
8039
8040
|
});
|
|
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 () => {
|
|
8132
|
+
const keys = Array.from(
|
|
8133
|
+
{ length: 12 }, (_, i) => `knowledge/moved/f-${i}.md`,
|
|
8134
|
+
);
|
|
8135
|
+
const h = startDeleteHarness(keys);
|
|
8136
|
+
await settle();
|
|
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
|
+
|
|
8041
8172
|
it("HQ_SYNC_DELETE_BULK_OVERRIDE=1 lets a whole-shard unlinkDir propagate", async () => {
|
|
8042
8173
|
process.env.HQ_SYNC_DELETE_BULK_OVERRIDE = "1";
|
|
8043
8174
|
try {
|