@indigoai-us/hq-cloud 6.10.1 → 6.11.0

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/src/cli/sync.ts CHANGED
@@ -539,6 +539,94 @@ async function reportNewFilesToNotify(
539
539
  }
540
540
  }
541
541
 
542
+ /** Timeout for the best-effort FILE_TOMBSTONE fetch (GET /v1/files/tombstones). */
543
+ const FETCH_TOMBSTONES_TIMEOUT_MS = 5000;
544
+
545
+ /**
546
+ * A FILE_TOMBSTONE as the pull planner needs it: the deleted key + when it was
547
+ * deleted. The `deletedAt` timestamp is the decisive precedence signal — a
548
+ * remote object newer than it is a genuine re-create (sync it), an object at or
549
+ * older than it is a stale resurrection of a deleted key (suppress it).
550
+ */
551
+ interface CompanyTombstone {
552
+ deletedAt: string;
553
+ }
554
+
555
+ /**
556
+ * Fetch the company's FILE_TOMBSTONE rows from hq-pro (GET /v1/files/tombstones)
557
+ * and return them as a POSIX-keyed map the pull planner consults to avoid
558
+ * resurrecting an intentionally-deleted object (delete-resync). The endpoint is
559
+ * ACL-filtered server-side, so the map only ever contains keys this caller can
560
+ * read — exactly the keys that can appear in the (STS-scoped) remote LIST.
561
+ *
562
+ * Best-effort and bounded by a 5s timeout: a tombstone read that fails, times
563
+ * out, or returns non-2xx degrades to an EMPTY map — i.e. to the pre-fix
564
+ * behavior (no suppression). That is the safe failure direction: a missed
565
+ * tombstone re-pulls a deleted file (a known, recoverable annoyance), whereas a
566
+ * spurious tombstone would HIDE a file the user wants. The failure is logged
567
+ * (never silently swallowed) so a persistently-degraded read is visible.
568
+ */
569
+ async function fetchCompanyTombstones(
570
+ vaultConfig: VaultServiceConfig,
571
+ companyUid: string,
572
+ ): Promise<Map<string, CompanyTombstone>> {
573
+ const out = new Map<string, CompanyTombstone>();
574
+ try {
575
+ const token =
576
+ typeof vaultConfig.authToken === "function"
577
+ ? await vaultConfig.authToken()
578
+ : vaultConfig.authToken;
579
+ const base = vaultConfig.apiUrl.replace(/\/+$/, "");
580
+ const url = `${base}/v1/files/tombstones?company=${encodeURIComponent(
581
+ companyUid,
582
+ )}`;
583
+ const controller = new AbortController();
584
+ const timer = setTimeout(
585
+ () => controller.abort(),
586
+ FETCH_TOMBSTONES_TIMEOUT_MS,
587
+ );
588
+ try {
589
+ const res = await fetch(url, {
590
+ method: "GET",
591
+ headers: { Authorization: `Bearer ${token}` },
592
+ signal: controller.signal,
593
+ });
594
+ if (!res.ok) {
595
+ // Non-2xx is non-fatal: log and degrade to no-suppression. A 404 means
596
+ // the endpoint is not deployed yet (hq-pro release lag) — the pull
597
+ // proceeds with the legacy behavior until it lands.
598
+ console.error(
599
+ `[hq-sync] tombstone fetch returned ${res.status} (degrading to no-suppression)`,
600
+ );
601
+ return out;
602
+ }
603
+ const body = (await res.json()) as {
604
+ tombstones?: Array<{ key?: string; deletedAt?: string }>;
605
+ };
606
+ for (const t of body.tombstones ?? []) {
607
+ if (typeof t.key === "string" && typeof t.deletedAt === "string") {
608
+ out.set(toPosixKey(t.key), { deletedAt: t.deletedAt });
609
+ }
610
+ }
611
+ } finally {
612
+ clearTimeout(timer);
613
+ }
614
+ } catch (err) {
615
+ // Best-effort: a failed tombstone read must never break the pull. Log
616
+ // (policy: never silently swallow) and degrade to no-suppression.
617
+ try {
618
+ console.error(
619
+ `[hq-sync] tombstone fetch failed (non-fatal, degrading to no-suppression): ${
620
+ err instanceof Error ? err.message : String(err)
621
+ }`,
622
+ );
623
+ } catch {
624
+ // swallow — logging must never break sync
625
+ }
626
+ }
627
+ return out;
628
+ }
629
+
542
630
  /**
543
631
  * Sync (pull) all allowed files from the entity vault.
544
632
  */
@@ -614,6 +702,14 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
614
702
  // List all remote files (IAM session policy filters at the AWS layer)
615
703
  const remoteFiles = await listRemoteFiles(ctx);
616
704
 
705
+ // Fetch the company's FILE_TOMBSTONE records so the planner can suppress
706
+ // resurrection of an intentionally-deleted object (delete-resync). Done in
707
+ // parallel intent with the LIST above conceptually, but kept serial here for
708
+ // a clean read of `ctx`; best-effort — a failed read degrades to an empty map
709
+ // (no suppression), preserving the pre-fix behavior. ctx.uid is the verified
710
+ // companyUid the tombstone rows are keyed under.
711
+ const tombstones = await fetchCompanyTombstones(vaultConfig, ctx.uid);
712
+
617
713
  // Stage 1: classify every remote file against the journal + local disk.
618
714
  // Hashing happens here (not in the transfer loop) so the plan event below
619
715
  // carries an accurate denominator before any progress events fire.
@@ -626,6 +722,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
626
722
  options.includeLocalCompanies === true,
627
723
  options.teamSyncedSlugs ?? null,
628
724
  currentPrefixSet,
725
+ tombstones,
629
726
  );
630
727
 
631
728
  emit({
@@ -637,7 +734,10 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
637
734
  bytesToUpload: 0,
638
735
  filesToSkip: plan.filesToSkip,
639
736
  filesToConflict: plan.filesToConflict,
640
- filesToDelete: 0,
737
+ // Authoritative FILE_TOMBSTONE suppressions (delete-resync) are the only
738
+ // deletes known at plan time; the journal-vs-LIST tombstones are
739
+ // HEAD-verified later and surfaced via the final filesTombstoned count.
740
+ filesToDelete: plan.filesToTombstoneDelete,
641
741
  });
642
742
 
643
743
  // ── Scope-shrink cleanup (US-005) ─────────────────────────────────────────
@@ -835,6 +935,58 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
835
935
  continue;
836
936
  }
837
937
 
938
+ if (item.action === "tombstone-delete") {
939
+ // Authoritative FILE_TOMBSTONE delete (delete-resync): the remote object
940
+ // is present but a tombstone marks the key intentionally deleted and it is
941
+ // not a newer re-create. Delete any local copy and drop the journal entry
942
+ // so it stays gone — the mirror of the journal-vs-LIST tombstone executor
943
+ // below, but WITHOUT the HEAD-verify (the remote object is present by
944
+ // definition; the FILE_TOMBSTONE is the deletion authority). The planner
945
+ // already routed any divergent local copy to `conflict`, so a local file
946
+ // reaching here matches the deleted baseline and is safe to remove.
947
+ const tombstoneKey = item.remoteFile.key;
948
+ // Same Windows-backslash landmine guard as the journal-tombstone executor:
949
+ // a malformed key must never reach fs.unlinkSync (path.join collapses the
950
+ // backslashes onto a REAL POSIX file). Drop the poisoned journal entry
951
+ // without touching disk.
952
+ if (isMalformedVaultKey(tombstoneKey)) {
953
+ removeEntry(journal, tombstoneKey);
954
+ continue;
955
+ }
956
+ try {
957
+ const lstat = fs.lstatSync(item.localPath);
958
+ if (lstat.isSymbolicLink() || lstat.isFile()) {
959
+ fs.unlinkSync(item.localPath);
960
+ }
961
+ // A directory at the key: don't recursively rm-rf the operator's dir;
962
+ // just drop the journal entry (safe-by-default, same as the other path).
963
+ } catch (err: unknown) {
964
+ const code =
965
+ err && typeof err === "object" && "code" in err
966
+ ? (err as { code?: string }).code
967
+ : undefined;
968
+ // ENOENT → local already absent (the common case: a fresh machine that
969
+ // never held the file, or a prior pull already removed it) → drop the
970
+ // journal entry and converge. Other errors (EACCES/EPERM/…) leave the
971
+ // file in place; surface and KEEP the journal entry so the next sync
972
+ // retries rather than forgetting the delete.
973
+ if (code !== "ENOENT") {
974
+ emit({
975
+ type: "error",
976
+ path: tombstoneKey,
977
+ message: `tombstone-suppress unlink failed: ${
978
+ err instanceof Error ? err.message : String(err)
979
+ }`,
980
+ });
981
+ continue;
982
+ }
983
+ }
984
+ removeEntry(journal, tombstoneKey);
985
+ filesTombstoned++;
986
+ emit({ type: "progress", path: tombstoneKey, bytes: 0 });
987
+ continue;
988
+ }
989
+
838
990
  if (item.action === "download") {
839
991
  downloadItems.push(item);
840
992
  continue;
@@ -1481,6 +1633,37 @@ function hasRemoteChanged(
1481
1633
  return remote.lastModified.getTime() > syncedAt;
1482
1634
  }
1483
1635
 
1636
+ /**
1637
+ * Decide whether a remote object present in the LIST is a GENUINE RE-CREATE
1638
+ * written AFTER a FILE_TOMBSTONE — in which case the tombstone is stale and the
1639
+ * object must still sync (the tombstone is not permanent suppression). Returns
1640
+ * true to ALLOW the download (re-create), false to honor the tombstone.
1641
+ *
1642
+ * Decisive signal: the remote object's `lastModified` strictly newer than the
1643
+ * tombstone's `deletedAt`. A delete-marker + tombstone are written together at
1644
+ * delete time, so any object that post-dates the tombstone is a new write at
1645
+ * that key. An object at-or-before the tombstone is the deleted version (a
1646
+ * stale re-push or an un-propagated delete-marker) → honor the tombstone.
1647
+ *
1648
+ * Fail-OPEN (treat as re-create, allow download) when the comparison can't be
1649
+ * made — a malformed `deletedAt`, or no remote `lastModified`. Hiding a file the
1650
+ * user can see is worse than re-pulling one they deleted; the latter is
1651
+ * recoverable, the former looks like data loss.
1652
+ */
1653
+ function isRemoteRecreateAfterTombstone(
1654
+ remote: { lastModified?: Date },
1655
+ tombstone: CompanyTombstone,
1656
+ ): boolean {
1657
+ const deletedAtMs = Date.parse(tombstone.deletedAt);
1658
+ if (Number.isNaN(deletedAtMs)) return true; // malformed tombstone → don't suppress
1659
+ const remoteMs =
1660
+ remote.lastModified instanceof Date
1661
+ ? remote.lastModified.getTime()
1662
+ : NaN;
1663
+ if (Number.isNaN(remoteMs)) return true; // no remote timestamp → don't suppress
1664
+ return remoteMs > deletedAtMs;
1665
+ }
1666
+
1484
1667
  /**
1485
1668
  * Stage-1 classification for a single remote object. Each remote file falls
1486
1669
  * into exactly one bucket; the executor in `sync()` switches on `action` to
@@ -1501,6 +1684,14 @@ type PullPlanItem =
1501
1684
  // the remote LIST (and accessible per STS) but deliberately not downloaded
1502
1685
  // because the membership's sync scope doesn't cover them.
1503
1686
  | { action: "skip-out-of-scope"; remoteFile: RemoteFile; localPath: string }
1687
+ // Remote key present in the LIST but carrying a FILE_TOMBSTONE that marks it
1688
+ // intentionally deleted (and the remote object is NOT a newer re-create). The
1689
+ // executor deletes any local copy and drops the journal entry — the
1690
+ // authoritative-delete arm of delete-resync. Unlike `PullPlan.tombstones`
1691
+ // (keys ABSENT from the LIST, HEAD-verified before delete), these are
1692
+ // suppressed purely on the FILE_TOMBSTONE authority and skip HEAD-verify (the
1693
+ // remote object is present by definition).
1694
+ | { action: "tombstone-delete"; remoteFile: RemoteFile; localPath: string }
1504
1695
  | {
1505
1696
  action: "conflict";
1506
1697
  remoteFile: RemoteFile;
@@ -1544,6 +1735,12 @@ interface PullPlan {
1544
1735
  * Carried on the plan so the executor can iterate without re-walking.
1545
1736
  */
1546
1737
  tombstones: string[];
1738
+ /**
1739
+ * Count of `tombstone-delete` items — remote keys present in the LIST but
1740
+ * suppressed by a FILE_TOMBSTONE (delete-resync). Surfaced on the plan event's
1741
+ * `filesToDelete` axis; the per-item executor applies the local delete.
1742
+ */
1743
+ filesToTombstoneDelete: number;
1547
1744
  }
1548
1745
 
1549
1746
  /**
@@ -1569,6 +1766,13 @@ function computePullPlan(
1569
1766
  // `[""]` (the `all`-mode value) covers everything via `isCoveredByAny`, so
1570
1767
  // the scope filter below becomes a no-op and legacy behavior is preserved.
1571
1768
  prefixSet: string[],
1769
+ // FILE_TOMBSTONE records (POSIX-keyed) for the company — the durable
1770
+ // "this key was intentionally deleted" signal the planner consults before
1771
+ // re-downloading a key, so a deleted folder does not resync back in
1772
+ // (delete-resync). An empty map (the default / degraded-fetch case)
1773
+ // preserves the legacy behavior bit-for-bit. (Named `fileTombstones` to avoid
1774
+ // shadowing the local `tombstones` string[] — the journal-vs-LIST delete set.)
1775
+ fileTombstones: ReadonlyMap<string, CompanyTombstone> = new Map(),
1572
1776
  ): PullPlan {
1573
1777
  const items: PullPlanItem[] = [];
1574
1778
 
@@ -1651,6 +1855,22 @@ function computePullPlan(
1651
1855
  }
1652
1856
 
1653
1857
  const journalEntry = getEntry(journal, remoteFile.key);
1858
+
1859
+ // ── FILE_TOMBSTONE consult (delete-resync) ───────────────────────────────
1860
+ // A remote object present in the LIST may be an intentionally-deleted key
1861
+ // (a peer re-pushed it, or its delete-marker hasn't propagated to this
1862
+ // caller's view). `tombstoneSuppresses` is true when a FILE_TOMBSTONE marks
1863
+ // this key deleted AND the remote object is NOT a newer re-create — i.e. the
1864
+ // object is the stale/deleted version and must NOT be downloaded. A genuine
1865
+ // re-create (object newer than the tombstone) leaves this false so the
1866
+ // normal download/merge path runs (the tombstone is not permanent
1867
+ // suppression). An empty tombstone map (the default / degraded-fetch case)
1868
+ // makes this always false — legacy behavior preserved bit-for-bit.
1869
+ const tombstone = fileTombstones.get(toPosixKey(remoteFile.key));
1870
+ const tombstoneSuppresses =
1871
+ tombstone !== undefined &&
1872
+ !isRemoteRecreateAfterTombstone(remoteFile, tombstone);
1873
+
1654
1874
  // lstat (not existsSync/statSync) handles three cases the legacy
1655
1875
  // checks got wrong for symlinks:
1656
1876
  // 1. A valid symlink at localPath: existsSync returns true and the
@@ -1750,6 +1970,32 @@ function computePullPlan(
1750
1970
  const remoteChanged =
1751
1971
  !!journalEntry && hasRemoteChanged(remoteFile, journalEntry);
1752
1972
 
1973
+ // ── Authoritative delete reaches a machine that still holds the file ──
1974
+ // A FILE_TOMBSTONE marks this key deleted and the remote object is not a
1975
+ // newer re-create, but this machine still has a local copy. Honor the
1976
+ // delete — EXCEPT never destroy unsynced local work: if the local copy
1977
+ // diverges from the synced baseline (a post-tombstone edit, or an
1978
+ // untracked local file with no journal entry), surface a CONFLICT instead
1979
+ // of silently deleting (the safety guard the brief requires + the 3-way
1980
+ // merge invariant). A local copy that still matches the deleted baseline
1981
+ // is the stale version → delete it + drop the journal entry so it stays
1982
+ // gone on this machine.
1983
+ if (tombstoneSuppresses) {
1984
+ if (localChanged || !journalEntry) {
1985
+ items.push({
1986
+ action: "conflict",
1987
+ remoteFile,
1988
+ localPath,
1989
+ localHash,
1990
+ localMtime: localLstat!.mtime,
1991
+ localSize: isLocalSymlink ? 0 : localLstat!.size,
1992
+ });
1993
+ } else {
1994
+ items.push({ action: "tombstone-delete", remoteFile, localPath });
1995
+ }
1996
+ continue;
1997
+ }
1998
+
1753
1999
  // Mirror the original 3-way merge from the inline loop. Tested by
1754
2000
  // `does NOT flag a pull conflict when only local changed since last
1755
2001
  // sync` and `detects conflicts with local changes…`.
@@ -1827,6 +2073,19 @@ function computePullPlan(
1827
2073
  // through to download.
1828
2074
  }
1829
2075
 
2076
+ // Suppress the re-download of an intentionally-deleted key (delete-resync).
2077
+ // Reaches here only for the `!localExists` case — the `localExists` branch
2078
+ // above already handled (and `continue`d) any suppressing tombstone, so a
2079
+ // tombstone that survives to here means "remote present, local absent, not a
2080
+ // re-create": the classic resurrection ("remote present → I'm behind →
2081
+ // download") the planner must NOT do. Route to `tombstone-delete` so the
2082
+ // executor drops any stale journal entry and the key stays gone, instead of
2083
+ // pulling the deleted object back in.
2084
+ if (tombstoneSuppresses) {
2085
+ items.push({ action: "tombstone-delete", remoteFile, localPath });
2086
+ continue;
2087
+ }
2088
+
1830
2089
  items.push({ action: "download", remoteFile, localPath, isNew: !localExists });
1831
2090
  }
1832
2091
 
@@ -1836,6 +2095,7 @@ function computePullPlan(
1836
2095
  let filesToConflict = 0;
1837
2096
  let filesExcludedByPolicy = 0;
1838
2097
  let filesOutOfScope = 0;
2098
+ let filesToTombstoneDelete = 0;
1839
2099
  const newFiles: Array<{ path: string; bytes: number }> = [];
1840
2100
  for (const item of items) {
1841
2101
  if (item.action === "download") {
@@ -1846,6 +2106,10 @@ function computePullPlan(
1846
2106
  }
1847
2107
  } else if (item.action === "conflict") {
1848
2108
  filesToConflict++;
2109
+ } else if (item.action === "tombstone-delete") {
2110
+ // Authoritative FILE_TOMBSTONE delete — its own axis so it never inflates
2111
+ // the "unchanged" tally. Surfaced via the plan event's filesToDelete.
2112
+ filesToTombstoneDelete++;
1849
2113
  } else if (item.action === "skip-excluded-policy") {
1850
2114
  filesExcludedByPolicy++;
1851
2115
  // Excluded-policy items don't roll into filesToSkip — they're a
@@ -1972,6 +2236,7 @@ function computePullPlan(
1972
2236
  newFilesCount: newFiles.length,
1973
2237
  filesExcludedByPolicy,
1974
2238
  filesOutOfScope,
2239
+ filesToTombstoneDelete,
1975
2240
  tombstones,
1976
2241
  };
1977
2242
  }
@@ -0,0 +1,22 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ // Vitest defaults to a 5_000ms per-test timeout and 10_000ms hook timeout.
4
+ // That is comfortable for the unit suite, but the `rescue-*.test.ts`
5
+ // integration suites build a real git repository in `beforeAll` and then shell
6
+ // out to `git` (and the rescue CLI, which itself spawns several `git`
7
+ // subprocesses) inside each `it`. Measured cold these tests run ~4–5s each;
8
+ // under full file-parallelism they contend for CPU and disk and routinely
9
+ // cross the 5s default, producing flaky "Test timed out in 5000ms" failures
10
+ // that have nothing to do with the code under test.
11
+ //
12
+ // Raise the global ceilings to give those git-bound tests headroom. The values
13
+ // are still tight enough to catch a genuinely hung test (an unresolved promise
14
+ // or a deadlocked subprocess) — they just stop punishing legitimately-slow
15
+ // integration work. Keep this central rather than sprinkling per-test timeouts
16
+ // so future git-heavy suites inherit the headroom automatically.
17
+ export default defineConfig({
18
+ test: {
19
+ testTimeout: 20_000,
20
+ hookTimeout: 30_000,
21
+ },
22
+ });