@indigoai-us/hq-cloud 5.24.0 → 5.26.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.
Files changed (66) hide show
  1. package/dist/bin/sync-runner.d.ts +151 -17
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +280 -18
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +429 -15
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/share.d.ts +9 -0
  8. package/dist/cli/share.d.ts.map +1 -1
  9. package/dist/cli/share.js +54 -1
  10. package/dist/cli/share.js.map +1 -1
  11. package/dist/cli/share.test.js +6 -3
  12. package/dist/cli/share.test.js.map +1 -1
  13. package/dist/cli/sync.d.ts +21 -0
  14. package/dist/cli/sync.d.ts.map +1 -1
  15. package/dist/cli/sync.js.map +1 -1
  16. package/dist/index.d.ts +4 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +6 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/personal-vault-exclusions.d.ts +128 -0
  21. package/dist/personal-vault-exclusions.d.ts.map +1 -0
  22. package/dist/personal-vault-exclusions.js +231 -0
  23. package/dist/personal-vault-exclusions.js.map +1 -0
  24. package/dist/personal-vault-exclusions.test.d.ts +22 -0
  25. package/dist/personal-vault-exclusions.test.d.ts.map +1 -0
  26. package/dist/personal-vault-exclusions.test.js +198 -0
  27. package/dist/personal-vault-exclusions.test.js.map +1 -0
  28. package/dist/sync/index.d.ts +11 -0
  29. package/dist/sync/index.d.ts.map +1 -0
  30. package/dist/sync/index.js +9 -0
  31. package/dist/sync/index.js.map +1 -0
  32. package/dist/sync/push-event.d.ts +110 -0
  33. package/dist/sync/push-event.d.ts.map +1 -0
  34. package/dist/sync/push-event.js +153 -0
  35. package/dist/sync/push-event.js.map +1 -0
  36. package/dist/sync/push-event.test.d.ts +15 -0
  37. package/dist/sync/push-event.test.d.ts.map +1 -0
  38. package/dist/sync/push-event.test.js +188 -0
  39. package/dist/sync/push-event.test.js.map +1 -0
  40. package/dist/sync/push-transport.d.ts +67 -0
  41. package/dist/sync/push-transport.d.ts.map +1 -0
  42. package/dist/sync/push-transport.js +66 -0
  43. package/dist/sync/push-transport.js.map +1 -0
  44. package/dist/watcher.d.ts +160 -0
  45. package/dist/watcher.d.ts.map +1 -1
  46. package/dist/watcher.js +298 -0
  47. package/dist/watcher.js.map +1 -1
  48. package/dist/watcher.test.d.ts +2 -0
  49. package/dist/watcher.test.d.ts.map +1 -0
  50. package/dist/watcher.test.js +334 -0
  51. package/dist/watcher.test.js.map +1 -0
  52. package/package.json +3 -2
  53. package/src/bin/sync-runner.test.ts +557 -15
  54. package/src/bin/sync-runner.ts +404 -27
  55. package/src/cli/share.test.ts +8 -3
  56. package/src/cli/share.ts +66 -1
  57. package/src/cli/sync.ts +22 -0
  58. package/src/index.ts +27 -0
  59. package/src/personal-vault-exclusions.test.ts +256 -0
  60. package/src/personal-vault-exclusions.ts +277 -0
  61. package/src/sync/index.ts +19 -0
  62. package/src/sync/push-event.test.ts +224 -0
  63. package/src/sync/push-event.ts +208 -0
  64. package/src/sync/push-transport.ts +84 -0
  65. package/src/watcher.test.ts +388 -0
  66. package/src/watcher.ts +386 -0
@@ -11,12 +11,21 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
11
11
  import * as fs from "fs";
12
12
  import * as os from "os";
13
13
  import * as path from "path";
14
- import { runRunner, resolveDeletePolicy } from "./sync-runner.js";
14
+ import {
15
+ runRunner,
16
+ runRunnerWithLoop,
17
+ resolveDeletePolicy,
18
+ resolveSkipPersonal,
19
+ routeChangeToTarget,
20
+ buildTargetedPushArgv,
21
+ } from "./sync-runner.js";
15
22
  import type {
16
23
  RunnerEvent,
17
24
  RunnerDeps,
18
25
  VaultClientSurface,
26
+ WatcherSurface,
19
27
  } from "./sync-runner.js";
28
+ import { FakeClock } from "../watcher.js";
20
29
  import type { SyncResult, SyncOptions } from "../cli/sync.js";
21
30
  import type { ShareResult, ShareOptions } from "../cli/share.js";
22
31
  import type {
@@ -80,6 +89,7 @@ function defaultShareResult(overrides: Partial<ShareResult> = {}): ShareResult {
80
89
  filesDeleted: 0,
81
90
  filesTombstoned: 0,
82
91
  filesRefusedStale: 0,
92
+ filesExcludedByPolicy: 0,
83
93
  conflictPaths: [],
84
94
  aborted: false,
85
95
  ...overrides,
@@ -687,7 +697,9 @@ describe("per-company fanout", () => {
687
697
  const complete = deps.stdout
688
698
  .events()
689
699
  .find((e) => e.type === "complete") as Extract<RunnerEvent, { type: "complete" }>;
690
- // Pull-only run: upload counters are 0.
700
+ // Pull-only run: upload counters are 0. Push-side counters added in
701
+ // 5.25 (filesTombstoned/filesRefusedStale/filesExcludedByPolicy) are
702
+ // also 0 because no push leg ran.
691
703
  expect(complete).toEqual({
692
704
  type: "complete",
693
705
  company: "acme",
@@ -699,6 +711,9 @@ describe("per-company fanout", () => {
699
711
  aborted: result.aborted,
700
712
  filesUploaded: 0,
701
713
  bytesUploaded: 0,
714
+ filesTombstoned: 0,
715
+ filesRefusedStale: 0,
716
+ filesExcludedByPolicy: 0,
702
717
  newFiles: result.newFiles,
703
718
  newFilesCount: result.newFilesCount,
704
719
  });
@@ -1696,6 +1711,483 @@ describe("watch mode argv parsing", () => {
1696
1711
  expect(code).toBe(1);
1697
1712
  expect(deps.stderr.raw()).toContain("--poll-remote-ms requires --watch");
1698
1713
  });
1714
+
1715
+ it("accepts --event-push together with --watch (parser-level)", async () => {
1716
+ const deps = makeDeps({
1717
+ createVaultClient: () => makeVaultStub({ memberships: [] }),
1718
+ });
1719
+ // One-shot single pass via runRunner (no loop) — the parser must accept
1720
+ // --event-push as a known flag. No memberships → setup-needed → exit 0.
1721
+ const code = await runRunner(
1722
+ ["--companies", "--watch", "--event-push"],
1723
+ deps,
1724
+ );
1725
+ expect(deps.stderr.raw()).not.toContain("Unknown argument");
1726
+ expect(code).toBe(0);
1727
+ });
1728
+
1729
+ it("rejects --event-push without --watch (the flag has no meaning otherwise)", async () => {
1730
+ const deps = makeDeps();
1731
+ const code = await runRunner(["--companies", "--event-push"], deps);
1732
+ expect(code).toBe(1);
1733
+ expect(deps.stderr.raw()).toContain("--event-push requires --watch");
1734
+ });
1735
+
1736
+ it("is OFF by default — plain --watch run does not require/accept event flags implicitly", async () => {
1737
+ const deps = makeDeps({
1738
+ createVaultClient: () => makeVaultStub({ memberships: [] }),
1739
+ });
1740
+ const code = await runRunner(["--companies", "--watch"], deps);
1741
+ expect(deps.stderr.raw()).not.toContain("Unknown argument");
1742
+ expect(code).toBe(0);
1743
+ });
1744
+ });
1745
+
1746
+ // ---------------------------------------------------------------------------
1747
+ // US-003 — event-driven push routing helpers (pure)
1748
+ // ---------------------------------------------------------------------------
1749
+
1750
+ describe("routeChangeToTarget", () => {
1751
+ it("routes companies/<slug>/... to a targeted company push", () => {
1752
+ expect(routeChangeToTarget("companies/indigo/board.json")).toEqual({
1753
+ kind: "company",
1754
+ slug: "indigo",
1755
+ });
1756
+ expect(
1757
+ routeChangeToTarget("companies/acme/projects/x/prd.json"),
1758
+ ).toEqual({ kind: "company", slug: "acme" });
1759
+ });
1760
+
1761
+ it("routes non-company top-levels to the personal target", () => {
1762
+ expect(routeChangeToTarget("personal/notes.md")).toEqual({
1763
+ kind: "personal",
1764
+ });
1765
+ expect(routeChangeToTarget("core/policies/x.md")).toEqual({
1766
+ kind: "personal",
1767
+ });
1768
+ expect(routeChangeToTarget("README.md")).toEqual({ kind: "personal" });
1769
+ });
1770
+
1771
+ it("normalizes OS path separators and ./ prefixes", () => {
1772
+ expect(
1773
+ routeChangeToTarget(["companies", "indigo", "f.json"].join(path.sep)),
1774
+ ).toEqual({ kind: "company", slug: "indigo" });
1775
+ expect(routeChangeToTarget("./companies/indigo/f.json")).toEqual({
1776
+ kind: "company",
1777
+ slug: "indigo",
1778
+ });
1779
+ });
1780
+
1781
+ it("returns null for unattributable paths (defensive)", () => {
1782
+ expect(routeChangeToTarget("")).toBeNull();
1783
+ expect(routeChangeToTarget("..")).toBeNull();
1784
+ expect(routeChangeToTarget("../escape")).toBeNull();
1785
+ // companies top-level with no slug segment is unattributable — a real
1786
+ // edit always lands at companies/<slug>/..., never at bare "companies".
1787
+ expect(routeChangeToTarget("companies")).toBeNull();
1788
+ expect(routeChangeToTarget("companies/")).toBeNull();
1789
+ });
1790
+ });
1791
+
1792
+ describe("buildTargetedPushArgv", () => {
1793
+ it("company route → --company <slug> --direction push", () => {
1794
+ expect(
1795
+ buildTargetedPushArgv({ kind: "company", slug: "indigo" }, [
1796
+ "--companies",
1797
+ "--hq-root",
1798
+ "/tmp/hq",
1799
+ "--on-conflict",
1800
+ "keep",
1801
+ ]),
1802
+ ).toEqual([
1803
+ "--company",
1804
+ "indigo",
1805
+ "--direction",
1806
+ "push",
1807
+ "--hq-root",
1808
+ "/tmp/hq",
1809
+ "--on-conflict",
1810
+ "keep",
1811
+ ]);
1812
+ });
1813
+
1814
+ it("personal route → --companies --direction push (inherits root/conflict)", () => {
1815
+ expect(
1816
+ buildTargetedPushArgv({ kind: "personal" }, [
1817
+ "--companies",
1818
+ "--hq-root",
1819
+ "/tmp/hq",
1820
+ ]),
1821
+ ).toEqual(["--companies", "--direction", "push", "--hq-root", "/tmp/hq"]);
1822
+ });
1823
+
1824
+ it("carries no flags when the base argv has none", () => {
1825
+ expect(buildTargetedPushArgv({ kind: "personal" }, ["--companies"])).toEqual(
1826
+ ["--companies", "--direction", "push"],
1827
+ );
1828
+ });
1829
+ });
1830
+
1831
+ // ---------------------------------------------------------------------------
1832
+ // US-003 — runRunnerWithLoop event-push wiring (deterministic, no real timers)
1833
+ // ---------------------------------------------------------------------------
1834
+ //
1835
+ // These exercise the full Phase 1 wiring through injectable seams:
1836
+ // - runPass: a spy standing in for runRunner (no real S3/auth)
1837
+ // - clock: FakeClock to advance the debounce window deterministically
1838
+ // - createWatcher: a controllable stub satisfying WatcherSurface
1839
+ // - sleep / onShutdownSignal: drive the poll loop + shutdown without real
1840
+ // timers or process signals
1841
+ //
1842
+ // The loop is an infinite poll; we drive ONE pass then trigger shutdown so the
1843
+ // promise resolves. A test helper wires everything and returns the controls.
1844
+
1845
+ interface WatcherStub extends WatcherSurface {
1846
+ emit(changedRelPath?: string): void;
1847
+ started: boolean;
1848
+ disposed: boolean;
1849
+ }
1850
+
1851
+ function makeWatcherStub(): WatcherStub {
1852
+ const listeners = new Set<(p?: string) => void>();
1853
+ const stub: WatcherStub = {
1854
+ started: false,
1855
+ disposed: false,
1856
+ onChange(listener) {
1857
+ listeners.add(listener);
1858
+ return () => listeners.delete(listener);
1859
+ },
1860
+ start() {
1861
+ this.started = true;
1862
+ },
1863
+ stop() {
1864
+ /* no-op for the stub */
1865
+ },
1866
+ dispose() {
1867
+ this.disposed = true;
1868
+ listeners.clear();
1869
+ },
1870
+ emit(changedRelPath?: string) {
1871
+ for (const l of [...listeners]) l(changedRelPath);
1872
+ },
1873
+ };
1874
+ return stub;
1875
+ }
1876
+
1877
+ describe("runRunnerWithLoop — event-push wiring", () => {
1878
+ it("starts the watcher alongside the poll loop when --event-push is on", async () => {
1879
+ const watcher = makeWatcherStub();
1880
+ let triggerShutdown = () => {};
1881
+ const runPass = vi.fn().mockResolvedValue(0);
1882
+
1883
+ const loop = runRunnerWithLoop(
1884
+ ["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
1885
+ {
1886
+ runPass,
1887
+ clock: new FakeClock(),
1888
+ createWatcher: () => watcher,
1889
+ sleep: () => new Promise<void>(() => {}), // never resolves; shutdown ends it
1890
+ onShutdownSignal: (handler) => {
1891
+ triggerShutdown = handler;
1892
+ return () => {};
1893
+ },
1894
+ },
1895
+ );
1896
+
1897
+ // Let the first poll pass run, then shut down.
1898
+ await Promise.resolve();
1899
+ await Promise.resolve();
1900
+ expect(watcher.started).toBe(true);
1901
+ triggerShutdown();
1902
+ await loop;
1903
+ // The poll pass ran at least once (safety-net path still runs).
1904
+ expect(runPass).toHaveBeenCalled();
1905
+ expect(watcher.disposed).toBe(true);
1906
+ });
1907
+
1908
+ it("trigger-on-change: a debounced changed signal runs a targeted company push", async () => {
1909
+ const watcher = makeWatcherStub();
1910
+ const clock = new FakeClock();
1911
+ let triggerShutdown = () => {};
1912
+ const runPass = vi.fn().mockResolvedValue(0);
1913
+
1914
+ const loop = runRunnerWithLoop(
1915
+ ["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
1916
+ {
1917
+ runPass,
1918
+ clock,
1919
+ createWatcher: () => watcher,
1920
+ sleep: () => new Promise<void>(() => {}),
1921
+ onShutdownSignal: (handler) => {
1922
+ triggerShutdown = handler;
1923
+ return () => {};
1924
+ },
1925
+ },
1926
+ );
1927
+
1928
+ // Drain the initial poll pass.
1929
+ await Promise.resolve();
1930
+ await Promise.resolve();
1931
+ runPass.mockClear();
1932
+
1933
+ // Fire a change for an indigo file; advance the (zero) driver window.
1934
+ watcher.emit("companies/indigo/board.json");
1935
+ clock.advance(0);
1936
+ await Promise.resolve();
1937
+ await Promise.resolve();
1938
+
1939
+ const targetedCall = runPass.mock.calls.find((c) =>
1940
+ (c[0] as string[]).includes("--company"),
1941
+ );
1942
+ expect(targetedCall).toBeDefined();
1943
+ expect(targetedCall![0]).toEqual([
1944
+ "--company",
1945
+ "indigo",
1946
+ "--direction",
1947
+ "push",
1948
+ "--hq-root",
1949
+ "/tmp/hq",
1950
+ ]);
1951
+
1952
+ triggerShutdown();
1953
+ await loop;
1954
+ });
1955
+
1956
+ it("bare signal (no path) routes the targeted push to the personal vault", async () => {
1957
+ const watcher = makeWatcherStub();
1958
+ const clock = new FakeClock();
1959
+ let triggerShutdown = () => {};
1960
+ const runPass = vi.fn().mockResolvedValue(0);
1961
+
1962
+ const loop = runRunnerWithLoop(
1963
+ ["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
1964
+ {
1965
+ runPass,
1966
+ clock,
1967
+ createWatcher: () => watcher,
1968
+ sleep: () => new Promise<void>(() => {}),
1969
+ onShutdownSignal: (handler) => {
1970
+ triggerShutdown = handler;
1971
+ return () => {};
1972
+ },
1973
+ },
1974
+ );
1975
+
1976
+ await Promise.resolve();
1977
+ await Promise.resolve();
1978
+ runPass.mockClear();
1979
+
1980
+ watcher.emit(); // no path — TreeWatcher's bare signal shape
1981
+ clock.advance(0);
1982
+ await Promise.resolve();
1983
+ await Promise.resolve();
1984
+
1985
+ const personalCall = runPass.mock.calls.find(
1986
+ (c) =>
1987
+ (c[0] as string[]).includes("--companies") &&
1988
+ (c[0] as string[]).includes("--direction"),
1989
+ );
1990
+ expect(personalCall).toBeDefined();
1991
+ expect(personalCall![0]).toEqual([
1992
+ "--companies",
1993
+ "--direction",
1994
+ "push",
1995
+ "--hq-root",
1996
+ "/tmp/hq",
1997
+ ]);
1998
+
1999
+ triggerShutdown();
2000
+ await loop;
2001
+ });
2002
+
2003
+ it("overlap guard: a change during an in-flight pass does not start a concurrent pass", async () => {
2004
+ const watcher = makeWatcherStub();
2005
+ const clock = new FakeClock();
2006
+ let triggerShutdown = () => {};
2007
+
2008
+ // A controllable in-flight pass: the first call hangs until we release it.
2009
+ let release: () => void = () => {};
2010
+ const firstGate = new Promise<void>((r) => {
2011
+ release = r;
2012
+ });
2013
+ let callCount = 0;
2014
+ let maxConcurrent = 0;
2015
+ let active = 0;
2016
+ const runPass = vi.fn().mockImplementation(async () => {
2017
+ active++;
2018
+ maxConcurrent = Math.max(maxConcurrent, active);
2019
+ callCount++;
2020
+ if (callCount === 1) await firstGate; // hold the first (poll) pass
2021
+ active--;
2022
+ return 0;
2023
+ });
2024
+
2025
+ const loop = runRunnerWithLoop(
2026
+ ["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
2027
+ {
2028
+ runPass,
2029
+ clock,
2030
+ createWatcher: () => watcher,
2031
+ sleep: () => new Promise<void>(() => {}),
2032
+ onShutdownSignal: (handler) => {
2033
+ triggerShutdown = handler;
2034
+ return () => {};
2035
+ },
2036
+ },
2037
+ );
2038
+
2039
+ // The first poll pass is now in flight and gated.
2040
+ await Promise.resolve();
2041
+ await Promise.resolve();
2042
+ expect(active).toBe(1);
2043
+
2044
+ // Fire a change while the pass is in flight → must be collapsed, not run.
2045
+ watcher.emit("companies/indigo/x.json");
2046
+ clock.advance(0);
2047
+ await Promise.resolve();
2048
+ await Promise.resolve();
2049
+ // Still exactly one active pass; the watcher trigger did not overlap.
2050
+ expect(maxConcurrent).toBe(1);
2051
+
2052
+ // Release the gated pass; the collapsed change re-arms and runs next.
2053
+ release();
2054
+ await Promise.resolve();
2055
+ await Promise.resolve();
2056
+ clock.advance(0);
2057
+ await Promise.resolve();
2058
+ await Promise.resolve();
2059
+
2060
+ // Never more than one pass concurrently across the whole run.
2061
+ expect(maxConcurrent).toBe(1);
2062
+
2063
+ triggerShutdown();
2064
+ await loop;
2065
+ });
2066
+
2067
+ it("poll-still-runs: the poll loop fires passes independent of the watcher", async () => {
2068
+ const watcher = makeWatcherStub();
2069
+ let triggerShutdown = () => {};
2070
+ let passes = 0;
2071
+ // Box the resolver so TS control-flow doesn't narrow it to `null` at the
2072
+ // call sites below (the assignment happens inside a deferred closure).
2073
+ const poll: { resolve: (() => void) | null } = { resolve: null };
2074
+ const runPass = vi.fn().mockImplementation(async () => {
2075
+ passes++;
2076
+ return 0;
2077
+ });
2078
+ // A sleep we can step: resolve once to allow a second poll pass.
2079
+ const sleep = () =>
2080
+ new Promise<void>((resolve) => {
2081
+ poll.resolve = resolve;
2082
+ });
2083
+
2084
+ const loop = runRunnerWithLoop(
2085
+ ["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
2086
+ {
2087
+ runPass,
2088
+ clock: new FakeClock(),
2089
+ createWatcher: () => watcher,
2090
+ sleep,
2091
+ onShutdownSignal: (handler) => {
2092
+ triggerShutdown = handler;
2093
+ return () => {};
2094
+ },
2095
+ },
2096
+ );
2097
+
2098
+ // First poll pass runs, then awaits sleep.
2099
+ await Promise.resolve();
2100
+ await Promise.resolve();
2101
+ expect(passes).toBe(1);
2102
+
2103
+ // Step the poll interval → a second poll pass runs (safety net cadence).
2104
+ poll.resolve?.();
2105
+ await Promise.resolve();
2106
+ await Promise.resolve();
2107
+ expect(passes).toBeGreaterThanOrEqual(2);
2108
+
2109
+ triggerShutdown();
2110
+ // Release any pending sleep so the loop can observe shutdown and exit.
2111
+ poll.resolve?.();
2112
+ await loop;
2113
+ });
2114
+
2115
+ it("clean shutdown: SIGTERM disposes the watcher and resolves the loop with no leaks", async () => {
2116
+ const watcher = makeWatcherStub();
2117
+ const clock = new FakeClock();
2118
+ let triggerShutdown = () => {};
2119
+ let detached = false;
2120
+ const runPass = vi.fn().mockResolvedValue(0);
2121
+
2122
+ const loop = runRunnerWithLoop(
2123
+ ["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
2124
+ {
2125
+ runPass,
2126
+ clock,
2127
+ createWatcher: () => watcher,
2128
+ sleep: () => new Promise<void>(() => {}),
2129
+ onShutdownSignal: (handler) => {
2130
+ triggerShutdown = handler;
2131
+ return () => {
2132
+ detached = true;
2133
+ };
2134
+ },
2135
+ },
2136
+ );
2137
+
2138
+ await Promise.resolve();
2139
+ await Promise.resolve();
2140
+ expect(watcher.started).toBe(true);
2141
+
2142
+ triggerShutdown();
2143
+ const code = await loop;
2144
+ expect(code).toBe(0);
2145
+ expect(watcher.disposed).toBe(true); // watcher torn down
2146
+ expect(detached).toBe(true); // signal handler detached (no leaked listener)
2147
+ // No pending FakeClock timers left behind by the driver's debounce.
2148
+ expect(clock.pendingTimerCount()).toBe(0);
2149
+ });
2150
+
2151
+ it("a non-zero poll pass exit returns immediately (hard error surfaces)", async () => {
2152
+ const watcher = makeWatcherStub();
2153
+ const runPass = vi.fn().mockResolvedValue(2);
2154
+
2155
+ const code = await runRunnerWithLoop(
2156
+ ["--companies", "--watch", "--event-push", "--hq-root", "/tmp/hq"],
2157
+ {
2158
+ runPass,
2159
+ clock: new FakeClock(),
2160
+ createWatcher: () => watcher,
2161
+ sleep: () => Promise.resolve(),
2162
+ onShutdownSignal: () => () => {},
2163
+ },
2164
+ );
2165
+ expect(code).toBe(2);
2166
+ expect(watcher.disposed).toBe(true);
2167
+ });
2168
+
2169
+ it("without --event-push, no watcher is created (poll-only safety net)", async () => {
2170
+ let triggerShutdown = () => {};
2171
+ const createWatcher = vi.fn(() => makeWatcherStub());
2172
+ const runPass = vi.fn().mockResolvedValue(0);
2173
+
2174
+ const loop = runRunnerWithLoop(["--companies", "--watch"], {
2175
+ runPass,
2176
+ createWatcher,
2177
+ sleep: () => new Promise<void>(() => {}),
2178
+ onShutdownSignal: (handler) => {
2179
+ triggerShutdown = handler;
2180
+ return () => {};
2181
+ },
2182
+ });
2183
+
2184
+ await Promise.resolve();
2185
+ await Promise.resolve();
2186
+ expect(createWatcher).not.toHaveBeenCalled();
2187
+ expect(runPass).toHaveBeenCalled();
2188
+ triggerShutdown();
2189
+ await loop;
2190
+ });
1699
2191
  });
1700
2192
 
1701
2193
  // ---------------------------------------------------------------------------
@@ -1709,11 +2201,11 @@ beforeEach(() => {
1709
2201
  // ── resolveDeletePolicy: env-var contract ───────────────────────────────────
1710
2202
  //
1711
2203
  // `HQ_SYNC_DELETE_POLICY` is the documented rollback knob for the
1712
- // currency-gated default landed in 5.24. The helper centralizes the
1713
- // allowlist + default so every callsite (both the personal and company push
1714
- // paths in the runner) gets identical semantics. Tests pin the four expected
1715
- // behaviors so a future regression on the allowlist or default surfaces here
1716
- // instead of in the field.
2204
+ // currency-gated default that became default in 5.25 (after a 5.24 soak).
2205
+ // The helper centralizes the allowlist + default so every callsite (both
2206
+ // the personal and company push paths in the runner) gets identical
2207
+ // semantics. Tests pin the four expected behaviors so a future regression
2208
+ // on the allowlist or default surfaces here instead of in the field.
1717
2209
 
1718
2210
  describe("resolveDeletePolicy", () => {
1719
2211
  let originalEnv: string | undefined;
@@ -1731,12 +2223,11 @@ describe("resolveDeletePolicy", () => {
1731
2223
  }
1732
2224
  });
1733
2225
 
1734
- it("defaults to 'owned-only' in 5.24 (staged-default rollout, flip in 5.25)", () => {
1735
- // 5.24 ships the currency-gated CODE PATH but holds the default flip
1736
- // for a soak window. When the default flips to 'currency-gated' in
1737
- // 5.25, this assertion changes — both edits live next to each other so
1738
- // the rollout sequence is obvious from the diff.
1739
- expect(resolveDeletePolicy()).toBe("owned-only");
2226
+ it("defaults to 'currency-gated' in 5.25 (post-soak default flip)", () => {
2227
+ // 5.24 shipped the code path with `owned-only` as default; 5.25 flips
2228
+ // the default to `currency-gated` after the soak window. Rollback knob
2229
+ // is `HQ_SYNC_DELETE_POLICY=owned-only` for anyone surprised.
2230
+ expect(resolveDeletePolicy()).toBe("currency-gated");
1740
2231
  });
1741
2232
 
1742
2233
  it.each(["currency-gated", "owned-only", "all"] as const)(
@@ -1749,11 +2240,62 @@ describe("resolveDeletePolicy", () => {
1749
2240
 
1750
2241
  it("falls back to default on unknown env values (no silent corruption)", () => {
1751
2242
  process.env.HQ_SYNC_DELETE_POLICY = "yolo";
1752
- expect(resolveDeletePolicy()).toBe("owned-only");
2243
+ expect(resolveDeletePolicy()).toBe("currency-gated");
1753
2244
  });
1754
2245
 
1755
2246
  it("treats empty string as unset → default", () => {
1756
2247
  process.env.HQ_SYNC_DELETE_POLICY = "";
1757
- expect(resolveDeletePolicy()).toBe("owned-only");
2248
+ expect(resolveDeletePolicy()).toBe("currency-gated");
2249
+ });
2250
+ });
2251
+
2252
+ // ── resolveSkipPersonal: flag-OR-env combination ───────────────────────────
2253
+ //
2254
+ // Two inputs combine: the `--skip-personal` CLI flag and the
2255
+ // `HQ_SYNC_SKIP_PERSONAL` env var. Either being truthy skips personal sync.
2256
+ // The flag is the explicit-for-this-invocation knob (menubar passes it when
2257
+ // the user toggled "Sync personal vault" off); the env is the persistent
2258
+ // child-process default. Both surfaces tested so a regression on either
2259
+ // short-circuit path surfaces here.
2260
+
2261
+ describe("resolveSkipPersonal", () => {
2262
+ let originalEnv: string | undefined;
2263
+
2264
+ beforeEach(() => {
2265
+ originalEnv = process.env.HQ_SYNC_SKIP_PERSONAL;
2266
+ delete process.env.HQ_SYNC_SKIP_PERSONAL;
2267
+ });
2268
+
2269
+ afterEach(() => {
2270
+ if (originalEnv === undefined) {
2271
+ delete process.env.HQ_SYNC_SKIP_PERSONAL;
2272
+ } else {
2273
+ process.env.HQ_SYNC_SKIP_PERSONAL = originalEnv;
2274
+ }
1758
2275
  });
2276
+
2277
+ it("defaults to false (personal sync enabled, current behavior)", () => {
2278
+ expect(resolveSkipPersonal(false)).toBe(false);
2279
+ });
2280
+
2281
+ it("flag=true short-circuits to true regardless of env", () => {
2282
+ process.env.HQ_SYNC_SKIP_PERSONAL = "no"; // explicit "no" in env
2283
+ expect(resolveSkipPersonal(true)).toBe(true);
2284
+ });
2285
+
2286
+ it.each(["1", "true", "yes", "TRUE", "Yes"])(
2287
+ "env value '%s' (truthy) -> true",
2288
+ (val) => {
2289
+ process.env.HQ_SYNC_SKIP_PERSONAL = val;
2290
+ expect(resolveSkipPersonal(false)).toBe(true);
2291
+ },
2292
+ );
2293
+
2294
+ it.each(["0", "false", "no", "", "unset-equiv"])(
2295
+ "env value '%s' (falsy) -> false",
2296
+ (val) => {
2297
+ process.env.HQ_SYNC_SKIP_PERSONAL = val;
2298
+ expect(resolveSkipPersonal(false)).toBe(false);
2299
+ },
2300
+ );
1759
2301
  });