omp-conductor 0.18.0 → 0.18.2

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 (65) hide show
  1. package/README.md +35 -1
  2. package/REFERENCE.md +61 -11
  3. package/agents/to-spec.md +94 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +35 -1
  6. package/src/admission.ts +204 -75
  7. package/src/arm-challenge.ts +250 -57
  8. package/src/ask.ts +268 -7
  9. package/src/board.ts +17 -3
  10. package/src/briefs/orchestrator.md +62 -21
  11. package/src/briefs/to-spec.md +88 -0
  12. package/src/briefs/worker.md +2 -1
  13. package/src/cli.ts +124 -1
  14. package/src/command-help.ts +11 -0
  15. package/src/command-manifest.ts +38 -5
  16. package/src/commands/arm.ts +1 -1
  17. package/src/commands/context.ts +1 -0
  18. package/src/commands/drain.ts +176 -0
  19. package/src/commands/extend.ts +6 -10
  20. package/src/commands/intake.ts +4 -19
  21. package/src/commands/status.ts +5 -1
  22. package/src/commands/watch.ts +51 -16
  23. package/src/commands/worker.ts +9 -10
  24. package/src/config-schema.ts +43 -6
  25. package/src/config.ts +65 -9
  26. package/src/daemon.ts +879 -41
  27. package/src/dashboard/app.js +4 -1
  28. package/src/dashboard/server.ts +5 -2
  29. package/src/decisions.ts +243 -17
  30. package/src/diff-flags.ts +75 -1
  31. package/src/doctor.ts +60 -82
  32. package/src/escalate.ts +31 -14
  33. package/src/failure-class.ts +28 -2
  34. package/src/fleet.ts +239 -240
  35. package/src/gitops.ts +188 -81
  36. package/src/graph-health.ts +35 -1
  37. package/src/graph.ts +66 -1
  38. package/src/harness-loader.ts +59 -0
  39. package/src/host.ts +242 -2
  40. package/src/lifecycle.ts +122 -1
  41. package/src/omp-settings.ts +19 -0
  42. package/src/omp.ts +183 -21
  43. package/src/orchestrator-tick.ts +1591 -32
  44. package/src/orchestrator.ts +12 -0
  45. package/src/privileged.ts +1 -4
  46. package/src/release-policy.ts +503 -9
  47. package/src/session-host.ts +65 -6
  48. package/src/settlement.ts +69 -17
  49. package/src/setup-host.ts +1225 -9
  50. package/src/setup-install.ts +28 -0
  51. package/src/setup-wizard.ts +154 -3
  52. package/src/setup.ts +83 -17
  53. package/src/shell.ts +15 -0
  54. package/src/status-render.ts +216 -12
  55. package/src/store.ts +443 -42
  56. package/src/to-spec.ts +408 -0
  57. package/src/tracker/github.ts +104 -14
  58. package/src/types.ts +405 -19
  59. package/src/upgrade-verify.ts +209 -2
  60. package/src/upgrade.ts +175 -1
  61. package/src/verbs/protocol.ts +39 -0
  62. package/src/verbs/server.ts +765 -56
  63. package/src/verbs/socket.ts +24 -5
  64. package/src/worker.ts +12 -2
  65. package/src/worktree.ts +29 -12
package/src/host.ts CHANGED
@@ -3,9 +3,11 @@
3
3
  * or the tracker. Pure so tests pin the thresholds without a real machine.
4
4
  */
5
5
 
6
- import { readFileSync } from "node:fs";
6
+ import { existsSync, readFileSync, statSync } from "node:fs";
7
7
  import { spawnSync } from "node:child_process";
8
- import { DEFAULT_CAPS } from "./types.ts";
8
+ import { availableParallelism } from "node:os";
9
+ import { dirname, join, resolve } from "node:path";
10
+ import { DEFAULT_CAPS, type HostConstraints } from "./types.ts";
9
11
 
10
12
  /** 16 GiB — below this, two in-process omp sessions plus the orchestrator are
11
13
  * a measured swap risk on a shared VPS (issue #51: 3–4GB peaks on 7.6GB). */
@@ -78,6 +80,86 @@ export function workerOvercommit(total: number, ram?: number | undefined): strin
78
80
  : undefined;
79
81
  }
80
82
 
83
+ /**
84
+ * CPU cores this process can actually use — affinity- and quota-aware, which
85
+ * is the load a worker would really get on a shared host. `undefined` mirrors
86
+ * {@link hostRamBytes}: the renderer simply omits the count.
87
+ */
88
+ export function hostCoreCount(): number | undefined {
89
+ try {
90
+ return availableParallelism();
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ }
95
+
96
+ /** What {@link hostFacts} reads off the host; injectable for render tests. */
97
+ export interface HostFacts {
98
+ cores?: number;
99
+ ramBytes?: number;
100
+ }
101
+
102
+ /**
103
+ * The host's numbers through the module's own readers — the same
104
+ * {@link hostRamBytes} the capacity decision and the wizard use, plus the
105
+ * core count — so the rendered brief, the worker-count recommendation and
106
+ * the status surfaces can never disagree about the box they run on. This is
107
+ * the default the brief renderer reaches for; tests inject facts instead.
108
+ */
109
+ export function hostFacts(): HostFacts {
110
+ return { cores: hostCoreCount(), ramBytes: hostRamBytes() };
111
+ }
112
+
113
+ /**
114
+ * The usable-size phrase the brief folds into the host line (`4 cores,
115
+ * 3.2 GB`); empty when the host will not say either number.
116
+ */
117
+ function hostSizePhrase(facts: HostFacts): string {
118
+ const parts: string[] = [];
119
+ if (facts.cores !== undefined) parts.push(`${facts.cores} cores`);
120
+ if (facts.ramBytes !== undefined) parts.push(formatRss(facts.ramBytes));
121
+ return parts.join(", ");
122
+ }
123
+
124
+ /**
125
+ * The worker-brief host-constraints paragraph (#721): the typed replacement
126
+ * for host facts hand-written into an untracked agent context file. Cores and
127
+ * RAM come from {@link hostFacts} (the module's own readers) and are folded
128
+ * into the operator's description; the PATH and per-repo convention lines are
129
+ * typed config. It never names a guarded command — the shared-host notice,
130
+ * derived from `SHARED_HOST_SCRIPTS`, stays the only list of refused suites.
131
+ *
132
+ * Empty when nothing renders — no section, and the brief is byte-for-byte
133
+ * today's for a fleet that never fills the config field. `facts` is a
134
+ * parameter so tests pin the rendering deterministically; the dispatch default
135
+ * is {@link hostFacts}. The return has no trailing newline and leads with one
136
+ * when non-empty, so the template can render it on the line below the
137
+ * shared-host notice without gluing the two paragraphs.
138
+ */
139
+ export function hostConstraintsNotice(
140
+ host: HostConstraints | undefined,
141
+ repoSlug: string,
142
+ facts: HostFacts = hostFacts(),
143
+ ): string {
144
+ if (host === undefined) return "";
145
+
146
+ const lines: string[] = [];
147
+ const description = host.description?.trim();
148
+ if (description !== undefined && description !== "") {
149
+ const size = hostSizePhrase(facts);
150
+ lines.push(`**Host:** ${description}${size === "" ? "" : ` (${size})`}`);
151
+ }
152
+ const path = host.path?.trim();
153
+ if (path !== undefined && path !== "") {
154
+ lines.push(`**PATH:** non-interactive invocations (scripts and \`ssh host '<cmd>'\`) must export PATH="${path}".`);
155
+ }
156
+ const convention = host.conventions?.[repoSlug]?.trim();
157
+ if (convention !== undefined && convention !== "") {
158
+ lines.push(`**Convention (${repoSlug}):** ${convention}`);
159
+ }
160
+ return lines.length === 0 ? "" : `\n${lines.join("\n\n")}`;
161
+ }
162
+
81
163
  /** Compact binary units for status lines (`3.2 GB`, `430 MB`). */
82
164
  export function formatRss(bytes: number): string {
83
165
  if (!Number.isFinite(bytes) || bytes < 0) return "?";
@@ -104,3 +186,161 @@ export function rssBytesFromHealthz(body: string | undefined): number | undefine
104
186
  return undefined;
105
187
  }
106
188
  }
189
+
190
+ // ------------------------------------------------------- worker host state (#894) --
191
+
192
+ /**
193
+ * The dedicated worker account earlier boundary slices created on this host.
194
+ *
195
+ * Worker sessions no longer launch under it (#894 restored the fleet-account
196
+ * runtime v0.18.0 used): the constants below survive only so `setup host` can
197
+ * keep staging and recognising the units it once installed until #895 retires
198
+ * that host lifecycle end to end. Nothing on the session-launch path reads
199
+ * them.
200
+ */
201
+ export const WORKER_ACCOUNT = "omp-worker";
202
+
203
+ /** The worker account's system home: its harness config, caches and state. */
204
+ export const WORKER_HOME_DIR = "/var/lib/omp-worker";
205
+
206
+ /**
207
+ * The harness package omp-conductor loads a session from. Held here, beside
208
+ * the identity that has to reach it, so `omp.ts` (which imports it) and the
209
+ * binding checks below can never name two different packages.
210
+ */
211
+ export const OMP_HARNESS_PACKAGE = "@oh-my-pi/pi-coding-agent";
212
+
213
+ /** Native addon package the harness imports at runtime. */
214
+ export const OMP_NATIVES_PACKAGE = "@oh-my-pi/pi-natives";
215
+
216
+ /**
217
+ * Where `setup host` binds the operator's install so a worker session can
218
+ * resolve it (#828).
219
+ *
220
+ * Bun's node_modules resolution needs **read** permission — not merely search
221
+ * — on the directory that holds a `node_modules`: it enumerates the directory
222
+ * to decide whether the child is there. The fleet account's home is granted to
223
+ * the worker search-only by design (#798), so a bare `@oh-my-pi/pi-coding-agent`
224
+ * import from `<fleet home>/node_modules/omp-conductor` did not find the
225
+ * operator's install at all and fell through to Bun's auto-install, which
226
+ * downloaded a *different* harness version into the worker's own cache whose
227
+ * native addon then failed to load — every dispatch on the host stopped before
228
+ * session start.
229
+ *
230
+ * The fix is a read-only bind of the operator's `node_modules` at a path whose
231
+ * every ancestor is world-readable. Worker children are launched from it, so
232
+ * the entry module, the peer import and every transitive import resolve inside
233
+ * one tree the worker can enumerate — and, because a bind shares inodes with
234
+ * its source, it is the operator's exact build rather than a copy that can
235
+ * drift.
236
+ *
237
+ * Deliberately **not** under {@link WORKER_HOME_DIR}: the worker owns its home
238
+ * between setups, and a symlink planted where a root-run mount point goes would
239
+ * redirect that mount to an arbitrary target (#816).
240
+ */
241
+ export const WORKER_HARNESS_DIR = "/var/lib/omp-worker-harness";
242
+
243
+ /** The bound `node_modules` itself. The leaf name is load-bearing: it is what
244
+ * Node/Bun resolution looks for walking up from the entry module. */
245
+ export const WORKER_HARNESS_NODE_MODULES = join(WORKER_HARNESS_DIR, "node_modules");
246
+
247
+ /**
248
+ * The install root a module path sits in — its nearest ancestor named
249
+ * `node_modules` — or `undefined` when it has none, which is this package
250
+ * running from a source checkout rather than an install.
251
+ */
252
+ export function packageNodeModulesRoot(modulePath: string): string | undefined {
253
+ const parts = resolve(modulePath)
254
+ .split("/")
255
+ .filter((part) => part !== "");
256
+ const idx = parts.lastIndexOf("node_modules");
257
+ return idx < 0 ? undefined : `/${parts.slice(0, idx + 1).join("/")}`;
258
+ }
259
+
260
+ /**
261
+ * `path` as a worker session sees it through the harness binding, or
262
+ * `undefined` when it is not inside `packageRoot` — a test seam pointing at a
263
+ * file elsewhere, or a source checkout with no install root at all.
264
+ *
265
+ * Production uses {@link WORKER_HARNESS_NODE_MODULES}; an explicit binding
266
+ * root lets the Linux regression build the same inode-sharing tree under its
267
+ * private temporary directory instead of touching the live host mount.
268
+ */
269
+ export function workerHarnessPath(
270
+ path: string,
271
+ packageRoot: string | undefined,
272
+ bindingRoot: string = WORKER_HARNESS_NODE_MODULES,
273
+ ): string | undefined {
274
+ if (packageRoot === undefined) return undefined;
275
+ const absolute = resolve(path);
276
+ const prefix = `${packageRoot}/`;
277
+ if (!absolute.startsWith(prefix)) return undefined;
278
+ return join(bindingRoot, absolute.slice(prefix.length));
279
+ }
280
+
281
+ /** One path's filesystem identity. Identity rather than bytes, because that is
282
+ * exactly what distinguishes a live bind of the operator's install (same
283
+ * device and inode) from an empty mount point or a copy that has drifted. */
284
+ function pathIdentity(path: string): string | undefined {
285
+ try {
286
+ const st = statSync(path);
287
+ return `${st.dev}:${st.ino}`;
288
+ } catch {
289
+ return undefined;
290
+ }
291
+ }
292
+
293
+ /** Read-only facts {@link harnessBindingProblem} decides on; injected by tests. */
294
+ export interface HarnessBindingDeps {
295
+ /** This module's own directory — the install root is derived from it. */
296
+ moduleDir?: string;
297
+ /** `dev:ino` of one path, or `undefined` when it cannot be stat'ed. */
298
+ identity?: (path: string) => string | undefined;
299
+ /** Alternate binding root for the isolated Linux currentness regression. */
300
+ bindingRoot?: string;
301
+ }
302
+
303
+ /**
304
+ * Why a worker session could not load the operator's harness through the
305
+ * binding, or `undefined` when it can.
306
+ *
307
+ * Both halves of the launch are checked, by filesystem identity: this package's
308
+ * own directory (the child's entry module comes from it) and the harness
309
+ * package directory (its peer import resolves to it). A mount point that is
310
+ * empty, stale, or bound to some other tree fails on the identity comparison
311
+ * rather than being taken on faith — which is the whole point, since the
312
+ * symptom this replaces was a *successful* import of the wrong build.
313
+ */
314
+ export function harnessBindingProblem(deps: HarnessBindingDeps = {}): string | undefined {
315
+ const moduleDir = deps.moduleDir ?? import.meta.dir;
316
+ const identity = deps.identity ?? pathIdentity;
317
+ const bindingRoot = deps.bindingRoot ?? WORKER_HARNESS_NODE_MODULES;
318
+ const packageRoot = packageNodeModulesRoot(moduleDir);
319
+ if (packageRoot === undefined) {
320
+ return (
321
+ `omp-conductor is running from ${moduleDir}, which is not inside a node_modules install root — ` +
322
+ "a worker session resolves its harness through a read-only bind of that root, so worker dispatch " +
323
+ "needs the installed package (install omp-conductor with its harness peer, then re-run `omp-conductor setup host`)"
324
+ );
325
+ }
326
+ for (const dir of [moduleDir, join(packageRoot, OMP_HARNESS_PACKAGE)]) {
327
+ const source = identity(dir);
328
+ if (source === undefined) {
329
+ return (
330
+ `${dir} is missing or unreadable — ${OMP_HARNESS_PACKAGE} must be installed alongside omp-conductor ` +
331
+ "for a worker session to load the same harness build the operator runs"
332
+ );
333
+ }
334
+ // Non-null by construction: `dir` is inside `packageRoot`.
335
+ const bound = workerHarnessPath(dir, packageRoot, bindingRoot) ?? dir;
336
+ if (identity(bound) !== source) {
337
+ return (
338
+ `${bound} does not resolve to ${dir} — the worker harness binding of ${packageRoot} at ` +
339
+ `${bindingRoot} is missing or stale, so a worker session would resolve a different ` +
340
+ "harness build (or none). Run `omp-conductor setup host` to establish it"
341
+ );
342
+ }
343
+ }
344
+ return undefined;
345
+ }
346
+
package/src/lifecycle.ts CHANGED
@@ -53,7 +53,7 @@ const READY_TIMEOUT_MS = 15_000;
53
53
  const READY_POLL_MS = 250;
54
54
 
55
55
  /** `/healthz` is a local, in-memory answer; a slow one means something is wrong. */
56
- const HEALTH_TIMEOUT_MS = 1_500;
56
+ export const HEALTH_TIMEOUT_MS = 1_500;
57
57
 
58
58
  /** Default grace period between `SIGTERM` and `SIGKILL`. */
59
59
  const STOP_TIMEOUT_MS = 10_000;
@@ -963,6 +963,127 @@ export function systemdMainPid(unit = SYSTEMD_UNIT): number | undefined {
963
963
  return ownership.kind === "active" ? ownership.pid : undefined;
964
964
  }
965
965
 
966
+ /**
967
+ * The run-control endpoint the `worker` verbs may address.
968
+ *
969
+ * `record` is the pidfile's own answer: the record port is authoritative
970
+ * because the daemon itself wrote it, and the record's `project` (when set)
971
+ * rides along so callers keep refusing a record pinned to another project
972
+ * exactly as before. `unit` is the systemd answer for a missing or stale
973
+ * record: the unit is active, its MainPID is alive, and `/healthz` on the
974
+ * probe port proved a conductor daemon serving `expectedProject` — the port
975
+ * was located, not assumed. `unserved` is that same live unit when no port
976
+ * proved itself: the daemon is running but its control address cannot be
977
+ * located, so callers must refuse rather than guess. `unknown` is the
978
+ * manager-failure answer: the unit's ownership could not be read (bus,
979
+ * permission, timeout), so neither running nor stopped is provable and the
980
+ * callers must refuse — collapsing that into `none` is how a control verb
981
+ * lies "daemon is not running" while the unit is up. `none` is the confirmed
982
+ * negative: no record, and no unit with a live MainPID.
983
+ */
984
+ export type DaemonControlTarget =
985
+ | { kind: "record"; pid: number; port: number; project?: string }
986
+ | { kind: "unit"; pid: number; port: number }
987
+ | { kind: "unserved"; pid: number; port: number }
988
+ | { kind: "unknown"; reason: string }
989
+ | { kind: "none" };
990
+
991
+ /**
992
+ * The port a unit-run daemon is probed on when the pidfile is absent: the
993
+ * generated unit always pins `--port` 8787 (`DEFAULT_PORT`), so that is the
994
+ * default candidate. The port is verified by `/healthz` before it is used —
995
+ * a listener is never trusted for being on the default port alone (#811).
996
+ *
997
+ * A test process redirects the probe with `OMP_CONDUCTOR_TEST_DAEMON_PORT`,
998
+ * the same seam discipline as `OMP_CONDUCTOR_TEST_SYSTEMCTL_STATE`: a CLI
999
+ * test cannot bind the real 8787 (the host's actual daemon may answer, the
1000
+ * #399 failure mode), so its fake unit daemon serves on an ephemeral port
1001
+ * that the state names.
1002
+ */
1003
+ function controlProbePort(): number {
1004
+ if (process.env["NODE_ENV"] === "test") {
1005
+ const override = process.env["OMP_CONDUCTOR_TEST_DAEMON_PORT"];
1006
+ if (override !== undefined && override.trim() !== "") {
1007
+ const port = Number.parseInt(override.trim(), 10);
1008
+ if (Number.isInteger(port) && port > 0 && port <= 65535) return port;
1009
+ }
1010
+ }
1011
+ return DEFAULT_PORT;
1012
+ }
1013
+
1014
+ /**
1015
+ * Resolve the running daemon's pid + port for the run-control verbs, or prove
1016
+ * the daemon is not running. The pidfile is authoritative while the process
1017
+ * it names is alive; when it is missing or stale, the systemd unit is the
1018
+ * liveness witness — the same consultation `status` performs (#716): an
1019
+ * active unit whose MainPID is alive proves the daemon runs even though no
1020
+ * record pins its port. The port is never assumed: `/healthz` on the probe
1021
+ * port must prove a conductor daemon answering for `expectedProject` before
1022
+ * that address is offered as something to operate (#811).
1023
+ *
1024
+ * Never throws — the caller decides whether `unserved` (a live unit with no
1025
+ * proved endpoint) or `none` (nothing running) is the answer to give.
1026
+ */
1027
+ export async function daemonControlTarget(
1028
+ expectedProject?: string,
1029
+ ): Promise<DaemonControlTarget> {
1030
+ const record = livingDaemon();
1031
+ if (record !== undefined) {
1032
+ return {
1033
+ kind: "record",
1034
+ pid: record.pid,
1035
+ port: record.port,
1036
+ ...(record.project === undefined ? {} : { project: record.project }),
1037
+ };
1038
+ }
1039
+ const ownership = probeUnit();
1040
+ // A manager query the manager could not answer is not "no unit": collapsing
1041
+ // it into `none` is exactly how a control verb reports a live unit-owned
1042
+ // daemon stopped. The caller must refuse on the honest indeterminacy.
1043
+ if (ownership.kind === "unknown") return { kind: "unknown", reason: ownership.reason };
1044
+ if (ownership.kind !== "active" || !isAlive(ownership.pid)) return { kind: "none" };
1045
+ const port = controlProbePort();
1046
+ const health = await healthCheck(port);
1047
+ if (!health.ok || !healthServesProject(health.body, expectedProject)) {
1048
+ return { kind: "unserved", pid: ownership.pid, port };
1049
+ }
1050
+ return { kind: "unit", pid: ownership.pid, port };
1051
+ }
1052
+
1053
+ /**
1054
+ * The reachable { pid, port } a run-control verb may operate, or the refusal
1055
+ * that names why it cannot. The refusal language is one shared place so
1056
+ * `worker` and `extend` cannot drift apart on the same endpoint questions:
1057
+ * `none` is the plain "not running"; a record pinned to another project is
1058
+ * the same refusal the verbs always gave; an unprovable live unit and an
1059
+ * unknown manager state both fail closed instead of contacting anything or
1060
+ * claiming the daemon stopped.
1061
+ */
1062
+ export async function requireDaemonControl(
1063
+ expectedProject: string,
1064
+ ): Promise<{ pid: number; port: number }> {
1065
+ const target = await daemonControlTarget(expectedProject);
1066
+ if (target.kind === "none") throw new Error("daemon is not running");
1067
+ if (target.kind === "unknown") {
1068
+ throw new Error(
1069
+ `cannot determine whether ${SYSTEMD_UNIT} is running (${target.reason}) — ` +
1070
+ `refusing to send run control while liveness is unknown; ` +
1071
+ `retry when systemctl answers, or check \`systemctl status ${SYSTEMD_UNIT}\``,
1072
+ );
1073
+ }
1074
+ if (target.kind === "record" && target.project !== undefined && target.project !== expectedProject) {
1075
+ throw new Error(
1076
+ `daemon serves project "${target.project}", not requested project "${expectedProject}"`,
1077
+ );
1078
+ }
1079
+ if (target.kind === "unserved") {
1080
+ throw new Error(
1081
+ `daemon is running (${SYSTEMD_UNIT} pid ${target.pid}) but not serving /healthz for project "${expectedProject}" on port ${target.port} — no run-control endpoint could be located, so nothing was changed`,
1082
+ );
1083
+ }
1084
+ return { pid: target.pid, port: target.port };
1085
+ }
1086
+
966
1087
  // ---------------------------------------------------------------------------
967
1088
  // internals
968
1089
  // ---------------------------------------------------------------------------
@@ -36,6 +36,25 @@ import type { ProjectConfig } from "./types.ts";
36
36
  /** The overlay filename inside a run's session directory. */
37
37
  export const OMP_SETTINGS_FILE = "omp-settings.yml";
38
38
 
39
+ /**
40
+ * The OMP model-role names one settings map declares — the keys of its
41
+ * `modelRoles` stanza (#875). This is the one grammar every surface that
42
+ * names an adjudicator role reads: omp's own `modelRoles` config, whether it
43
+ * sits in the daemon account's global settings, a project's overlay, or both.
44
+ * Anything that is not a flat string-map names no roles at all — an overlay
45
+ * whose `modelRoles` is, say, a YAML list is omp's to reject, and offering
46
+ * garbage role names here would only train the setup dialog on values OMP
47
+ * would refuse. `null` and any other non-object input — including an empty
48
+ * or comments-only settings file whose YAML parses to `null` — is zero
49
+ * roles, never a fault.
50
+ */
51
+ export function modelRolesIn(settings: unknown): readonly string[] {
52
+ if (settings === null || typeof settings !== "object" || Array.isArray(settings)) return [];
53
+ const roles = (settings as Record<string, unknown>)["modelRoles"];
54
+ if (roles === null || typeof roles !== "object" || Array.isArray(roles)) return [];
55
+ return Object.keys(roles as Record<string, unknown>);
56
+ }
57
+
39
58
  /**
40
59
  * The effective overlay map for a project: the opaque `ompSettings` map plus
41
60
  * the retry keys (`retry.modelFallback`, `retry.fallbackChains.default`)