@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cloud",
3
- "version": "6.14.35",
3
+ "version": "6.14.36",
4
4
  "description": "HQ by Indigo cloud sync engine — bidirectional S3 sync for mobile access",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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
- // 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
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
- quarantinedDeleteRoots.clear();
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: { gateInitialPull?: boolean } = {},
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", "--direction", "both",
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 {