@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.
- package/dist/active-company.d.ts +43 -0
- package/dist/active-company.d.ts.map +1 -0
- package/dist/active-company.js +132 -0
- package/dist/active-company.js.map +1 -0
- package/dist/active-company.test.d.ts +2 -0
- package/dist/active-company.test.d.ts.map +1 -0
- package/dist/active-company.test.js +149 -0
- package/dist/active-company.test.js.map +1 -0
- package/dist/bin/sync-runner-planning.d.ts +27 -1
- package/dist/bin/sync-runner-planning.d.ts.map +1 -1
- package/dist/bin/sync-runner-planning.js +33 -4
- package/dist/bin/sync-runner-planning.js.map +1 -1
- package/dist/bin/sync-runner-planning.test.d.ts +2 -0
- package/dist/bin/sync-runner-planning.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-planning.test.js +115 -0
- package/dist/bin/sync-runner-planning.test.js.map +1 -0
- package/dist/bin/sync-runner.d.ts +22 -7
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +92 -16
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +357 -5
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +127 -2
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +88 -0
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/manifest-reconcile.d.ts +118 -5
- package/dist/manifest-reconcile.d.ts.map +1 -1
- package/dist/manifest-reconcile.js +319 -62
- package/dist/manifest-reconcile.js.map +1 -1
- package/dist/manifest-reconcile.test.js +824 -2
- package/dist/manifest-reconcile.test.js.map +1 -1
- package/dist/personal-vault-exclusions.d.ts +1 -6
- package/dist/personal-vault-exclusions.d.ts.map +1 -1
- package/dist/personal-vault-exclusions.js +34 -6
- package/dist/personal-vault-exclusions.js.map +1 -1
- package/dist/personal-vault-exclusions.test.js +22 -0
- package/dist/personal-vault-exclusions.test.js.map +1 -1
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +9 -1
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +27 -1
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/vault-client.d.ts +8 -1
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +29 -1
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +10 -1
- package/dist/vault-client.test.js.map +1 -1
- package/package.json +1 -1
- package/pnpm-workspace.yaml +1 -1
- package/src/active-company.test.ts +188 -0
- package/src/active-company.ts +168 -0
- package/src/bin/sync-runner-planning.test.ts +131 -0
- package/src/bin/sync-runner-planning.ts +60 -7
- package/src/bin/sync-runner.test.ts +430 -10
- package/src/bin/sync-runner.ts +129 -23
- package/src/cli/reindex.test.ts +116 -0
- package/src/cli/reindex.ts +125 -2
- package/src/manifest-reconcile.test.ts +1019 -3
- package/src/manifest-reconcile.ts +424 -66
- package/src/personal-vault-exclusions.test.ts +24 -0
- package/src/personal-vault-exclusions.ts +35 -5
- package/src/personal-vault.test.ts +30 -0
- package/src/personal-vault.ts +9 -1
- package/src/vault-client.test.ts +12 -1
- package/src/vault-client.ts +44 -2
- 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<
|
|
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
|
-
|
|
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({
|
|
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
|
|