@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +88 -4
  2. package/README.md +12 -0
  3. package/assets/bot-workers/setup/context/USER-GUIDE.md +4 -4
  4. package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +74 -18
  5. package/dist/command-catalog.generated.d.ts +34 -1
  6. package/dist/command-catalog.generated.js +43 -1
  7. package/dist/commands/cloud.js +22 -9
  8. package/dist/commands/company.js +66 -3
  9. package/dist/commands/core-checkpoint-gate-setting.d.ts +69 -0
  10. package/dist/commands/core-checkpoint-gate-setting.js +163 -0
  11. package/dist/commands/core-checkpoint.d.ts +4 -2
  12. package/dist/commands/core-checkpoint.js +34 -3
  13. package/dist/commands/core-command-catalog.d.ts +4 -2
  14. package/dist/commands/core-command-catalog.js +5 -2
  15. package/dist/commands/core-ontology.js +3 -3
  16. package/dist/commands/core.js +3 -1
  17. package/dist/commands/daemon.d.ts +1 -0
  18. package/dist/commands/daemon.js +9 -3
  19. package/dist/commands/feedback.d.ts +6 -0
  20. package/dist/commands/feedback.js +86 -37
  21. package/dist/commands/lanes.js +33 -11
  22. package/dist/commands/run-feature-telemetry.d.ts +3 -0
  23. package/dist/commands/run-feature-telemetry.js +6 -0
  24. package/dist/commands/run.js +3 -2
  25. package/dist/commands/secrets.js +7 -1
  26. package/dist/commands/whoami.d.ts +4 -6
  27. package/dist/commands/whoami.js +39 -8
  28. package/dist/fast-core.d.ts +6 -7
  29. package/dist/fast-core.js +22 -8
  30. package/dist/fast-scaffold.d.ts +16 -7
  31. package/dist/fast-scaffold.js +13 -8
  32. package/dist/index.js +16 -1
  33. package/dist/lib/billing/plan-lock.d.ts +3 -1
  34. package/dist/lib/billing/plan-lock.js +7 -1
  35. package/dist/lib/checkpoint-ontology/company.d.ts +8 -4
  36. package/dist/lib/checkpoint-ontology/company.js +15 -7
  37. package/dist/lib/checkpoint-ontology/plan.d.ts +23 -0
  38. package/dist/lib/checkpoint-ontology/plan.js +53 -4
  39. package/dist/lib/checkpoint-ontology/queued-touches.d.ts +32 -0
  40. package/dist/lib/checkpoint-ontology/queued-touches.js +193 -0
  41. package/dist/lib/core-utils/timeout-guard-native.d.ts +7 -0
  42. package/dist/lib/core-utils/timeout-guard-native.js +40 -0
  43. package/dist/lib/core-utils/timeout-guard.d.ts +3 -3
  44. package/dist/lib/core-utils/timeout-guard.js +28 -13
  45. package/dist/lib/daemon/catalog.d.ts +7 -0
  46. package/dist/lib/daemon/catalog.js +6 -0
  47. package/dist/lib/daemon/flag.d.ts +23 -0
  48. package/dist/lib/daemon/flag.js +57 -0
  49. package/dist/lib/daemon/install.js +6 -1
  50. package/dist/lib/daemon/remote-control.d.ts +6 -0
  51. package/dist/lib/daemon/remote-control.js +12 -0
  52. package/dist/lib/daemon/run.js +115 -2
  53. package/dist/lib/daemon/succession.d.ts +116 -0
  54. package/dist/lib/daemon/succession.js +262 -0
  55. package/dist/lib/daemon/supervisor.d.ts +34 -1
  56. package/dist/lib/daemon/supervisor.js +190 -23
  57. package/dist/lib/flag-registry.d.ts +12 -7
  58. package/dist/lib/flag-registry.js +66 -75
  59. package/dist/lib/flag-snapshot-file.d.ts +20 -0
  60. package/dist/lib/flag-snapshot-file.js +91 -0
  61. package/dist/lib/lanes/host-capacity.js +7 -1
  62. package/dist/lib/lanes/models.d.ts +2 -0
  63. package/dist/lib/lanes/models.js +6 -1
  64. package/dist/lib/lanes/reconcile.js +20 -4
  65. package/dist/lib/process-flag-company-context.d.ts +8 -0
  66. package/dist/lib/process-flag-company-context.js +67 -0
  67. package/dist/main.js +3 -1
  68. package/dist/native-fast-route.d.ts +3 -0
  69. package/dist/native-fast-route.js +39 -0
  70. package/dist/utils/cli-telemetry.d.ts +26 -2
  71. package/dist/utils/cli-telemetry.js +178 -3
  72. package/dist/utils/hq-root-resolver.d.ts +13 -0
  73. package/dist/utils/hq-root-resolver.js +103 -0
  74. package/dist/utils/scaffold-fast-import-guard.js +6 -1
  75. package/dist/utils/vault-api.d.ts +8 -0
  76. package/dist/utils/vault-api.js +13 -1
  77. 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
- function planKey(plan) {
28
- return JSON.stringify([plan.kind, plan.restart, plan.intervalSec, plan.initialDelaySec, plan.command, plan.env]);
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
- /** Stop the slot's child (if any), then run `then` once it has exited. */
79
- const terminate = (slot, then) => {
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 = setT(() => {
89
- slot.killTimer = undefined;
90
- if (slot.child === child) {
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
- try {
93
- child.kill("SIGKILL");
94
- }
95
- catch (err) {
96
- deps.log("error", `could not SIGKILL ${slot.plan.id}: ${errorMessage(err)}`);
97
- }
98
- }
99
- }, STOP_ESCALATION_MS);
100
- try {
101
- child.kill("SIGTERM");
102
- }
103
- catch (err) {
104
- deps.log("error", `could not stop ${slot.plan.id}: ${errorMessage(err)}`);
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 key = planKey(plan);
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 ProcessFlagCompanyContext {
26
- companyUid?: string;
27
- companySlug?: string;
27
+ export interface CompanyFlagGateDependencies {
28
+ endpoint?: string;
29
+ requestTimeoutMs?: number;
30
+ createClient?: typeof createFlagClient;
28
31
  }
29
- /** Resolve the same company identity used by the process-wide flag reader. */
30
- export declare function resolveProcessFlagCompanyContext(env?: NodeJS.ProcessEnv): ProcessFlagCompanyContext;
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
- function normalizedCompanySlug(value) {
34
- const candidate = value?.trim() ?? "";
35
- return COMPANY_SLUG_RE.test(candidate) ? candidate : undefined;
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
- /** Resolve the same company identity used by the process-wide flag reader. */
82
- export function resolveProcessFlagCompanyContext(env = process.env) {
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
- function processSnapshotReader(companyIdentifiers) {
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: process.env,
96
- snapshot: heldSnapshot,
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 onHost = input.lanes.filter((lane) => laneRunsOnHost(lane, admissionHost(input.host)));
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];