omp-conductor 0.4.1 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
package/src/config.ts CHANGED
@@ -219,6 +219,18 @@ export function loadConfig(): ConductorConfig {
219
219
  * a truncated one. Mode 0600 because a config carries chat ids and clone URLs.
220
220
  */
221
221
  export function saveConfig(c: ConductorConfig): void {
222
+ writeConfigFile(c);
223
+ }
224
+
225
+ /**
226
+ * Atomic write of whatever object is handed in.
227
+ *
228
+ * Takes `unknown` rather than `ConductorConfig` so the credentials migration
229
+ * can persist the operator's own parsed file with one key added, instead of a
230
+ * value that has been through the loader and back out. That round-trip is what
231
+ * rewrote a live fleet's config into a dialect the previous release rejected.
232
+ */
233
+ export function writeConfigFile(c: unknown): void {
222
234
  const dir = stateDir();
223
235
  const created = mkdirSync(dir, { recursive: true, mode: 0o700 });
224
236
  // mkdir's mode is masked by umask; chmod only what we just created so an
@@ -931,9 +943,39 @@ export function migrateCredentialsOnDisk(): { migrated: string[]; path: string }
931
943
  }
932
944
  const migrated = projectsMissingCredentials(parsed);
933
945
  if (migrated.length === 0) return { migrated: [], path };
934
- // Through the loader and back out, so the rewrite is the same normalisation
935
- // every other key already gets rather than a second, divergent writer.
936
- saveConfig(loadConfig());
946
+
947
+ // A SURGICAL edit: add the one missing key to the projects that lack it and
948
+ // leave every other byte exactly as the operator wrote it.
949
+ //
950
+ // This was `saveConfig(loadConfig())` once, and that round-trip took a live
951
+ // fleet down. Normalising on the way out rewrites the whole file into the
952
+ // current dialect — `defaults.planUsage`, `releasePolicy` as a per-shape map
953
+ // — and the previous release rejects both. The daemon performing the
954
+ // migration is started *by* an upgrade still running the previous CLI, so
955
+ // that CLI's next `loadConfig` failed, and its rollback reinstalled a version
956
+ // which then could not read the file either. A migration must never make a
957
+ // config unreadable by the release you may have to roll back to.
958
+ // Verified against the real previous release, not just against this parser:
959
+ // omp-conductor@0.3.25 loads a config carrying `credentials` and prints
960
+ // `status` normally, because it ignores unknown *project* keys while
961
+ // rejecting unknown `defaults` keys and a non-string `releasePolicy` — the
962
+ // two things the old round-trip wrote. Re-check that with `npm install
963
+ // omp-conductor@<previous>` before widening what this writes.
964
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return { migrated: [], path };
965
+ const projects = (parsed as Raw)["projects"];
966
+ if (!Array.isArray(projects)) return { migrated: [], path };
967
+ for (const entry of projects) {
968
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
969
+ const raw = entry as Raw;
970
+ const credentials = raw["credentials"];
971
+ const explicit =
972
+ typeof credentials === "object" &&
973
+ credentials !== null &&
974
+ !Array.isArray(credentials) &&
975
+ (credentials as Raw)["isolation"] !== undefined;
976
+ if (!explicit) (raw as Record<string, unknown>)["credentials"] = { isolation: "none" };
977
+ }
978
+ writeConfigFile(parsed);
937
979
  return { migrated, path };
938
980
  }
939
981
 
package/src/upgrade.ts CHANGED
@@ -3,7 +3,7 @@ import { inspectBriefLayout, type BriefLayout } from "./brief-upgrade.ts";
3
3
  import { setPaused, statusSnapshot } from "./daemon.ts";
4
4
  import { fleetLayers, telegramStateDir, type DispatchLayer, type FleetLayers } from "./fleet.ts";
5
5
  import { livingDaemon, restartDaemon } from "./lifecycle.ts";
6
- import { findProject, loadConfig, resolveCaps } from "./config.ts";
6
+ import { configPath, findProject, loadConfig, resolveCaps, writeConfigFile } from "./config.ts";
7
7
  import { renderBriefForProject } from "./setup.ts";
8
8
  import {
9
9
  STAGED_SERVICE_NAME,
@@ -296,8 +296,30 @@ async function rollbackUpgrade(
296
296
  briefChanged: boolean,
297
297
  herdrReloadStarted: boolean,
298
298
  daemonReloadStarted: boolean,
299
+ configBefore?: string,
299
300
  ): Promise<void> {
300
301
  const failures: string[] = [];
302
+
303
+ // Restored FIRST, before the packages that have to read it.
304
+ //
305
+ // Rolling back binaries alone is not a rollback. The new daemon starts during
306
+ // the upgrade and may migrate config.json; the reinstated older release then
307
+ // cannot parse its own config and will not boot — which is exactly how an
308
+ // upgrade failure turned into an outage. The snapshot is taken before the
309
+ // first install, and restoring it is unconditional: writing back a file that
310
+ // never changed is harmless, and detecting "did it change" is one more thing
311
+ // to get wrong while the fleet is down.
312
+ if (configBefore !== undefined) {
313
+ try {
314
+ const path = configPath();
315
+ if (readFileSync(path, "utf8") !== configBefore) {
316
+ deps.log("rollback: conductor config.json");
317
+ writeConfigFile(JSON.parse(configBefore));
318
+ }
319
+ } catch (err) {
320
+ failures.push(`could not restore config.json: ${err instanceof Error ? err.message : String(err)}`);
321
+ }
322
+ }
301
323
  const restore = async (label: string, command: string, args: readonly string[]): Promise<void> => {
302
324
  deps.log(`rollback: ${label}`);
303
325
  try {
@@ -498,6 +520,18 @@ export async function upgradeConductor(
498
520
  };
499
521
  }
500
522
 
523
+ // Snapshotted before anything is installed, because the new daemon starts
524
+ // during this transaction and may migrate the file — see the restore in
525
+ // `rollbackUpgrade`. Read as bytes, not through the loader: the point is to
526
+ // put back exactly what was there, dialect and all.
527
+ let configBefore: string | undefined;
528
+ try {
529
+ configBefore = readFileSync(configPath(), "utf8");
530
+ } catch {
531
+ // No readable config is not a reason to refuse an upgrade; there is simply
532
+ // nothing to put back.
533
+ }
534
+
501
535
  if (brief.kind === "missing") throw new Error("no ORCHESTRATOR.md exists for the configured project");
502
536
  if (initial.herdr === "unknown") throw new Error("cannot determine whether herdr-fleet.service is active");
503
537
 
@@ -611,6 +645,7 @@ export async function upgradeConductor(
611
645
  briefChanged,
612
646
  herdrReloadStarted,
613
647
  daemonReloadStarted,
648
+ configBefore,
614
649
  );
615
650
  } catch (rollbackErr) {
616
651
  const rollback = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);