@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cloud",
3
- "version": "6.14.34",
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",
@@ -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
- `Set HQ_SYNC_DELETE_BULK_OVERRIDE=1 to propagate it deliberately.\n`,
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: { gateInitialPull?: boolean } = {},
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", "--direction", "both",
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 {