@indigoai-us/hq-cloud 6.14.17 → 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/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/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/manifest-reconcile.test.ts +1019 -3
- package/src/manifest-reconcile.ts +424 -66
- package/test/joiner-manifest-reconcile.integration.test.ts +283 -0
|
@@ -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
|
|
|
@@ -52,6 +52,11 @@ import type {
|
|
|
52
52
|
import { VaultAuthError, VaultNotFoundError } from "../vault-client.js";
|
|
53
53
|
import { CognitoRefreshError } from "../cognito-auth.js";
|
|
54
54
|
import type { PushEvent } from "../sync/push-event.js";
|
|
55
|
+
import {
|
|
56
|
+
reconcileCompanyManifest,
|
|
57
|
+
type ManifestReconcileOptions,
|
|
58
|
+
} from "../manifest-reconcile.js";
|
|
59
|
+
import yaml from "js-yaml";
|
|
55
60
|
|
|
56
61
|
// ---------------------------------------------------------------------------
|
|
57
62
|
// Hermetic journal state — the runner now calls migratePersonalVaultJournal()
|
|
@@ -227,7 +232,9 @@ function makeDeps(overrides: Partial<RunnerDeps> = {}): TestDeps {
|
|
|
227
232
|
embedded: false,
|
|
228
233
|
pendingDirty: false,
|
|
229
234
|
})),
|
|
230
|
-
reconcileManifest: vi
|
|
235
|
+
reconcileManifest: vi
|
|
236
|
+
.fn()
|
|
237
|
+
.mockResolvedValue({ written: false, added: [], updated: [], skipped: [] }),
|
|
231
238
|
...overrides,
|
|
232
239
|
stdout,
|
|
233
240
|
stderr,
|
|
@@ -279,7 +286,9 @@ describe("argv parsing", () => {
|
|
|
279
286
|
const code = await runRunner(["--companies", "--json"], deps);
|
|
280
287
|
expect(code).toBe(0);
|
|
281
288
|
// Empty memberships → setup-needed, not a parse error
|
|
282
|
-
expect(deps.stdout.events()).toEqual([
|
|
289
|
+
expect(deps.stdout.events()).toEqual([
|
|
290
|
+
{ type: "setup-needed", reason: "no-memberships" },
|
|
291
|
+
]);
|
|
283
292
|
});
|
|
284
293
|
});
|
|
285
294
|
|
|
@@ -618,7 +627,80 @@ describe("claim-dance", () => {
|
|
|
618
627
|
expect(ensureSpy).not.toHaveBeenCalled();
|
|
619
628
|
expect(claimSpy).not.toHaveBeenCalled();
|
|
620
629
|
// No memberships, no invites — truly empty → setup-needed is correct here.
|
|
621
|
-
expect(deps.stdout.events()).toEqual([
|
|
630
|
+
expect(deps.stdout.events()).toEqual([
|
|
631
|
+
{ type: "setup-needed", reason: "no-memberships" },
|
|
632
|
+
]);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// The michelle@boringecom.com shape: an invite exists, the claim attempt does
|
|
636
|
+
// not convert it into a membership, and the user is told they are solo. The
|
|
637
|
+
// count is what turns "you have no team" into "you have an invite to accept".
|
|
638
|
+
it("US-004: carries pendingInviteCount when invites did not become memberships", async () => {
|
|
639
|
+
const deps = makeDeps({
|
|
640
|
+
createVaultClient: () =>
|
|
641
|
+
makeVaultStub({
|
|
642
|
+
pendingInvites: [{ uid: "inv_1" }, { uid: "inv_2" }] as never,
|
|
643
|
+
ensurePerson: vi
|
|
644
|
+
.fn()
|
|
645
|
+
.mockResolvedValue({ uid: "ent_person" }) as unknown as VaultClientSurface["ensureMyPersonEntity"],
|
|
646
|
+
claim: vi
|
|
647
|
+
.fn()
|
|
648
|
+
.mockResolvedValue(undefined) as unknown as VaultClientSurface["claimPendingInvitesByEmail"],
|
|
649
|
+
}),
|
|
650
|
+
getIdTokenClaims: () => claims,
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
654
|
+
expect(deps.stdout.events()).toEqual([
|
|
655
|
+
{ type: "setup-needed", reason: "no-memberships", pendingInviteCount: 2 },
|
|
656
|
+
]);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it("US-004: omits pendingInviteCount entirely when there were no invites", async () => {
|
|
660
|
+
const deps = makeDeps({
|
|
661
|
+
createVaultClient: () => makeVaultStub({ pendingInvites: [] }),
|
|
662
|
+
getIdTokenClaims: () => claims,
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
666
|
+
const [event] = deps.stdout.events();
|
|
667
|
+
expect(event).toEqual({ type: "setup-needed", reason: "no-memberships" });
|
|
668
|
+
expect("pendingInviteCount" in event).toBe(false);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("US-004: reports claim-dance skipped as a diagnostic, not a bare stderr line", async () => {
|
|
672
|
+
const deps = makeDeps({
|
|
673
|
+
createVaultClient: () =>
|
|
674
|
+
makeVaultStub({
|
|
675
|
+
pendingInvites: [{ uid: "inv_1" }] as never,
|
|
676
|
+
ensurePerson: vi
|
|
677
|
+
.fn()
|
|
678
|
+
.mockRejectedValue(
|
|
679
|
+
new Error("BatchWriteItem denied"),
|
|
680
|
+
) as unknown as VaultClientSurface["ensureMyPersonEntity"],
|
|
681
|
+
}),
|
|
682
|
+
getIdTokenClaims: () => claims,
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// Non-throwing by contract: the failure never changes the exit code...
|
|
686
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
687
|
+
// ...but it is no longer invisible.
|
|
688
|
+
expect(deps.stderr.raw()).toContain("runner.claim_dance.skipped");
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it("US-004: a claim-dance that cannot identify the caller still reports the pending invites", async () => {
|
|
692
|
+
const deps = makeDeps({
|
|
693
|
+
createVaultClient: () =>
|
|
694
|
+
makeVaultStub({ pendingInvites: [{ uid: "inv_1" }] as never }),
|
|
695
|
+
// No sub/name — the claim cannot proceed, but the invites are still real.
|
|
696
|
+
getIdTokenClaims: () => ({}) as never,
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
700
|
+
expect(deps.stdout.events()).toEqual([
|
|
701
|
+
{ type: "setup-needed", reason: "no-memberships", pendingInviteCount: 1 },
|
|
702
|
+
]);
|
|
703
|
+
expect(deps.stderr.raw()).toContain("runner.claim_dance.skipped");
|
|
622
704
|
});
|
|
623
705
|
|
|
624
706
|
it("skips claim-dance entirely when no idToken claims are available", async () => {
|
|
@@ -696,7 +778,9 @@ describe("target resolution", () => {
|
|
|
696
778
|
const deps = makeDeps();
|
|
697
779
|
const code = await runRunner(["--companies"], deps);
|
|
698
780
|
expect(code).toBe(0);
|
|
699
|
-
expect(deps.stdout.events()).toEqual([
|
|
781
|
+
expect(deps.stdout.events()).toEqual([
|
|
782
|
+
{ type: "setup-needed", reason: "no-memberships" },
|
|
783
|
+
]);
|
|
700
784
|
// sync should NOT have been called — no targets
|
|
701
785
|
expect(deps.sync).not.toHaveBeenCalled();
|
|
702
786
|
});
|
|
@@ -1031,7 +1115,11 @@ describe("fanout-plan", () => {
|
|
|
1031
1115
|
RunnerEvent,
|
|
1032
1116
|
{ type: "fanout-plan" }
|
|
1033
1117
|
>;
|
|
1034
|
-
|
|
1118
|
+
// The surviving target now also carries the plan-time entity snapshot the
|
|
1119
|
+
// manifest reconciler reads instead of re-fetching (US-002).
|
|
1120
|
+
expect(plan.companies).toEqual([
|
|
1121
|
+
{ uid: "cmp_live", slug: "live", entityType: "company", entityStatus: "active" },
|
|
1122
|
+
]);
|
|
1035
1123
|
});
|
|
1036
1124
|
|
|
1037
1125
|
it("reconciles after the personal leg completes", async () => {
|
|
@@ -1066,6 +1154,7 @@ describe("fanout-plan", () => {
|
|
|
1066
1154
|
}),
|
|
1067
1155
|
reconcileManifest: vi.fn().mockImplementation(async () => {
|
|
1068
1156
|
order.push("reconcile");
|
|
1157
|
+
return { written: false, added: [], updated: [], skipped: [] };
|
|
1069
1158
|
}),
|
|
1070
1159
|
});
|
|
1071
1160
|
|
|
@@ -2723,11 +2812,9 @@ describe("personal slot fanout", () => {
|
|
|
2723
2812
|
// companies/personal company journal.
|
|
2724
2813
|
expect(lastEntry.slug).toBe("personal");
|
|
2725
2814
|
expect(lastEntry.uid).toBe("prs_older");
|
|
2726
|
-
expect(
|
|
2727
|
-
expect(
|
|
2728
|
-
expect(
|
|
2729
|
-
PERSONAL_VAULT_JOURNAL_SLUG,
|
|
2730
|
-
);
|
|
2815
|
+
expect(lastEntry.bucketName).toBe("hq-vault-prs-older");
|
|
2816
|
+
expect(lastEntry.personalMode).toBe(true);
|
|
2817
|
+
expect(lastEntry.journalSlug).toBe(PERSONAL_VAULT_JOURNAL_SLUG);
|
|
2731
2818
|
});
|
|
2732
2819
|
|
|
2733
2820
|
it("B: syncFn invoked with personalMode: true + reserved journalSlug for personal slot", async () => {
|
|
@@ -6581,3 +6668,336 @@ describe("US-004 — --personal for an AGENT machine identity (HARD GATE)", () =
|
|
|
6581
6668
|
expect(cmp(HQ_CLOUD_VERSION, FLOOR)).toBeGreaterThanOrEqual(0);
|
|
6582
6669
|
});
|
|
6583
6670
|
});
|
|
6671
|
+
|
|
6672
|
+
// ---------------------------------------------------------------------------
|
|
6673
|
+
// US-002 — post-fanout manifest reconciliation wiring
|
|
6674
|
+
//
|
|
6675
|
+
// The reconciler itself is unit-tested in manifest-reconcile.test.ts. These
|
|
6676
|
+
// tests own the SEAM: which run modes reach it, which targets cross it, and
|
|
6677
|
+
// what the runner does with the result it hands back.
|
|
6678
|
+
// ---------------------------------------------------------------------------
|
|
6679
|
+
|
|
6680
|
+
describe("US-002 — manifest reconciliation wiring", () => {
|
|
6681
|
+
const PERSON_ENTITY = {
|
|
6682
|
+
uid: "prs_me",
|
|
6683
|
+
slug: "me",
|
|
6684
|
+
type: "person",
|
|
6685
|
+
status: "active",
|
|
6686
|
+
bucketName: "hq-vault-prs-me",
|
|
6687
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
6688
|
+
} as unknown as EntityInfo;
|
|
6689
|
+
|
|
6690
|
+
function noopReconcileResult() {
|
|
6691
|
+
return { written: false, added: [], updated: [], skipped: [] };
|
|
6692
|
+
}
|
|
6693
|
+
|
|
6694
|
+
/** A vault stub whose companies all resolve to `cmp_x` → slug `x`. */
|
|
6695
|
+
function companiesClient(companyUids: string[]): VaultClientSurface {
|
|
6696
|
+
return makeVaultStub({
|
|
6697
|
+
memberships: companyUids.map((companyUid) => ({ companyUid })),
|
|
6698
|
+
entityGet: (uid: string) =>
|
|
6699
|
+
Promise.resolve({
|
|
6700
|
+
uid,
|
|
6701
|
+
slug: uid.replace(/^cmp_/, ""),
|
|
6702
|
+
name: `${uid} Inc`,
|
|
6703
|
+
type: "company",
|
|
6704
|
+
status: "active",
|
|
6705
|
+
bucketName: `bucket-${uid}`,
|
|
6706
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
6707
|
+
} as unknown as EntityInfo),
|
|
6708
|
+
listPersons: () => Promise.resolve([PERSON_ENTITY]),
|
|
6709
|
+
});
|
|
6710
|
+
}
|
|
6711
|
+
|
|
6712
|
+
function reconcileArgs(
|
|
6713
|
+
reconcile: ReturnType<typeof vi.fn>,
|
|
6714
|
+
): ManifestReconcileOptions {
|
|
6715
|
+
return reconcile.mock.calls[0]![0] as ManifestReconcileOptions;
|
|
6716
|
+
}
|
|
6717
|
+
|
|
6718
|
+
/** Diagnostics ride stderr as ndjson; pick out the manifest ones. */
|
|
6719
|
+
function manifestDiagnostics(stderr: CapturingWriter): Record<string, unknown>[] {
|
|
6720
|
+
return stderr
|
|
6721
|
+
.lines()
|
|
6722
|
+
.flatMap((line) => {
|
|
6723
|
+
try {
|
|
6724
|
+
return [JSON.parse(line) as Record<string, unknown>];
|
|
6725
|
+
} catch {
|
|
6726
|
+
return [];
|
|
6727
|
+
}
|
|
6728
|
+
})
|
|
6729
|
+
.filter((entry) => entry.component === "manifest-reconcile");
|
|
6730
|
+
}
|
|
6731
|
+
|
|
6732
|
+
it("AC2: an errored company is not eligible — only the completing company is", async () => {
|
|
6733
|
+
const reconcile = vi.fn().mockResolvedValue(noopReconcileResult());
|
|
6734
|
+
const deps = makeDeps({
|
|
6735
|
+
createVaultClient: () => companiesClient(["cmp_ok", "cmp_bad"]),
|
|
6736
|
+
sync: vi.fn().mockImplementation(async (options: SyncOptions) => {
|
|
6737
|
+
if (options.company === "cmp_bad") throw new Error("pull leg exploded");
|
|
6738
|
+
return defaultSyncResult();
|
|
6739
|
+
}),
|
|
6740
|
+
reconcileManifest: reconcile,
|
|
6741
|
+
});
|
|
6742
|
+
|
|
6743
|
+
await runRunner(["--companies"], deps);
|
|
6744
|
+
|
|
6745
|
+
expect(reconcile).toHaveBeenCalledTimes(1);
|
|
6746
|
+
const args = reconcileArgs(reconcile);
|
|
6747
|
+
// The completion filter is `completedCompanySlugs`; assert the EFFECTIVE
|
|
6748
|
+
// eligible set, which is what actually reaches the manifest.
|
|
6749
|
+
const eligible = args.targets.filter((target) =>
|
|
6750
|
+
args.completedCompanySlugs.has(target.slug),
|
|
6751
|
+
);
|
|
6752
|
+
expect(eligible.map((target) => target.slug)).toEqual(["ok"]);
|
|
6753
|
+
expect(args.completedCompanySlugs.has("bad")).toBe(false);
|
|
6754
|
+
});
|
|
6755
|
+
|
|
6756
|
+
it("AC3: the personal target is never passed to the reconciler", async () => {
|
|
6757
|
+
const reconcile = vi.fn().mockResolvedValue(noopReconcileResult());
|
|
6758
|
+
const deps = makeDeps({
|
|
6759
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
6760
|
+
reconcileManifest: reconcile,
|
|
6761
|
+
});
|
|
6762
|
+
|
|
6763
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
6764
|
+
|
|
6765
|
+
const args = reconcileArgs(reconcile);
|
|
6766
|
+
// The personal leg definitely ran — otherwise this assertion is vacuous.
|
|
6767
|
+
const plan = deps.stdout
|
|
6768
|
+
.events()
|
|
6769
|
+
.find((e) => e.type === "fanout-plan") as Extract<
|
|
6770
|
+
RunnerEvent,
|
|
6771
|
+
{ type: "fanout-plan" }
|
|
6772
|
+
>;
|
|
6773
|
+
expect(plan.companies.map((t) => t.slug)).toContain("personal");
|
|
6774
|
+
|
|
6775
|
+
expect(args.targets.map((target) => target.slug)).toEqual(["acme"]);
|
|
6776
|
+
expect(args.targets.some((target) => target.personalMode === true)).toBe(false);
|
|
6777
|
+
expect(args.targets.some((target) => target.slug === "personal")).toBe(false);
|
|
6778
|
+
});
|
|
6779
|
+
|
|
6780
|
+
it("AC4: --personal mode does not reconcile", async () => {
|
|
6781
|
+
const reconcile = vi.fn().mockResolvedValue(noopReconcileResult());
|
|
6782
|
+
const deps = makeDeps({
|
|
6783
|
+
createVaultClient: () =>
|
|
6784
|
+
makeVaultStub({ listPersons: () => Promise.resolve([PERSON_ENTITY]) }),
|
|
6785
|
+
reconcileManifest: reconcile,
|
|
6786
|
+
});
|
|
6787
|
+
|
|
6788
|
+
expect(await runRunner(["--personal"], deps)).toBe(0);
|
|
6789
|
+
expect(reconcile).not.toHaveBeenCalled();
|
|
6790
|
+
});
|
|
6791
|
+
|
|
6792
|
+
it("AC4: single-company mode (--company <slug>) does not reconcile", async () => {
|
|
6793
|
+
const reconcile = vi.fn().mockResolvedValue(noopReconcileResult());
|
|
6794
|
+
const deps = makeDeps({
|
|
6795
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
6796
|
+
reconcileManifest: reconcile,
|
|
6797
|
+
});
|
|
6798
|
+
|
|
6799
|
+
expect(await runRunner(["--company", "cmp_acme"], deps)).toBe(0);
|
|
6800
|
+
expect(reconcile).not.toHaveBeenCalled();
|
|
6801
|
+
});
|
|
6802
|
+
|
|
6803
|
+
it("AC5: company targets carry the plan-resolved bucketName, so no getEntity is passed", async () => {
|
|
6804
|
+
const reconcile = vi.fn().mockResolvedValue(noopReconcileResult());
|
|
6805
|
+
const deps = makeDeps({
|
|
6806
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
6807
|
+
reconcileManifest: reconcile,
|
|
6808
|
+
});
|
|
6809
|
+
|
|
6810
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
6811
|
+
|
|
6812
|
+
const args = reconcileArgs(reconcile);
|
|
6813
|
+
expect(args.targets[0]).toMatchObject({
|
|
6814
|
+
uid: "cmp_acme",
|
|
6815
|
+
slug: "acme",
|
|
6816
|
+
name: "cmp_acme Inc",
|
|
6817
|
+
bucketName: "bucket-cmp_acme",
|
|
6818
|
+
// Liveness evidence travels with the target. Dropping the round trip
|
|
6819
|
+
// must not drop the guarantee it used to provide.
|
|
6820
|
+
entityType: "company",
|
|
6821
|
+
entityStatus: "active",
|
|
6822
|
+
});
|
|
6823
|
+
// Zero extra API calls: the runner hands over no entity resolver at all.
|
|
6824
|
+
expect(args.getEntity).toBeUndefined();
|
|
6825
|
+
});
|
|
6826
|
+
|
|
6827
|
+
it("END-TO-END: a suspended company completes its sync leg but never reaches the manifest", async () => {
|
|
6828
|
+
// Exercises the REAL reconciler through the runner — no stub — because the
|
|
6829
|
+
// defect this guards was invisible to every stubbed assertion.
|
|
6830
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-runner-manifest-"));
|
|
6831
|
+
try {
|
|
6832
|
+
fs.mkdirSync(path.join(root, "companies", "live"), { recursive: true });
|
|
6833
|
+
fs.mkdirSync(path.join(root, "companies", "susp"), { recursive: true });
|
|
6834
|
+
const manifestPath = path.join(root, "companies", "manifest.yaml");
|
|
6835
|
+
fs.writeFileSync(manifestPath, "companies: {}\n");
|
|
6836
|
+
|
|
6837
|
+
const deps = makeDeps({
|
|
6838
|
+
createVaultClient: () =>
|
|
6839
|
+
makeVaultStub({
|
|
6840
|
+
memberships: [{ companyUid: "cmp_live" }, { companyUid: "cmp_susp" }],
|
|
6841
|
+
entityGet: (uid: string) =>
|
|
6842
|
+
Promise.resolve({
|
|
6843
|
+
uid,
|
|
6844
|
+
slug: uid === "cmp_live" ? "live" : "susp",
|
|
6845
|
+
type: "company",
|
|
6846
|
+
// The suspended company is still a perfectly syncable target.
|
|
6847
|
+
status: uid === "cmp_live" ? "active" : "suspended",
|
|
6848
|
+
bucketName: `bucket-${uid}`,
|
|
6849
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
6850
|
+
} as unknown as EntityInfo),
|
|
6851
|
+
listPersons: () => Promise.resolve([PERSON_ENTITY]),
|
|
6852
|
+
}),
|
|
6853
|
+
// The REAL reconciler, not the makeDeps stub — that stub is exactly
|
|
6854
|
+
// what let this defect hide from every other assertion here.
|
|
6855
|
+
reconcileManifest: reconcileCompanyManifest,
|
|
6856
|
+
});
|
|
6857
|
+
|
|
6858
|
+
expect(await runRunner(["--companies", "--hq-root", root], deps)).toBe(0);
|
|
6859
|
+
|
|
6860
|
+
// Both companies synced (the guard must not have leaked into sync).
|
|
6861
|
+
const syncedCompanies = (deps.sync as ReturnType<typeof vi.fn>).mock.calls
|
|
6862
|
+
.map((call) => (call[0] as SyncOptions).company)
|
|
6863
|
+
.filter(Boolean);
|
|
6864
|
+
expect(syncedCompanies).toContain("cmp_live");
|
|
6865
|
+
expect(syncedCompanies).toContain("cmp_susp");
|
|
6866
|
+
|
|
6867
|
+
// ...but only the active one earned a manifest entry.
|
|
6868
|
+
const written = yaml.load(fs.readFileSync(manifestPath, "utf8")) as {
|
|
6869
|
+
companies?: Record<string, unknown>;
|
|
6870
|
+
};
|
|
6871
|
+
expect(Object.keys(written.companies ?? {})).toEqual(["live"]);
|
|
6872
|
+
} finally {
|
|
6873
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
6874
|
+
}
|
|
6875
|
+
});
|
|
6876
|
+
|
|
6877
|
+
it("US-003: seeds activeCompany from the completed company set", async () => {
|
|
6878
|
+
const seed = vi.fn().mockReturnValue({ written: true, activeCompany: "acme" });
|
|
6879
|
+
const deps = makeDeps({
|
|
6880
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
6881
|
+
seedActiveCompany: seed,
|
|
6882
|
+
});
|
|
6883
|
+
|
|
6884
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
6885
|
+
expect(seed).toHaveBeenCalledTimes(1);
|
|
6886
|
+
expect(seed.mock.calls[0][0]).toMatchObject({ companySlugs: ["acme"] });
|
|
6887
|
+
expect(deps.stderr.raw()).toContain("active-company-seeded");
|
|
6888
|
+
});
|
|
6889
|
+
|
|
6890
|
+
it("US-003: the personal target is never offered as an activeCompany candidate", async () => {
|
|
6891
|
+
const seed = vi.fn().mockReturnValue({ written: false, skippedReason: "already-set" });
|
|
6892
|
+
const deps = makeDeps({
|
|
6893
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
6894
|
+
seedActiveCompany: seed,
|
|
6895
|
+
});
|
|
6896
|
+
|
|
6897
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
6898
|
+
expect(seed.mock.calls[0][0].companySlugs).not.toContain("personal");
|
|
6899
|
+
});
|
|
6900
|
+
|
|
6901
|
+
// The two writes are independent repairs. A joiner whose manifest write fails
|
|
6902
|
+
// should still get routed, and vice versa.
|
|
6903
|
+
it("US-003: a failing manifest reconcile does not prevent activeCompany seeding", async () => {
|
|
6904
|
+
const seed = vi.fn().mockReturnValue({ written: true, activeCompany: "acme" });
|
|
6905
|
+
const deps = makeDeps({
|
|
6906
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
6907
|
+
reconcileManifest: vi.fn().mockRejectedValue(new Error("manifest write blew up")),
|
|
6908
|
+
seedActiveCompany: seed,
|
|
6909
|
+
});
|
|
6910
|
+
|
|
6911
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
6912
|
+
expect(deps.stderr.raw()).toContain("runner.manifest_reconcile.failed");
|
|
6913
|
+
expect(seed).toHaveBeenCalledTimes(1);
|
|
6914
|
+
});
|
|
6915
|
+
|
|
6916
|
+
it("US-003: a throwing seeder still emits all-complete and exits 0", async () => {
|
|
6917
|
+
const deps = makeDeps({
|
|
6918
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
6919
|
+
seedActiveCompany: vi.fn().mockImplementation(() => {
|
|
6920
|
+
throw new Error("config write blew up");
|
|
6921
|
+
}),
|
|
6922
|
+
});
|
|
6923
|
+
|
|
6924
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
6925
|
+
expect(deps.stdout.events().some((e) => e.type === "all-complete")).toBe(true);
|
|
6926
|
+
expect(deps.stderr.raw()).toContain("runner.active_company.failed");
|
|
6927
|
+
});
|
|
6928
|
+
|
|
6929
|
+
it("US-003: a no-op seeding is silent", async () => {
|
|
6930
|
+
const deps = makeDeps({
|
|
6931
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
6932
|
+
seedActiveCompany: vi
|
|
6933
|
+
.fn()
|
|
6934
|
+
.mockReturnValue({ written: false, skippedReason: "already-set" }),
|
|
6935
|
+
});
|
|
6936
|
+
|
|
6937
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
6938
|
+
expect(deps.stderr.raw()).not.toContain("active-company-seeded");
|
|
6939
|
+
});
|
|
6940
|
+
|
|
6941
|
+
it("US-003: single-company mode does not seed activeCompany", async () => {
|
|
6942
|
+
const seed = vi.fn();
|
|
6943
|
+
const deps = makeDeps({
|
|
6944
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
6945
|
+
seedActiveCompany: seed,
|
|
6946
|
+
});
|
|
6947
|
+
|
|
6948
|
+
await runRunner(["--company", "acme"], deps);
|
|
6949
|
+
expect(seed).not.toHaveBeenCalled();
|
|
6950
|
+
});
|
|
6951
|
+
|
|
6952
|
+
it("AC6: a throwing reconciler still emits all-complete and exits 0", async () => {
|
|
6953
|
+
const deps = makeDeps({
|
|
6954
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
6955
|
+
reconcileManifest: vi
|
|
6956
|
+
.fn()
|
|
6957
|
+
.mockRejectedValue(new Error("manifest write blew up")),
|
|
6958
|
+
});
|
|
6959
|
+
|
|
6960
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
6961
|
+
expect(deps.stdout.events().some((e) => e.type === "all-complete")).toBe(true);
|
|
6962
|
+
expect(deps.stderr.raw()).toContain("runner.manifest_reconcile.failed");
|
|
6963
|
+
});
|
|
6964
|
+
|
|
6965
|
+
it("AC7: a write emits a manifest-reconciled diagnostic naming the slugs", async () => {
|
|
6966
|
+
const deps = makeDeps({
|
|
6967
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
6968
|
+
reconcileManifest: vi.fn().mockResolvedValue({
|
|
6969
|
+
written: true,
|
|
6970
|
+
added: ["acme"],
|
|
6971
|
+
updated: ["beta"],
|
|
6972
|
+
skipped: [],
|
|
6973
|
+
}),
|
|
6974
|
+
});
|
|
6975
|
+
|
|
6976
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
6977
|
+
|
|
6978
|
+
const diagnostics = manifestDiagnostics(deps.stderr);
|
|
6979
|
+
expect(diagnostics).toHaveLength(1);
|
|
6980
|
+
expect(diagnostics[0]).toMatchObject({
|
|
6981
|
+
component: "manifest-reconcile",
|
|
6982
|
+
event: "manifest-reconciled",
|
|
6983
|
+
added: ["acme"],
|
|
6984
|
+
updated: ["beta"],
|
|
6985
|
+
});
|
|
6986
|
+
});
|
|
6987
|
+
|
|
6988
|
+
it("AC7: a no-op reconciliation is completely silent (steady-state sync)", async () => {
|
|
6989
|
+
const deps = makeDeps({
|
|
6990
|
+
createVaultClient: () => companiesClient(["cmp_acme"]),
|
|
6991
|
+
reconcileManifest: vi.fn().mockResolvedValue({
|
|
6992
|
+
written: false,
|
|
6993
|
+
added: [],
|
|
6994
|
+
updated: [],
|
|
6995
|
+
skipped: ["weird-slug"],
|
|
6996
|
+
}),
|
|
6997
|
+
});
|
|
6998
|
+
|
|
6999
|
+
expect(await runRunner(["--companies"], deps)).toBe(0);
|
|
7000
|
+
expect(manifestDiagnostics(deps.stderr)).toEqual([]);
|
|
7001
|
+
expect(deps.stderr.raw()).not.toContain("manifest-reconciled");
|
|
7002
|
+
});
|
|
7003
|
+
});
|