omp-conductor 0.2.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/src/config.ts ADDED
@@ -0,0 +1,446 @@
1
+ /**
2
+ * On-disk configuration for omp-conductor.
3
+ *
4
+ * The dispatcher runs unattended, so a malformed config must fail loudly at
5
+ * load time rather than surface hours later as `undefined.inProgress` inside a
6
+ * worker. `loadConfig` therefore validates and normalises in one pass and
7
+ * reports *every* problem it found, because fixing a hand-written config one
8
+ * error per run is miserable.
9
+ *
10
+ * The whole config tree lives under `$OMP_CONDUCTOR_HOME` (default
11
+ * `~/.omp/conductor`), read on every call so a test — or a second fleet on the
12
+ * same machine — can redirect it without reloading the module.
13
+ */
14
+
15
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { dirname, join } from "node:path";
18
+ import {
19
+ CONFIG_VERSION,
20
+ DEFAULT_CAPS,
21
+ DEFAULT_REPORT_SCOPE,
22
+ READABLE_CONFIG_VERSIONS,
23
+ REPORT_SCOPES,
24
+ type Caps,
25
+ type ConductorConfig,
26
+ type ProjectConfig,
27
+ type ReportScope,
28
+ type RepoTarget,
29
+ } from "./types.ts";
30
+
31
+ /**
32
+ * A JSON node whose fields are all still unproven. Reading a field off a
33
+ * non-object (string, number, null) yields `undefined` at runtime, so every
34
+ * field read below is safe and the `typeof` checks on the values do the real
35
+ * validating — no structural guard needed.
36
+ *
37
+ * ponytail: this is hand-rolled validation, not a schema. It stays honest only
38
+ * because `ConductorConfig` is small; the upgrade path when it grows is to
39
+ * parse with zod/valibot at this one boundary and delete `validate` below.
40
+ */
41
+ type Raw = { readonly [key: string]: unknown };
42
+
43
+ /** Derived from the data so a new `Caps` field cannot be silently ignored. */
44
+ const CAP_KEYS = Object.keys(DEFAULT_CAPS) as (keyof Caps)[];
45
+
46
+ /** Quoted for error messages, from the same data the guard below reads. */
47
+ const REPORT_SCOPE_LIST = REPORT_SCOPES.map((s) => `"${s}"`).join(" or ");
48
+
49
+ /** `owner/repo`, the only tracker spelling `gh` accepts without a host. */
50
+ const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
51
+
52
+ /**
53
+ * Used when a project omits `stateLabels`. Namespaced so a human scanning the
54
+ * tracker can tell dispatcher-written labels from their own.
55
+ */
56
+ const DEFAULT_STATE_LABELS: ProjectConfig["stateLabels"] = {
57
+ inProgress: "agent:in-progress",
58
+ blocked: "agent:blocked",
59
+ failed: "agent:failed",
60
+ };
61
+
62
+ /**
63
+ * Default routing prefix. Namespaced rather than empty: a bare `api` label
64
+ * would collide with ordinary topic labels and route work by accident.
65
+ */
66
+ const DEFAULT_LABEL_PREFIX = "repo:";
67
+
68
+ /** Absolute path of the config file, honouring `$OMP_CONDUCTOR_HOME`. */
69
+ export function configPath(): string {
70
+ const override = process.env["OMP_CONDUCTOR_HOME"];
71
+ const home = override !== undefined && override.length > 0 ? override : join(homedir(), ".omp", "conductor");
72
+ return join(home, "config.json");
73
+ }
74
+
75
+ /** Directory holding the config, the sqlite store, and the pause sentinel. */
76
+ export function stateDir(): string {
77
+ return dirname(configPath());
78
+ }
79
+
80
+ /**
81
+ * Reads, validates and normalises the config. Throws an `Error` naming the
82
+ * path and the fix; never returns a partially-shaped `ConductorConfig`.
83
+ */
84
+ export function loadConfig(): ConductorConfig {
85
+ const path = configPath();
86
+
87
+ if (!existsSync(path)) {
88
+ throw new Error(`No conductor config at ${path} — run /conductor setup to create one.`);
89
+ }
90
+
91
+ let raw: string;
92
+ try {
93
+ raw = readFileSync(path, "utf8");
94
+ } catch (err) {
95
+ throw new Error(`Cannot read conductor config at ${path}: ${err instanceof Error ? err.message : String(err)}`);
96
+ }
97
+
98
+ let parsed: unknown;
99
+ try {
100
+ parsed = JSON.parse(raw) as unknown;
101
+ } catch (err) {
102
+ throw new Error(
103
+ `Conductor config at ${path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
104
+ );
105
+ }
106
+
107
+ return validate(parsed, path);
108
+ }
109
+
110
+ /**
111
+ * Persists the config atomically: a temp file in the same directory, then
112
+ * `rename`, so a crash mid-write leaves the previous config intact instead of
113
+ * a truncated one. Mode 0600 because a config carries chat ids and clone URLs.
114
+ */
115
+ export function saveConfig(c: ConductorConfig): void {
116
+ const dir = stateDir();
117
+ const created = mkdirSync(dir, { recursive: true, mode: 0o700 });
118
+ // mkdir's mode is masked by umask; chmod only what we just created so an
119
+ // existing directory keeps whatever the user chose for it.
120
+ if (created !== undefined) chmodSync(dir, 0o700);
121
+
122
+ const target = configPath();
123
+ const tmp = join(dir, `.config.json.${process.pid.toString(36)}.${Date.now().toString(36)}.tmp`);
124
+ try {
125
+ writeFileSync(tmp, `${JSON.stringify(c, null, 2)}\n`, { mode: 0o600 });
126
+ renameSync(tmp, target);
127
+ } catch (err) {
128
+ rmSync(tmp, { force: true });
129
+ throw err;
130
+ }
131
+ chmodSync(target, 0o600);
132
+ }
133
+
134
+ /**
135
+ * Layers a project's overrides on the global defaults, field by field, so a
136
+ * project that pins one cap still inherits the other five. `??` not `||`: a
137
+ * deliberate `dailySpendUsd: 0` is a hard stop, not "unset". Spelled out per
138
+ * field so adding a `Caps` member fails to compile here instead of resolving
139
+ * to `undefined` at a call site.
140
+ */
141
+ export function resolveCaps(p: ProjectConfig, defaults: Caps): Caps {
142
+ const o: Partial<Caps> = p.caps ?? {};
143
+ return {
144
+ maxConcurrentWorkers: o.maxConcurrentWorkers ?? defaults.maxConcurrentWorkers,
145
+ dailySpendUsd: o.dailySpendUsd ?? defaults.dailySpendUsd,
146
+ workerMaxTurns: o.workerMaxTurns ?? defaults.workerMaxTurns,
147
+ workerWallClockMs: o.workerWallClockMs ?? defaults.workerWallClockMs,
148
+ maxAttemptsPerIssue: o.maxAttemptsPerIssue ?? defaults.maxAttemptsPerIssue,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Resolves a project by name, or the only project when the name is omitted.
154
+ * Refuses to guess between several: picking one silently would spend the wrong
155
+ * project's budget.
156
+ */
157
+ export function findProject(c: ConductorConfig, name?: string): ProjectConfig {
158
+ const names = c.projects.map((p) => p.name);
159
+
160
+ if (name === undefined) {
161
+ const only = c.projects[0];
162
+ if (c.projects.length !== 1 || only === undefined) {
163
+ throw new Error(
164
+ `Ambiguous project: config has ${c.projects.length} projects (${names.join(", ") || "none"}) — name one explicitly.`,
165
+ );
166
+ }
167
+ return only;
168
+ }
169
+
170
+ const hit = c.projects.find((p) => p.name === name);
171
+ if (hit === undefined) {
172
+ throw new Error(`Unknown project "${name}" — configured projects: ${names.join(", ") || "none"}.`);
173
+ }
174
+ return hit;
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // validation / normalisation
179
+ // ---------------------------------------------------------------------------
180
+
181
+ function validate(parsed: unknown, path: string): ConductorConfig {
182
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
183
+ throw new Error(`Conductor config at ${path} must be a JSON object — run /conductor setup to recreate it.`);
184
+ }
185
+ const root = parsed as Raw;
186
+ const problems: string[] = [];
187
+
188
+ const version = root["version"];
189
+ // A v1 file predates the retirement of a cap key, so its caps are read
190
+ // leniently and the result is normalised up to v2. Any other version is a
191
+ // config this build cannot honestly claim to understand.
192
+ const legacyCaps = version === 1;
193
+ if (!READABLE_CONFIG_VERSIONS.some((v) => v === version)) {
194
+ problems.push(
195
+ `"version" must be ${READABLE_CONFIG_VERSIONS.join(" or ")}, found ${JSON.stringify(version)} — this config was written by a different conductor`,
196
+ );
197
+ }
198
+
199
+ const defaults: Caps = {
200
+ ...DEFAULT_CAPS,
201
+ ...coerceCaps(root["defaults"], `"defaults"`, problems, legacyCaps),
202
+ };
203
+
204
+ const rawProjects = root["projects"];
205
+ const projects: ProjectConfig[] = [];
206
+ if (!Array.isArray(rawProjects) || rawProjects.length === 0) {
207
+ problems.push(`"projects" must be a non-empty array — the dispatcher has nothing to service otherwise`);
208
+ } else {
209
+ rawProjects.forEach((p: unknown, i) => {
210
+ const project = normalizeProject(p, i, problems, legacyCaps);
211
+ if (project !== undefined) projects.push(project);
212
+ });
213
+ }
214
+
215
+ if (problems.length > 0) {
216
+ throw new Error(
217
+ `Invalid conductor config at ${path}:\n${problems.map((p) => ` - ${p}`).join("\n")}\n` +
218
+ `Fix the file or run /conductor setup.`,
219
+ );
220
+ }
221
+
222
+ // Always v2 out: a loaded v1 config is migrated in memory, and the next
223
+ // `saveConfig` is what persists the migration. `loadConfig` stays read-only.
224
+ return { version: CONFIG_VERSION, defaults, projects };
225
+ }
226
+
227
+ /** Returns `undefined` when the project was too broken to shape; problems are appended. */
228
+ function normalizeProject(
229
+ parsed: unknown,
230
+ index: number,
231
+ problems: string[],
232
+ legacyCaps: boolean,
233
+ ): ProjectConfig | undefined {
234
+ const at = `projects[${index}]`;
235
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
236
+ problems.push(`${at} must be an object`);
237
+ return undefined;
238
+ }
239
+ const raw = parsed as Raw;
240
+ const before = problems.length;
241
+
242
+ const rawName = raw["name"];
243
+ let name = "";
244
+ if (nonEmptyString(rawName)) name = rawName;
245
+ else problems.push(`${at}.name must be a non-empty string`);
246
+ const label = name === "" ? at : `project "${name}"`;
247
+
248
+ const tracker = raw["tracker"] as Raw | undefined;
249
+ const rawRepo = tracker?.["repo"];
250
+ let trackerRepo = "";
251
+ if (nonEmptyString(rawRepo) && REPO_RE.test(rawRepo)) trackerRepo = rawRepo;
252
+ else problems.push(`${label}: tracker.repo must look like "owner/repo", found ${JSON.stringify(rawRepo)}`);
253
+ const rawKind = tracker?.["kind"];
254
+ if (rawKind !== undefined && rawKind !== "github") {
255
+ problems.push(`${label}: tracker.kind must be "github", found ${JSON.stringify(rawKind)}`);
256
+ }
257
+
258
+ const rawQueueLabel = raw["queueLabel"];
259
+ let queueLabel = "";
260
+ if (nonEmptyString(rawQueueLabel)) queueLabel = rawQueueLabel;
261
+ else problems.push(`${label}: queueLabel must be a non-empty string — it is the human sign-off gate`);
262
+
263
+ const routing = raw["routing"] as Raw | undefined;
264
+ const rawPrefix = routing?.["labelPrefix"];
265
+ const labelPrefix = typeof rawPrefix === "string" ? rawPrefix : DEFAULT_LABEL_PREFIX;
266
+ const repos = normalizeRepos(routing?.["repos"], label, problems);
267
+
268
+ const stateLabels = raw["stateLabels"] as Raw | undefined;
269
+
270
+ const escalationIn = raw["escalation"] as Raw | undefined;
271
+ const chatId = escalationIn?.["telegramChatId"];
272
+ const escalation: ProjectConfig["escalation"] = {
273
+ // Absent means "yes, still tell me": a silently stuck run is the worst case.
274
+ fallbackToIssueComment: escalationIn?.["fallbackToIssueComment"] !== false,
275
+ };
276
+ if (nonEmptyString(chatId)) escalation.telegramChatId = chatId;
277
+
278
+ const caps = coerceCaps(raw["caps"], `${label}: caps`, problems, legacyCaps);
279
+ const reporting = normalizeReporting(raw["reporting"], label, problems);
280
+ // A hint passed to the harness, not a budget guard: an unusable value is
281
+ // dropped rather than reported, and the session's own model-fallback notice
282
+ // (logged by `runWorker`) is what tells the operator the pattern missed.
283
+ const rawWorkerModel = raw["workerModel"];
284
+ const workerModel = nonEmptyString(rawWorkerModel) ? rawWorkerModel : undefined;
285
+
286
+ if (problems.length > before) return undefined;
287
+
288
+ return {
289
+ name,
290
+ tracker: { kind: "github", repo: trackerRepo },
291
+ queueLabel,
292
+ stateLabels: {
293
+ inProgress: pickString(stateLabels?.["inProgress"], DEFAULT_STATE_LABELS.inProgress),
294
+ blocked: pickString(stateLabels?.["blocked"], DEFAULT_STATE_LABELS.blocked),
295
+ failed: pickString(stateLabels?.["failed"], DEFAULT_STATE_LABELS.failed),
296
+ },
297
+ routing: { labelPrefix, repos },
298
+ caps,
299
+ ...(workerModel === undefined ? {} : { workerModel }),
300
+ escalation,
301
+ reporting,
302
+ workspaceRoot: expandHome(pickString(raw["workspaceRoot"], join(stateDir(), "worktrees"))),
303
+ mirrorRoot: expandHome(pickString(raw["mirrorRoot"], join(stateDir(), "mirrors"))),
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Reporting scope decides whether the orchestrator speaks up or stays quiet, so
309
+ * a typo is rejected rather than folded to the default: a misspelt `"materal"`
310
+ * that silently resolved to `"material"` would read as configured on the day the
311
+ * operator meant to turn the volume down, and the config would keep lying.
312
+ */
313
+ function normalizeReporting(parsed: unknown, label: string, problems: string[]): ProjectConfig["reporting"] {
314
+ if (parsed === undefined) return { scope: DEFAULT_REPORT_SCOPE };
315
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
316
+ problems.push(`${label}: reporting must be an object with a "scope" of ${REPORT_SCOPE_LIST}`);
317
+ return { scope: DEFAULT_REPORT_SCOPE };
318
+ }
319
+ const raw = parsed as Raw;
320
+
321
+ const unknownKeys = Object.keys(raw).filter((k) => k !== "scope");
322
+ if (unknownKeys.length > 0) {
323
+ // Stricter than caps, which tolerate a retired key: `reporting` has exactly
324
+ // one member, so an unrecognised key here is a typo every time, and the
325
+ // block that ignores it looks configured either way.
326
+ problems.push(`${label}: reporting has unknown key(s): ${unknownKeys.join(", ")}`);
327
+ }
328
+
329
+ const declared = raw["scope"];
330
+ if (declared === undefined) return { scope: DEFAULT_REPORT_SCOPE };
331
+ const scope = REPORT_SCOPES.find((s) => s === declared);
332
+ if (scope === undefined) {
333
+ problems.push(`${label}: reporting.scope must be ${REPORT_SCOPE_LIST}, found ${JSON.stringify(declared)}`);
334
+ return { scope: DEFAULT_REPORT_SCOPE };
335
+ }
336
+ return { scope };
337
+ }
338
+
339
+ function normalizeRepos(parsed: unknown, label: string, problems: string[]): Record<string, RepoTarget> {
340
+ const repos: Record<string, RepoTarget> = {};
341
+
342
+ const raw = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Raw) : undefined;
343
+ const entries = raw === undefined ? [] : Object.entries(raw);
344
+ if (entries.length === 0) {
345
+ problems.push(`${label}: routing.repos needs at least one repo entry, or no issue can be routed`);
346
+ return repos;
347
+ }
348
+
349
+ for (const [key, entry] of entries) {
350
+ const value = entry as Raw | undefined;
351
+ const cloneUrl = value?.["cloneUrl"];
352
+ if (!nonEmptyString(cloneUrl)) {
353
+ problems.push(`${label}: routing.repos.${key}.cloneUrl must be a non-empty string`);
354
+ continue;
355
+ }
356
+ repos[key] = {
357
+ name: pickString(value?.["name"], key),
358
+ cloneUrl,
359
+ defaultBranch: pickString(value?.["defaultBranch"], "main"),
360
+ gates: normalizeGates(value?.["gates"], `${label}: routing.repos.${key}`, problems),
361
+ };
362
+ }
363
+
364
+ return repos;
365
+ }
366
+
367
+ /**
368
+ * Gates are the pre-push CI equivalent, so a malformed entry is an error, not
369
+ * something to drop quietly: a skipped gate is exactly how a lint failure
370
+ * reaches the runners unattended.
371
+ */
372
+ function normalizeGates(parsed: unknown, label: string, problems: string[]): { cmd: string; cwd: string }[] {
373
+ if (parsed === undefined) return [];
374
+ if (!Array.isArray(parsed)) {
375
+ problems.push(`${label}.gates must be an array of { cmd, cwd }`);
376
+ return [];
377
+ }
378
+
379
+ const gates: { cmd: string; cwd: string }[] = [];
380
+ parsed.forEach((entry: unknown, i) => {
381
+ const gate = entry as Raw | undefined;
382
+ const cmd = gate?.["cmd"];
383
+ if (!nonEmptyString(cmd)) {
384
+ problems.push(`${label}.gates[${i}] must be { cmd, cwd } with a non-empty cmd`);
385
+ return;
386
+ }
387
+ gates.push({ cmd, cwd: pickString(gate?.["cwd"], ".") });
388
+ });
389
+ return gates;
390
+ }
391
+
392
+ /**
393
+ * Keeps only well-formed numeric caps; a value of the wrong shape is always
394
+ * reported, because a ceiling the daemon cannot read is worth stopping for.
395
+ *
396
+ * `legacy` decides what an *unrecognised* key means. In a v1 file it is a cap
397
+ * this version retired, so it is dropped and the config still loads — refusing
398
+ * would strand a fleet on upgrade. In a v2 file every key this build writes is
399
+ * current, so an unknown one is a typo and is reported: otherwise a mistyped
400
+ * `dailySpendUsd` reads as configured while the real ceiling is the default.
401
+ */
402
+ function coerceCaps(parsed: unknown, label: string, problems: string[], legacy: boolean): Partial<Caps> {
403
+ const out: Partial<Caps> = {};
404
+ if (parsed === undefined) return out;
405
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
406
+ problems.push(`${label} must be an object`);
407
+ return out;
408
+ }
409
+ const raw = parsed as Raw;
410
+
411
+ for (const key of CAP_KEYS) {
412
+ const v = raw[key];
413
+ if (v === undefined) continue;
414
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0) {
415
+ problems.push(`${label}.${key} must be a non-negative finite number, found ${JSON.stringify(v)}`);
416
+ continue;
417
+ }
418
+ out[key] = v;
419
+ }
420
+
421
+ if (!legacy) {
422
+ const unknownKeys = Object.keys(raw).filter((k) => !(CAP_KEYS as string[]).includes(k));
423
+ if (unknownKeys.length > 0) {
424
+ problems.push(
425
+ `${label} has unknown key(s): ${unknownKeys.join(", ")} — remove them, or drop "version" to 1 if they are caps an older conductor wrote`,
426
+ );
427
+ }
428
+ }
429
+
430
+ return out;
431
+ }
432
+
433
+ function nonEmptyString(v: unknown): v is string {
434
+ return typeof v === "string" && v.trim().length > 0;
435
+ }
436
+
437
+ /** One rule for "a usable string, else the documented default", used throughout. */
438
+ function pickString(v: unknown, fallback: string): string {
439
+ return nonEmptyString(v) ? v : fallback;
440
+ }
441
+
442
+ /** `~/x` in a hand-written config must not create a literal `~` directory. */
443
+ function expandHome(p: string): string {
444
+ if (p === "~") return homedir();
445
+ return p.startsWith("~/") ? join(homedir(), p.slice(2)) : p;
446
+ }