@indigoai-us/hq-cloud 6.14.16 → 6.14.18

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 (69) hide show
  1. package/dist/active-company.d.ts +43 -0
  2. package/dist/active-company.d.ts.map +1 -0
  3. package/dist/active-company.js +132 -0
  4. package/dist/active-company.js.map +1 -0
  5. package/dist/active-company.test.d.ts +2 -0
  6. package/dist/active-company.test.d.ts.map +1 -0
  7. package/dist/active-company.test.js +149 -0
  8. package/dist/active-company.test.js.map +1 -0
  9. package/dist/bin/sync-runner-planning.d.ts +27 -1
  10. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-planning.js +33 -4
  12. package/dist/bin/sync-runner-planning.js.map +1 -1
  13. package/dist/bin/sync-runner-planning.test.d.ts +2 -0
  14. package/dist/bin/sync-runner-planning.test.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-planning.test.js +115 -0
  16. package/dist/bin/sync-runner-planning.test.js.map +1 -0
  17. package/dist/bin/sync-runner.d.ts +22 -7
  18. package/dist/bin/sync-runner.d.ts.map +1 -1
  19. package/dist/bin/sync-runner.js +92 -16
  20. package/dist/bin/sync-runner.js.map +1 -1
  21. package/dist/bin/sync-runner.test.js +357 -5
  22. package/dist/bin/sync-runner.test.js.map +1 -1
  23. package/dist/cli/reindex.d.ts.map +1 -1
  24. package/dist/cli/reindex.js +127 -2
  25. package/dist/cli/reindex.js.map +1 -1
  26. package/dist/cli/reindex.test.js +88 -0
  27. package/dist/cli/reindex.test.js.map +1 -1
  28. package/dist/manifest-reconcile.d.ts +118 -5
  29. package/dist/manifest-reconcile.d.ts.map +1 -1
  30. package/dist/manifest-reconcile.js +319 -62
  31. package/dist/manifest-reconcile.js.map +1 -1
  32. package/dist/manifest-reconcile.test.js +824 -2
  33. package/dist/manifest-reconcile.test.js.map +1 -1
  34. package/dist/personal-vault-exclusions.d.ts +1 -6
  35. package/dist/personal-vault-exclusions.d.ts.map +1 -1
  36. package/dist/personal-vault-exclusions.js +34 -6
  37. package/dist/personal-vault-exclusions.js.map +1 -1
  38. package/dist/personal-vault-exclusions.test.js +22 -0
  39. package/dist/personal-vault-exclusions.test.js.map +1 -1
  40. package/dist/personal-vault.d.ts.map +1 -1
  41. package/dist/personal-vault.js +9 -1
  42. package/dist/personal-vault.js.map +1 -1
  43. package/dist/personal-vault.test.js +27 -1
  44. package/dist/personal-vault.test.js.map +1 -1
  45. package/dist/vault-client.d.ts +8 -1
  46. package/dist/vault-client.d.ts.map +1 -1
  47. package/dist/vault-client.js +29 -1
  48. package/dist/vault-client.js.map +1 -1
  49. package/dist/vault-client.test.js +10 -1
  50. package/dist/vault-client.test.js.map +1 -1
  51. package/package.json +1 -1
  52. package/pnpm-workspace.yaml +1 -1
  53. package/src/active-company.test.ts +188 -0
  54. package/src/active-company.ts +168 -0
  55. package/src/bin/sync-runner-planning.test.ts +131 -0
  56. package/src/bin/sync-runner-planning.ts +60 -7
  57. package/src/bin/sync-runner.test.ts +430 -10
  58. package/src/bin/sync-runner.ts +129 -23
  59. package/src/cli/reindex.test.ts +116 -0
  60. package/src/cli/reindex.ts +125 -2
  61. package/src/manifest-reconcile.test.ts +1019 -3
  62. package/src/manifest-reconcile.ts +424 -66
  63. package/src/personal-vault-exclusions.test.ts +24 -0
  64. package/src/personal-vault-exclusions.ts +35 -5
  65. package/src/personal-vault.test.ts +30 -0
  66. package/src/personal-vault.ts +9 -1
  67. package/src/vault-client.test.ts +12 -1
  68. package/src/vault-client.ts +44 -2
  69. package/test/joiner-manifest-reconcile.integration.test.ts +283 -0
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Seed `activeCompany` in `.hq/config.json` for a joiner who has exactly one
3
+ * cloud company.
4
+ *
5
+ * A newly invited member arrives with no local company routing at all. The
6
+ * manifest reconciler restores their `companies/manifest.yaml` entry, but
7
+ * nothing tells HQ which company is *current*, so they stay unrouted even
8
+ * though membership resolved fine. When the answer is unambiguous — one company
9
+ * and no existing choice — recording it is strictly better than leaving the
10
+ * user to guess.
11
+ *
12
+ * Everything here is deliberately conservative. This runs unattended on every
13
+ * sync, against a file the user also edits by hand, so the bar for writing is
14
+ * "the answer cannot be wrong": never overwrite an existing choice, never guess
15
+ * between two companies, and never clobber a file we could not parse.
16
+ */
17
+
18
+ import { randomUUID } from "node:crypto";
19
+ import * as fs from "node:fs";
20
+ import * as path from "node:path";
21
+
22
+ export interface SeedActiveCompanyOptions {
23
+ hqRoot: string;
24
+ /** Slugs of companies that reconciled successfully this run. */
25
+ companySlugs: readonly string[];
26
+ reportDiagnostic?: (diagnostic: {
27
+ event: string;
28
+ message: string;
29
+ err: unknown;
30
+ context: Record<string, unknown>;
31
+ }) => void;
32
+ }
33
+
34
+ export interface SeedActiveCompanyResult {
35
+ /** True only when `.hq/config.json` was actually written. */
36
+ written: boolean;
37
+ /** The slug that was seeded, when one was. */
38
+ activeCompany?: string;
39
+ /** Why nothing was written. Absent when `written` is true. */
40
+ skippedReason?:
41
+ | "ambiguous"
42
+ | "no-companies"
43
+ | "already-set"
44
+ | "unreadable-config"
45
+ | "write-failed";
46
+ }
47
+
48
+ interface HqConfig {
49
+ activeCompany?: unknown;
50
+ companySlug?: unknown;
51
+ [key: string]: unknown;
52
+ }
53
+
54
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
55
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
56
+ const proto = Object.getPrototypeOf(value) as object | null;
57
+ return proto === Object.prototype || proto === null;
58
+ }
59
+
60
+ function isNonEmptyString(value: unknown): value is string {
61
+ return typeof value === "string" && value.length > 0;
62
+ }
63
+
64
+ /**
65
+ * Write `config` to `configPath` via temp-file + rename, so a reader never
66
+ * observes a half-written config and a crash cannot truncate the original.
67
+ */
68
+ function writeConfigAtomically(configPath: string, config: HqConfig): void {
69
+ const dir = path.dirname(configPath);
70
+ fs.mkdirSync(dir, { recursive: true });
71
+ const temporaryPath = path.join(dir, `.config-${process.pid}-${randomUUID()}.json`);
72
+ try {
73
+ fs.writeFileSync(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
74
+ fs.renameSync(temporaryPath, configPath);
75
+ } finally {
76
+ // The rename consumed the temp file on the success path; this only fires
77
+ // when writing or renaming threw, and must not mask the original error.
78
+ try {
79
+ fs.rmSync(temporaryPath, { force: true });
80
+ } catch {
81
+ // Best effort — a stray temp file is not worth failing a sync over.
82
+ }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Seed `activeCompany` when — and only when — the answer is unambiguous.
88
+ *
89
+ * Never throws: the caller is a sync run whose exit code must not depend on
90
+ * local routing convenience.
91
+ */
92
+ export function seedActiveCompany(
93
+ options: SeedActiveCompanyOptions,
94
+ ): SeedActiveCompanyResult {
95
+ const slugs = Array.from(new Set(options.companySlugs.filter(isNonEmptyString)));
96
+ if (slugs.length === 0) return { written: false, skippedReason: "no-companies" };
97
+ // Two or more companies is a genuine choice and belongs to the user. Picking
98
+ // one would route them somewhere arbitrary and look like a bug.
99
+ if (slugs.length > 1) return { written: false, skippedReason: "ambiguous" };
100
+
101
+ const activeCompany = slugs[0];
102
+ const configPath = path.join(options.hqRoot, ".hq", "config.json");
103
+
104
+ let config: HqConfig = {};
105
+ let raw: string | undefined;
106
+ try {
107
+ raw = fs.readFileSync(configPath, "utf8");
108
+ } catch (err) {
109
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
110
+ // An unreadable config is user state we cannot reason about. Leave it.
111
+ options.reportDiagnostic?.({
112
+ event: "runner.active_company.config_unreadable",
113
+ message: "could not read .hq/config.json; activeCompany not seeded",
114
+ err,
115
+ context: { configPath },
116
+ });
117
+ return { written: false, skippedReason: "unreadable-config" };
118
+ }
119
+ }
120
+
121
+ if (raw !== undefined) {
122
+ let parsed: unknown;
123
+ try {
124
+ parsed = JSON.parse(raw);
125
+ } catch (err) {
126
+ // Malformed JSON is almost always a half-finished hand edit. Overwriting
127
+ // it would destroy whatever the user was in the middle of doing.
128
+ options.reportDiagnostic?.({
129
+ event: "runner.active_company.config_malformed",
130
+ message: ".hq/config.json is not valid JSON; left untouched",
131
+ err,
132
+ context: { configPath },
133
+ });
134
+ return { written: false, skippedReason: "unreadable-config" };
135
+ }
136
+ if (!isPlainObject(parsed)) {
137
+ options.reportDiagnostic?.({
138
+ event: "runner.active_company.config_malformed",
139
+ message: ".hq/config.json is not a JSON object; left untouched",
140
+ err: undefined,
141
+ context: { configPath },
142
+ });
143
+ return { written: false, skippedReason: "unreadable-config" };
144
+ }
145
+ config = parsed;
146
+ }
147
+
148
+ // `companySlug` counts as already-set: sync-core resolves it as the active
149
+ // company when `activeCompany` is absent, so writing `activeCompany` here
150
+ // would silently re-route a user who already had an effective answer.
151
+ if (isNonEmptyString(config.activeCompany) || isNonEmptyString(config.companySlug)) {
152
+ return { written: false, skippedReason: "already-set" };
153
+ }
154
+
155
+ try {
156
+ writeConfigAtomically(configPath, { ...config, activeCompany });
157
+ } catch (err) {
158
+ options.reportDiagnostic?.({
159
+ event: "runner.active_company.write_failed",
160
+ message: "failed to seed activeCompany in .hq/config.json",
161
+ err,
162
+ context: { configPath, activeCompany },
163
+ });
164
+ return { written: false, skippedReason: "write-failed" };
165
+ }
166
+
167
+ return { written: true, activeCompany };
168
+ }
@@ -0,0 +1,131 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { buildFanoutPlan, resolveMembershipsForRun } from "./sync-runner-planning.js";
4
+ import type { VaultClientSurface } from "./sync-runner.js";
5
+
6
+ const noopStderr = { write: () => true };
7
+
8
+ function clientStub(overrides: Record<string, unknown> = {}): VaultClientSurface {
9
+ return {
10
+ entity: {
11
+ get: () => Promise.resolve(undefined),
12
+ listByType: () => Promise.resolve([]),
13
+ },
14
+ ...overrides,
15
+ } as unknown as VaultClientSurface;
16
+ }
17
+
18
+ describe("resolveMembershipsForRun — setup-needed reasons", () => {
19
+ // Each reason must come from its OWN trigger. Collapsing them back into a
20
+ // bare `setup-needed` is what left joiners unable to tell "you have no team"
21
+ // apart from "you have an invite waiting".
22
+ it("reports no-memberships when the membership list came back empty", async () => {
23
+ const result = await resolveMembershipsForRun({
24
+ personal: false,
25
+ companies: true,
26
+ client: clientStub(),
27
+ claims: null,
28
+ stderr: noopStderr,
29
+ runClaimDance: () => Promise.resolve(0),
30
+ listMemberships: () => Promise.resolve([]),
31
+ });
32
+
33
+ expect(result).toEqual({ status: "setup-needed", reason: "no-memberships" });
34
+ });
35
+
36
+ it("attaches pendingInviteCount when the claim dance left invites unconverted", async () => {
37
+ const result = await resolveMembershipsForRun({
38
+ personal: false,
39
+ companies: true,
40
+ client: clientStub(),
41
+ claims: { sub: "sub-1" } as never,
42
+ stderr: noopStderr,
43
+ runClaimDance: () => Promise.resolve(3),
44
+ listMemberships: () => Promise.resolve([]),
45
+ });
46
+
47
+ expect(result).toEqual({
48
+ status: "setup-needed",
49
+ reason: "no-memberships",
50
+ pendingInviteCount: 3,
51
+ });
52
+ });
53
+
54
+ it("omits pendingInviteCount rather than reporting zero", async () => {
55
+ const result = await resolveMembershipsForRun({
56
+ personal: false,
57
+ companies: true,
58
+ client: clientStub(),
59
+ claims: { sub: "sub-1" } as never,
60
+ stderr: noopStderr,
61
+ runClaimDance: () => Promise.resolve(0),
62
+ listMemberships: () => Promise.resolve([]),
63
+ });
64
+
65
+ expect(result).toEqual({ status: "setup-needed", reason: "no-memberships" });
66
+ expect("pendingInviteCount" in result).toBe(false);
67
+ });
68
+
69
+ it("does not report setup-needed at all when memberships exist", async () => {
70
+ const runClaimDance = vi.fn().mockResolvedValue(2);
71
+ const result = await resolveMembershipsForRun({
72
+ personal: false,
73
+ companies: true,
74
+ client: clientStub(),
75
+ claims: { sub: "sub-1" } as never,
76
+ stderr: noopStderr,
77
+ runClaimDance,
78
+ listMemberships: () => Promise.resolve([{ companyUid: "cmp_a" }]),
79
+ });
80
+
81
+ // Pending invites are only meaningful as an explanation for emptiness.
82
+ expect(result).toEqual({
83
+ status: "memberships",
84
+ memberships: [{ companyUid: "cmp_a" }],
85
+ });
86
+ });
87
+ });
88
+
89
+ describe("buildFanoutPlan — setup-needed reasons", () => {
90
+ it("reports no-person-entity when --personal has no resolvable personal target", async () => {
91
+ const result = await buildFanoutPlan({
92
+ memberships: [],
93
+ companies: false,
94
+ personal: true,
95
+ skipPersonal: false,
96
+ client: clientStub(),
97
+ claims: null,
98
+ resolveSkipPersonal: () => false,
99
+ });
100
+
101
+ expect(result).toEqual({ status: "setup-needed", reason: "no-person-entity" });
102
+ });
103
+
104
+ // A --companies run does not need a personal target to be useful, so an
105
+ // unresolvable one must not abort the whole run.
106
+ it("does not report no-person-entity for a --companies run", async () => {
107
+ const result = await buildFanoutPlan({
108
+ memberships: [{ companyUid: "cmp_a" }],
109
+ companies: true,
110
+ personal: false,
111
+ skipPersonal: false,
112
+ client: clientStub({
113
+ entity: {
114
+ get: () =>
115
+ Promise.resolve({
116
+ uid: "cmp_a",
117
+ slug: "acme",
118
+ type: "company",
119
+ status: "active",
120
+ bucketName: "bucket-a",
121
+ }),
122
+ listByType: () => Promise.resolve([]),
123
+ },
124
+ }),
125
+ claims: null,
126
+ resolveSkipPersonal: () => false,
127
+ });
128
+
129
+ expect(result.status).toBe("plan");
130
+ });
131
+ });
@@ -29,12 +29,35 @@ export interface RunnerTarget {
29
29
  slug: string;
30
30
  name?: string;
31
31
  bucketName?: string;
32
+ /**
33
+ * Entity liveness as observed AT PLAN TIME, copied off the `entity.get`
34
+ * result this plan was built from. Carried so a downstream consumer can make
35
+ * a liveness decision without paying a second round trip.
36
+ *
37
+ * These deliberately do NOT gate SYNC. A suspended company still syncs — that
38
+ * is user-visible behavior and not ours to change here. They exist so the
39
+ * MANIFEST decision (see `manifest-reconcile.ts`) can be as strict as it was
40
+ * when it re-fetched the entity itself. Absent fields mean "unknown", and
41
+ * every consumer of them is required to fail closed.
42
+ */
43
+ entityType?: string;
44
+ entityStatus?: string;
32
45
  personalMode?: boolean;
33
46
  journalSlug?: string;
34
47
  }
35
48
 
49
+ /**
50
+ * Why a run cannot proceed to a fanout.
51
+ *
52
+ * - `no-memberships` — the caller resolved fine but belongs to no company.
53
+ * Usually a joiner whose invite is still pending acceptance.
54
+ * - `no-person-entity` — the caller is signed in but has no personal entity to
55
+ * sync into, so even `--personal` has no target.
56
+ */
57
+ export type SetupNeededReason = "no-memberships" | "no-person-entity";
58
+
36
59
  export type MembershipResolution =
37
- | { status: "setup-needed" }
60
+ | { status: "setup-needed"; reason: SetupNeededReason; pendingInviteCount?: number }
38
61
  | { status: "memberships"; memberships: Pick<Membership, "companyUid">[] };
39
62
 
40
63
  export async function resolveMembershipsForRun(options: {
@@ -44,11 +67,12 @@ export async function resolveMembershipsForRun(options: {
44
67
  client: VaultClientSurface;
45
68
  claims: IdentityClaims | null;
46
69
  stderr: { write: (chunk: string) => boolean | void };
70
+ /** Returns how many invites were still pending after the claim attempt. */
47
71
  runClaimDance: (
48
72
  client: VaultClientSurface,
49
73
  claims: IdentityClaims,
50
74
  stderr: { write: (chunk: string) => boolean | void },
51
- ) => Promise<void>;
75
+ ) => Promise<number>;
52
76
  listMemberships: (
53
77
  client: VaultClientSurface,
54
78
  ) => Promise<Pick<Membership, "companyUid">[]>;
@@ -58,8 +82,9 @@ export async function resolveMembershipsForRun(options: {
58
82
  }
59
83
 
60
84
  if (options.companies) {
85
+ let pendingInviteCount = 0;
61
86
  if (options.claims) {
62
- await options.runClaimDance(
87
+ pendingInviteCount = await options.runClaimDance(
63
88
  options.client,
64
89
  options.claims,
65
90
  options.stderr,
@@ -68,7 +93,15 @@ export async function resolveMembershipsForRun(options: {
68
93
 
69
94
  const memberships = await options.listMemberships(options.client);
70
95
  if (memberships.length === 0) {
71
- return { status: "setup-needed" };
96
+ // Pending invites that did NOT become memberships are the single most
97
+ // useful thing we can tell this user: they are not "solo", they have an
98
+ // invite waiting to be accepted. Reported only on the empty path, where
99
+ // it explains the emptiness.
100
+ return {
101
+ status: "setup-needed",
102
+ reason: "no-memberships",
103
+ ...(pendingInviteCount > 0 ? { pendingInviteCount } : {}),
104
+ };
72
105
  }
73
106
  return { status: "memberships", memberships };
74
107
  }
@@ -88,18 +121,31 @@ export async function buildFanoutPlan(options: {
88
121
  claims: IdentityClaims | null;
89
122
  resolveSkipPersonal: (flag: boolean) => boolean;
90
123
  }): Promise<
91
- | { status: "setup-needed" }
124
+ | { status: "setup-needed"; reason: SetupNeededReason }
92
125
  | { status: "plan"; plan: RunnerTarget[] }
93
126
  > {
94
127
  const plan: RunnerTarget[] = [];
95
128
  for (const m of options.memberships) {
96
129
  let slug = m.companyUid;
97
130
  let name: string | undefined;
131
+ // Carried onto the target so downstream consumers (manifest reconciliation)
132
+ // can use the bucket, and judge liveness, without re-fetching the entity we
133
+ // already resolved here. On the degraded catch path below these stay
134
+ // undefined, which every consumer must read as "unknown" and reject.
135
+ let bucketName: string | undefined;
136
+ let entityType: string | undefined;
137
+ let entityStatus: string | undefined;
98
138
  try {
99
139
  const info = await options.client.entity.get(m.companyUid);
100
140
  if (!info) continue;
101
141
  slug = info.slug || m.companyUid;
102
142
  name = info.name;
143
+ bucketName = info.bucketName;
144
+ // Recorded, never acted on here: a suspended or unexpectedly-typed
145
+ // company still gets a sync leg exactly as before. Only the manifest
146
+ // decision consumes these.
147
+ entityType = info.type;
148
+ entityStatus = info.status;
103
149
  } catch (err) {
104
150
  if (err instanceof VaultAuthError) throw err;
105
151
  // A stale membership can outlive its company entity. Do not schedule a
@@ -117,7 +163,14 @@ export async function buildFanoutPlan(options: {
117
163
  }
118
164
  // Best-effort — keep UID as the display identifier.
119
165
  }
120
- plan.push({ uid: m.companyUid, slug, ...(name ? { name } : {}) });
166
+ plan.push({
167
+ uid: m.companyUid,
168
+ slug,
169
+ ...(name ? { name } : {}),
170
+ ...(bucketName ? { bucketName } : {}),
171
+ ...(entityType ? { entityType } : {}),
172
+ ...(entityStatus ? { entityStatus } : {}),
173
+ });
121
174
  }
122
175
 
123
176
  if (
@@ -153,7 +206,7 @@ export async function buildFanoutPlan(options: {
153
206
  journalSlug: PERSONAL_VAULT_JOURNAL_SLUG,
154
207
  });
155
208
  } else if (options.personal) {
156
- return { status: "setup-needed" };
209
+ return { status: "setup-needed", reason: "no-person-entity" };
157
210
  }
158
211
  }
159
212