omp-conductor 0.15.9 → 0.15.11

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 (86) hide show
  1. package/README.md +273 -2543
  2. package/REFERENCE.md +2638 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +8 -23
  5. package/src/arm-challenge.ts +112 -0
  6. package/src/ask.ts +434 -0
  7. package/src/board.ts +81 -15
  8. package/src/brief-upgrade.ts +114 -8
  9. package/src/briefs/orchestrator.md +55 -29
  10. package/src/briefs/policy.md +14 -5
  11. package/src/briefs/worker.md +7 -1
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +190 -1391
  15. package/src/commands/arm.ts +21 -0
  16. package/src/commands/board.ts +23 -0
  17. package/src/commands/brief-upgrade.ts +186 -0
  18. package/src/commands/context.ts +49 -0
  19. package/src/commands/daemon.ts +71 -0
  20. package/src/commands/dashboard.ts +74 -0
  21. package/src/commands/decision.ts +103 -0
  22. package/src/commands/disarm.ts +21 -0
  23. package/src/commands/doctor.ts +98 -0
  24. package/src/commands/event.ts +62 -0
  25. package/src/commands/extend.ts +64 -0
  26. package/src/commands/friction.ts +56 -0
  27. package/src/commands/help.ts +9 -0
  28. package/src/commands/hold.ts +26 -0
  29. package/src/commands/intake.ts +134 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +48 -0
  32. package/src/commands/report.ts +170 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +93 -0
  36. package/src/commands/start.ts +23 -0
  37. package/src/commands/stats.ts +131 -0
  38. package/src/commands/status.ts +48 -0
  39. package/src/commands/stop.ts +51 -0
  40. package/src/commands/tail.ts +109 -0
  41. package/src/commands/unblock.ts +39 -0
  42. package/src/commands/upgrade-install.ts +31 -0
  43. package/src/commands/upgrade-rollback.ts +23 -0
  44. package/src/commands/upgrade.ts +25 -0
  45. package/src/commands/verb.ts +83 -0
  46. package/src/commands/version.ts +30 -0
  47. package/src/commands/worker.ts +100 -0
  48. package/src/config-schema.ts +38 -1
  49. package/src/config.ts +10 -3
  50. package/src/daemon.ts +613 -94
  51. package/src/dashboard/app.js +120 -0
  52. package/src/dashboard/index.html +34 -0
  53. package/src/dashboard/server.ts +267 -0
  54. package/src/dashboard/style.css +180 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +795 -0
  58. package/src/escalate.ts +60 -19
  59. package/src/failure-class.ts +29 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/graph-health.ts +1 -1
  62. package/src/label-projection.ts +1 -1
  63. package/src/lifecycle.ts +198 -2
  64. package/src/notices.ts +9 -0
  65. package/src/omp.ts +2 -0
  66. package/src/orchestrator-tick.ts +315 -17
  67. package/src/release-policy.ts +135 -23
  68. package/src/reports.ts +19 -5
  69. package/src/setup-host.ts +420 -8
  70. package/src/setup-install.ts +69 -14
  71. package/src/setup-wizard.ts +199 -61
  72. package/src/setup.ts +131 -35
  73. package/src/stats.ts +331 -0
  74. package/src/store.ts +206 -21
  75. package/src/tracker/github.ts +27 -4
  76. package/src/types.ts +144 -31
  77. package/src/unblock.ts +55 -11
  78. package/src/upgrade-journal.ts +220 -0
  79. package/src/upgrade-verify.ts +506 -0
  80. package/src/upgrade.ts +295 -26
  81. package/src/verbs/actions.ts +73 -1
  82. package/src/verbs/protocol.ts +29 -4
  83. package/src/verbs/server.ts +183 -20
  84. package/systemd/omp-conductor-recover.sh +433 -0
  85. package/systemd/omp-conductor.service.example +7 -0
  86. package/systemd/recover-unit-test.sh +428 -0
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `upgrade-rollback` — the detached rollback unit (#486).
3
+ *
4
+ * Reads the state-dir upgrade journal, restores every surface the failed
5
+ * install touched from the durable pre-install snapshot, reloads the services
6
+ * the install reloaded, and writes the outcome back to the journal plus the
7
+ * durable outbox. Runs as its own transient unit so the restarts it performs
8
+ * pass it by — the same trust boundary as the install: the process that rolls
9
+ * back is not the process that failed.
10
+ */
11
+
12
+ import type { CommandContext } from "./context.ts";
13
+ import { rollbackFromJournal } from "../upgrade.ts";
14
+
15
+ export async function upgradeRollbackCommand(ctx: CommandContext): Promise<void> {
16
+ const result = await rollbackFromJournal();
17
+ process.stdout.write(
18
+ `rolled back to omp-conductor@${result.restoredVersion} after the failed ` +
19
+ `upgrade to ${result.failedVersion}\n` +
20
+ ` surfaces restored from the pre-install snapshot\n` +
21
+ ` dispatch paused — the next tick verifies the old version and resumes\n`,
22
+ );
23
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `upgrade` — update the Bun-global CLI, omp plugin, Herdr recovery plugin and brief as one pinned release.
3
+ *
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.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { upgradeConductor } from "../upgrade.ts";
11
+
12
+ export async function upgradeCommand(ctx: CommandContext): Promise<void> {
13
+ const result = await upgradeConductor({
14
+ version: ctx.flag("to"),
15
+ project: ctx.projectFlag,
16
+ });
17
+ process.stdout.write(
18
+ `${result.alreadyCurrent ? "already current" : "upgrade complete"}:\n` +
19
+ ` Bun-global CLI omp-conductor@${result.version}\n` +
20
+ ` omp plugin omp-conductor@${result.version}\n` +
21
+ ` Herdr plugin herdr-conductor@${result.gitHead}\n` +
22
+ ` orchestrator brief managed ORCHESTRATOR.md floor current; POLICY.md preserved\n` +
23
+ ` dispatch ${result.dispatch}${result.alreadyCurrent ? "" : " restored"}\n`,
24
+ );
25
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `verb` — run one `conductor_*` verb as the orchestrator, from the CLI: through the daemon's own checks and ledger rows.
3
+ *
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.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { findProject, loadConfig } from "../config.ts";
11
+ import { isPaused, pausedAt } from "../daemon.ts";
12
+ import { readBaseChain } from "../gitops.ts";
13
+ import { dbPath, openStore } from "../store.ts";
14
+ import { makeTracker } from "../tracker/github.ts";
15
+ import { VERB_NAMES } from "../types.ts";
16
+ import { githubVerbActions } from "../verbs/actions.ts";
17
+ import { handleVerbCall, type VerbChannel } from "../verbs/server.ts";
18
+
19
+ export async function verbCommand(ctx: CommandContext): Promise<void> {
20
+ const name = ctx.argv[1];
21
+ if (name === undefined || !VERB_NAMES.some((n) => n === name)) {
22
+ process.stderr.write(
23
+ `omp-conductor: unknown verb "${name ?? ""}". Known verbs: ${VERB_NAMES.join(", ")}\n`,
24
+ );
25
+ process.exit(2);
26
+ }
27
+ const cfg = loadConfig();
28
+ const project = findProject(cfg, ctx.projectFlag);
29
+ const args: Record<string, string> = {};
30
+ for (let i = 2; i < ctx.argv.length; i++) {
31
+ const token = ctx.argv[i];
32
+ if (token === "--arg") {
33
+ const pair = ctx.argv[i + 1];
34
+ if (pair === undefined || !pair.includes("=")) {
35
+ process.stderr.write(`omp-conductor: verb --arg needs k=v, got "${pair ?? ""}"\n`);
36
+ process.exit(2);
37
+ }
38
+ const eq = pair.indexOf("=");
39
+ args[pair.slice(0, eq)] = pair.slice(eq + 1);
40
+ i++;
41
+ } else if (token === "--project" || token?.startsWith("--project=") === true) {
42
+ // Consumed by `ctx.projectFlag` above; both spellings skip here.
43
+ if (token === "--project") i++;
44
+ } else {
45
+ process.stderr.write(
46
+ `omp-conductor: verb: unexpected argument "${token ?? ""}" (pass verb arguments as --arg k=v)\n`,
47
+ );
48
+ process.exit(2);
49
+ }
50
+ }
51
+ const store = openStore(dbPath());
52
+ try {
53
+ const channel: VerbChannel = {
54
+ kind: "orchestrator",
55
+ path: "cli",
56
+ project: project.name,
57
+ role: "orchestrator",
58
+ };
59
+ const reply = await handleVerbCall(
60
+ {
61
+ project: () => findProject(loadConfig(), project.name),
62
+ store,
63
+ tracker: makeTracker(project),
64
+ actions: githubVerbActions(project),
65
+ fleetStop: () =>
66
+ isPaused(project.name)
67
+ ? "claiming is paused for this fleet (omp-conductor hold or stop)"
68
+ : undefined,
69
+ pausedAt: () => pausedAt(project.name),
70
+ log: (m) => process.stderr.write(`${m}\n`),
71
+ now: () => Date.now(),
72
+ chain: { readBaseChain },
73
+ },
74
+ channel,
75
+ { verb: name, args },
76
+ );
77
+ process.stdout.write(`${reply.text}\n`);
78
+ // A refusal must not read as success to a script calling this.
79
+ if (!reply.ok) process.exitCode = 3;
80
+ } finally {
81
+ store.close();
82
+ }
83
+ }
@@ -0,0 +1,30 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * The installed package version, read from the package manifest once per run.
6
+ * The path is one level deeper than cli.ts's original — this module lives in
7
+ * src/commands/ — but resolves to the same omp/package.json.
8
+ */
9
+ function packageVersion(): string {
10
+ const parsed = JSON.parse(
11
+ readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8"),
12
+ ) as {
13
+ version?: unknown;
14
+ };
15
+ if (typeof parsed.version !== "string" || parsed.version.length === 0) {
16
+ throw new Error("installed package.json has no version");
17
+ }
18
+ return parsed.version;
19
+ }
20
+
21
+ /**
22
+ * `--version`, `-V` and `version`: print the installed package version.
23
+ *
24
+ * Moved out of cli.ts's switch by the per-verb module split (#462); only the
25
+ * case wrapper and the imports changed from the original body.
26
+ */
27
+
28
+ export async function versionCommand(): Promise<void> {
29
+ process.stdout.write(`${packageVersion()}\n`);
30
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * `worker` — pause/resume/stop one live worker session through the daemon's run-control HTTP endpoints.
3
+ *
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.
7
+ */
8
+
9
+ import type { CommandContext } from "./context.ts";
10
+ import { findProject, loadConfig } from "../config.ts";
11
+ import { livingDaemon } from "../lifecycle.ts";
12
+
13
+ export async function workerCommand(ctx: CommandContext): Promise<void> {
14
+ const sub = ctx.argv[1];
15
+ if (sub !== "pause" && sub !== "resume" && sub !== "stop") {
16
+ process.stderr.write(
17
+ "omp-conductor: worker needs pause, resume, or stop, then an issue number\n",
18
+ );
19
+ process.exit(2);
20
+ }
21
+ const issue = ctx.issueArg("worker", ctx.argv[2]);
22
+ const rawReason = sub === "stop" ? ctx.flag("reason") : undefined;
23
+ const reason = rawReason?.trim().replace(/\s+/g, " ");
24
+ if (sub === "stop" && (reason === undefined || reason === "" || reason.length > 500)) {
25
+ process.stderr.write(
26
+ "omp-conductor: worker stop needs --reason with 1-500 characters\n",
27
+ );
28
+ process.exit(2);
29
+ }
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
+ }
38
+ const response = await fetch(
39
+ `http://127.0.0.1:${daemon.port}/runs/${issue}/${sub}`,
40
+ {
41
+ method: "PUT",
42
+ headers: { "content-type": "application/json" },
43
+ body: JSON.stringify({
44
+ project: project.name,
45
+ ...(reason === undefined ? {} : { reason }),
46
+ }),
47
+ },
48
+ );
49
+ const payload = (await response.json()) as {
50
+ error?: unknown;
51
+ runId?: unknown;
52
+ phase?: unknown;
53
+ outcome?: unknown;
54
+ state?: unknown;
55
+ reason?: unknown;
56
+ salvageSha?: unknown;
57
+ salvageError?: unknown;
58
+ worktree?: unknown;
59
+ };
60
+ if (!response.ok) {
61
+ throw new Error(
62
+ typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`,
63
+ );
64
+ }
65
+ if (sub === "stop") {
66
+ if (
67
+ typeof payload.runId !== "string" ||
68
+ typeof payload.state !== "string" ||
69
+ (payload.outcome !== "stopped" && payload.outcome !== "already-terminal")
70
+ ) {
71
+ throw new Error("daemon returned an invalid worker-stop response");
72
+ }
73
+ if (payload.outcome === "already-terminal") {
74
+ process.stdout.write(
75
+ `#${issue} worker already terminal: ${payload.state} (run ${payload.runId})\n`,
76
+ );
77
+ return;
78
+ }
79
+ if (typeof payload.reason !== "string") {
80
+ throw new Error("daemon returned an invalid worker-stop response");
81
+ }
82
+ process.stdout.write(
83
+ `#${issue} worker stopped (run ${payload.runId}): ${payload.reason}\n`,
84
+ );
85
+ if (typeof payload.salvageSha === "string") {
86
+ process.stdout.write(`work preserved: ${payload.salvageSha}\n`);
87
+ }
88
+ if (typeof payload.salvageError === "string") {
89
+ process.stdout.write(
90
+ `WIP SALVAGE FAILED: ${payload.salvageError}\n` +
91
+ `worktree kept: ${typeof payload.worktree === "string" ? payload.worktree : "(path unavailable)"}\n`,
92
+ );
93
+ }
94
+ return;
95
+ }
96
+ if (typeof payload.runId !== "string" || typeof payload.phase !== "string") {
97
+ throw new Error("daemon returned an invalid worker-control response");
98
+ }
99
+ process.stdout.write(`#${issue} worker ${payload.phase} (run ${payload.runId})\n`);
100
+ }
@@ -226,6 +226,7 @@ const authoritySchema = z
226
226
  .object({
227
227
  merge: authorityHolderEnum.default(DEFAULT_AUTHORITY.merge),
228
228
  release: authorityHolderEnum.default(DEFAULT_AUTHORITY.release),
229
+ promotion: authorityHolderEnum.default(DEFAULT_AUTHORITY.promotion),
229
230
  })
230
231
  .strict();
231
232
 
@@ -352,7 +353,43 @@ export const ConfigSchema = configSchema;
352
353
  * validate a hand-written config against the same vocabulary the loader enforces.
353
354
  */
354
355
  export function configJsonSchema(): object {
355
- return z.toJSONSchema(ConfigSchema) as object;
356
+ return unrequireDefaultedKeys(z.toJSONSchema(ConfigSchema) as object);
357
+ }
358
+
359
+ /** True for a JSON object value (null and arrays excluded). */
360
+ function isObject(value: unknown): value is Record<string, unknown> {
361
+ return typeof value === "object" && value !== null;
362
+ }
363
+
364
+ /**
365
+ * zod's JSON-Schema renderer lists a field carrying `.default(...)` as
366
+ * `required` anyway — its required computation keys off optionality, while
367
+ * the only trace a default leaves in the output is the `default` annotation.
368
+ * The loader accepts a config that omits any defaulted key, so a schema that
369
+ * demands one rejects a file the daemon reads cleanly (the pre-#469
370
+ * back-compat promise). Walk the rendered schema and drop every `required`
371
+ * key whose property schema declares a `default`; a key with no default is
372
+ * untouched. Works in place on the tree `z.toJSONSchema` just built.
373
+ */
374
+ function unrequireDefaultedKeys(schema: object): object {
375
+ if (Array.isArray(schema)) {
376
+ for (const item of schema) if (isObject(item)) unrequireDefaultedKeys(item);
377
+ return schema;
378
+ }
379
+ const node = schema as Record<string, unknown>;
380
+ const properties = node.properties;
381
+ if (isObject(properties) && Array.isArray(node.required)) {
382
+ const kept = (node.required as unknown[]).filter((key) => {
383
+ const property = properties[String(key)];
384
+ return !isObject(property) || !("default" in property);
385
+ });
386
+ if (kept.length === 0) delete node.required;
387
+ else node.required = kept;
388
+ }
389
+ for (const value of Object.values(node)) {
390
+ if (isObject(value)) unrequireDefaultedKeys(value);
391
+ }
392
+ return schema;
356
393
  }
357
394
 
358
395
  // Re-exports the renderer consumes without duplicating the lists.
package/src/config.ts CHANGED
@@ -558,13 +558,17 @@ function checkDigest(d: Raw, label: string, problems: string[]): void {
558
558
  function checkAuthority(a: Raw, label: string, problems: string[]): void {
559
559
  const merge = a["merge"];
560
560
  const release = a["release"];
561
+ const promotion = a["promotion"];
561
562
  if (merge !== undefined && typeof merge === "string" && !(AUTHORITY_HOLDERS as readonly string[]).includes(merge)) {
562
563
  problems.push(`${label}: authority.merge must be ${AUTHORITY_HOLDER_LIST}, found ${JSON.stringify(merge)}`);
563
564
  }
564
565
  if (release !== undefined && typeof release === "string" && !(AUTHORITY_HOLDERS as readonly string[]).includes(release)) {
565
566
  problems.push(`${label}: authority.release must be ${AUTHORITY_HOLDER_LIST}, found ${JSON.stringify(release)}`);
566
567
  }
567
- const unknown = Object.keys(a).filter((k) => k !== "merge" && k !== "release");
568
+ if (promotion !== undefined && typeof promotion === "string" && !(AUTHORITY_HOLDERS as readonly string[]).includes(promotion)) {
569
+ problems.push(`${label}: authority.promotion must be ${AUTHORITY_HOLDER_LIST}, found ${JSON.stringify(promotion)}`);
570
+ }
571
+ const unknown = Object.keys(a).filter((k) => k !== "merge" && k !== "release" && k !== "promotion");
568
572
  if (unknown.length > 0) problems.push(`${label}: authority has unknown key(s): ${unknown.join(", ")}`);
569
573
  }
570
574
 
@@ -817,7 +821,7 @@ function clauseFor(rel: readonly PropertyKey[], issue: {
817
821
  }
818
822
  if (rel.length === 1 && relStr === "escalation") return `escalation must be an object`;
819
823
  if (rel.length === 1 && relStr === "authority") {
820
- return `authority must be an object with "merge" and "release" of ${AUTHORITY_HOLDER_LIST}`;
824
+ return `authority must be an object with "merge", "release" and "promotion" of ${AUTHORITY_HOLDER_LIST}`;
821
825
  }
822
826
  if (rel.length === 1 && relStr === "policy") {
823
827
  return `policy must be an object with "merge" and "release" sections`;
@@ -909,7 +913,7 @@ function clauseFor(rel: readonly PropertyKey[], issue: {
909
913
  }
910
914
  if (pathStr === "reporting.digest.cadence") return `${pathStr} must be ${DIGEST_CADENCE_LIST}, found ${found()}`;
911
915
  if (pathStr === "escalation.orchestrator") return `${pathStr} must be ${ORCHESTRATOR_MODE_LIST}, found ${found()}`;
912
- if (pathStr === "authority.merge" || pathStr === "authority.release") {
916
+ if (pathStr === "authority.merge" || pathStr === "authority.release" || pathStr === "authority.promotion") {
913
917
  return `${pathStr} must be ${AUTHORITY_HOLDER_LIST}, found ${found()}`;
914
918
  }
915
919
  if (pathStr === "policy.merge.baseFreshness") return `${pathStr} must be ${BASE_FRESHNESS_LIST}, found ${found()}`;
@@ -1136,6 +1140,9 @@ function finalizeAuthority(parsed: Raw | undefined): ProjectConfig["authority"]
1136
1140
  return {
1137
1141
  merge: (parsed["merge"] as ProjectConfig["authority"]["merge"]) ?? DEFAULT_AUTHORITY.merge,
1138
1142
  release: (parsed["release"] as ProjectConfig["authority"]["release"]) ?? DEFAULT_AUTHORITY.release,
1143
+ // Unstated defaults to the human: a config written before promotion was a
1144
+ // key must load as operator-owned, never as a silent delegation.
1145
+ promotion: (parsed["promotion"] as ProjectConfig["authority"]["promotion"] | undefined) ?? DEFAULT_AUTHORITY.promotion,
1139
1146
  };
1140
1147
  }
1141
1148