@indigoai-us/hq-cli 5.185.0 → 5.185.1
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/CHANGELOG.md +10 -0
- package/dist/commands/lanes.d.ts +1 -1
- package/dist/commands/lanes.js +85 -20
- package/dist/lib/lanes/registry-flag.d.ts +27 -0
- package/dist/lib/lanes/registry-flag.js +129 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.185.1] — 2026-09-25
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `lanes.hire-grants` resolves its company-scoped hq-flags value before gating.
|
|
10
|
+
A valid local override wins; missing rows or failed reads leave enforcement
|
|
11
|
+
off. Backfills check the unscoped value and each target company's value
|
|
12
|
+
before writing. `hire_grant_exhausted` names the grantor and includes the
|
|
13
|
+
same recovery message in JSON and on stderr.
|
|
14
|
+
|
|
5
15
|
## [5.185.0] — 2026-09-25
|
|
6
16
|
|
|
7
17
|
### Fixed
|
package/dist/commands/lanes.d.ts
CHANGED
|
@@ -79,7 +79,7 @@ export interface LanesCommandDeps {
|
|
|
79
79
|
/** Test seam for the default-off outpost placement rollout flag. */
|
|
80
80
|
lanePlacementEnabled?: () => boolean;
|
|
81
81
|
/** Test seam for the default-off lane hire-grants rollout gate. */
|
|
82
|
-
laneHireGrantsEnabled?: () => boolean
|
|
82
|
+
laneHireGrantsEnabled?: (company?: LaneCompanyRef | null) => boolean | Promise<boolean>;
|
|
83
83
|
}
|
|
84
84
|
export declare const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
|
|
85
85
|
export declare function parseSeniorRef(raw: string): SeniorRef;
|
package/dist/commands/lanes.js
CHANGED
|
@@ -13,6 +13,7 @@ import * as path from "node:path";
|
|
|
13
13
|
import { registerOutpostPrunerCommand } from "./outpost-pruner.js";
|
|
14
14
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
15
15
|
import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
|
|
16
|
+
import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
|
|
16
17
|
import { resolveProcessFlagGate } from "../lib/flag-registry.js";
|
|
17
18
|
import { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity, resolveAdmissionHost, } from "../lib/lanes/admission-host.js";
|
|
18
19
|
export { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity } from "../lib/lanes/admission-host.js";
|
|
@@ -52,8 +53,8 @@ export const LANE_OUTPOST_LOAD_BALANCING_FLAG = "lanes.outpost-load-balancing";
|
|
|
52
53
|
function defaultLanePlacementEnabled() {
|
|
53
54
|
return resolveProcessFlagGate(LANE_OUTPOST_LOAD_BALANCING_FLAG, { fallback: false }, () => false);
|
|
54
55
|
}
|
|
55
|
-
function defaultLaneHireGrantsEnabled() {
|
|
56
|
-
return
|
|
56
|
+
function defaultLaneHireGrantsEnabled(company) {
|
|
57
|
+
return readLanesRegistryFlag(LANE_HIRE_GRANTS_FLAG, { company });
|
|
57
58
|
}
|
|
58
59
|
function withoutProviderHome(value) {
|
|
59
60
|
if (Array.isArray(value))
|
|
@@ -73,6 +74,9 @@ function printJson(value) {
|
|
|
73
74
|
}
|
|
74
75
|
function failJson(err) {
|
|
75
76
|
if (isLanesError(err)) {
|
|
77
|
+
if (err.error === "hire_grant_exhausted") {
|
|
78
|
+
process.stderr.write(`hq lanes: ${err.message}\n`);
|
|
79
|
+
}
|
|
76
80
|
printJson(err.toJSON());
|
|
77
81
|
process.exit(1);
|
|
78
82
|
}
|
|
@@ -515,12 +519,14 @@ const OUTPOST_HIRE_SUMMARY_WAIT_MS = 15_000;
|
|
|
515
519
|
const OUTPOST_HIRE_SUMMARY_CONCURRENCY = 4;
|
|
516
520
|
async function laneHirePresentation(ctx, lane, lanes) {
|
|
517
521
|
const local = laneHireFields(lane, lanes);
|
|
518
|
-
if (
|
|
519
|
-
lane.host.kind !== "outpost" ||
|
|
522
|
+
if (lane.host.kind !== "outpost" ||
|
|
520
523
|
isTerminalLaneState(lane.state) ||
|
|
521
524
|
laneRunsHere(lane, reconcileHostFromCtx(ctx))) {
|
|
522
525
|
return { ...local, hire_source: "local" };
|
|
523
526
|
}
|
|
527
|
+
if (!await ctx.laneHireGrantsEnabled(lane.company)) {
|
|
528
|
+
return { ...local, hire_source: "local" };
|
|
529
|
+
}
|
|
524
530
|
try {
|
|
525
531
|
return { ...(await ownerLaneHireFields(ctx, lane)), hire_source: "owner" };
|
|
526
532
|
}
|
|
@@ -548,14 +554,15 @@ async function mapWithConcurrency(values, concurrency, map) {
|
|
|
548
554
|
await Promise.all(workers);
|
|
549
555
|
return results;
|
|
550
556
|
}
|
|
551
|
-
function withResumeHireAdmission(ctx, lane, fn) {
|
|
557
|
+
async function withResumeHireAdmission(ctx, lane, fn) {
|
|
552
558
|
const hqRoot = ctx.hqRoot();
|
|
553
|
-
if (!
|
|
554
|
-
!isTerminalLaneState(lane.state) ||
|
|
559
|
+
if (!isTerminalLaneState(lane.state) ||
|
|
555
560
|
lane.senior.kind !== "lane" ||
|
|
556
561
|
!lane.senior.id) {
|
|
557
562
|
return fn();
|
|
558
563
|
}
|
|
564
|
+
if (!await ctx.laneHireGrantsEnabled(lane.company))
|
|
565
|
+
return fn();
|
|
559
566
|
return withHireGrantAdmission(hqRoot, lane.senior.id, ({ parent, children, parentGrant, availableChildGrant }) => {
|
|
560
567
|
const current = readLane(hqRoot, lane.lane_id);
|
|
561
568
|
// A different resume already reactivated this lane; it occupies the slot
|
|
@@ -588,11 +595,33 @@ function withResumeHireAdmission(ctx, lane, fn) {
|
|
|
588
595
|
}
|
|
589
596
|
function hireGrantCapacityError(parent, children) {
|
|
590
597
|
const grant = parent.hire_grant ?? 0;
|
|
591
|
-
|
|
598
|
+
const grantor = (parent.senior.kind === "lane" || parent.senior.kind === "session") &&
|
|
599
|
+
parent.senior.id
|
|
600
|
+
? { kind: parent.senior.kind, id: parent.senior.id }
|
|
601
|
+
: null;
|
|
602
|
+
const grantorReason = grantor
|
|
603
|
+
? undefined
|
|
604
|
+
: parent.senior.kind === "user"
|
|
605
|
+
? "senior_is_user"
|
|
606
|
+
: "parent_senior_missing_id";
|
|
607
|
+
const capacity = children.length === grant
|
|
608
|
+
? `is at its hire grant (${children.length} of ${grant} alive children)`
|
|
609
|
+
: `has exhausted its hire grant (${children.length} alive children; grant ${grant})`;
|
|
610
|
+
const grantCommand = `hq lanes grant ${parent.lane_id} --hire <n>`;
|
|
611
|
+
const authorityGuidance = grantor
|
|
612
|
+
? `Its senior ${grantor.kind} ${grantor.id} can raise it with: ${grantCommand}`
|
|
613
|
+
: parent.senior.kind === "user"
|
|
614
|
+
? `Its senior is the user and can raise it with: ${grantCommand}`
|
|
615
|
+
: `No senior is recorded, so an operator must resolve ownership before running: ${grantCommand}`;
|
|
616
|
+
const message = `Lane ${parent.lane_id} ${capacity}. ${authorityGuidance}`;
|
|
617
|
+
return new LanesError("hire_grant_exhausted", message, {
|
|
592
618
|
senior_lane_id: parent.lane_id,
|
|
593
619
|
parent_grant: grant,
|
|
594
620
|
alive_children: children.map((child) => child.lane_id),
|
|
595
621
|
alive_child_count: children.length,
|
|
622
|
+
grantor,
|
|
623
|
+
...(grantorReason ? { grantor_reason: grantorReason } : {}),
|
|
624
|
+
message,
|
|
596
625
|
ask_guidance: {
|
|
597
626
|
envelope: "decision=ask; include the desired total grant and the reason",
|
|
598
627
|
senior_command: `hq lanes grant ${parent.lane_id} --hire <n>`,
|
|
@@ -1858,7 +1887,12 @@ async function runCreate(ctx, opts) {
|
|
|
1858
1887
|
}
|
|
1859
1888
|
const senior = opts.senior ? parseSeniorRef(opts.senior) : defaultSenior(ctx);
|
|
1860
1889
|
assertSeniorExists(hqRoot, senior, ctx);
|
|
1861
|
-
|
|
1890
|
+
// Resolve tenant context before the registry read so the flag evaluator
|
|
1891
|
+
// applies the same company scope as this lane.
|
|
1892
|
+
const company = companyFlag
|
|
1893
|
+
? await ctx.resolveCompany(companyFlag)
|
|
1894
|
+
: null;
|
|
1895
|
+
const hireGrantsEnabled = await ctx.laneHireGrantsEnabled(company);
|
|
1862
1896
|
if (requestedHireGrant !== undefined &&
|
|
1863
1897
|
!callerMatchesSenior(ctx, senior)) {
|
|
1864
1898
|
throw new LanesError("hire_grant_unauthorized", `Only senior ${seniorKey(senior)} may set a lane's hire grant`, { senior, hire_grant: requestedHireGrant });
|
|
@@ -1874,9 +1908,6 @@ async function runCreate(ctx, opts) {
|
|
|
1874
1908
|
throw new LanesError("max_lane_depth", `Lane depth ${laneDepth} exceeds cap ${MAX_LANE_DEPTH}`, { depth: laneDepth, max: MAX_LANE_DEPTH });
|
|
1875
1909
|
}
|
|
1876
1910
|
// A no-company lane must not call company resolution (openMeshTransport).
|
|
1877
|
-
const company = companyFlag
|
|
1878
|
-
? await ctx.resolveCompany(companyFlag)
|
|
1879
|
-
: null;
|
|
1880
1911
|
let meshCompany = company;
|
|
1881
1912
|
if (meshCompanyFlag) {
|
|
1882
1913
|
const resolvedMeshCompany = await ctx.resolveCompany(meshCompanyFlag);
|
|
@@ -3024,6 +3055,17 @@ async function runList(ctx, opts) {
|
|
|
3024
3055
|
return true;
|
|
3025
3056
|
})
|
|
3026
3057
|
.sort(compareLaneQueueOrder);
|
|
3058
|
+
const companyContexts = new Map();
|
|
3059
|
+
for (const lane of selectedLanes) {
|
|
3060
|
+
if (lane.host.kind !== "outpost" ||
|
|
3061
|
+
isTerminalLaneState(lane.state) ||
|
|
3062
|
+
laneRunsHere(lane, host)) {
|
|
3063
|
+
continue;
|
|
3064
|
+
}
|
|
3065
|
+
const company = lane.company;
|
|
3066
|
+
companyContexts.set(JSON.stringify([company?.uid ?? null, company?.slug ?? null]), company);
|
|
3067
|
+
}
|
|
3068
|
+
await Promise.all([...companyContexts.values()].map((company) => ctx.laneHireGrantsEnabled(company)));
|
|
3027
3069
|
const hireByLane = new Map(await mapWithConcurrency(selectedLanes, OUTPOST_HIRE_SUMMARY_CONCURRENCY, async (lane) => [
|
|
3028
3070
|
lane.lane_id,
|
|
3029
3071
|
await laneHirePresentation(ctx, lane, allLanes),
|
|
@@ -3545,6 +3587,7 @@ async function runGrant(ctx, laneId, opts) {
|
|
|
3545
3587
|
await dispatchOutpostLaneCommand(ctx, initial, "grant", { hire: String(hireGrant) });
|
|
3546
3588
|
return;
|
|
3547
3589
|
}
|
|
3590
|
+
const enforce = await ctx.laneHireGrantsEnabled(initial.company);
|
|
3548
3591
|
const lockedLaneIds = [
|
|
3549
3592
|
laneId,
|
|
3550
3593
|
...(initial.senior.kind === "lane" && initial.senior.id
|
|
@@ -3557,7 +3600,6 @@ async function runGrant(ctx, laneId, opts) {
|
|
|
3557
3600
|
throw new LanesError("hire_grant_unauthorized", `Only senior ${seniorKey(current.senior)} may change lane ${laneId}'s hire grant`, { lane_id: laneId, senior: current.senior });
|
|
3558
3601
|
}
|
|
3559
3602
|
const hierarchy = loadAllLanes(hqRoot);
|
|
3560
|
-
const enforce = ctx.laneHireGrantsEnabled();
|
|
3561
3603
|
if (enforce && current.senior.kind === "lane") {
|
|
3562
3604
|
if (!current.senior.id) {
|
|
3563
3605
|
throw new LanesError("hire_grant_senior_invalid", `Lane ${laneId} has a lane senior without an id`, {
|
|
@@ -3611,7 +3653,7 @@ async function runBackfillGrants(ctx, opts) {
|
|
|
3611
3653
|
}
|
|
3612
3654
|
const apply = Boolean(opts.apply);
|
|
3613
3655
|
const hqRoot = ctx.hqRoot();
|
|
3614
|
-
if (apply && ctx.laneHireGrantsEnabled()) {
|
|
3656
|
+
if (apply && await ctx.laneHireGrantsEnabled(null)) {
|
|
3615
3657
|
throw new LanesError("hire_grant_backfill_gate_enabled", "Backfill must be applied and verified before lanes.hire-grants is enabled", { flag: LANE_HIRE_GRANTS_FLAG });
|
|
3616
3658
|
}
|
|
3617
3659
|
if (apply && ctx.env.HQ_LANE_ID?.trim()) {
|
|
@@ -3642,6 +3684,24 @@ async function runBackfillGrants(ctx, opts) {
|
|
|
3642
3684
|
}
|
|
3643
3685
|
let rows = planHireGrantBackfill(allLanes, profileMaxConcurrent);
|
|
3644
3686
|
if (apply) {
|
|
3687
|
+
const lanesById = new Map(allLanes.map((lane) => [lane.lane_id, lane]));
|
|
3688
|
+
const targetCompanies = new Map();
|
|
3689
|
+
for (const row of rows) {
|
|
3690
|
+
if (row.action === "unchanged")
|
|
3691
|
+
continue;
|
|
3692
|
+
const lane = lanesById.get(row.lane_id);
|
|
3693
|
+
if (!lane) {
|
|
3694
|
+
throw new LanesError("hire_grant_backfill_target_changed", `Backfill target lane ${row.lane_id} is unavailable`, { lane_id: row.lane_id });
|
|
3695
|
+
}
|
|
3696
|
+
const company = lane.company;
|
|
3697
|
+
if (!company)
|
|
3698
|
+
continue;
|
|
3699
|
+
targetCompanies.set(JSON.stringify([company.uid ?? null, company.slug ?? null]), company);
|
|
3700
|
+
}
|
|
3701
|
+
const companyGates = await Promise.all([...targetCompanies.values()].map((company) => ctx.laneHireGrantsEnabled(company)));
|
|
3702
|
+
if (companyGates.some(Boolean)) {
|
|
3703
|
+
throw new LanesError("hire_grant_backfill_gate_enabled", "Backfill must be applied and verified before lanes.hire-grants is enabled", { flag: LANE_HIRE_GRANTS_FLAG });
|
|
3704
|
+
}
|
|
3645
3705
|
const shortfalls = rows.filter((row) => row.grant_shortfall > 0);
|
|
3646
3706
|
if (shortfalls.length > 0) {
|
|
3647
3707
|
throw new LanesError("hire_grant_backfill_allocation_shortfall", "Existing parent grants do not cover the grants allocated to alive children; resolve these rows before applying the backfill", { lanes: shortfalls });
|
|
@@ -4009,7 +4069,7 @@ async function prepareAccountMove(ctx, lane, input) {
|
|
|
4009
4069
|
if (admission.action === "refuse" && admission.error)
|
|
4010
4070
|
throw admission.error;
|
|
4011
4071
|
const queued = admission.action === "queue";
|
|
4012
|
-
const moved = withResumeHireAdmission(ctx, lane, () => casUpdateLane(hqRoot, laneId, (current) => {
|
|
4072
|
+
const moved = await withResumeHireAdmission(ctx, lane, () => casUpdateLane(hqRoot, laneId, (current) => {
|
|
4013
4073
|
// The copy and every check above were made from `lane`; a record
|
|
4014
4074
|
// that moved on since (another move, a spawn, a cancel) invalidates
|
|
4015
4075
|
// them, so the write is refused rather than applied to a state it
|
|
@@ -4061,7 +4121,7 @@ async function runMessage(ctx, laneId, opts) {
|
|
|
4061
4121
|
assertLaneRunsHereForOperatorMutation(ctx, lane, "message");
|
|
4062
4122
|
// Check before transcript recovery, account probing, or message writes.
|
|
4063
4123
|
// The final state transition repeats this check under the senior lock.
|
|
4064
|
-
withResumeHireAdmission(ctx, lane, () => undefined);
|
|
4124
|
+
await withResumeHireAdmission(ctx, lane, () => undefined);
|
|
4065
4125
|
lane = laneWithRecoveredProviderSession(hqRoot, lane);
|
|
4066
4126
|
// An account move is refused on its own terms first: a running lane and a
|
|
4067
4127
|
// provider without session resume get the move's named errors, not the
|
|
@@ -4341,7 +4401,7 @@ async function runResume(ctx, laneId, opts) {
|
|
|
4341
4401
|
}
|
|
4342
4402
|
// Fail before provider/session work if a terminal child would consume a
|
|
4343
4403
|
// slot that its senior has already assigned elsewhere.
|
|
4344
|
-
withResumeHireAdmission(ctx, lane, () => undefined);
|
|
4404
|
+
await withResumeHireAdmission(ctx, lane, () => undefined);
|
|
4345
4405
|
assertLaneRunsHereForOperatorMutation(ctx, lane, "resume");
|
|
4346
4406
|
lane = laneWithRecoveredProviderSession(hqRoot, lane);
|
|
4347
4407
|
if (opts.model !== undefined && opts.account === undefined) {
|
|
@@ -4913,14 +4973,19 @@ async function runReparent(ctx, laneId, opts) {
|
|
|
4913
4973
|
const nextSenior = parseSeniorRef(opts.senior);
|
|
4914
4974
|
assertSeniorExists(hqRoot, nextSenior, ctx);
|
|
4915
4975
|
const initial = readLane(hqRoot, laneId);
|
|
4976
|
+
const needsHireGrantGate = nextSenior.kind === "lane" &&
|
|
4977
|
+
seniorKey(initial.senior) !== seniorKey(nextSenior);
|
|
4978
|
+
const hireGrantsEnabled = needsHireGrantGate
|
|
4979
|
+
? await ctx.laneHireGrantsEnabled(initial.company)
|
|
4980
|
+
: false;
|
|
4916
4981
|
const lockIds = [
|
|
4917
4982
|
laneId,
|
|
4918
4983
|
...(initial.senior.kind === "lane" && initial.senior.id ? [initial.senior.id] : []),
|
|
4919
4984
|
...(nextSenior.kind === "lane" && nextSenior.id ? [nextSenior.id] : []),
|
|
4920
4985
|
];
|
|
4921
|
-
withLaneHireLocks(hqRoot, lockIds, () => runReparentLocked(ctx, laneId, opts));
|
|
4986
|
+
withLaneHireLocks(hqRoot, lockIds, () => runReparentLocked(ctx, laneId, opts, hireGrantsEnabled));
|
|
4922
4987
|
}
|
|
4923
|
-
function runReparentLocked(ctx, laneId, opts) {
|
|
4988
|
+
function runReparentLocked(ctx, laneId, opts, hireGrantsEnabled) {
|
|
4924
4989
|
const hqRoot = ctx.hqRoot();
|
|
4925
4990
|
if (!opts.senior) {
|
|
4926
4991
|
throw new LanesError("missing_senior", "--senior is required", {});
|
|
@@ -4934,7 +4999,7 @@ function runReparentLocked(ctx, laneId, opts) {
|
|
|
4934
4999
|
}
|
|
4935
5000
|
const seniorCompany = companyOfSenior(hqRoot, nextSenior, ctx);
|
|
4936
5001
|
assertSameTenant(lane.company, nextSenior, seniorCompany, { lane_id: laneId });
|
|
4937
|
-
if (
|
|
5002
|
+
if (hireGrantsEnabled &&
|
|
4938
5003
|
nextSenior.kind === "lane" &&
|
|
4939
5004
|
seniorKey(lane.senior) !== seniorKey(nextSenior)) {
|
|
4940
5005
|
const parent = readLane(hqRoot, nextSenior.id);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type FlagClientOptions, type FlagResolution } from "@indigoai-us/hq-flags-client";
|
|
2
|
+
import type { LaneCompanyRef } from "./types.js";
|
|
3
|
+
export declare const LANES_FLAG_API_DEFAULT_ENDPOINT = "https://hqapi.hq.computer";
|
|
4
|
+
export declare const LANES_FLAG_READ_TIMEOUT_MS = 5000;
|
|
5
|
+
export interface LanesFlagClient {
|
|
6
|
+
ready(): Promise<void>;
|
|
7
|
+
explain(flagKey: string, options?: {
|
|
8
|
+
fallback?: boolean;
|
|
9
|
+
}): FlagResolution;
|
|
10
|
+
close(): void;
|
|
11
|
+
}
|
|
12
|
+
export type LanesFlagClientFactory = (options: FlagClientOptions) => LanesFlagClient;
|
|
13
|
+
export interface ReadLanesFlagOptions {
|
|
14
|
+
company?: LaneCompanyRef | null;
|
|
15
|
+
env?: NodeJS.ProcessEnv;
|
|
16
|
+
createClient?: LanesFlagClientFactory;
|
|
17
|
+
getToken?: () => string | Promise<string>;
|
|
18
|
+
reportFailure?: (error: unknown) => void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Read a lanes flag once for each company identity in this CLI process. The
|
|
22
|
+
* cache contains only an in-flight or settled boolean promise; it is never
|
|
23
|
+
* written to disk. A local override is checked on every call and wins first.
|
|
24
|
+
*/
|
|
25
|
+
export declare function readLanesRegistryFlag(flagKey: string, options?: ReadLanesFlagOptions): Promise<boolean>;
|
|
26
|
+
export declare function _resetLanesRegistryFlagCacheForTests(): void;
|
|
27
|
+
//# sourceMappingURL=registry-flag.d.ts.map
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createFlagClient, readLocalOverride, } from "@indigoai-us/hq-flags-client";
|
|
2
|
+
import { ensureCognitoIdToken } from "../../utils/cognito-session.js";
|
|
3
|
+
export const LANES_FLAG_API_DEFAULT_ENDPOINT = "https://hqapi.hq.computer";
|
|
4
|
+
export const LANES_FLAG_READ_TIMEOUT_MS = 5_000;
|
|
5
|
+
const registryReads = new Map();
|
|
6
|
+
class MissingFlagTokenError extends Error {
|
|
7
|
+
constructor() {
|
|
8
|
+
super("No cached HQ identity token is available");
|
|
9
|
+
this.name = "MissingFlagTokenError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
class MissingFlagRowError extends Error {
|
|
13
|
+
constructor(flagKey) {
|
|
14
|
+
super(`The hq-flags registry did not resolve ${flagKey}`);
|
|
15
|
+
this.name = "MissingFlagRowError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function errorClass(error) {
|
|
19
|
+
return error instanceof Error ? error.name : "UnknownError";
|
|
20
|
+
}
|
|
21
|
+
function reportReadFailure(flagKey, error) {
|
|
22
|
+
const suffix = flagKey.replace(/^lanes\./, "").replaceAll(/[^a-zA-Z0-9]+/g, "_");
|
|
23
|
+
process.stderr.write(`${JSON.stringify({
|
|
24
|
+
event: `lanes.${suffix}.flag_read_failed`,
|
|
25
|
+
flag: flagKey,
|
|
26
|
+
error: errorClass(error),
|
|
27
|
+
fallback: false,
|
|
28
|
+
})}\n`);
|
|
29
|
+
}
|
|
30
|
+
function cacheKey(flagKey, company, endpoint) {
|
|
31
|
+
return JSON.stringify([
|
|
32
|
+
flagKey,
|
|
33
|
+
endpoint,
|
|
34
|
+
company?.uid ?? null,
|
|
35
|
+
company?.slug ?? null,
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Read a lanes flag once for each company identity in this CLI process. The
|
|
40
|
+
* cache contains only an in-flight or settled boolean promise; it is never
|
|
41
|
+
* written to disk. A local override is checked on every call and wins first.
|
|
42
|
+
*/
|
|
43
|
+
export async function readLanesRegistryFlag(flagKey, options = {}) {
|
|
44
|
+
const env = options.env ?? process.env;
|
|
45
|
+
const localOverride = readLocalOverride(flagKey, env);
|
|
46
|
+
if (localOverride !== null)
|
|
47
|
+
return localOverride;
|
|
48
|
+
const endpoint = env.HQ_FLAGS_API_URL?.trim() || LANES_FLAG_API_DEFAULT_ENDPOINT;
|
|
49
|
+
const key = cacheKey(flagKey, options.company, endpoint);
|
|
50
|
+
const cached = registryReads.get(key);
|
|
51
|
+
if (cached)
|
|
52
|
+
return cached;
|
|
53
|
+
const pending = readRegistryValue(flagKey, endpoint, options);
|
|
54
|
+
registryReads.set(key, pending);
|
|
55
|
+
return pending;
|
|
56
|
+
}
|
|
57
|
+
async function readRegistryValue(flagKey, endpoint, options) {
|
|
58
|
+
const company = options.company;
|
|
59
|
+
const companyUid = company?.uid?.trim();
|
|
60
|
+
const companySlug = company?.slug?.trim();
|
|
61
|
+
let client;
|
|
62
|
+
let enabled = false;
|
|
63
|
+
let failed = false;
|
|
64
|
+
let reported = false;
|
|
65
|
+
const reportFailure = (error) => {
|
|
66
|
+
failed = true;
|
|
67
|
+
if (reported)
|
|
68
|
+
return;
|
|
69
|
+
reported = true;
|
|
70
|
+
(options.reportFailure ?? ((cause) => reportReadFailure(flagKey, cause)))(error);
|
|
71
|
+
};
|
|
72
|
+
try {
|
|
73
|
+
const acquireToken = options.getToken ?? (async () => {
|
|
74
|
+
try {
|
|
75
|
+
return await ensureCognitoIdToken({ interactive: false });
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error instanceof Error &&
|
|
79
|
+
error.message.startsWith("No valid HQ session")) {
|
|
80
|
+
throw new MissingFlagTokenError();
|
|
81
|
+
}
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
const getToken = async () => {
|
|
86
|
+
const token = await acquireToken();
|
|
87
|
+
if (typeof token !== "string" || token.trim().length === 0) {
|
|
88
|
+
throw new MissingFlagTokenError();
|
|
89
|
+
}
|
|
90
|
+
return token;
|
|
91
|
+
};
|
|
92
|
+
client = (options.createClient ?? createFlagClient)({
|
|
93
|
+
endpoint,
|
|
94
|
+
getToken,
|
|
95
|
+
env: {},
|
|
96
|
+
refreshIntervalMs: 0,
|
|
97
|
+
requestTimeoutMs: LANES_FLAG_READ_TIMEOUT_MS,
|
|
98
|
+
...(companyUid ? { companyUid } : {}),
|
|
99
|
+
...(companySlug ? { companyIdentifiers: [companySlug] } : {}),
|
|
100
|
+
onError: reportFailure,
|
|
101
|
+
});
|
|
102
|
+
await client.ready();
|
|
103
|
+
const resolution = client.explain(flagKey, { fallback: false });
|
|
104
|
+
if (failed)
|
|
105
|
+
return false;
|
|
106
|
+
if (resolution.source !== "snapshot" && resolution.source !== "local-override") {
|
|
107
|
+
reportFailure(new MissingFlagRowError(flagKey));
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
enabled = resolution.value;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
reportFailure(error);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
try {
|
|
118
|
+
client?.close();
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
reportFailure(error);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return failed ? false : enabled;
|
|
125
|
+
}
|
|
126
|
+
export function _resetLanesRegistryFlagCacheForTests() {
|
|
127
|
+
registryReads.clear();
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=registry-flag.js.map
|