omp-conductor 0.15.10 → 0.15.12

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 (92) hide show
  1. package/README.md +273 -2544
  2. package/REFERENCE.md +2680 -0
  3. package/package.json +3 -2
  4. package/schema/config.schema.json +11 -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 +113 -34
  10. package/src/briefs/policy.md +33 -8
  11. package/src/briefs/worker.md +18 -9
  12. package/src/chain-check.ts +1 -1
  13. package/src/check-trailing-newlines.ts +82 -0
  14. package/src/cli.ts +225 -1406
  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 +150 -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 +100 -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 +155 -0
  30. package/src/commands/ledger.ts +69 -0
  31. package/src/commands/message.ts +96 -0
  32. package/src/commands/report.ts +206 -0
  33. package/src/commands/restart.ts +76 -0
  34. package/src/commands/resume.ts +58 -0
  35. package/src/commands/setup.ts +143 -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 +32 -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 +47 -1
  49. package/src/config.ts +45 -4
  50. package/src/daemon.ts +972 -101
  51. package/src/dashboard/app.js +459 -0
  52. package/src/dashboard/index.html +61 -0
  53. package/src/dashboard/server.ts +481 -0
  54. package/src/dashboard/style.css +348 -0
  55. package/src/decisions.ts +39 -14
  56. package/src/diff-flags.ts +131 -241
  57. package/src/doctor.ts +932 -0
  58. package/src/escalate.ts +2 -2
  59. package/src/failure-class.ts +66 -3
  60. package/src/fleet.ts +58 -1
  61. package/src/gitops.ts +157 -0
  62. package/src/graph-health.ts +1 -1
  63. package/src/label-projection.ts +1 -1
  64. package/src/lifecycle.ts +198 -2
  65. package/src/model-fallback.ts +177 -0
  66. package/src/notices.ts +9 -0
  67. package/src/omp.ts +93 -13
  68. package/src/orchestrator-tick.ts +414 -20
  69. package/src/orchestrator.ts +4 -4
  70. package/src/privileged.ts +10 -0
  71. package/src/release-policy.ts +342 -30
  72. package/src/reports.ts +19 -5
  73. package/src/session-host.ts +11 -5
  74. package/src/setup-host.ts +663 -25
  75. package/src/setup-install.ts +292 -28
  76. package/src/setup-wizard.ts +255 -74
  77. package/src/setup.ts +156 -35
  78. package/src/stats.ts +331 -0
  79. package/src/store.ts +219 -22
  80. package/src/tracker/github.ts +74 -4
  81. package/src/types.ts +215 -31
  82. package/src/unblock.ts +55 -11
  83. package/src/upgrade-journal.ts +220 -0
  84. package/src/upgrade-verify.ts +506 -0
  85. package/src/upgrade.ts +385 -58
  86. package/src/verbs/actions.ts +73 -1
  87. package/src/verbs/protocol.ts +29 -4
  88. package/src/verbs/server.ts +183 -20
  89. package/src/worker.ts +3 -3
  90. package/systemd/omp-conductor-recover.sh +433 -0
  91. package/systemd/omp-conductor.service.example +14 -3
  92. package/systemd/recover-unit-test.sh +428 -0
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `arm` — proof-gated: send a Telegram challenge and write the arm marker only after the reply appears in the orchestrator transcript.
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 { armTicks } from "../fleet.ts";
11
+
12
+ export async function armCommand(ctx: CommandContext): Promise<void> {
13
+ for (const project of ctx.targetProjects()) {
14
+ process.stdout.write("arm: sending inbound Telegram challenge…\n");
15
+ const r = await armTicks(project.name);
16
+ process.stdout.write(
17
+ `ARMED — inbound round-trip proved with owner ${r.owner}; ticks are now live.\n` +
18
+ `marker ${r.path}${r.alreadyArmed ? " (replaced previous marker)" : ""}\n`,
19
+ );
20
+ }
21
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `board` — the live keyboard-driven fleet board, or a one-shot --json snapshot of the same lanes.
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 { boardJson, boardSnapshotOnce, runBoard } from "../board.ts";
11
+
12
+ export async function boardCommand(ctx: CommandContext): Promise<void> {
13
+ const project = ctx.projectFlag;
14
+ // Headless one-shot mode: `--json` asks for it explicitly, and a
15
+ // non-interactive stdin or stdout (pipe, redirect, cron, script) cannot
16
+ // host the key-driven board anyway. The TTY throw inside runBoard stays
17
+ // as a backstop for direct callers.
18
+ if (ctx.argv.includes("--json") || !process.stdout.isTTY || !process.stdin.isTTY) {
19
+ process.stdout.write(`${boardJson(await boardSnapshotOnce(project))}\n`);
20
+ return;
21
+ }
22
+ await runBoard(project);
23
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * `briefUpgrade` — inspect the brief overlay (package floor + POLICY.md) and run the `--migrate` / `--retrofit` paths.
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 { existsSync, readFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import {
13
+ applyRetrofit,
14
+ formatBriefReport,
15
+ formatMigrateResult,
16
+ formatRetrofitProposal,
17
+ formatRetrofitRefusal,
18
+ inspectBriefLayout,
19
+ migrateToPolicy,
20
+ missingSections,
21
+ proposeRetrofit,
22
+ repairPolicyBannerCrumbs,
23
+ sharedPolicyPath,
24
+ } from "../brief-upgrade.ts";
25
+ import { findProject, loadConfig } from "../config.ts";
26
+ import {
27
+ briefPathForProject,
28
+ policyPathForProject,
29
+ renderBriefForProject,
30
+ renderFloorForProject,
31
+ shippedBriefTemplate,
32
+ } from "../setup.ts";
33
+ import type { ProjectConfig } from "../types.ts";
34
+
35
+ export async function briefUpgradeCommand(ctx: CommandContext): Promise<void> {
36
+ const override = ctx.flag("file");
37
+ let project: ProjectConfig | undefined;
38
+ let path: string;
39
+ if (override === undefined) {
40
+ project = findProject(loadConfig(), ctx.projectFlag);
41
+ path = briefPathForProject(project);
42
+ } else {
43
+ path = override;
44
+ try {
45
+ project = findProject(loadConfig(), ctx.projectFlag);
46
+ } catch {
47
+ project = undefined;
48
+ }
49
+ }
50
+
51
+ const workspaceRoot = project?.workspaceRoot ?? dirname(path);
52
+ const rendered =
53
+ project === undefined ? shippedBriefTemplate() : renderBriefForProject(project);
54
+ const floor = project === undefined ? shippedBriefTemplate() : renderFloorForProject(project);
55
+ const layout = inspectBriefLayout(workspaceRoot, rendered);
56
+ // Host-wide shared policy, reported as a layer when present (#363).
57
+ const sharedBrief = existsSync(sharedPolicyPath()) ? sharedPolicyPath() : undefined;
58
+
59
+ if (ctx.argv.includes("--retrofit")) {
60
+ let live: string;
61
+ try {
62
+ live = readFileSync(path, "utf8");
63
+ } catch {
64
+ process.stderr.write(`omp-conductor: no brief at ${path}.\n`);
65
+ process.exit(1);
66
+ }
67
+ const result = proposeRetrofit(live);
68
+ if (result.kind === "no-cut") {
69
+ process.stdout.write(
70
+ `brief ${path}\n\nNo Releases / Project context / Reporting / Amendments heading found — cannot classify a cut.\n`,
71
+ );
72
+ process.exit(1);
73
+ }
74
+ if (result.kind === "interleaved") {
75
+ process.stdout.write(`${formatRetrofitRefusal(path, result)}\n`);
76
+ process.exit(1);
77
+ }
78
+ process.stdout.write(`${formatRetrofitProposal(path, result.proposal)}\n`);
79
+ if (ctx.argv.includes("--apply")) {
80
+ const backup = applyRetrofit(path, result.proposal);
81
+ process.stdout.write(`\napplied retrofit — previous brief kept at ${backup}\n`);
82
+ }
83
+ return;
84
+ }
85
+
86
+ if (ctx.argv.includes("--migrate")) {
87
+ if (layout.kind === "overlay") {
88
+ if (!ctx.argv.includes("--apply")) {
89
+ process.stdout.write(
90
+ `${formatBriefReport(path, layout, [], sharedBrief)}\n\n` +
91
+ "POLICY.md already present. --migrate --apply will strip any leading\n" +
92
+ "banner-comment crumbs from POLICY.md and recompose ORCHESTRATOR.md.\n",
93
+ );
94
+ return;
95
+ }
96
+ if (project === undefined) {
97
+ process.stderr.write(
98
+ "omp-conductor: repairing an overlay needs --project (or a config) so the floor renders.\n",
99
+ );
100
+ process.exit(1);
101
+ }
102
+ const repaired = repairPolicyBannerCrumbs({
103
+ orchestratorPath: layout.orchestratorPath,
104
+ policyPath: layout.policyPath,
105
+ floor: renderFloorForProject(project),
106
+ });
107
+ if (repaired === undefined) {
108
+ process.stdout.write(
109
+ `${formatBriefReport(path, layout, [], sharedBrief)}\n\nrecomposed ORCHESTRATOR.md — POLICY.md needed no crumb strip.\n`,
110
+ );
111
+ } else {
112
+ process.stdout.write(`${formatMigrateResult(repaired)}\n`);
113
+ }
114
+ return;
115
+ }
116
+ if (layout.kind === "missing") {
117
+ process.stderr.write(`omp-conductor: no brief at ${path} to migrate.\n`);
118
+ process.exit(1);
119
+ }
120
+ if (layout.kind === "legacy-handwritten") {
121
+ process.stdout.write(`${formatBriefReport(path, layout, layout.missing, sharedBrief)}\n`);
122
+ process.exit(1);
123
+ }
124
+ if (project === undefined && /\{\{[A-Za-z0-9_]+\}\}/.test(floor)) {
125
+ process.stderr.write(
126
+ "omp-conductor: --migrate needs --project (or a config) so the floor renders without {{PLACEHOLDER}}s.\n",
127
+ );
128
+ process.exit(1);
129
+ }
130
+ if (!ctx.argv.includes("--apply")) {
131
+ process.stdout.write(
132
+ [
133
+ `migrate ${layout.orchestratorPath}`,
134
+ "",
135
+ "Would write POLICY.md from the owned half below YOURS TO EDIT,",
136
+ "then recompose ORCHESTRATOR.md from the package floor + that policy.",
137
+ "",
138
+ "Apply: omp-conductor brief-upgrade --migrate --apply",
139
+ ].join("\n") + "\n",
140
+ );
141
+ return;
142
+ }
143
+ const policyPath = project ? policyPathForProject(project) : join(workspaceRoot, "POLICY.md");
144
+ const result = migrateToPolicy({
145
+ orchestratorPath: layout.orchestratorPath,
146
+ policyPath,
147
+ floor: project ? renderFloorForProject(project) : floor,
148
+ owned: layout.owned,
149
+ });
150
+ process.stdout.write(`${formatMigrateResult(result)}\n`);
151
+ return;
152
+ }
153
+
154
+ // No `--migrate` / `--retrofit`: report the layout and what to run. The
155
+ // legacy single-file merge that used to live here is gone (#131) — a bare
156
+ // `--apply` now says so rather than silently doing nothing.
157
+ if (ctx.argv.includes("--apply")) {
158
+ process.stderr.write(
159
+ "brief-upgrade: the legacy single-file merge was removed in 0.4.3 — use --migrate --apply (bannered) or --retrofit --apply (hand-written)\n",
160
+ );
161
+ process.exit(2);
162
+ }
163
+
164
+ let missing: string[] = [];
165
+ if (layout.kind !== "overlay" && layout.kind !== "missing") {
166
+ let live: string;
167
+ try {
168
+ live = readFileSync(path, "utf8");
169
+ } catch {
170
+ process.stderr.write(
171
+ `omp-conductor: no brief at ${path}` +
172
+ `${override === undefined ? " — run `omp-conductor setup brief` and say yes to writing ORCHESTRATOR.md." : "."}\n`,
173
+ );
174
+ process.exit(1);
175
+ }
176
+ missing = missingSections(live, rendered);
177
+ }
178
+
179
+ process.stdout.write(`${formatBriefReport(path, layout, missing, sharedBrief)}\n`);
180
+ if (layout.kind !== "overlay" && project === undefined) {
181
+ process.stdout.write(
182
+ "\nnote: no conductor config resolved on this host, so the template's\n" +
183
+ "{{PLACEHOLDER}} coordinates are unsubstituted. Section names are unaffected.\n",
184
+ );
185
+ }
186
+ }
@@ -0,0 +1,150 @@
1
+ import { findProject } from "../config.ts";
2
+ import type { ConductorConfig, ProjectConfig } from "../types.ts";
3
+
4
+ /**
5
+ * What a command handler may read about the invocation it was dispatched for.
6
+ *
7
+ * Argument parsing stays in cli.ts — the flag helpers, the shared usage text
8
+ * and the registration table — and each handler receives the parsed essentials
9
+ * plus the lookups it needs through this object. A command module therefore
10
+ * never imports the entry point back: no import cycle, and no module-level
11
+ * mutable state for one verb to change under another.
12
+ */
13
+ export interface CommandContext {
14
+ /** The full argv including the verb at index 0, read once per process. */
15
+ argv: string[];
16
+ /**
17
+ * The `--project` value, parsed once because nearly every verb takes it.
18
+ * Named `projectFlag` rather than `project` because many handlers bind their
19
+ * own resolved `ProjectConfig` under that name.
20
+ */
21
+ projectFlag: string | undefined;
22
+ /** Applies both `--flag VALUE` and `--flag=VALUE`; undefined when absent. */
23
+ flag(name: string): string | undefined;
24
+ /**
25
+ * `--port` for the daemon-bound verbs. Exits 2 rather than defaulting,
26
+ * because silently ignoring a typo'd port would leave the operator probing
27
+ * an endpoint the daemon is not on.
28
+ */
29
+ portFlag(): number | undefined;
30
+ /** Required positive integer for `extend`; no partial parses such as `180x`. */
31
+ turnsFlag(): number;
32
+ /**
33
+ * `report`'s comma-separated `--events` / `--notices` ledger row ids, in the
34
+ * three shapes the runtime actually mints. Exits 2 on anything else.
35
+ */
36
+ digestIdsFlag(name: "events" | "notices"): string[];
37
+ /**
38
+ * The `<issue>` positional, for the verbs that take one. Exits 2 rather
39
+ * than proceeding with run #NaN; `verb` is named in the message.
40
+ */
41
+ issueArg(verb: string, raw: string | undefined): number;
42
+ /**
43
+ * The projects a fleet-level verb targets: `--all`, `--project NAME`, or the
44
+ * sole configured project (throwing when the choice would be ambiguous).
45
+ */
46
+ targetProjects(): ProjectConfig[];
47
+ }
48
+
49
+ /** One operator subcommand: what the registration table maps a verb to. */
50
+ export type CommandHandler = (ctx: CommandContext) => Promise<void> | void;
51
+
52
+ /**
53
+ * The project scope a command's work is bound to, declared once per verb
54
+ * (#514). Every command in `omp/src/commands/` carries one, and the
55
+ * resolution a command performs must follow it rather than defaulting to
56
+ * `findProject`:
57
+ *
58
+ * - `"project"` — the verb acts on exactly one configured project. It
59
+ * resolves through {@link findProject}, so a multi-project config demands
60
+ * `--project NAME` (ledger, stats, event, report, decision, …).
61
+ * - `"host"` — the verb acts on the one shared host (units, daemon, package
62
+ * surfaces), which no configured project can make ambiguous. It never
63
+ * resolves a project; `--project` is either refused (upgrade/restart
64
+ * refuse to narrow the shared multi-project daemon, #389) or accepted only
65
+ * for the documented per-project tail it affects (start's /healthz check,
66
+ * the `daemon --once` drill).
67
+ * - `"fleet"` — the verb acts on every configured project by default and
68
+ * narrows with `--project NAME` / `--all`, the `targetProjects` shape
69
+ * (arm, disarm, hold, resume, stop, status, board).
70
+ * - `"none"` — the verb takes no project at all (dashboard, help, version).
71
+ */
72
+ export type CommandScope = "project" | "host" | "fleet" | "none";
73
+
74
+ /**
75
+ * The explicit scope of every registered command, keyed by the exact strings
76
+ * the CLI accepts (aliases included). `cli.test.ts` asserts each module in
77
+ * `commands/` is classified and that each scope resolves the way the table
78
+ * below says; REFERENCE.md's CLI table carries the same classification in
79
+ * human form.
80
+ */
81
+ export const COMMAND_SCOPES: Readonly<Record<string, CommandScope>> = {
82
+ // project — exactly one project; findProject demands --project when several
83
+ "brief-upgrade": "project",
84
+ decision: "project",
85
+ event: "project",
86
+ extend: "project",
87
+ friction: "project",
88
+ intake: "project",
89
+ ledger: "project",
90
+ message: "project",
91
+ report: "project",
92
+ setup: "project", // the wizard and `setup graph` configure one project
93
+ stats: "project",
94
+ tail: "project",
95
+ unblock: "project",
96
+ verb: "project",
97
+ worker: "project",
98
+ // host — the shared daemon/package surfaces; never resolves a project
99
+ daemon: "host",
100
+ restart: "host",
101
+ start: "host",
102
+ upgrade: "host",
103
+ "upgrade-install": "host",
104
+ "upgrade-rollback": "host",
105
+ // fleet — every project by default, --project narrows
106
+ arm: "fleet",
107
+ board: "fleet",
108
+ disarm: "fleet",
109
+ doctor: "fleet",
110
+ hold: "fleet",
111
+ resume: "fleet",
112
+ status: "fleet",
113
+ stop: "fleet",
114
+ // none — no project at all
115
+ dashboard: "none",
116
+ help: "none",
117
+ "--help": "none",
118
+ "-h": "none",
119
+ version: "none",
120
+ "--version": "none",
121
+ "-V": "none",
122
+ };
123
+
124
+ /**
125
+ * Resolve the projects one command's work covers, from its declared scope
126
+ * (#514) — the single mechanism every scope falls out of, so a host-scoped
127
+ * command can never inherit `findProject`'s ambiguity by accident.
128
+ *
129
+ * `"host"`/`"none"` resolve to nothing. `"fleet"` returns every configured
130
+ * project with `all`, the named one, or — with neither — the sole configured
131
+ * project, refusing a multi-project config exactly as `targetProjects` always
132
+ * has. `"project"` returns exactly one, via {@link findProject}.
133
+ */
134
+ export function resolveProjectsByScope(
135
+ cfg: ConductorConfig,
136
+ scope: CommandScope,
137
+ name: string | undefined,
138
+ all: boolean,
139
+ ): ProjectConfig[] {
140
+ if (scope === "host" || scope === "none") return [];
141
+ if (scope === "fleet") {
142
+ if (all) return [...cfg.projects];
143
+ if (name === undefined && cfg.projects.length > 1) {
144
+ throw new Error(
145
+ `config has ${cfg.projects.length} projects; use --project NAME or --all`,
146
+ );
147
+ }
148
+ }
149
+ return [findProject(cfg, name)];
150
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `daemon` — run the dispatch loop in the foreground; --once runs a single tick and exits. This is what `start` launches.
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 { runDaemon } from "../daemon.ts";
11
+ import { clearRecord, DEFAULT_PORT, livingDaemon, writeRecord } from "../lifecycle.ts";
12
+
13
+ /**
14
+ * `DaemonRecord.logFile` for a daemon nobody spawned. The field is required and
15
+ * `status` prints it, so it has to say something true: a foreground daemon
16
+ * opened no log of its own — whoever started it owns its stdout, be that
17
+ * systemd's journal, a terminal, or a pane.
18
+ */
19
+ const FOREGROUND_LOG = "<inherited stdout — started in the foreground>";
20
+
21
+ export async function daemonCommand(ctx: CommandContext): Promise<void> {
22
+ // Until now only `lifecycle.startDaemon()` — the spawn path — wrote the
23
+ // pidfile, which left a daemon started in the foreground (which is how
24
+ // systemd runs it) invisible twice over: `omp-conductor status` reported
25
+ // no daemon at all, and a `daemon --once` drill run beside it saw
26
+ // `livingDaemon() === undefined`, concluded nothing else was dispatching,
27
+ // and reconciled the live daemon's in-flight runs as orphans. Writing the
28
+ // record here closes both holes.
29
+ const once = ctx.argv.includes("--once");
30
+ const port = ctx.portFlag();
31
+ const project = ctx.projectFlag;
32
+
33
+ // A drill is still a dispatcher: it settles rows, projects labels,
34
+ // admits, and launches workers, so it holds the same exclusive daemon/
35
+ // state ownership as the long-running dispatcher for the whole tick
36
+ // (#431). `runDaemon` acquires that ownership up front (the one choke
37
+ // point every once tick passes through) and refuses — having done no
38
+ // mutation — while another live daemon owns the project; the thrown
39
+ // refusal surfaces as `omp-conductor: another daemon is alive (pid N)…`.
40
+ if (once) {
41
+ await runDaemon({ once, port, project });
42
+ return;
43
+ }
44
+
45
+ const running = livingDaemon();
46
+ if (running !== undefined && running.pid !== process.pid) {
47
+ process.stderr.write(`omp-conductor: another daemon is alive (pid ${running.pid}); stop it first\n`);
48
+ process.exit(1);
49
+ }
50
+
51
+ // A living record that already names this pid was written by the `start`
52
+ // that spawned us, and it knows the log file our stdout is really going
53
+ // to. Replacing it with a guess would be a downgrade.
54
+ if (running === undefined) {
55
+ writeRecord({
56
+ pid: process.pid,
57
+ port: port ?? DEFAULT_PORT,
58
+ startedAt: Date.now(),
59
+ logFile: FOREGROUND_LOG,
60
+ ...(project === undefined ? {} : { project }),
61
+ });
62
+ }
63
+
64
+ try {
65
+ await runDaemon({ once, port, project });
66
+ } finally {
67
+ // The record names a pid that is about to stop existing. Leaving it
68
+ // behind makes the next reader probe a ghost before believing us.
69
+ clearRecord();
70
+ }
71
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `dashboard` — serve the fleet dashboard in a browser (epic #292, slice 1/5):
3
+ * the UI shell plus one bearer-authenticated read endpoint, `/api/projects`,
4
+ * from a dedicated `Bun.serve` process — never a route on the daemon port,
5
+ * whose unauthenticated loopback surface stays exactly as
6
+ * `verbs/server.test.ts` pins it.
7
+ *
8
+ * Argument parsing follows `stats.ts`'s template: every token after the verb
9
+ * is accounted for, and an unexpected one exits 2 rather than being silently
10
+ * ignored.
11
+ */
12
+
13
+ import type { CommandContext } from "./context.ts";
14
+ import { DASHBOARD_HOST, startDashboard } from "../dashboard/server.ts";
15
+
16
+ const DASHBOARD_USAGE = `omp-conductor dashboard — serve the fleet dashboard in your browser.
17
+
18
+ usage:
19
+ omp-conductor dashboard [--port N] [--host ADDR]
20
+
21
+ Serves the dashboard UI and GET /api/projects from a dedicated process —
22
+ separate from the daemon, which it only ever reads. /api/projects answers with
23
+ every configured project annotated with its daemon state (the same
24
+ livingDaemon + /healthz classification status and board use), the daemon's
25
+ port, and the raw /healthz body when it answers. Every /api request must carry
26
+ Authorization: Bearer <token>; anything else is a bare 401. The token is
27
+ minted mode 0600 at <stateDir()>/dashboard-token on first start (the
28
+ $OMP_CONDUCTOR_HOME state directory, next to config.json) and reused
29
+ afterwards; the page asks for it once and keeps it in localStorage. Static
30
+ assets are served without auth.
31
+
32
+ Binds 127.0.0.1:8788 by default. A non-loopback --host (say, a tailnet IP)
33
+ still starts, but prints a one-line warning naming the token file: the token
34
+ is then the only thing between anyone who can reach that address and the
35
+ project list. Ctrl-C stops the server.
36
+
37
+ flags:
38
+ --port PORT listen port (default 8788)
39
+ --host ADDR bind address (default 127.0.0.1)`;
40
+
41
+ export async function dashboardCommand(ctx: CommandContext): Promise<void> {
42
+ // Help first: parsing stops before any config read, token mint or socket.
43
+ if (ctx.argv[1] === "--help" || ctx.argv[1] === "-h") {
44
+ process.stdout.write(`${DASHBOARD_USAGE}\n`);
45
+ return;
46
+ }
47
+ // Every remaining token after the verb is accounted for: the two value
48
+ // flags cli.ts already parsed (--port via portFlag, --host via flag).
49
+ for (let i = 1; i < ctx.argv.length; i++) {
50
+ const token = ctx.argv[i];
51
+ if (token === undefined) continue;
52
+ if (
53
+ token !== "--port" &&
54
+ token !== "--host" &&
55
+ !token.startsWith("--port=") &&
56
+ !token.startsWith("--host=")
57
+ ) {
58
+ process.stderr.write(`omp-conductor: dashboard: unexpected argument "${token}"\n`);
59
+ process.exit(2);
60
+ }
61
+ if (token === "--port" || token === "--host") i += 1;
62
+ }
63
+ const host = ctx.flag("host");
64
+ if (host === undefined && ctx.argv.includes("--host")) {
65
+ process.stderr.write(`omp-conductor: dashboard --host needs an address\n`);
66
+ process.exit(2);
67
+ }
68
+ if (host !== undefined && host.length === 0) {
69
+ process.stderr.write(`omp-conductor: dashboard --host needs an address\n`);
70
+ process.exit(2);
71
+ }
72
+
73
+ await startDashboard({ port: ctx.portFlag(), host: host ?? DASHBOARD_HOST });
74
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `decision` — record, list and close the questions put to the operator.
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 { CONDITION_FORMS, parseCondition } from "../decisions.ts";
12
+ import { dbPath, openStore } from "../store.ts";
13
+
14
+ export async function decisionCommand(ctx: CommandContext): Promise<void> {
15
+ const sub = ctx.argv[1];
16
+ const project = findProject(loadConfig(), ctx.projectFlag);
17
+ const store = openStore(dbPath());
18
+ try {
19
+ if (sub === "open") {
20
+ const question = ctx.flag("question")?.trim();
21
+ if (question === undefined || question.length === 0 || question.startsWith("--")) {
22
+ process.stderr.write(
23
+ "omp-conductor: decision open needs --question with the question you sent\n",
24
+ );
25
+ process.exit(2);
26
+ }
27
+ const condition = ctx.flag("resolves-when")?.trim();
28
+ if (condition !== undefined && parseCondition(condition) === undefined) {
29
+ process.stderr.write(
30
+ `omp-conductor: --resolves-when must be one of:\n${CONDITION_FORMS.map((f) => ` ${f}`).join("\n")}\n`,
31
+ );
32
+ process.exit(2);
33
+ }
34
+ const blocks = ctx.flag("blocks")?.trim();
35
+ const decision = store.createDecision({
36
+ project: project.name,
37
+ question,
38
+ ...(blocks === undefined || blocks.length === 0 ? {} : { blocks }),
39
+ ...(condition === undefined ? {} : { condition }),
40
+ at: Date.now(),
41
+ });
42
+ process.stdout.write(
43
+ `decision ${decision.id} recorded — resolve with: omp-conductor decision resolve ${decision.id} --answer "..."\n`,
44
+ );
45
+ return;
46
+ }
47
+
48
+ if (sub === "resolve" || sub === "withdraw") {
49
+ const id = ctx.argv[2];
50
+ if (id === undefined || id.startsWith("--")) {
51
+ process.stderr.write(`omp-conductor: decision ${sub} needs the decision id\n`);
52
+ process.exit(2);
53
+ }
54
+ const answered = sub === "resolve";
55
+ const text = answered ? ctx.flag("answer")?.trim() : ctx.flag("reason")?.trim();
56
+ if (answered && (text === undefined || text.length === 0)) {
57
+ process.stderr.write("omp-conductor: decision resolve needs --answer with what was decided\n");
58
+ process.exit(2);
59
+ }
60
+ const ok = store.resolveDecision(
61
+ id,
62
+ answered ? "answered" : "withdrawn",
63
+ text === undefined || text.length === 0 ? "withdrawn" : text,
64
+ Date.now(),
65
+ );
66
+ if (!ok) {
67
+ // A decision that is not open is a different mistake from an id that
68
+ // never existed, and the operator can only act on one of them.
69
+ process.stderr.write(
70
+ `omp-conductor: no open decision ${id} for ${project.name} — it was answered, withdrawn or expired, or the id is wrong\n`,
71
+ );
72
+ process.exit(1);
73
+ }
74
+ process.stdout.write(`decision ${id} ${answered ? "answered" : "withdrawn"}\n`);
75
+ return;
76
+ }
77
+
78
+ if (sub === "list" || sub === undefined) {
79
+ const open = store.openDecisions(project.name);
80
+ if (open.length === 0) {
81
+ process.stdout.write("no open decisions\n");
82
+ return;
83
+ }
84
+ const now = Date.now();
85
+ for (const d of open) {
86
+ const condition =
87
+ d.condition === undefined ? "-" : d.conditionMetAt === undefined ? "pending" : "met";
88
+ const hours = Math.max(0, Math.round((now - d.askedAt) / 3_600_000));
89
+ process.stdout.write(
90
+ `${d.id} ${hours}h blocks:${d.blocks ?? "-"} condition:${condition} ${d.question}\n`,
91
+ );
92
+ }
93
+ return;
94
+ }
95
+
96
+ process.stderr.write(
97
+ `omp-conductor: unknown decision subcommand "${sub}" — expected open, resolve, withdraw or list\n`,
98
+ );
99
+ process.exit(2);
100
+ } finally {
101
+ store.close();
102
+ }
103
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * `disarm` — remove the arm marker so ticks skip. Processes untouched.
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 { disarmTicks } from "../fleet.ts";
11
+
12
+ export async function disarmCommand(ctx: CommandContext): Promise<void> {
13
+ for (const project of ctx.targetProjects()) {
14
+ const r = disarmTicks(project.name);
15
+ process.stdout.write(
16
+ `disarmed — ticks will be skipped` +
17
+ `${r.wasArmed ? "" : " (was already disarmed)"}\n` +
18
+ `marker ${r.path}\n`,
19
+ );
20
+ }
21
+ }