@indigoai-us/hq-cloud 6.14.35 → 6.14.37

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.37",
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",
@@ -183,15 +183,20 @@ export async function executeCompanyFanout(options: {
183
183
  ...(event.errorCode ? { errorCode: event.errorCode } : {}),
184
184
  });
185
185
  } else if (event.type === "skip-junk-key-spelling") {
186
- // Deliberate skip: forwarded for visibility, NEVER pushed into
187
- // errors[] (policy hq-sync-deliberate-skip-not-fatal-error-exit2 —
188
- // a benign recurring skip must not gate exit 2 / Sentry alerts).
189
186
  options.emit({
190
187
  type: "skip-junk-key-spelling",
191
188
  company: companyLabel,
192
189
  path: event.path,
193
190
  journaledKey: event.journaledKey,
194
191
  });
192
+ } else if (event.type === "not-shipped") {
193
+ options.emit({
194
+ type: "not-shipped",
195
+ company: companyLabel,
196
+ reason: event.reason,
197
+ count: event.count,
198
+ samplePaths: event.samplePaths,
199
+ });
195
200
  }
196
201
  };
197
202
 
@@ -208,6 +213,8 @@ export async function executeCompanyFanout(options: {
208
213
  filesExcludedByPolicy: 0,
209
214
  filesExcludedByScope: 0,
210
215
  filesExcludedByIgnore: 0,
216
+ unreachablePaths: [],
217
+ linkedSubtreesNotShipped: [],
211
218
  conflictPaths: [],
212
219
  aborted: false,
213
220
  };
@@ -288,6 +295,15 @@ export async function executeCompanyFanout(options: {
288
295
  skipUnchanged: true,
289
296
  propagateDeletes: true,
290
297
  propagateDeletePolicy: options.resolveDeletePolicy(),
298
+ // `pushPaths` are INTERNAL walk roots (the company folder / the
299
+ // personal-vault dirs), never user input, so an unreachable entry
300
+ // here is a benign race — a directory removed between the scan and
301
+ // the push — and must not fail an unattended background sync. Record
302
+ // + report it (`not-shipped` event, `ShareResult.unreachablePaths`)
303
+ // and carry on. The interactive `hq sync push <path>` keeps the
304
+ // default "error" policy, which is where a user-typed path that
305
+ // silently ships nothing is a real bug (feedback_a51cb63d ask #2).
306
+ unreachablePathPolicy: "warn",
291
307
  onEvent: tagAndEmit,
292
308
  ...(options.uploadAuthor ? { author: options.uploadAuthor } : {}),
293
309
  ...(target.personalMode !== undefined
@@ -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
@@ -157,6 +157,8 @@ function defaultShareResult(overrides: Partial<ShareResult> = {}): ShareResult {
157
157
  filesExcludedByPolicy: 0,
158
158
  filesExcludedByScope: 0,
159
159
  filesExcludedByIgnore: 0,
160
+ unreachablePaths: [],
161
+ linkedSubtreesNotShipped: [],
160
162
  conflictPaths: [],
161
163
  aborted: false,
162
164
  ...overrides,
@@ -7548,7 +7550,11 @@ describe("US-002 — manifest reconciliation wiring", () => {
7548
7550
  describe("watcher delete intents without pre-seeded authorization", () => {
7549
7551
  function startDeleteHarness(
7550
7552
  keys: string[],
7551
- options: { gateInitialPull?: boolean } = {},
7553
+ options: {
7554
+ gateInitialPull?: boolean;
7555
+ onPass?: (argv: string[]) => Promise<void> | void;
7556
+ direction?: "both" | "push";
7557
+ } = {},
7552
7558
  ) {
7553
7559
  const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watch-delete-"));
7554
7560
  const companyRoot = path.join(hqRoot, "companies", "indigo");
@@ -7591,6 +7597,9 @@ describe("watcher delete intents without pre-seeded authorization", () => {
7591
7597
  });
7592
7598
  let initialPullGated = options.gateInitialPull === true;
7593
7599
  const runPass = vi.fn(async (argv: string[]) => {
7600
+ // Lets a test hold a pass mid-flight and act while the loop is awaiting
7601
+ // it, which is where producer-side state that outlives a pass is decided.
7602
+ await options.onPass?.(argv);
7594
7603
  const direction = argv[argv.indexOf("--direction") + 1];
7595
7604
  if (direction === "push") {
7596
7605
  const journal = readJournal("indigo");
@@ -7665,7 +7674,8 @@ describe("watcher delete intents without pre-seeded authorization", () => {
7665
7674
  });
7666
7675
  const loop = runRunnerWithLoop(
7667
7676
  [
7668
- "--companies", "--watch", "--event-push", "--direction", "both",
7677
+ "--companies", "--watch", "--event-push",
7678
+ "--direction", options.direction ?? "both",
7669
7679
  "--hq-root", hqRoot, "--poll-remote-ms", "60000",
7670
7680
  ],
7671
7681
  {
@@ -8169,6 +8179,99 @@ describe("watcher delete intents without pre-seeded authorization", () => {
8169
8179
  await stopHarness(h);
8170
8180
  });
8171
8181
 
8182
+ // A pass is awaited, so a bulk removal observed while one is in flight
8183
+ // registers its quarantine root only after that pass's batch was taken — its
8184
+ // own snapshots ride the NEXT batch. Expiring the whole set once the pass
8185
+ // returns would drop the root before `prepareWatcherChanges` ever sees that
8186
+ // batch, re-stamping the revoked descendant intents and handing the engine
8187
+ // the bulk delete the quarantine refused (remotely deleting the subtree under
8188
+ // HQ_SYNC_DELETE_POLICY=all).
8189
+ it("a bulk removal observed during an in-flight pass keeps its quarantine", async () => {
8190
+ const keys = Array.from(
8191
+ { length: 12 }, (_, i) => `knowledge/moved/f-${i}.md`,
8192
+ );
8193
+ let releaseGatedPush = () => {};
8194
+ const gatedPush = new Promise<void>((resolve) => {
8195
+ releaseGatedPush = resolve;
8196
+ });
8197
+ let gateArmed = false;
8198
+ let gateReached = false;
8199
+ // Push-only: a pull would restore the quarantined subtree (the documented
8200
+ // recovery for an intent-less disappearance) before the batch carrying the
8201
+ // episode is prepared, masking whether the refusal itself survived.
8202
+ const h = startDeleteHarness([...keys, "knowledge/other.md"], {
8203
+ direction: "push",
8204
+ onPass: async (_argv) => {
8205
+ if (!gateArmed) return;
8206
+ gateArmed = false;
8207
+ gateReached = true;
8208
+ await gatedPush;
8209
+ },
8210
+ });
8211
+ await settle();
8212
+ const intentCount = () =>
8213
+ Object.values(readJournal("indigo").files).filter(
8214
+ (entry) => entry.localDeleteIntent,
8215
+ ).length;
8216
+
8217
+ // An unrelated edit drains as a scoped push, which then blocks on the gate
8218
+ // with its batch already taken from the pending buffer.
8219
+ gateArmed = true;
8220
+ const otherPath = path.join(h.companyRoot, "knowledge/other.md");
8221
+ fs.writeFileSync(otherPath, "edited");
8222
+ h.watcher.emit("companies/indigo/knowledge/other.md", {
8223
+ paths: new Map([[otherPath, "companies/indigo/knowledge/other.md"]]),
8224
+ } as TreeChangeBatch);
8225
+ h.tick();
8226
+ await settle();
8227
+ expect(gateReached).toBe(true);
8228
+
8229
+ // The whole subtree disappears while that pass is still in flight.
8230
+ const childChanges = new Map<string, TreeChange>();
8231
+ for (const key of keys) {
8232
+ const absolutePath = path.join(h.companyRoot, key);
8233
+ fs.unlinkSync(absolutePath);
8234
+ childChanges.set(absolutePath, {
8235
+ kind: "unlink",
8236
+ deleteSnapshots: h.capture()(`companies/indigo/${key}`, "unlink"),
8237
+ });
8238
+ }
8239
+ const subtree = path.join(h.companyRoot, "knowledge/moved");
8240
+ fs.rmSync(subtree, { recursive: true, force: true });
8241
+ const dirSnapshots = h.capture()(
8242
+ "companies/indigo/knowledge/moved", "unlinkDir",
8243
+ );
8244
+ expect(dirSnapshots).toHaveLength(0);
8245
+ expect(intentCount()).toBe(0);
8246
+ h.watcher.emit("companies/indigo/knowledge/moved", {
8247
+ paths: new Map<string, string>([
8248
+ ...keys.map(
8249
+ (key) =>
8250
+ [
8251
+ path.join(h.companyRoot, key),
8252
+ `companies/indigo/${key}`,
8253
+ ] as [string, string],
8254
+ ),
8255
+ [subtree, "companies/indigo/knowledge/moved"],
8256
+ ]),
8257
+ changes: new Map<string, TreeChange>([
8258
+ ...childChanges,
8259
+ [subtree, { kind: "unlinkDir", deleteSnapshots: dirSnapshots }],
8260
+ ]),
8261
+ } as TreeChangeBatch);
8262
+
8263
+ // Completing the overlapped pass must not expire the refusal recorded
8264
+ // during it: the next drain prepares the batch that carries the episode.
8265
+ releaseGatedPush();
8266
+ await settle();
8267
+ h.tick();
8268
+ await settle();
8269
+
8270
+ expect(intentCount()).toBe(0);
8271
+ for (const key of keys) expect(h.remote.has(key)).toBe(true);
8272
+ await stopHarness(h);
8273
+ });
8274
+
8172
8275
  it("HQ_SYNC_DELETE_BULK_OVERRIDE=1 lets a whole-shard unlinkDir propagate", async () => {
8173
8276
  process.env.HQ_SYNC_DELETE_BULK_OVERRIDE = "1";
8174
8277
  try {
@@ -402,6 +402,10 @@ export type RunnerEvent =
402
402
  Extract<SyncProgressEvent, { type: "skip-junk-key-spelling" }>,
403
403
  "type"
404
404
  >)
405
+ | ({ type: "not-shipped"; company: string } & Omit<
406
+ Extract<SyncProgressEvent, { type: "not-shipped" }>,
407
+ "type"
408
+ >)
405
409
  | ({
406
410
  type: "complete";
407
411
  company: string;