@indigoai-us/hq-cloud 6.14.35 → 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 +13 -3
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.test.js +85 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-watch-loop.ts +13 -3
- package/src/bin/sync-runner.test.ts +103 -2
package/package.json
CHANGED
|
@@ -1006,6 +1006,15 @@ export async function runWatchLoop(
|
|
|
1006
1006
|
while (!stopped) {
|
|
1007
1007
|
pollTick++;
|
|
1008
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);
|
|
1009
1018
|
const shouldRunFull =
|
|
1010
1019
|
pollTick === 1 ||
|
|
1011
1020
|
pending.bare ||
|
|
@@ -1014,11 +1023,12 @@ export async function runWatchLoop(
|
|
|
1014
1023
|
const result = shouldRunFull
|
|
1015
1024
|
? await runGuarded(passArgv)
|
|
1016
1025
|
: await runScopedDrain(pending.batch);
|
|
1017
|
-
//
|
|
1018
|
-
// carrying that episode has now been prepared and pushed, so a later
|
|
1026
|
+
// This batch's episode has now been prepared and pushed, so a later
|
|
1019
1027
|
// removal under the same path is judged fresh — a root that never
|
|
1020
1028
|
// expired would silently refuse ordinary deletes from here on.
|
|
1021
|
-
|
|
1029
|
+
for (const root of drainedQuarantineRoots) {
|
|
1030
|
+
quarantinedDeleteRoots.delete(root);
|
|
1031
|
+
}
|
|
1022
1032
|
const exitCode = passExitCode(result);
|
|
1023
1033
|
if (exitCode === runtime.authRequiredPassExit) {
|
|
1024
1034
|
// The pass already emitted auth-error. Stop the unattended loop but
|
|
@@ -7548,7 +7548,11 @@ describe("US-002 — manifest reconciliation wiring", () => {
|
|
|
7548
7548
|
describe("watcher delete intents without pre-seeded authorization", () => {
|
|
7549
7549
|
function startDeleteHarness(
|
|
7550
7550
|
keys: string[],
|
|
7551
|
-
options: {
|
|
7551
|
+
options: {
|
|
7552
|
+
gateInitialPull?: boolean;
|
|
7553
|
+
onPass?: (argv: string[]) => Promise<void> | void;
|
|
7554
|
+
direction?: "both" | "push";
|
|
7555
|
+
} = {},
|
|
7552
7556
|
) {
|
|
7553
7557
|
const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-delete-"));
|
|
7554
7558
|
const companyRoot = path.join(hqRoot, "companies", "indigo");
|
|
@@ -7591,6 +7595,9 @@ describe("watcher delete intents without pre-seeded authorization", () => {
|
|
|
7591
7595
|
});
|
|
7592
7596
|
let initialPullGated = options.gateInitialPull === true;
|
|
7593
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);
|
|
7594
7601
|
const direction = argv[argv.indexOf("--direction") + 1];
|
|
7595
7602
|
if (direction === "push") {
|
|
7596
7603
|
const journal = readJournal("indigo");
|
|
@@ -7665,7 +7672,8 @@ describe("watcher delete intents without pre-seeded authorization", () => {
|
|
|
7665
7672
|
});
|
|
7666
7673
|
const loop = runRunnerWithLoop(
|
|
7667
7674
|
[
|
|
7668
|
-
"--companies", "--watch", "--event-push",
|
|
7675
|
+
"--companies", "--watch", "--event-push",
|
|
7676
|
+
"--direction", options.direction ?? "both",
|
|
7669
7677
|
"--hq-root", hqRoot, "--poll-remote-ms", "60000",
|
|
7670
7678
|
],
|
|
7671
7679
|
{
|
|
@@ -8169,6 +8177,99 @@ describe("watcher delete intents without pre-seeded authorization", () => {
|
|
|
8169
8177
|
await stopHarness(h);
|
|
8170
8178
|
});
|
|
8171
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
|
+
|
|
8172
8273
|
it("HQ_SYNC_DELETE_BULK_OVERRIDE=1 lets a whole-shard unlinkDir propagate", async () => {
|
|
8173
8274
|
process.env.HQ_SYNC_DELETE_BULK_OVERRIDE = "1";
|
|
8174
8275
|
try {
|