@indigoai-us/hq-cli 5.274.1 → 5.276.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +88 -4
- package/README.md +12 -0
- package/assets/bot-workers/setup/context/USER-GUIDE.md +4 -4
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +74 -18
- package/dist/command-catalog.generated.d.ts +34 -1
- package/dist/command-catalog.generated.js +43 -1
- package/dist/commands/cloud.js +22 -9
- package/dist/commands/company.js +66 -3
- package/dist/commands/core-checkpoint-gate-setting.d.ts +69 -0
- package/dist/commands/core-checkpoint-gate-setting.js +163 -0
- package/dist/commands/core-checkpoint.d.ts +4 -2
- package/dist/commands/core-checkpoint.js +34 -3
- package/dist/commands/core-command-catalog.d.ts +4 -2
- package/dist/commands/core-command-catalog.js +5 -2
- package/dist/commands/core-ontology.js +3 -3
- package/dist/commands/core.js +3 -1
- package/dist/commands/daemon.d.ts +1 -0
- package/dist/commands/daemon.js +9 -3
- package/dist/commands/feedback.d.ts +6 -0
- package/dist/commands/feedback.js +86 -37
- package/dist/commands/lanes.js +33 -11
- package/dist/commands/run-feature-telemetry.d.ts +3 -0
- package/dist/commands/run-feature-telemetry.js +6 -0
- package/dist/commands/run.js +3 -2
- package/dist/commands/secrets.js +7 -1
- package/dist/commands/whoami.d.ts +4 -6
- package/dist/commands/whoami.js +39 -8
- package/dist/fast-core.d.ts +6 -7
- package/dist/fast-core.js +22 -8
- package/dist/fast-scaffold.d.ts +16 -7
- package/dist/fast-scaffold.js +13 -8
- package/dist/index.js +16 -1
- package/dist/lib/billing/plan-lock.d.ts +3 -1
- package/dist/lib/billing/plan-lock.js +7 -1
- package/dist/lib/checkpoint-ontology/company.d.ts +8 -4
- package/dist/lib/checkpoint-ontology/company.js +15 -7
- package/dist/lib/checkpoint-ontology/plan.d.ts +23 -0
- package/dist/lib/checkpoint-ontology/plan.js +53 -4
- package/dist/lib/checkpoint-ontology/queued-touches.d.ts +32 -0
- package/dist/lib/checkpoint-ontology/queued-touches.js +193 -0
- package/dist/lib/core-utils/timeout-guard-native.d.ts +7 -0
- package/dist/lib/core-utils/timeout-guard-native.js +40 -0
- package/dist/lib/core-utils/timeout-guard.d.ts +3 -3
- package/dist/lib/core-utils/timeout-guard.js +28 -13
- package/dist/lib/daemon/catalog.d.ts +7 -0
- package/dist/lib/daemon/catalog.js +6 -0
- package/dist/lib/daemon/flag.d.ts +23 -0
- package/dist/lib/daemon/flag.js +57 -0
- package/dist/lib/daemon/install.js +6 -1
- package/dist/lib/daemon/remote-control.d.ts +6 -0
- package/dist/lib/daemon/remote-control.js +12 -0
- package/dist/lib/daemon/run.js +115 -2
- package/dist/lib/daemon/succession.d.ts +116 -0
- package/dist/lib/daemon/succession.js +262 -0
- package/dist/lib/daemon/supervisor.d.ts +34 -1
- package/dist/lib/daemon/supervisor.js +190 -23
- package/dist/lib/flag-registry.d.ts +12 -7
- package/dist/lib/flag-registry.js +66 -75
- package/dist/lib/flag-snapshot-file.d.ts +20 -0
- package/dist/lib/flag-snapshot-file.js +91 -0
- package/dist/lib/lanes/host-capacity.js +7 -1
- package/dist/lib/lanes/models.d.ts +2 -0
- package/dist/lib/lanes/models.js +6 -1
- package/dist/lib/lanes/reconcile.js +20 -4
- package/dist/lib/process-flag-company-context.d.ts +8 -0
- package/dist/lib/process-flag-company-context.js +67 -0
- package/dist/main.js +3 -1
- package/dist/native-fast-route.d.ts +3 -0
- package/dist/native-fast-route.js +39 -0
- package/dist/utils/cli-telemetry.d.ts +26 -2
- package/dist/utils/cli-telemetry.js +178 -3
- package/dist/utils/hq-root-resolver.d.ts +13 -0
- package/dist/utils/hq-root-resolver.js +103 -0
- package/dist/utils/scaffold-fast-import-guard.js +6 -1
- package/dist/utils/vault-api.d.ts +8 -0
- package/dist/utils/vault-api.js +13 -1
- package/package.json +1 -1
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
* - restart/trigger/stop requests from the CLI are polled and applied; a
|
|
14
14
|
* request for a unit that is not planned yet re-reads the plan first;
|
|
15
15
|
* - stop() sends SIGTERM to every child, escalates to SIGKILL after
|
|
16
|
-
* STOP_ESCALATION_MS, and resolves once every child has exited
|
|
16
|
+
* STOP_ESCALATION_MS, and resolves once every child has exited;
|
|
17
|
+
* - release() instead leaves every child running and reports the long-running
|
|
18
|
+
* ones, so the next daemon can adopt them (`adopt`). Adopted units whose
|
|
19
|
+
* command is unchanged keep running; changed ones are replaced one at a
|
|
20
|
+
* time, draining first when the unit has a drain signal.
|
|
17
21
|
*
|
|
18
22
|
* Processes, time, and state output are injected; `run.ts` wires the real ones.
|
|
19
23
|
*/
|
|
@@ -21,11 +25,24 @@ export const BACKOFF_BASE_MS = 1_000;
|
|
|
21
25
|
export const BACKOFF_MAX_MS = 60_000;
|
|
22
26
|
export const HEALTHY_RUN_MS = 60_000;
|
|
23
27
|
export const STOP_ESCALATION_MS = 10_000;
|
|
28
|
+
/** After a handoff, how long a replaced unit must stay up before the next one is replaced. */
|
|
29
|
+
export const HANDOFF_STAGGER_MS = 15_000;
|
|
30
|
+
/** How long a draining unit may take to finish its work before it gets SIGTERM. */
|
|
31
|
+
export const HANDOFF_DRAIN_MAX_MS = 15 * 60_000;
|
|
24
32
|
export function backoffMs(attempt) {
|
|
25
33
|
return Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.max(0, attempt));
|
|
26
34
|
}
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
/**
|
|
36
|
+
* What a unit's process was started from; a unit whose key changes is
|
|
37
|
+
* restarted. The CLI version counts only for `hq` commands, so a CLI update
|
|
38
|
+
* leaves hq-cloud runners, other programs, and units marked
|
|
39
|
+
* `keepAcrossCliUpdates` running.
|
|
40
|
+
*/
|
|
41
|
+
export function planKey(plan, cliVersion) {
|
|
42
|
+
const fields = [plan.kind, plan.restart, plan.intervalSec, plan.initialDelaySec, plan.command, plan.env];
|
|
43
|
+
if (cliVersion !== undefined && plan.command?.type === "hq" && !plan.keepAcrossCliUpdates)
|
|
44
|
+
fields.push(cliVersion);
|
|
45
|
+
return JSON.stringify(fields);
|
|
29
46
|
}
|
|
30
47
|
function errorMessage(err) {
|
|
31
48
|
return err instanceof Error ? err.message : String(err);
|
|
@@ -42,6 +59,10 @@ export function createSupervisor(deps) {
|
|
|
42
59
|
let started = false;
|
|
43
60
|
let reconcileTimer;
|
|
44
61
|
let requestTimer;
|
|
62
|
+
/** Adopted units whose command changed, replaced one at a time. */
|
|
63
|
+
const handoffQueue = [];
|
|
64
|
+
let handoffActive = false;
|
|
65
|
+
let handoffTimer;
|
|
45
66
|
/** Every child process not yet known to have exited, including ones the slots have let go of. */
|
|
46
67
|
const live = new Set();
|
|
47
68
|
let drained = null;
|
|
@@ -75,8 +96,20 @@ export function createSupervisor(deps) {
|
|
|
75
96
|
delete slot.state.pid;
|
|
76
97
|
delete slot.state.nextRunAt;
|
|
77
98
|
};
|
|
78
|
-
|
|
79
|
-
|
|
99
|
+
const signalChild = (slot, child, signal) => {
|
|
100
|
+
try {
|
|
101
|
+
child.kill(signal);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
deps.log("error", `could not send ${signal} to ${slot.plan.id}: ${errorMessage(err)}`);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* Stop the slot's child (if any), then run `then` once it has exited. With
|
|
109
|
+
* `drain`, a unit that has a drain signal gets it first and SIGTERM only
|
|
110
|
+
* after HANDOFF_DRAIN_MAX_MS; SIGKILL follows SIGTERM after STOP_ESCALATION_MS.
|
|
111
|
+
*/
|
|
112
|
+
const terminate = (slot, then, drain = false) => {
|
|
80
113
|
clearTimer(slot);
|
|
81
114
|
const child = slot.child;
|
|
82
115
|
if (!child) {
|
|
@@ -85,24 +118,32 @@ export function createSupervisor(deps) {
|
|
|
85
118
|
}
|
|
86
119
|
slot.expectingExit = true;
|
|
87
120
|
slot.afterExit = then;
|
|
88
|
-
slot.killTimer
|
|
89
|
-
slot.killTimer
|
|
90
|
-
|
|
121
|
+
if (slot.killTimer !== undefined)
|
|
122
|
+
clearT(slot.killTimer);
|
|
123
|
+
const escalateToKill = () => {
|
|
124
|
+
slot.killTimer = setT(() => {
|
|
125
|
+
slot.killTimer = undefined;
|
|
126
|
+
if (slot.child !== child)
|
|
127
|
+
return;
|
|
91
128
|
deps.log("warn", `${slot.plan.id} ignored SIGTERM; sending SIGKILL`);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
129
|
+
signalChild(slot, child, "SIGKILL");
|
|
130
|
+
}, STOP_ESCALATION_MS);
|
|
131
|
+
};
|
|
132
|
+
const drainSignal = drain ? slot.plan.drainSignal : undefined;
|
|
133
|
+
if (drainSignal) {
|
|
134
|
+
slot.killTimer = setT(() => {
|
|
135
|
+
slot.killTimer = undefined;
|
|
136
|
+
if (slot.child !== child)
|
|
137
|
+
return;
|
|
138
|
+
deps.log("warn", `${slot.plan.id} did not finish draining; sending SIGTERM`);
|
|
139
|
+
escalateToKill();
|
|
140
|
+
signalChild(slot, child, "SIGTERM");
|
|
141
|
+
}, HANDOFF_DRAIN_MAX_MS);
|
|
142
|
+
signalChild(slot, child, drainSignal);
|
|
143
|
+
return;
|
|
105
144
|
}
|
|
145
|
+
escalateToKill();
|
|
146
|
+
signalChild(slot, child, "SIGTERM");
|
|
106
147
|
};
|
|
107
148
|
const scheduleRestart = (slot) => {
|
|
108
149
|
if (stopped)
|
|
@@ -198,6 +239,7 @@ export function createSupervisor(deps) {
|
|
|
198
239
|
return;
|
|
199
240
|
}
|
|
200
241
|
slot.child = child;
|
|
242
|
+
slot.childKey = slot.key;
|
|
201
243
|
slot.childStartedAt = now();
|
|
202
244
|
slot.state.status = "running";
|
|
203
245
|
slot.state.startedAt = slot.childStartedAt;
|
|
@@ -263,6 +305,7 @@ export function createSupervisor(deps) {
|
|
|
263
305
|
return;
|
|
264
306
|
}
|
|
265
307
|
slot.child = child;
|
|
308
|
+
slot.childKey = slot.key;
|
|
266
309
|
slot.childStartedAt = now();
|
|
267
310
|
slot.state.status = "running";
|
|
268
311
|
delete slot.state.reason;
|
|
@@ -290,7 +333,7 @@ export function createSupervisor(deps) {
|
|
|
290
333
|
};
|
|
291
334
|
const newSlot = (plan) => ({
|
|
292
335
|
plan,
|
|
293
|
-
key: planKey(plan),
|
|
336
|
+
key: planKey(plan, deps.cliVersion),
|
|
294
337
|
attempts: 0,
|
|
295
338
|
expectingExit: false,
|
|
296
339
|
busy: false,
|
|
@@ -334,7 +377,9 @@ export function createSupervisor(deps) {
|
|
|
334
377
|
continue;
|
|
335
378
|
}
|
|
336
379
|
next.set(plan.id, existing);
|
|
337
|
-
const
|
|
380
|
+
const adopted = existing.adopted === true;
|
|
381
|
+
existing.adopted = false;
|
|
382
|
+
const key = planKey(plan, deps.cliVersion);
|
|
338
383
|
const wasBlocked = existing.plan.blocked !== undefined;
|
|
339
384
|
const changed = key !== existing.key;
|
|
340
385
|
existing.plan = plan;
|
|
@@ -358,6 +403,10 @@ export function createSupervisor(deps) {
|
|
|
358
403
|
else
|
|
359
404
|
activate(existing);
|
|
360
405
|
}
|
|
406
|
+
else if (changed && adopted && existing.child) {
|
|
407
|
+
deps.log("info", `will replace ${plan.id}: it runs an older CLI or command`);
|
|
408
|
+
handoffQueue.push(existing);
|
|
409
|
+
}
|
|
361
410
|
else if (changed) {
|
|
362
411
|
deps.log("info", `restarting ${plan.id}: its command changed`);
|
|
363
412
|
existing.attempts = 0;
|
|
@@ -367,6 +416,93 @@ export function createSupervisor(deps) {
|
|
|
367
416
|
}
|
|
368
417
|
slots = next;
|
|
369
418
|
emit();
|
|
419
|
+
pumpHandoff();
|
|
420
|
+
};
|
|
421
|
+
/** Replace the next queued adopted unit once the previous replacement has settled. */
|
|
422
|
+
const pumpHandoff = () => {
|
|
423
|
+
if (stopped || handoffActive)
|
|
424
|
+
return;
|
|
425
|
+
let slot;
|
|
426
|
+
while ((slot = handoffQueue.shift())) {
|
|
427
|
+
// Skip units that left the plan, were paused, or already restarted on their own.
|
|
428
|
+
if (slots.get(slot.plan.id) === slot && !slot.plan.blocked && slot.child && slot.childKey !== slot.key)
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
if (!slot)
|
|
432
|
+
return;
|
|
433
|
+
const target = slot;
|
|
434
|
+
handoffActive = true;
|
|
435
|
+
deps.log("info", `replacing ${target.plan.id}${target.plan.drainSignal ? " once it finishes its current work" : ""}`);
|
|
436
|
+
target.attempts = 0;
|
|
437
|
+
target.state.restarts = 0;
|
|
438
|
+
terminate(target, () => activate(target), true);
|
|
439
|
+
const settle = (delay) => {
|
|
440
|
+
handoffTimer = setT(() => {
|
|
441
|
+
handoffTimer = undefined;
|
|
442
|
+
if (stopped)
|
|
443
|
+
return;
|
|
444
|
+
const gone = slots.get(target.plan.id) !== target || target.plan.blocked !== undefined;
|
|
445
|
+
if (!gone && target.expectingExit)
|
|
446
|
+
return settle(HANDOFF_STAGGER_MS);
|
|
447
|
+
if (!gone && target.child) {
|
|
448
|
+
const ranFor = now() - (target.childStartedAt ?? now());
|
|
449
|
+
if (ranFor < HANDOFF_STAGGER_MS)
|
|
450
|
+
return settle(HANDOFF_STAGGER_MS - ranFor);
|
|
451
|
+
}
|
|
452
|
+
else if (!gone && target.state.status === "backoff") {
|
|
453
|
+
return settle(HANDOFF_STAGGER_MS);
|
|
454
|
+
}
|
|
455
|
+
handoffActive = false;
|
|
456
|
+
pumpHandoff();
|
|
457
|
+
}, delay);
|
|
458
|
+
};
|
|
459
|
+
settle(HANDOFF_STAGGER_MS);
|
|
460
|
+
};
|
|
461
|
+
/** Stop a process the plan has no place for. */
|
|
462
|
+
const stopStray = (id, child) => {
|
|
463
|
+
const slot = newSlot({ id, service: id, description: id, kind: "long-running", restart: "always", command: null, env: {} });
|
|
464
|
+
slot.child = child;
|
|
465
|
+
watch(slot, child);
|
|
466
|
+
terminate(slot);
|
|
467
|
+
};
|
|
468
|
+
/** Take over the previous daemon's services before the first plan read starts anything. */
|
|
469
|
+
const adoptPrevious = () => {
|
|
470
|
+
const adopt = deps.adopt ?? [];
|
|
471
|
+
if (adopt.length === 0)
|
|
472
|
+
return;
|
|
473
|
+
let plans = [];
|
|
474
|
+
try {
|
|
475
|
+
const result = deps.plan();
|
|
476
|
+
if (!Array.isArray(result))
|
|
477
|
+
throw new Error("plan did not return a list of units");
|
|
478
|
+
plans = result;
|
|
479
|
+
}
|
|
480
|
+
catch (err) {
|
|
481
|
+
deps.log("error", `could not plan units; stopping the services left by the previous daemon: ${errorMessage(err)}`);
|
|
482
|
+
}
|
|
483
|
+
const byId = new Map(plans.map((p) => [p.id, p]));
|
|
484
|
+
for (const unit of adopt) {
|
|
485
|
+
const plan = byId.get(unit.id);
|
|
486
|
+
if (!plan || plan.kind !== "long-running" || slots.has(unit.id)) {
|
|
487
|
+
deps.log("info", `stopping ${unit.id} left by the previous daemon: no longer planned as a service`);
|
|
488
|
+
stopStray(unit.id, unit.child);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
// Recorded as runnable, so the first plan read pauses it when it is now blocked.
|
|
492
|
+
const slot = newSlot({ ...plan, blocked: undefined });
|
|
493
|
+
slot.key = unit.key;
|
|
494
|
+
slot.adopted = true;
|
|
495
|
+
slot.child = unit.child;
|
|
496
|
+
slot.childKey = unit.key;
|
|
497
|
+
slot.childStartedAt = now();
|
|
498
|
+
slot.state.status = "running";
|
|
499
|
+
slot.state.startedAt = slot.childStartedAt;
|
|
500
|
+
if (unit.child.pid !== undefined)
|
|
501
|
+
slot.state.pid = unit.child.pid;
|
|
502
|
+
watch(slot, unit.child);
|
|
503
|
+
slots.set(unit.id, slot);
|
|
504
|
+
deps.log("info", `adopted ${unit.id} (pid ${unit.child.pid ?? "?"}) from the previous daemon`);
|
|
505
|
+
}
|
|
370
506
|
};
|
|
371
507
|
const handleRequest = (req) => {
|
|
372
508
|
if (req.action === "restart-daemon") {
|
|
@@ -461,6 +597,7 @@ export function createSupervisor(deps) {
|
|
|
461
597
|
if (started)
|
|
462
598
|
return;
|
|
463
599
|
started = true;
|
|
600
|
+
adoptPrevious();
|
|
464
601
|
reconcile();
|
|
465
602
|
reconcileTimer = setT(reconcileLoop, reconcileMs);
|
|
466
603
|
requestTimer = setT(pollRequests, requestPollMs);
|
|
@@ -473,6 +610,8 @@ export function createSupervisor(deps) {
|
|
|
473
610
|
clearT(reconcileTimer);
|
|
474
611
|
if (requestTimer !== undefined)
|
|
475
612
|
clearT(requestTimer);
|
|
613
|
+
if (handoffTimer !== undefined)
|
|
614
|
+
clearT(handoffTimer);
|
|
476
615
|
for (const slot of slots.values()) {
|
|
477
616
|
clearTimer(slot);
|
|
478
617
|
slot.child = undefined;
|
|
@@ -519,6 +658,34 @@ export function createSupervisor(deps) {
|
|
|
519
658
|
},
|
|
520
659
|
reconcile,
|
|
521
660
|
state: snapshot,
|
|
661
|
+
release() {
|
|
662
|
+
const released = [];
|
|
663
|
+
for (const slot of slots.values()) {
|
|
664
|
+
const pid = slot.child?.pid;
|
|
665
|
+
if (slot.plan.kind === "long-running" && pid !== undefined && slot.childKey !== undefined) {
|
|
666
|
+
released.push({ id: slot.plan.id, pid, key: slot.childKey });
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
stopped = true;
|
|
670
|
+
if (reconcileTimer !== undefined)
|
|
671
|
+
clearT(reconcileTimer);
|
|
672
|
+
if (requestTimer !== undefined)
|
|
673
|
+
clearT(requestTimer);
|
|
674
|
+
if (handoffTimer !== undefined)
|
|
675
|
+
clearT(handoffTimer);
|
|
676
|
+
for (const slot of slots.values()) {
|
|
677
|
+
clearTimer(slot);
|
|
678
|
+
if (slot.killTimer !== undefined)
|
|
679
|
+
clearT(slot.killTimer);
|
|
680
|
+
slot.killTimer = undefined;
|
|
681
|
+
slot.child = undefined;
|
|
682
|
+
slot.state.status = "stopped";
|
|
683
|
+
delete slot.state.pid;
|
|
684
|
+
delete slot.state.nextRunAt;
|
|
685
|
+
}
|
|
686
|
+
emit();
|
|
687
|
+
return released;
|
|
688
|
+
},
|
|
522
689
|
};
|
|
523
690
|
}
|
|
524
691
|
//# sourceMappingURL=supervisor.js.map
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* keep a short-lived, offline CLI alive until the client's network deadline.
|
|
7
7
|
*/
|
|
8
8
|
import { type WorkerOptions } from "node:worker_threads";
|
|
9
|
-
import { type FlagClient, type FlagLookupOptions, type FlagSnapshot } from "@indigoai-us/hq-flags-client";
|
|
9
|
+
import { createFlagClient, type FlagClient, type FlagLookupOptions, type FlagSnapshot } from "@indigoai-us/hq-flags-client";
|
|
10
|
+
export { resolveProcessFlagCompanyContext } from "./process-flag-company-context.js";
|
|
11
|
+
export type { ProcessFlagCompanyContext } from "./process-flag-company-context.js";
|
|
10
12
|
export { FLAG_REGISTRY_ENDPOINT_ENV } from "./flag-registry-endpoint.js";
|
|
11
13
|
/** The synchronous capability that command gate call sites need. */
|
|
12
14
|
export type FlagReader = Pick<FlagClient, "isEnabled">;
|
|
@@ -22,12 +24,13 @@ export interface FlagRegistryDependencies {
|
|
|
22
24
|
workerSource?: string;
|
|
23
25
|
captureWorkerFailure?: (error: Error, tags: Record<string, string>) => void;
|
|
24
26
|
}
|
|
25
|
-
export interface
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
export interface CompanyFlagGateDependencies {
|
|
28
|
+
endpoint?: string;
|
|
29
|
+
requestTimeoutMs?: number;
|
|
30
|
+
createClient?: typeof createFlagClient;
|
|
28
31
|
}
|
|
29
|
-
/**
|
|
30
|
-
export declare function
|
|
32
|
+
/** Create the same snapshot-backed reader used by process gates. */
|
|
33
|
+
export declare function createProcessSnapshotReader(companyIdentifiers: string[], getSnapshot?: () => FlagSnapshot | null, env?: NodeJS.ProcessEnv): FlagReader;
|
|
31
34
|
/**
|
|
32
35
|
* Begin one registry refresh without awaiting it or retaining the CLI process.
|
|
33
36
|
*
|
|
@@ -43,7 +46,9 @@ export declare function kickFlagRegistryReadiness(dependencies?: FlagRegistryDep
|
|
|
43
46
|
* `isEnabled` itself is synchronous and snapshot-only; any reader failure
|
|
44
47
|
* falls through to the exact legacy predicate owned by the caller.
|
|
45
48
|
*/
|
|
46
|
-
export declare function resolveFlagGate(reader: FlagReader | null | undefined, flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean): boolean;
|
|
49
|
+
export declare function resolveFlagGate(reader: FlagReader | null | undefined, flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean, env?: NodeJS.ProcessEnv): boolean;
|
|
50
|
+
/** Resolve one explicit company's flag without relying on process-wide binding. */
|
|
51
|
+
export declare function resolveCompanyFlagGate(companyUid: string, idToken: string, flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean, dependencies?: CompanyFlagGateDependencies): Promise<boolean>;
|
|
47
52
|
/** Resolve a process gate against the shared snapshot reader, when configured. */
|
|
48
53
|
export declare function resolveProcessFlagGate(flagKey: string, lookup: FlagLookupOptions, legacyFallback: () => boolean): boolean;
|
|
49
54
|
/**
|
|
@@ -7,99 +7,43 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { Worker } from "node:worker_threads";
|
|
9
9
|
import * as Sentry from "@sentry/node";
|
|
10
|
-
import { isFlagSnapshot, readLocalOverride, resolveFlag, } from "@indigoai-us/hq-flags-client";
|
|
11
|
-
import { readMachineCompanyBinding } from "./work-context/company.js";
|
|
12
|
-
import { readSessionState } from "./work-context/state.js";
|
|
13
|
-
import { workContextRoot } from "./work-context/paths.js";
|
|
14
|
-
import { readCompaniesManifestMap } from "./work-context/repo-remote.js";
|
|
15
|
-
import { resolveDefaultHqRoot } from "../utils/cognito-session.js";
|
|
10
|
+
import { createFlagClient, isFlagSnapshot, readLocalOverride, resolveFlag, } from "@indigoai-us/hq-flags-client";
|
|
16
11
|
import { resolveFlagRegistryEndpoint, } from "./flag-registry-endpoint.js";
|
|
17
12
|
import { FLAG_REGISTRY_WORKER_SOURCE } from "./flag-registry-worker-source.generated.js";
|
|
13
|
+
import { resolveProcessFlagCompanyContext } from "./process-flag-company-context.js";
|
|
14
|
+
import { writeFlagSnapshotFile } from "./flag-snapshot-file.js";
|
|
15
|
+
export { resolveProcessFlagCompanyContext } from "./process-flag-company-context.js";
|
|
18
16
|
export { FLAG_REGISTRY_ENDPOINT_ENV } from "./flag-registry-endpoint.js";
|
|
19
17
|
const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
|
|
20
|
-
const COMPANY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
21
|
-
let heldSnapshot = null;
|
|
22
|
-
let processReader = null;
|
|
23
|
-
function processLocalOverride(flagKey) {
|
|
24
|
-
return readLocalOverride(flagKey, process.env);
|
|
25
|
-
}
|
|
26
|
-
function localOverrideOrFallback(flagKey, legacyFallback) {
|
|
27
|
-
return processLocalOverride(flagKey) ?? legacyFallback();
|
|
28
|
-
}
|
|
29
18
|
function normalizedCompanyUid(value) {
|
|
30
19
|
const candidate = value?.trim() ?? "";
|
|
31
20
|
return COMPANY_UID_RE.test(candidate) ? candidate : undefined;
|
|
32
21
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Resolve only an explicit machine/session company binding. Device defaults
|
|
39
|
-
* and cwd/repository heuristics do not scope process-wide feature flags.
|
|
40
|
-
*/
|
|
41
|
-
function processCompanyContext(env = process.env) {
|
|
42
|
-
const explicitUid = normalizedCompanyUid(env.HQ_COMPANY_UID);
|
|
43
|
-
const explicitSlug = normalizedCompanySlug(env.HQ_COMPANY_SLUG);
|
|
44
|
-
if (explicitUid || explicitSlug) {
|
|
45
|
-
return { companyUid: explicitUid, companySlug: explicitSlug };
|
|
46
|
-
}
|
|
47
|
-
const machine = readMachineCompanyBinding(env);
|
|
48
|
-
const spawnValue = env.HQ_SPAWN_COMPANY?.trim();
|
|
49
|
-
const spawnSlug = normalizedCompanySlug(spawnValue);
|
|
50
|
-
const machineContext = spawnValue && COMPANY_UID_RE.test(spawnValue)
|
|
51
|
-
? { companyUid: spawnValue }
|
|
52
|
-
: spawnSlug
|
|
53
|
-
? { companySlug: spawnSlug }
|
|
54
|
-
: {
|
|
55
|
-
companyUid: normalizedCompanyUid(machine?.uid),
|
|
56
|
-
companySlug: normalizedCompanySlug(machine?.slug),
|
|
57
|
-
};
|
|
58
|
-
if (machineContext.companyUid || machineContext.companySlug)
|
|
59
|
-
return machineContext;
|
|
60
|
-
const sessionId = env.HQ_SESSION_ID?.trim();
|
|
61
|
-
if (!sessionId)
|
|
62
|
-
return {};
|
|
63
|
-
const session = readSessionState(sessionId, workContextRoot(undefined, env));
|
|
64
|
-
if (session?.contextStatus !== "bound" || session.companyConfidence === "device_default") {
|
|
65
|
-
return {};
|
|
66
|
-
}
|
|
67
|
-
return {
|
|
68
|
-
companyUid: normalizedCompanyUid(session.companyUid),
|
|
69
|
-
companySlug: normalizedCompanySlug(session.companySlug),
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
function companyUidForContext(context, env = process.env) {
|
|
73
|
-
if (context.companyUid)
|
|
74
|
-
return context.companyUid;
|
|
75
|
-
if (!context.companySlug)
|
|
76
|
-
return undefined;
|
|
77
|
-
const hqRoot = resolveDefaultHqRoot({ env });
|
|
78
|
-
const manifestUid = readCompaniesManifestMap(hqRoot)?.[context.companySlug]?.cloud_uid;
|
|
79
|
-
return typeof manifestUid === "string" ? normalizedCompanyUid(manifestUid) : undefined;
|
|
22
|
+
let heldSnapshot = null;
|
|
23
|
+
let processReader = null;
|
|
24
|
+
function processLocalOverride(flagKey, env = process.env) {
|
|
25
|
+
return readLocalOverride(flagKey, env);
|
|
80
26
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const context = processCompanyContext(env);
|
|
84
|
-
const companyUid = companyUidForContext(context, env);
|
|
85
|
-
return {
|
|
86
|
-
...(companyUid ? { companyUid } : {}),
|
|
87
|
-
...(context.companySlug ? { companySlug: context.companySlug } : {}),
|
|
88
|
-
};
|
|
27
|
+
function localOverrideOrFallback(flagKey, legacyFallback, env = process.env) {
|
|
28
|
+
return processLocalOverride(flagKey, env) ?? legacyFallback();
|
|
89
29
|
}
|
|
90
|
-
|
|
30
|
+
/** Create the same snapshot-backed reader used by process gates. */
|
|
31
|
+
export function createProcessSnapshotReader(companyIdentifiers, getSnapshot = () => heldSnapshot, env = process.env) {
|
|
91
32
|
return {
|
|
92
33
|
isEnabled(flagKey, lookup = {}) {
|
|
93
34
|
return resolveFlag({
|
|
94
35
|
flagKey,
|
|
95
|
-
env
|
|
96
|
-
snapshot:
|
|
36
|
+
env,
|
|
37
|
+
snapshot: getSnapshot(),
|
|
97
38
|
companyIdentifiers,
|
|
98
39
|
lookup,
|
|
99
40
|
}).value;
|
|
100
41
|
},
|
|
101
42
|
};
|
|
102
43
|
}
|
|
44
|
+
function processSnapshotReader(companyIdentifiers) {
|
|
45
|
+
return createProcessSnapshotReader(companyIdentifiers);
|
|
46
|
+
}
|
|
103
47
|
/**
|
|
104
48
|
* Begin one registry refresh without awaiting it or retaining the CLI process.
|
|
105
49
|
*
|
|
@@ -150,6 +94,19 @@ export function kickFlagRegistryReadiness(dependencies = {}) {
|
|
|
150
94
|
worker.on("message", (value) => {
|
|
151
95
|
if (isFlagSnapshot(value)) {
|
|
152
96
|
heldSnapshot = value;
|
|
97
|
+
if (companyUid) {
|
|
98
|
+
try {
|
|
99
|
+
writeFlagSnapshotFile(companyUid, value.flags);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
Sentry.captureException(error, {
|
|
103
|
+
tags: {
|
|
104
|
+
component: "flag-snapshot-cache-write",
|
|
105
|
+
company_uid_present: "true",
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
153
110
|
}
|
|
154
111
|
});
|
|
155
112
|
// A failed background refresh must stay invisible to an offline CLI.
|
|
@@ -169,15 +126,49 @@ export function kickFlagRegistryReadiness(dependencies = {}) {
|
|
|
169
126
|
* `isEnabled` itself is synchronous and snapshot-only; any reader failure
|
|
170
127
|
* falls through to the exact legacy predicate owned by the caller.
|
|
171
128
|
*/
|
|
172
|
-
export function resolveFlagGate(reader, flagKey, lookup, legacyFallback) {
|
|
129
|
+
export function resolveFlagGate(reader, flagKey, lookup, legacyFallback, env = process.env) {
|
|
173
130
|
if (!reader)
|
|
174
|
-
return localOverrideOrFallback(flagKey, legacyFallback);
|
|
131
|
+
return localOverrideOrFallback(flagKey, legacyFallback, env);
|
|
175
132
|
try {
|
|
176
133
|
return reader.isEnabled(flagKey, lookup);
|
|
177
134
|
}
|
|
178
135
|
catch {
|
|
136
|
+
return localOverrideOrFallback(flagKey, legacyFallback, env);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Resolve one explicit company's flag without relying on process-wide binding. */
|
|
140
|
+
export async function resolveCompanyFlagGate(companyUid, idToken, flagKey, lookup, legacyFallback, dependencies = {}) {
|
|
141
|
+
const localOverride = processLocalOverride(flagKey);
|
|
142
|
+
if (localOverride !== null)
|
|
143
|
+
return localOverride;
|
|
144
|
+
const targetCompanyUid = normalizedCompanyUid(companyUid);
|
|
145
|
+
if (!targetCompanyUid || idToken.trim() === "") {
|
|
179
146
|
return localOverrideOrFallback(flagKey, legacyFallback);
|
|
180
147
|
}
|
|
148
|
+
let client;
|
|
149
|
+
try {
|
|
150
|
+
client = (dependencies.createClient ?? createFlagClient)({
|
|
151
|
+
endpoint: resolveFlagRegistryEndpoint(dependencies.endpoint, process.env),
|
|
152
|
+
getToken: () => idToken,
|
|
153
|
+
companyUid: targetCompanyUid,
|
|
154
|
+
refreshIntervalMs: 0,
|
|
155
|
+
requestTimeoutMs: dependencies.requestTimeoutMs ?? 1_200,
|
|
156
|
+
onError: (error) => {
|
|
157
|
+
const errorClass = error instanceof Error ? error.name : "unknown";
|
|
158
|
+
console.error(`hq: Could not resolve a company flag (${errorClass}); using its fallback.`);
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
await client.ready();
|
|
162
|
+
return resolveFlagGate(client, flagKey, lookup, legacyFallback);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
const errorClass = error instanceof Error ? error.name : "unknown";
|
|
166
|
+
console.error(`hq: Could not resolve a company flag (${errorClass}); using its fallback.`);
|
|
167
|
+
return localOverrideOrFallback(flagKey, legacyFallback);
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
client?.close();
|
|
171
|
+
}
|
|
181
172
|
}
|
|
182
173
|
/** Resolve a process gate against the shared snapshot reader, when configured. */
|
|
183
174
|
export function resolveProcessFlagGate(flagKey, lookup, legacyFallback) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Private, synchronous, offline snapshot used by the native CLI fast route. */
|
|
2
|
+
export declare const FLAG_SNAPSHOT_MAX_AGE_MS: number;
|
|
3
|
+
export interface PersistedFlagSnapshot {
|
|
4
|
+
company_uid: string;
|
|
5
|
+
fetched_at: string;
|
|
6
|
+
flags: Record<string, boolean>;
|
|
7
|
+
}
|
|
8
|
+
export declare function flagSnapshotPath(companyUid: string, env?: NodeJS.ProcessEnv): string | undefined;
|
|
9
|
+
/** Write just the validated flag decisions and company scope, never auth data. */
|
|
10
|
+
export declare function writeFlagSnapshotFile(companyUid: string, flags: Readonly<Record<string, unknown>>, options?: {
|
|
11
|
+
env?: NodeJS.ProcessEnv;
|
|
12
|
+
fetchedAt?: string;
|
|
13
|
+
}): string;
|
|
14
|
+
/** Return null for every miss, malformed file, company mismatch, or stale file. */
|
|
15
|
+
export declare function readFlagSnapshotFile(companyUid: string, options?: {
|
|
16
|
+
env?: NodeJS.ProcessEnv;
|
|
17
|
+
now?: number;
|
|
18
|
+
maxAgeMs?: number;
|
|
19
|
+
}): PersistedFlagSnapshot | null;
|
|
20
|
+
//# sourceMappingURL=flag-snapshot-file.d.ts.map
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** Private, synchronous, offline snapshot used by the native CLI fast route. */
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
export const FLAG_SNAPSHOT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
|
|
7
|
+
export function flagSnapshotPath(companyUid, env = process.env) {
|
|
8
|
+
if (!COMPANY_UID_RE.test(companyUid))
|
|
9
|
+
return undefined;
|
|
10
|
+
const cacheHome = env.XDG_CACHE_HOME?.trim() || path.join(os.homedir(), ".cache");
|
|
11
|
+
return path.join(cacheHome, "hq", "flags", `snapshot-${companyUid}.json`);
|
|
12
|
+
}
|
|
13
|
+
/** Write just the validated flag decisions and company scope, never auth data. */
|
|
14
|
+
export function writeFlagSnapshotFile(companyUid, flags, options = {}) {
|
|
15
|
+
const file = flagSnapshotPath(companyUid, options.env);
|
|
16
|
+
if (!file)
|
|
17
|
+
throw new Error("Cannot persist a flag snapshot without a valid company uid");
|
|
18
|
+
const normalizedFlags = Object.fromEntries(Object.entries(flags).filter((entry) => Boolean(entry[0].trim()) && typeof entry[1] === "boolean"));
|
|
19
|
+
const snapshot = {
|
|
20
|
+
company_uid: companyUid,
|
|
21
|
+
fetched_at: options.fetchedAt ?? new Date().toISOString(),
|
|
22
|
+
flags: normalizedFlags,
|
|
23
|
+
};
|
|
24
|
+
if (!Number.isFinite(Date.parse(snapshot.fetched_at))) {
|
|
25
|
+
throw new Error("Cannot persist a flag snapshot with an invalid fetched_at timestamp");
|
|
26
|
+
}
|
|
27
|
+
const directory = path.dirname(file);
|
|
28
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
29
|
+
fs.chmodSync(directory, 0o700);
|
|
30
|
+
const temporary = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
31
|
+
let fd;
|
|
32
|
+
try {
|
|
33
|
+
fd = fs.openSync(temporary, "wx", 0o600);
|
|
34
|
+
fs.writeFileSync(fd, `${JSON.stringify(snapshot)}\n`, "utf8");
|
|
35
|
+
fs.fsyncSync(fd);
|
|
36
|
+
fs.closeSync(fd);
|
|
37
|
+
fd = undefined;
|
|
38
|
+
fs.renameSync(temporary, file);
|
|
39
|
+
fs.chmodSync(file, 0o600);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (fd !== undefined)
|
|
43
|
+
fs.closeSync(fd);
|
|
44
|
+
try {
|
|
45
|
+
fs.unlinkSync(temporary);
|
|
46
|
+
}
|
|
47
|
+
catch (cleanupError) {
|
|
48
|
+
if (cleanupError.code !== "ENOENT")
|
|
49
|
+
throw cleanupError;
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
return file;
|
|
54
|
+
}
|
|
55
|
+
/** Return null for every miss, malformed file, company mismatch, or stale file. */
|
|
56
|
+
export function readFlagSnapshotFile(companyUid, options = {}) {
|
|
57
|
+
const file = flagSnapshotPath(companyUid, options.env);
|
|
58
|
+
if (!file)
|
|
59
|
+
return null;
|
|
60
|
+
let parsed;
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
68
|
+
return null;
|
|
69
|
+
const value = parsed;
|
|
70
|
+
if (Object.keys(value).sort().join(",") !== "company_uid,fetched_at,flags" ||
|
|
71
|
+
value.company_uid !== companyUid ||
|
|
72
|
+
typeof value.fetched_at !== "string" ||
|
|
73
|
+
!value.flags || typeof value.flags !== "object" || Array.isArray(value.flags))
|
|
74
|
+
return null; // Expected on missing, malformed, unreadable, or cross-company cache data.
|
|
75
|
+
const fetchedAt = Date.parse(value.fetched_at);
|
|
76
|
+
const age = (options.now ?? Date.now()) - fetchedAt;
|
|
77
|
+
if (!Number.isFinite(fetchedAt) || age < 0 || age > (options.maxAgeMs ?? FLAG_SNAPSHOT_MAX_AGE_MS)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const flags = value.flags;
|
|
81
|
+
for (const enabled of Object.values(flags)) {
|
|
82
|
+
if (typeof enabled !== "boolean")
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
company_uid: companyUid,
|
|
87
|
+
fetched_at: value.fetched_at,
|
|
88
|
+
flags: flags,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=flag-snapshot-file.js.map
|
|
@@ -10,7 +10,13 @@ function admissionHost(host) {
|
|
|
10
10
|
/** Snapshot the same per-host running-slot predicate used by lane admission. */
|
|
11
11
|
export function buildHostCapacitySnapshot(input) {
|
|
12
12
|
const at = input.now.getTime();
|
|
13
|
-
const
|
|
13
|
+
const localHostname = input.hostname?.trim();
|
|
14
|
+
const onHost = input.lanes.filter((lane) => laneRunsOnHost(lane, admissionHost(input.host)) ||
|
|
15
|
+
(input.host.kind === "outpost" &&
|
|
16
|
+
localHostname !== undefined &&
|
|
17
|
+
localHostname !== "" &&
|
|
18
|
+
lane.host.kind === "local" &&
|
|
19
|
+
lane.host.id === localHostname));
|
|
14
20
|
const providers = {};
|
|
15
21
|
for (const provider of ["claude", "codex", "grok"]) {
|
|
16
22
|
const providerConfig = input.config.providers[provider];
|