@indigoai-us/hq-cloud 5.31.0 → 5.32.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 (49) hide show
  1. package/dist/bin/sync-runner.d.ts.map +1 -1
  2. package/dist/bin/sync-runner.js +69 -2
  3. package/dist/bin/sync-runner.js.map +1 -1
  4. package/dist/bin/sync-runner.test.js +292 -0
  5. package/dist/bin/sync-runner.test.js.map +1 -1
  6. package/dist/cli/share.d.ts +26 -0
  7. package/dist/cli/share.d.ts.map +1 -1
  8. package/dist/cli/share.js +105 -8
  9. package/dist/cli/share.js.map +1 -1
  10. package/dist/cli/share.test.js +210 -0
  11. package/dist/cli/share.test.js.map +1 -1
  12. package/dist/cli/sync.d.ts +28 -2
  13. package/dist/cli/sync.d.ts.map +1 -1
  14. package/dist/cli/sync.js +25 -4
  15. package/dist/cli/sync.js.map +1 -1
  16. package/dist/cli/sync.test.js +82 -0
  17. package/dist/cli/sync.test.js.map +1 -1
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/lib/describe-error.d.ts +21 -0
  23. package/dist/lib/describe-error.d.ts.map +1 -0
  24. package/dist/lib/describe-error.js +53 -0
  25. package/dist/lib/describe-error.js.map +1 -0
  26. package/dist/lib/describe-error.test.d.ts +2 -0
  27. package/dist/lib/describe-error.test.d.ts.map +1 -0
  28. package/dist/lib/describe-error.test.js +89 -0
  29. package/dist/lib/describe-error.test.js.map +1 -0
  30. package/dist/personal-vault.d.ts +63 -7
  31. package/dist/personal-vault.d.ts.map +1 -1
  32. package/dist/personal-vault.js +112 -8
  33. package/dist/personal-vault.js.map +1 -1
  34. package/dist/personal-vault.test.d.ts +14 -0
  35. package/dist/personal-vault.test.d.ts.map +1 -0
  36. package/dist/personal-vault.test.js +191 -0
  37. package/dist/personal-vault.test.js.map +1 -0
  38. package/package.json +1 -1
  39. package/src/bin/sync-runner.test.ts +364 -0
  40. package/src/bin/sync-runner.ts +73 -2
  41. package/src/cli/share.test.ts +243 -0
  42. package/src/cli/share.ts +142 -8
  43. package/src/cli/sync.test.ts +91 -0
  44. package/src/cli/sync.ts +56 -4
  45. package/src/index.ts +3 -0
  46. package/src/lib/describe-error.test.ts +100 -0
  47. package/src/lib/describe-error.ts +58 -0
  48. package/src/personal-vault.test.ts +231 -0
  49. package/src/personal-vault.ts +134 -8
@@ -915,6 +915,64 @@ describe("per-company fanout", () => {
915
915
  expect(betaComplete?.filesDownloaded).toBe(1);
916
916
  });
917
917
 
918
+ /**
919
+ * Regression test: AWS SDK v3 raises an opaque `UnknownError` (name and
920
+ * message both literally "UnknownError") when it cannot match a Node-side
921
+ * networking failure to a modeled exception. The actual root cause —
922
+ * ENOTFOUND from a stale DNS cache, ECONNRESET on a flapping link, etc. —
923
+ * is parked on `err.cause`. Pre-fix the per-company catch extracted only
924
+ * `err.message`, so an unactionable bare "UnknownError" reached operators
925
+ * and the underlying syscall/hostname were lost. The catch now runs the
926
+ * thrown value through `describeError`, which walks the cause chain and
927
+ * stitches a one-line diagnostic the operator can act on.
928
+ */
929
+ it("per-company error event surfaces cause-chain (ENOTFOUND from SDK UnknownError)", async () => {
930
+ const deps = makeDeps({
931
+ createVaultClient: () =>
932
+ makeVaultStub({
933
+ memberships: [{ companyUid: "cmp_x" }],
934
+ entityGet: (uid: string) =>
935
+ Promise.resolve({ uid, slug: "privy" } as unknown as EntityInfo),
936
+ }),
937
+ sync: vi
938
+ .fn<(opts: SyncOptions) => Promise<SyncResult>>()
939
+ .mockImplementationOnce(async () => {
940
+ // Shape mirrors @aws-sdk/client-s3 v3 behaviour when the underlying
941
+ // node-http-handler raises a getaddrinfo failure: opaque wrapper
942
+ // with the real error on `.cause`.
943
+ const sdkErr = new Error("UnknownError");
944
+ sdkErr.name = "UnknownError";
945
+ const dnsErr = new Error("getaddrinfo ENOTFOUND privy.s3.us-east-1.amazonaws.com");
946
+ (dnsErr as Error & { code?: string; syscall?: string; hostname?: string }).code =
947
+ "ENOTFOUND";
948
+ (dnsErr as Error & { code?: string; syscall?: string; hostname?: string }).syscall =
949
+ "getaddrinfo";
950
+ (dnsErr as Error & { code?: string; syscall?: string; hostname?: string }).hostname =
951
+ "privy.s3.us-east-1.amazonaws.com";
952
+ (sdkErr as Error & { cause?: unknown }).cause = dnsErr;
953
+ throw sdkErr;
954
+ }),
955
+ });
956
+
957
+ const code = await runRunner(["--companies"], deps);
958
+ expect(code).toBe(2);
959
+
960
+ const companyErr = deps.stderr
961
+ .events()
962
+ .find(
963
+ (e): e is Extract<RunnerEvent, { type: "error" }> =>
964
+ e.type === "error" && e.company === "privy",
965
+ );
966
+ expect(companyErr).toBeDefined();
967
+ expect(companyErr?.path).toBe("(company)");
968
+ // The diagnostic must carry the original networking signal — without
969
+ // this the operator only sees "UnknownError" and the run is unactionable.
970
+ expect(companyErr?.message).toContain("UnknownError");
971
+ expect(companyErr?.message).toContain("ENOTFOUND");
972
+ expect(companyErr?.message).toContain("getaddrinfo");
973
+ expect(companyErr?.message).toContain("privy.s3.us-east-1.amazonaws.com");
974
+ });
975
+
918
976
  /**
919
977
  * Regression test for the rollup-bug from the personal-sync 401 incident.
920
978
  *
@@ -1638,6 +1696,312 @@ describe("personal slot fanout", () => {
1638
1696
  }
1639
1697
  });
1640
1698
 
1699
+ it("G: personal slot includes companies/{slug}/ subdirs when cloud:false marker is set AND HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1, excluding _template + team-synced slugs + missing/cloud:true markers", async () => {
1700
+ // Gate the new behavior: without this env var the runner falls back to
1701
+ // the legacy "companies/ never enumerated" personal vault. Test H below
1702
+ // pins the OFF case.
1703
+ vi.stubEnv("HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL", "1");
1704
+ const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
1705
+ const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
1706
+ try {
1707
+ // Top-level personal-vault content — anchors the assertion that the
1708
+ // existing top-level walk still runs alongside the new company-subdir
1709
+ // discovery.
1710
+ fs.mkdirSync(path.join(tmpHqRoot, "knowledge"));
1711
+
1712
+ // companies/ subdirs covering every branch of the eligibility filter.
1713
+ const mkCompany = (slug: string, yaml: string | null): void => {
1714
+ const dir = path.join(tmpHqRoot, "companies", slug);
1715
+ fs.mkdirSync(dir, { recursive: true });
1716
+ if (yaml !== null) {
1717
+ fs.writeFileSync(path.join(dir, "company.yaml"), yaml);
1718
+ }
1719
+ };
1720
+ mkCompany("free-co", "cloud: false\nname: Free Co\n"); // INCLUDED
1721
+ mkCompany("acme", "cloud: false\n"); // EXCLUDED — team-synced (membership below)
1722
+ mkCompany("_template", "cloud: false\n"); // EXCLUDED — hard-listed slug
1723
+ mkCompany("cloud-team", "cloud: true\n"); // EXCLUDED — wrong marker
1724
+ mkCompany("zilch", null); // EXCLUDED — no company.yaml at all
1725
+
1726
+ const deps = makeDeps({
1727
+ createVaultClient: () =>
1728
+ makeVaultStub({
1729
+ memberships: [{ companyUid: "cmp_a" }],
1730
+ entityGet: (uid: string) =>
1731
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
1732
+ listPersons: () => Promise.resolve([olderPerson]),
1733
+ }),
1734
+ share: shareSpy,
1735
+ });
1736
+
1737
+ const code = await runRunner(
1738
+ ["--companies", "--direction", "push", "--hq-root", tmpHqRoot],
1739
+ deps,
1740
+ );
1741
+ expect(code).toBe(0);
1742
+
1743
+ const personalCall = (shareSpy.mock.calls as Array<[ShareOptions]>).find(
1744
+ (c) => c[0].company?.startsWith("prs_"),
1745
+ );
1746
+ expect(personalCall).toBeDefined();
1747
+ const personalArgs = personalCall![0];
1748
+
1749
+ // Convert paths to a normalized form (relative to tmpHqRoot) for
1750
+ // easier matching. Comparing as a set since fs.readdirSync order is
1751
+ // not guaranteed.
1752
+ const rel = personalArgs.paths
1753
+ .map((p) => path.relative(tmpHqRoot, p))
1754
+ .sort();
1755
+
1756
+ // free-co MUST be present (cloud:false, no membership, not _template).
1757
+ expect(rel).toContain(path.join("companies", "free-co"));
1758
+ // knowledge/ MUST still be present (top-level walk unchanged).
1759
+ expect(rel).toContain("knowledge");
1760
+
1761
+ // All other companies MUST be filtered out.
1762
+ for (const forbidden of [
1763
+ path.join("companies", "acme"), // team-synced — goes to cmp_a's bucket
1764
+ path.join("companies", "_template"), // hard-listed
1765
+ path.join("companies", "cloud-team"), // cloud:true
1766
+ path.join("companies", "zilch"), // no marker
1767
+ ]) {
1768
+ expect(rel).not.toContain(forbidden);
1769
+ }
1770
+
1771
+ // The top-level `companies/` directory itself MUST NOT be in the
1772
+ // share list — only specific opt-in subdirs are. This preserves the
1773
+ // invariant that share() never walks the whole companies/ tree.
1774
+ expect(rel).not.toContain("companies");
1775
+
1776
+ // The team target for cmp_a must still receive the unchanged
1777
+ // `companies/acme` path (not double-routed to the personal slot).
1778
+ const acmeCall = (shareSpy.mock.calls as Array<[ShareOptions]>).find(
1779
+ (c) => c[0].company === "cmp_a",
1780
+ );
1781
+ expect(acmeCall).toBeDefined();
1782
+ expect(acmeCall![0].paths).toEqual([
1783
+ path.join(tmpHqRoot, "companies", "acme"),
1784
+ ]);
1785
+ } finally {
1786
+ fs.rmSync(tmpHqRoot, { recursive: true, force: true });
1787
+ vi.unstubAllEnvs();
1788
+ }
1789
+ });
1790
+
1791
+ it("H: with HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL unset, companies/ subdirs are NEVER added to the personal slot — even with a valid cloud:false marker", async () => {
1792
+ // Explicitly assert the OFF case: the env var is the gate. Without it,
1793
+ // a `cloud: false` marker is necessary-but-insufficient; the personal
1794
+ // vault stays in its pre-5.20 shape (top-level entries only, never
1795
+ // any company subdir). Defensive `unstub` in case a leaked stub from
1796
+ // an earlier test bleeds into this one.
1797
+ vi.unstubAllEnvs();
1798
+ const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
1799
+ const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
1800
+ try {
1801
+ fs.mkdirSync(path.join(tmpHqRoot, "knowledge"));
1802
+ // Same fixture as G — but the gate is off, so free-co must NOT appear.
1803
+ const dir = path.join(tmpHqRoot, "companies", "free-co");
1804
+ fs.mkdirSync(dir, { recursive: true });
1805
+ fs.writeFileSync(path.join(dir, "company.yaml"), "cloud: false\n");
1806
+
1807
+ const deps = makeDeps({
1808
+ createVaultClient: () =>
1809
+ makeVaultStub({
1810
+ memberships: [{ companyUid: "cmp_a" }],
1811
+ entityGet: (uid: string) =>
1812
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
1813
+ listPersons: () => Promise.resolve([olderPerson]),
1814
+ }),
1815
+ share: shareSpy,
1816
+ });
1817
+
1818
+ const code = await runRunner(
1819
+ ["--companies", "--direction", "push", "--hq-root", tmpHqRoot],
1820
+ deps,
1821
+ );
1822
+ expect(code).toBe(0);
1823
+
1824
+ const personalCall = (shareSpy.mock.calls as Array<[ShareOptions]>).find(
1825
+ (c) => c[0].company?.startsWith("prs_"),
1826
+ );
1827
+ expect(personalCall).toBeDefined();
1828
+ const rel = personalCall![0].paths
1829
+ .map((p) => path.relative(tmpHqRoot, p))
1830
+ .sort();
1831
+
1832
+ // free-co must NOT appear because the env-var gate is off.
1833
+ expect(rel).not.toContain(path.join("companies", "free-co"));
1834
+ // knowledge/ must still appear — the legacy top-level walk is unchanged.
1835
+ expect(rel).toContain("knowledge");
1836
+ } finally {
1837
+ fs.rmSync(tmpHqRoot, { recursive: true, force: true });
1838
+ }
1839
+ });
1840
+
1841
+ it("I: personal slot receives decommissionPrefixes = ['companies/{slug}'] for every team-synced slug; company slots receive none", async () => {
1842
+ // Decommission cleanup runs UNCONDITIONALLY — not gated on
1843
+ // HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL — because the off-ramp must
1844
+ // always be safe to traverse. A user who turned the feature off after
1845
+ // syncing some companies to personal would otherwise be stuck with
1846
+ // permanent orphans.
1847
+ vi.unstubAllEnvs();
1848
+ const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
1849
+ const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
1850
+ try {
1851
+ const deps = makeDeps({
1852
+ createVaultClient: () =>
1853
+ makeVaultStub({
1854
+ // Two team-backed companies — both slugs should land in the
1855
+ // personal slot's decommissionPrefixes list.
1856
+ memberships: [
1857
+ { companyUid: "cmp_a" },
1858
+ { companyUid: "cmp_b" },
1859
+ ],
1860
+ entityGet: (uid: string) =>
1861
+ Promise.resolve({
1862
+ uid,
1863
+ slug: uid === "cmp_a" ? "acme" : "beta",
1864
+ } as unknown as EntityInfo),
1865
+ listPersons: () => Promise.resolve([olderPerson]),
1866
+ }),
1867
+ share: shareSpy,
1868
+ });
1869
+
1870
+ const code = await runRunner(
1871
+ ["--companies", "--direction", "push", "--hq-root", tmpHqRoot],
1872
+ deps,
1873
+ );
1874
+ expect(code).toBe(0);
1875
+
1876
+ // Personal slot: decommissionPrefixes covers both team-synced slugs.
1877
+ const personalCall = (shareSpy.mock.calls as Array<[ShareOptions]>).find(
1878
+ (c) => c[0].company?.startsWith("prs_"),
1879
+ );
1880
+ expect(personalCall).toBeDefined();
1881
+ const personalArgs = personalCall![0] as ShareOptions & {
1882
+ decommissionPrefixes?: string[];
1883
+ };
1884
+ expect(personalArgs.decommissionPrefixes?.sort()).toEqual([
1885
+ "companies/acme",
1886
+ "companies/beta",
1887
+ ]);
1888
+
1889
+ // Company slots: NO decommissionPrefixes key at all (preserves the
1890
+ // contract that company-target args stay identical to pre-decommission
1891
+ // shape — symmetric to test F's personalMode/journalSlug pin).
1892
+ const companyCalls = (shareSpy.mock.calls as Array<[ShareOptions]>).filter(
1893
+ (c) => c[0].company?.startsWith("cmp_"),
1894
+ );
1895
+ expect(companyCalls.length).toBeGreaterThan(0);
1896
+ for (const [args] of companyCalls) {
1897
+ expect(Object.keys(args)).not.toContain("decommissionPrefixes");
1898
+ }
1899
+ } finally {
1900
+ fs.rmSync(tmpHqRoot, { recursive: true, force: true });
1901
+ }
1902
+ });
1903
+
1904
+ it("J: personal slot's syncFn (pull) receives includeLocalCompanies + teamSyncedSlugs; company slots receive neither", async () => {
1905
+ // Symmetric to test I (push-side decommissionPrefixes). The pull side
1906
+ // needs the same context to:
1907
+ // • allow `companies/{cloud-false-slug}/...` keys through when the
1908
+ // operator opts in (HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1)
1909
+ // • drop `companies/{team-synced-slug}/...` orphans even when push-
1910
+ // side decommission hasn't run (e.g. pull-only mode)
1911
+ //
1912
+ // Default direction for runRunner is "pull" so this test exercises
1913
+ // the pull call without needing --direction.
1914
+ vi.stubEnv("HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL", "1");
1915
+ const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
1916
+ try {
1917
+ const deps = makeDeps({
1918
+ createVaultClient: () =>
1919
+ makeVaultStub({
1920
+ memberships: [
1921
+ { companyUid: "cmp_a" },
1922
+ { companyUid: "cmp_b" },
1923
+ ],
1924
+ entityGet: (uid: string) =>
1925
+ Promise.resolve({
1926
+ uid,
1927
+ slug: uid === "cmp_a" ? "acme" : "beta",
1928
+ } as unknown as EntityInfo),
1929
+ listPersons: () => Promise.resolve([olderPerson]),
1930
+ }),
1931
+ sync: syncSpy,
1932
+ });
1933
+
1934
+ const code = await runRunner(["--companies"], deps);
1935
+ expect(code).toBe(0);
1936
+
1937
+ // Personal slot: both args present, populated with the team-synced
1938
+ // slug set + the env-var-derived gate flag.
1939
+ const personalCall = (syncSpy.mock.calls as Array<[SyncOptions]>).find(
1940
+ (c) => c[0].company?.startsWith("prs_"),
1941
+ );
1942
+ expect(personalCall).toBeDefined();
1943
+ const personalArgs = personalCall![0] as SyncOptions & {
1944
+ includeLocalCompanies?: boolean;
1945
+ teamSyncedSlugs?: ReadonlySet<string>;
1946
+ };
1947
+ expect(personalArgs.includeLocalCompanies).toBe(true);
1948
+ expect(Array.from(personalArgs.teamSyncedSlugs ?? []).sort()).toEqual([
1949
+ "acme",
1950
+ "beta",
1951
+ ]);
1952
+
1953
+ // Company slots: NEITHER key present (preserves the contract that
1954
+ // company-target args stay identical to pre-Slice-2 shape — symmetric
1955
+ // to test C's personalMode/journalSlug pin and test I's
1956
+ // decommissionPrefixes pin).
1957
+ const companyCalls = (syncSpy.mock.calls as Array<[SyncOptions]>).filter(
1958
+ (c) => c[0].company?.startsWith("cmp_"),
1959
+ );
1960
+ expect(companyCalls.length).toBeGreaterThan(0);
1961
+ for (const [args] of companyCalls) {
1962
+ expect(Object.keys(args)).not.toContain("includeLocalCompanies");
1963
+ expect(Object.keys(args)).not.toContain("teamSyncedSlugs");
1964
+ }
1965
+ } finally {
1966
+ vi.unstubAllEnvs();
1967
+ }
1968
+ });
1969
+
1970
+ it("K: personal slot's syncFn (pull) sets includeLocalCompanies=false when env var is unset — pull stays in legacy shape", async () => {
1971
+ // Symmetric to test H (env-var-OFF case for push). Without the gate,
1972
+ // the pull side preserves the pre-5.20 "drop all companies/... keys"
1973
+ // contract regardless of what's actually in the personal bucket.
1974
+ // Defensive unstub in case a previous test leaked a stub.
1975
+ vi.unstubAllEnvs();
1976
+ const syncSpy = vi.fn().mockResolvedValue(defaultSyncResult());
1977
+ const deps = makeDeps({
1978
+ createVaultClient: () =>
1979
+ makeVaultStub({
1980
+ memberships: [{ companyUid: "cmp_a" }],
1981
+ entityGet: (uid: string) =>
1982
+ Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
1983
+ listPersons: () => Promise.resolve([olderPerson]),
1984
+ }),
1985
+ sync: syncSpy,
1986
+ });
1987
+
1988
+ const code = await runRunner(["--companies"], deps);
1989
+ expect(code).toBe(0);
1990
+
1991
+ const personalCall = (syncSpy.mock.calls as Array<[SyncOptions]>).find(
1992
+ (c) => c[0].company?.startsWith("prs_"),
1993
+ );
1994
+ expect(personalCall).toBeDefined();
1995
+ const personalArgs = personalCall![0] as SyncOptions & {
1996
+ includeLocalCompanies?: boolean;
1997
+ };
1998
+ // Explicitly false — not "undefined" — because the runner always
1999
+ // computes the value from the env var, then spreads it for the
2000
+ // personal slot regardless of the value (allows downstream to know
2001
+ // the gate was evaluated, not just "caller forgot to set it").
2002
+ expect(personalArgs.includeLocalCompanies).toBe(false);
2003
+ });
2004
+
1641
2005
  it("F: shareFn paths for company slots stay [hqRoot/companies/{slug}] with no personalMode/journalSlug keys", async () => {
1642
2006
  const shareSpy = vi.fn().mockResolvedValue(defaultShareResult());
1643
2007
  const tmpHqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-test-"));
@@ -88,6 +88,7 @@ import type { ShareOptions, ShareResult } from "../cli/share.js";
88
88
  import type { ConflictStrategy } from "../cli/conflict.js";
89
89
  import type { UploadAuthor } from "../s3.js";
90
90
  import { collectAndSendTelemetry } from "../telemetry.js";
91
+ import { describeError } from "../lib/describe-error.js";
91
92
  import {
92
93
  TreeWatcher,
93
94
  WatchPushDriver,
@@ -973,11 +974,59 @@ export async function runRunner(
973
974
  // target walks every top-level entry under hqRoot minus the exclusion
974
975
  // list (see PERSONAL_VAULT_EXCLUDED_TOP_LEVEL). `skipUnchanged: true`
975
976
  // keeps both cases efficient on re-runs.
977
+ // Shared between push and pull for the personal slot. Hoisted out of
978
+ // the if-blocks below so doPush + doPull see the same set:
979
+ //
980
+ // `HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL=1` opts INTO the
981
+ // cloud:false → personal-bucket fallback. Default OFF keeps the
982
+ // personal vault identical to the pre-5.20 shape until operators
983
+ // explicitly enable it.
984
+ //
985
+ // `teamSyncedSlugs` is the slug set the operator currently has
986
+ // active team-bucket Memberships for, derived from the live plan.
987
+ // Used by:
988
+ // • push: filter `companies/` subdir enumeration so a team-synced
989
+ // company never rides the personal slot; build
990
+ // `decommissionPrefixes` so share() sweeps orphan keys for
991
+ // any slug that's now team-backed.
992
+ // • pull: filter `listRemoteFiles` so pre-decommission orphan
993
+ // keys at `companies/{team-synced-slug}/...` don't get
994
+ // re-downloaded into the disk paths the team-bucket pull
995
+ // now manages.
996
+ const includeLocalCompanies =
997
+ process.env.HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL === "1";
998
+ const teamSyncedSlugs = new Set(
999
+ plan
1000
+ .filter((p) => p.personalMode !== true)
1001
+ .map((p) => p.slug),
1002
+ );
1003
+
976
1004
  if (doPush) {
977
1005
  activePhase = "push";
1006
+ // For the personal slot we hand share() both (a) the top-level
1007
+ // hqRoot entries that are part of the personal vault and (b) any
1008
+ // `companies/{slug}/` subdirs the user has opted into the personal
1009
+ // bucket via `cloud: false` in `company.yaml`. Slugs that already
1010
+ // own a team bucket (anything else in `plan`) are excluded so a
1011
+ // company is never double-written.
978
1012
  const pushPaths = target.personalMode === true
979
- ? computePersonalVaultPaths(parsed.hqRoot)
1013
+ ? computePersonalVaultPaths(parsed.hqRoot, {
1014
+ includeLocalCompanies,
1015
+ teamSyncedSlugs,
1016
+ })
980
1017
  : [path.join(parsed.hqRoot, "companies", target.slug)];
1018
+ // For the personal slot, hand share() a list of `companies/{slug}/`
1019
+ // prefixes for every team-synced slug. If the personal-bucket
1020
+ // journal still holds entries under any of those prefixes — i.e.
1021
+ // the company used to ride the personal-bucket fallback and was
1022
+ // since promoted to its own team bucket — share() will sweep
1023
+ // those orphans via DeleteObject + journal removal. Unconditional
1024
+ // (not gated on `HQ_SYNC_LOCAL_COMPANIES_TO_PERSONAL`) so cleanup
1025
+ // still runs after an operator turns the feature off: the on-ramp
1026
+ // is opt-in but the off-ramp is always safe to traverse.
1027
+ const decommissionPrefixes = target.personalMode === true
1028
+ ? Array.from(teamSyncedSlugs).map((slug) => `companies/${slug}`)
1029
+ : undefined;
981
1030
  pushResult = await shareFn({
982
1031
  paths: pushPaths,
983
1032
  company: target.uid,
@@ -1007,6 +1056,9 @@ export async function runRunner(
1007
1056
  // in sync-runner.test.ts pins that contract).
1008
1057
  ...(target.personalMode !== undefined ? { personalMode: target.personalMode } : {}),
1009
1058
  ...(target.journalSlug !== undefined ? { journalSlug: target.journalSlug } : {}),
1059
+ ...(decommissionPrefixes && decommissionPrefixes.length > 0
1060
+ ? { decommissionPrefixes }
1061
+ : {}),
1010
1062
  });
1011
1063
  }
1012
1064
 
@@ -1022,6 +1074,22 @@ export async function runRunner(
1022
1074
  onConflict: parsed.onConflict,
1023
1075
  ...(target.personalMode !== undefined ? { personalMode: target.personalMode } : {}),
1024
1076
  ...(target.journalSlug !== undefined ? { journalSlug: target.journalSlug } : {}),
1077
+ // Symmetric to the push side: for the personal slot, tell sync()
1078
+ // how to interpret `companies/{slug}/...` keys in the personal
1079
+ // bucket. With `includeLocalCompanies: true`, keys for slugs NOT
1080
+ // in `teamSyncedSlugs` are downloaded (legitimate cloud:false
1081
+ // opt-in content from another machine); keys for slugs IN that
1082
+ // set are dropped as orphans (pre-decommission residue from a
1083
+ // promoted company). With `includeLocalCompanies: false` the
1084
+ // legacy pre-5.20 behavior holds: all `companies/...` keys are
1085
+ // dropped. Only spread for the personal slot so company-target
1086
+ // args stay identical to pre-Slice-2 shape (test C pin).
1087
+ ...(target.personalMode === true
1088
+ ? {
1089
+ includeLocalCompanies,
1090
+ teamSyncedSlugs,
1091
+ }
1092
+ : {}),
1025
1093
  onEvent: tagAndEmit,
1026
1094
  });
1027
1095
  }
@@ -1086,7 +1154,10 @@ export async function runRunner(
1086
1154
  allConflicts.push({ company: companyLabel, path: p, direction: "push" });
1087
1155
  }
1088
1156
  } catch (err) {
1089
- const message = err instanceof Error ? err.message : String(err);
1157
+ // describeError walks the cause chain so AWS SDK v3's "UnknownError"
1158
+ // wrapper surfaces the underlying Node networking error (ENOTFOUND,
1159
+ // ECONNRESET, …) instead of an unactionable bare "UnknownError".
1160
+ const message = describeError(err);
1090
1161
  errors.push({ company: companyLabel, message });
1091
1162
  // `state.status` was seeded as "errored" at loop entry — the throw
1092
1163
  // path leaves it there, and `state.files{Down,Up}loaded` reflects the