omp-conductor 0.18.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +34 -0
  2. package/REFERENCE.md +60 -10
  3. package/agents/to-spec.md +90 -0
  4. package/package.json +2 -1
  5. package/schema/config.schema.json +29 -0
  6. package/src/admission.ts +204 -75
  7. package/src/ask.ts +268 -7
  8. package/src/board.ts +17 -3
  9. package/src/briefs/orchestrator.md +42 -14
  10. package/src/briefs/to-spec.md +84 -0
  11. package/src/briefs/worker.md +2 -1
  12. package/src/cli.ts +2 -0
  13. package/src/command-help.ts +11 -0
  14. package/src/command-manifest.ts +22 -0
  15. package/src/commands/context.ts +1 -0
  16. package/src/commands/drain.ts +176 -0
  17. package/src/commands/extend.ts +6 -10
  18. package/src/commands/status.ts +5 -1
  19. package/src/commands/watch.ts +50 -2
  20. package/src/commands/worker.ts +9 -10
  21. package/src/config-schema.ts +24 -0
  22. package/src/config.ts +42 -1
  23. package/src/daemon.ts +965 -36
  24. package/src/dashboard/app.js +4 -1
  25. package/src/dashboard/server.ts +5 -2
  26. package/src/decisions.ts +235 -17
  27. package/src/diff-flags.ts +75 -1
  28. package/src/doctor.ts +52 -0
  29. package/src/escalate.ts +9 -3
  30. package/src/failure-class.ts +28 -2
  31. package/src/fleet.ts +146 -22
  32. package/src/gitops.ts +188 -81
  33. package/src/graph-health.ts +35 -1
  34. package/src/graph.ts +66 -1
  35. package/src/harness-loader.ts +59 -0
  36. package/src/host.ts +567 -2
  37. package/src/lifecycle.ts +122 -1
  38. package/src/omp.ts +227 -20
  39. package/src/orchestrator-tick.ts +1386 -15
  40. package/src/orchestrator.ts +12 -0
  41. package/src/privileged.ts +1 -4
  42. package/src/release-policy.ts +503 -9
  43. package/src/session-host.ts +99 -5
  44. package/src/settlement.ts +69 -17
  45. package/src/setup-host.ts +1205 -6
  46. package/src/setup-install.ts +28 -0
  47. package/src/setup-wizard.ts +13 -2
  48. package/src/setup.ts +29 -13
  49. package/src/shell.ts +15 -0
  50. package/src/status-render.ts +78 -11
  51. package/src/store.ts +443 -42
  52. package/src/to-spec.ts +387 -0
  53. package/src/tracker/github.ts +104 -14
  54. package/src/types.ts +343 -13
  55. package/src/upgrade-verify.ts +209 -2
  56. package/src/upgrade.ts +175 -1
  57. package/src/verbs/protocol.ts +39 -0
  58. package/src/verbs/server.ts +730 -56
  59. package/src/verbs/socket.ts +24 -5
  60. package/src/worker.ts +25 -2
  61. package/src/worktree.ts +29 -12
@@ -0,0 +1,176 @@
1
+ /**
2
+ * `drain` — start, inspect, or cancel the project's self-expiring admission fence (#484).
3
+ *
4
+ * The bounded drain state machine landed in #776 (`createDrain`/`readDrain`/
5
+ * `cancelDrain` on daemon.ts, re-exported from fleet.ts); this verb is the
6
+ * operator surface over it. A drain is the release-window sibling of `hold`:
7
+ * the same admission boundary — settlement above it, nothing claimed below —
8
+ * but recorded with an absolute deadline, so admission resumes on its own even
9
+ * if the orchestrator dies. The only state it touches is the project's drain
10
+ * record: never the pause sentinel, the arm marker, or any queue label.
11
+ */
12
+
13
+ import type { CommandContext } from "./context.ts";
14
+ import { findProject, loadConfig } from "../config.ts";
15
+ import { statusSnapshot } from "../daemon.ts";
16
+ import { cancelDrain, createDrain, readDrain } from "../fleet.ts";
17
+ import { dim, ok } from "../ui/style.ts";
18
+
19
+ const DRAIN_USAGE = `omp-conductor drain — start, inspect, or cancel the project's admission drain.
20
+
21
+ usage:
22
+ omp-conductor drain start --until ISO|DURATION [--reason TEXT] [--project NAME]
23
+ omp-conductor drain status [--project NAME]
24
+ omp-conductor drain cancel [--project NAME]
25
+
26
+ A drain is a durable, self-expiring admission fence: new claims pause while
27
+ existing runs settle, and admission resumes automatically at the absolute
28
+ deadline — even if the orchestrator crashes. start replaces any prior drain of
29
+ the project; --until takes an ISO instant or a relative duration (90s, 45m,
30
+ 2h, 1d) that must be bounded and in the future. status reports the active
31
+ drain's creation time, absolute expiry, reason, and remaining active runs.
32
+ cancel removes the project's drain and is idempotent. A drain never touches
33
+ the pause sentinel, the arm marker, or any queue label — it is the file record
34
+ that expires on its own.`;
35
+
36
+ /** The flags each drain subcommand accepts, after the subcommand itself. */
37
+ const DRAIN_FLAGS: Readonly<Record<string, readonly string[]>> = {
38
+ start: ["--project", "--until", "--reason"],
39
+ status: ["--project"],
40
+ cancel: ["--project"],
41
+ };
42
+
43
+ /** Relative durations accepted by `drain start --until`. */
44
+ const DURATION_RE = /^(\d+)([smhd])$/;
45
+ const DURATION_UNIT_MS: Readonly<Record<string, number>> = {
46
+ s: 1_000,
47
+ m: 60_000,
48
+ h: 3_600_000,
49
+ d: 86_400_000,
50
+ };
51
+
52
+ /**
53
+ * `--until` value → epoch-ms deadline, or undefined when the input is not a
54
+ * bounded expiry. A duration is relative to now; an ISO instant is absolute.
55
+ * Both forms must land on a finite safe-integer timestamp — the same bound
56
+ * `createDrain` persists as an absolute ISO instant, so a value beyond the
57
+ * Date range (e.g. 99999999999999999999d) is refused here instead of blowing
58
+ * up inside the record write. The absolute form must look like a calendar
59
+ * date: a bare number such as `3000` parses as a year in some engines, and an
60
+ * operator typing that almost certainly meant a duration.
61
+ */
62
+ function parseExpiry(raw: string, now: number): number | undefined {
63
+ const duration = DURATION_RE.exec(raw);
64
+ if (duration !== null) {
65
+ const expiresAt = now + Number.parseInt(duration[1]!, 10) * DURATION_UNIT_MS[duration[2]!]!;
66
+ return Number.isSafeInteger(expiresAt) ? expiresAt : undefined;
67
+ }
68
+ if (!/^\d{4}-\d{2}-\d{2}/.test(raw)) return undefined;
69
+ const absolute = Date.parse(raw);
70
+ return Number.isSafeInteger(absolute) ? absolute : undefined;
71
+ }
72
+
73
+ /**
74
+ * Rejects trailing tokens a drain subcommand does not declare, so a typo'd
75
+ * flag cannot be silently ignored — the same exit-2 scan `intake` and `stats`
76
+ * run. Both `--flag VALUE` and `--flag=VALUE` are accepted, matching the
77
+ * shared `flag()` parser.
78
+ */
79
+ function assertKnownArgs(ctx: CommandContext, sub: string): void {
80
+ const allowed = DRAIN_FLAGS[sub] ?? [];
81
+ for (let i = 2; i < ctx.argv.length; i++) {
82
+ const token = ctx.argv[i];
83
+ if (token === undefined) continue;
84
+ const eq = token.startsWith("--") ? token.indexOf("=") : -1;
85
+ const name = eq < 0 ? token : token.slice(0, eq);
86
+ if (allowed.includes(name)) {
87
+ if (eq < 0) i += 1; // consume the flag's value token
88
+ continue;
89
+ }
90
+ process.stderr.write(`omp-conductor: drain ${sub}: unexpected argument "${token}"\n`);
91
+ process.exit(2);
92
+ }
93
+ }
94
+
95
+ export async function drainCommand(ctx: CommandContext): Promise<void> {
96
+ const sub = ctx.argv[1];
97
+ if (sub === "--help" || sub === "-h") {
98
+ process.stdout.write(DRAIN_USAGE);
99
+ return;
100
+ }
101
+ if (sub !== "start" && sub !== "status" && sub !== "cancel") {
102
+ process.stderr.write("omp-conductor: drain needs start, status, or cancel\n");
103
+ process.exit(2);
104
+ }
105
+ assertKnownArgs(ctx, sub);
106
+ const project = findProject(loadConfig(), ctx.projectFlag);
107
+
108
+ if (sub === "start") {
109
+ const rawUntil = ctx.flag("until");
110
+ if (rawUntil === undefined || rawUntil.length === 0) {
111
+ process.stderr.write(
112
+ "omp-conductor: drain start needs --until with an ISO instant or a duration (90s, 45m, 2h, 1d)\n",
113
+ );
114
+ process.exit(2);
115
+ }
116
+ const now = Date.now();
117
+ const expiresAt = parseExpiry(rawUntil, now);
118
+ if (expiresAt === undefined) {
119
+ process.stderr.write(
120
+ `omp-conductor: drain start: "${rawUntil}" is not a bounded expiry — ` +
121
+ "use an ISO instant (e.g. 2026-08-20T10:00:00Z) or a duration (90s, 45m, 2h, 1d)\n",
122
+ );
123
+ process.exit(2);
124
+ }
125
+ if (expiresAt <= now) {
126
+ process.stderr.write(`omp-conductor: drain start: --until "${rawUntil}" must be in the future\n`);
127
+ process.exit(2);
128
+ }
129
+ const rawReason = ctx.flag("reason")?.trim().replace(/\s+/g, " ");
130
+ if (rawReason !== undefined && (rawReason === "" || rawReason.length > 500)) {
131
+ process.stderr.write("omp-conductor: drain start --reason needs 1-500 characters\n");
132
+ process.exit(2);
133
+ }
134
+ createDrain(project.name, {
135
+ expiresAt,
136
+ ...(rawReason === undefined ? {} : { reason: rawReason }),
137
+ });
138
+ process.stdout.write(
139
+ ok(
140
+ `drain started for ${project.name} — claiming paused until ${new Date(expiresAt).toISOString()}` +
141
+ (rawReason === undefined ? "" : ` (${rawReason})`),
142
+ ) + "\n",
143
+ );
144
+ process.stdout.write(
145
+ dim(
146
+ "the drain is a durable record: it survives crashes and resumes admission at the deadline on its own",
147
+ ) + "\n",
148
+ );
149
+ return;
150
+ }
151
+
152
+ if (sub === "status") {
153
+ const drain = statusSnapshot(project.name).drain;
154
+ if (drain === undefined) {
155
+ process.stdout.write(`drain: ${project.name} inactive — admission is open\n`);
156
+ return;
157
+ }
158
+ const lines = [
159
+ `drain: ${project.name} active`,
160
+ ` since ${new Date(drain.since).toISOString()}`,
161
+ ` expires at ${new Date(drain.expiresAt).toISOString()}`,
162
+ ...(drain.reason === undefined ? [] : [` reason ${drain.reason}`]),
163
+ ` remaining ${drain.remainingRuns} active run${drain.remainingRuns === 1 ? "" : "s"}`,
164
+ ];
165
+ process.stdout.write(`${lines.join("\n")}\n`);
166
+ return;
167
+ }
168
+
169
+ const had = readDrain(project.name);
170
+ cancelDrain(project.name);
171
+ process.stdout.write(
172
+ had.kind === "active"
173
+ ? ok(`drain cancelled for ${project.name} — admission resumes on the next dispatch pass`) + "\n"
174
+ : `drain: ${project.name} had no active drain — nothing to cancel\n`,
175
+ );
176
+ }
@@ -2,25 +2,21 @@
2
2
  * `extend` — raise a live run's turn ceiling, or set a bounded one-shot ceiling after a failed, killed, orphaned or blocked run.
3
3
  *
4
4
  * Moved out of cli.ts's switch by the per-verb module split (#462);
5
- * only the case wrapper, the injected `ctx` lookups and the imports
6
- * changed from the original bodies.
5
+ * the daemon discovery resolves through {@link requireDaemonControl}, the
6
+ * same run-control target `worker` uses, so a live systemd-run daemon
7
+ * stays reachable when its pidfile is missing (#811) and the refusal
8
+ * answers cannot drift between the two verbs.
7
9
  */
8
10
 
9
11
  import type { CommandContext } from "./context.ts";
10
12
  import { findProject, loadConfig } from "../config.ts";
11
- import { livingDaemon } from "../lifecycle.ts";
13
+ import { requireDaemonControl } from "../lifecycle.ts";
12
14
 
13
15
  export async function extendCommand(ctx: CommandContext): Promise<void> {
14
16
  const issue = ctx.issueArg("extend", ctx.argv[1]);
15
17
  const maxTurns = ctx.turnsFlag();
16
18
  const project = findProject(loadConfig(), ctx.projectFlag);
17
- const daemon = livingDaemon();
18
- if (daemon === undefined) throw new Error("daemon is not running");
19
- if (daemon.project !== undefined && daemon.project !== project.name) {
20
- throw new Error(
21
- `daemon serves project "${daemon.project}", not requested project "${project.name}"`,
22
- );
23
- }
19
+ const daemon = await requireDaemonControl(project.name);
24
20
  const response = await fetch(
25
21
  `http://127.0.0.1:${daemon.port}/runs/${issue}/turn-limit`,
26
22
  {
@@ -47,7 +47,6 @@ function styleStatus(text: string): string {
47
47
  .map((line) => {
48
48
  if (
49
49
  line === "caps" ||
50
- line === "daemon" ||
51
50
  line === "active runs" ||
52
51
  line === "active runs (none)" ||
53
52
  line.startsWith("project ")
@@ -56,6 +55,11 @@ function styleStatus(text: string): string {
56
55
  if (line.includes("STALLED")) return fail(line);
57
56
  if (/^(dispatch|ticks|pane|herdr|telegram| healthz)\s+.*\b(running|healthy|ok)\b/.test(line))
58
57
  return ok(line);
58
+ // The daemon headline carries its own three states (#685): green while
59
+ // serving, amber when the probe timed out or the pid is gone — a bare
60
+ // `daemon` heading line no longer exists to style.
61
+ if (/^daemon\s+.*\b(not running|unresponsive)\b/.test(line)) return warn(line);
62
+ if (/^daemon\s+running\b/.test(line)) return ok(line);
59
63
  if (/^(dispatch|ticks|daemon)\s+.*\b(paused|stopped|not running|overdue)\b/.test(line))
60
64
  return warn(line);
61
65
  return line;
@@ -21,6 +21,7 @@
21
21
  import type { CommandContext } from "./context.ts";
22
22
  import { findProject, loadConfig } from "../config.ts";
23
23
  import { CONDITION_FORMS, parseCondition } from "../decisions.ts";
24
+ import { shellQuote } from "../shell.ts";
24
25
  import { dbPath, openStore } from "../store.ts";
25
26
 
26
27
  const WATCH_USAGE = `omp-conductor watch — set a condition or carry note for the orchestrator itself.
@@ -36,6 +37,44 @@ one with a recorded reason — the verb that creates a watch is the verb that
36
37
  ends it. A watch whose PR condition can no longer be observed (the PR merged
37
38
  or closed first) is withdrawn by the daemon itself.`;
38
39
 
40
+ /** The flags each watch subcommand accepts, after the subcommand itself.
41
+ * `add`'s positional note and `withdraw`'s positional id are validated by
42
+ * the branches below, not through this table. */
43
+ const WATCH_FLAGS: Readonly<Record<string, readonly string[]>> = {
44
+ add: ["--blocks", "--note", "--project", "--resolves-when"],
45
+ list: ["--json", "--project"],
46
+ withdraw: ["--project", "--reason"],
47
+ };
48
+
49
+ /** Flags that take no value — `list --json` must not consume the token after it. */
50
+ const WATCH_BOOL_FLAGS: Readonly<Record<string, true>> = { "--json": true };
51
+
52
+ /**
53
+ * Rejects any token after the subcommand that its surface does not declare,
54
+ * so a typo'd flag can never be silently ignored — the defect where
55
+ * `--condition` created a watch with no condition at all. Both `--flag VALUE`
56
+ * and `--flag=VALUE` are accepted, matching the shared `flag()` parser; a
57
+ * value flag consumes the next token, a `--flag=VALUE` form or a boolean flag
58
+ * never does. The same exit-2 scan `drain` and `stats` run (#462).
59
+ */
60
+ function assertKnownWatchArgs(ctx: CommandContext, sub: string, from: number): void {
61
+ const allowed = WATCH_FLAGS[sub] ?? [];
62
+ for (let i = from; i < ctx.argv.length; i++) {
63
+ const token = ctx.argv[i];
64
+ if (token === undefined) continue;
65
+ const eq = token.startsWith("--") ? token.indexOf("=") : -1;
66
+ const name = eq < 0 ? token : token.slice(0, eq);
67
+ if (allowed.includes(name)) {
68
+ if (eq < 0 && WATCH_BOOL_FLAGS[name] !== true) i += 1; // consume the flag's value token
69
+ continue;
70
+ }
71
+ process.stderr.write(
72
+ `omp-conductor: watch ${sub}: unexpected argument "${token}" (known: ${allowed.join(" ")}) — nothing was recorded\n`,
73
+ );
74
+ process.exit(2);
75
+ }
76
+ }
77
+
39
78
  export async function watchCommand(ctx: CommandContext): Promise<void> {
40
79
  const sub = ctx.argv[1];
41
80
  if (sub === "--help" || sub === "-h") {
@@ -46,6 +85,7 @@ export async function watchCommand(ctx: CommandContext): Promise<void> {
46
85
  const store = openStore(dbPath());
47
86
  try {
48
87
  if (sub === "add") {
88
+ assertKnownWatchArgs(ctx, "add", 2);
49
89
  const note = ctx.flag("note")?.trim();
50
90
  if (note === undefined || note.length === 0 || note.startsWith("--")) {
51
91
  process.stderr.write("omp-conductor: watch add needs --note with what the next tick should know\n");
@@ -68,13 +108,17 @@ export async function watchCommand(ctx: CommandContext): Promise<void> {
68
108
  at: Date.now(),
69
109
  });
70
110
  const wake = condition === undefined ? "read by the next tick" : "the daemon wakes the next tick when it is met";
111
+ // The verb that ends the row names the project it was created in: this
112
+ // command is copied, and on a host with several projects the bare form is
113
+ // ambiguous (#810).
71
114
  process.stdout.write(
72
- `watch ${watch.id} added — ${wake} (no operator answer needed); end with: omp-conductor watch withdraw ${watch.id}\n`,
115
+ `watch ${watch.id} added — ${wake} (no operator answer needed); end with: omp-conductor watch withdraw ${watch.id} --project ${shellQuote(project.name)}\n`,
73
116
  );
74
117
  return;
75
118
  }
76
119
 
77
120
  if (sub === "list" || sub === undefined) {
121
+ assertKnownWatchArgs(ctx, "list", 2);
78
122
  const open = store.openDecisions(project.name).filter((d) => d.kind === "watch");
79
123
  const now = Date.now();
80
124
  const watches = open.map((d) => ({
@@ -95,13 +139,17 @@ export async function watchCommand(ctx: CommandContext): Promise<void> {
95
139
  for (const watch of watches) {
96
140
  process.stdout.write(
97
141
  `${watch.id} ${watch.ageHours}h blocks:${watch.blocks ?? "-"} ` +
98
- `condition:${watch.condition ?? "-"} ${watch.note} (end: omp-conductor watch withdraw ${watch.id})\n`,
142
+ // The listing already resolved the project, so the cleanup command
143
+ // it displays must carry it: an operator copying a command from
144
+ // this output should get one that runs as written (#810).
145
+ `condition:${watch.condition ?? "-"} ${watch.note} (end: omp-conductor watch withdraw ${watch.id} --project ${shellQuote(project.name)})\n`,
99
146
  );
100
147
  }
101
148
  return;
102
149
  }
103
150
 
104
151
  if (sub === "withdraw") {
152
+ assertKnownWatchArgs(ctx, "withdraw", 3);
105
153
  const id = ctx.argv[2];
106
154
  if (id === undefined || id.startsWith("--")) {
107
155
  process.stderr.write("omp-conductor: watch withdraw needs the watch id\n");
@@ -2,13 +2,13 @@
2
2
  * `worker` — pause/resume/stop one live worker session through the daemon's run-control HTTP endpoints.
3
3
  *
4
4
  * Moved out of cli.ts's switch by the per-verb module split (#462);
5
- * only the case wrapper, the injected `ctx` lookups and the imports
6
- * changed from the original bodies.
5
+ * the daemon discovery resolves through {@link requireDaemonControl} so a
6
+ * live systemd-run daemon stays reachable when its pidfile is missing (#811).
7
7
  */
8
8
 
9
9
  import type { CommandContext } from "./context.ts";
10
10
  import { findProject, loadConfig } from "../config.ts";
11
- import { livingDaemon } from "../lifecycle.ts";
11
+ import { requireDaemonControl } from "../lifecycle.ts";
12
12
 
13
13
  export async function workerCommand(ctx: CommandContext): Promise<void> {
14
14
  const sub = ctx.argv[1];
@@ -28,13 +28,12 @@ if (sub === "stop" && (reason === undefined || reason === "" || reason.length >
28
28
  process.exit(2);
29
29
  }
30
30
  const project = findProject(loadConfig(), ctx.projectFlag);
31
- const daemon = livingDaemon();
32
- if (daemon === undefined) throw new Error("daemon is not running");
33
- if (daemon.project !== undefined && daemon.project !== project.name) {
34
- throw new Error(
35
- `daemon serves project "${daemon.project}", not requested project "${project.name}"`,
36
- );
37
- }
31
+ // The pidfile is authoritative when it names a live process; a missing or
32
+ // stale record falls back to the systemd unit, proved through its own
33
+ // /healthz answer, so a live unit-owned daemon is never reported stopped
34
+ // because the runtime directory was wiped (#811). The refusal chain is
35
+ // shared with `extend` through requireDaemonControl.
36
+ const daemon = await requireDaemonControl(project.name);
38
37
  const response = await fetch(
39
38
  `http://127.0.0.1:${daemon.port}/runs/${issue}/${sub}`,
40
39
  {
@@ -336,6 +336,25 @@ const stateLabelsSchema = z
336
336
  })
337
337
  .partial();
338
338
 
339
+ // ---------------------------------------------------------------------------
340
+ // Host constraints (#721)
341
+ // ---------------------------------------------------------------------------
342
+
343
+ const hostConstraintsSchema = z
344
+ .object({
345
+ description: z.string().min(1).describe("What this host is and what else it runs"),
346
+ path: z
347
+ .string()
348
+ .min(1)
349
+ .describe('The non-interactive PATH a script or `ssh host "<cmd>"` invocation must export'),
350
+ conventions: z
351
+ .record(z.string().min(1), z.string().min(1))
352
+ .describe("Per-repo command conventions, keyed by the brief's `owner/repo` slug"),
353
+ })
354
+ .strict()
355
+ .partial()
356
+ .describe("Operator-authored host facts rendered into every worker brief");
357
+
339
358
  // ---------------------------------------------------------------------------
340
359
  // Project / root
341
360
  // ---------------------------------------------------------------------------
@@ -409,6 +428,11 @@ const configSchema = z
409
428
  .min(1, "must name a directory")
410
429
  .optional()
411
430
  .describe("Absolute directory for restorable conductor.db snapshots; defaults to <stateDir>/backups/db"),
431
+ // Cores and RAM are derived by host.ts at render time; only what the
432
+ // operator must type belongs in this block.
433
+ host: hostConstraintsSchema
434
+ .optional()
435
+ .describe("Host facts every worker brief renders; absent renders no section"),
412
436
  projects: z.array(projectSchema).min(1, `"projects" must be a non-empty array — the dispatcher has nothing to service otherwise`),
413
437
  })
414
438
  .loose()
package/src/config.ts CHANGED
@@ -49,6 +49,7 @@ import {
49
49
  type Caps,
50
50
  type ConductorConfig,
51
51
  type DigestCadence,
52
+ type HostConstraints,
52
53
  type InterruptCategory,
53
54
  type MergePreconditions,
54
55
  type PlanUsageCap,
@@ -1092,8 +1093,48 @@ function finalize(data: unknown, path: string): ConductorConfig {
1092
1093
  });
1093
1094
  }
1094
1095
 
1096
+ const rawHost = root["host"] as Raw | undefined;
1097
+ const host = finalizeHost(rawHost);
1098
+ const rawDbBackupDir = root["dbBackupDir"];
1099
+ const dbBackupDir = typeof rawDbBackupDir === "string" ? rawDbBackupDir : undefined;
1100
+
1095
1101
  if (problems.length > 0) throw new Error(problemEnvelope(path, problems));
1096
- return { version: CONFIG_VERSION, defaults, projects };
1102
+ // `ConductorConfig` is rebuilt here, so every optional top-level key must be
1103
+ // spelled out or it silently vanishes from every loaded config. `dbBackupDir`
1104
+ // and `host` are the two today; a third needs its own line below (#773).
1105
+ return {
1106
+ version: CONFIG_VERSION,
1107
+ defaults,
1108
+ projects,
1109
+ ...(dbBackupDir === undefined ? {} : { dbBackupDir }),
1110
+ ...(host === undefined ? {} : { host }),
1111
+ };
1112
+ }
1113
+
1114
+ /**
1115
+ * The typed host-constraints block (#721), or `undefined` when nothing usable
1116
+ * was configured — matching the schema's "absent renders no section" promise.
1117
+ * Trimming and empty-drop mirror the other hand-edited string fields; an
1118
+ * object whose every key is empty loads as if the field were absent.
1119
+ */
1120
+ function finalizeHost(parsed: Raw | undefined): HostConstraints | undefined {
1121
+ if (parsed === undefined) return undefined;
1122
+ const out: HostConstraints = {};
1123
+ const description = parsed["description"];
1124
+ if (typeof description === "string" && description.trim() !== "") out.description = description.trim();
1125
+ const path = parsed["path"];
1126
+ if (typeof path === "string" && path.trim() !== "") out.path = path.trim();
1127
+ const conventions = parsed["conventions"];
1128
+ if (typeof conventions === "object" && conventions !== null && !Array.isArray(conventions)) {
1129
+ const kept: Record<string, string> = {};
1130
+ for (const [slug, value] of Object.entries(conventions as Record<string, unknown>)) {
1131
+ if (typeof value === "string" && value.trim() !== "" && slug.trim() !== "") {
1132
+ kept[slug.trim()] = value.trim();
1133
+ }
1134
+ }
1135
+ if (Object.keys(kept).length > 0) out.conventions = kept;
1136
+ }
1137
+ return Object.keys(out).length === 0 ? undefined : out;
1097
1138
  }
1098
1139
 
1099
1140
  function finalizeProject(