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.
- package/README.md +273 -2543
- package/REFERENCE.md +2638 -0
- package/package.json +3 -2
- package/schema/config.schema.json +8 -23
- package/src/arm-challenge.ts +112 -0
- package/src/ask.ts +434 -0
- package/src/board.ts +81 -15
- package/src/brief-upgrade.ts +114 -8
- package/src/briefs/orchestrator.md +55 -29
- package/src/briefs/policy.md +14 -5
- package/src/briefs/worker.md +7 -1
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +190 -1391
- package/src/commands/arm.ts +21 -0
- package/src/commands/board.ts +23 -0
- package/src/commands/brief-upgrade.ts +186 -0
- package/src/commands/context.ts +49 -0
- package/src/commands/daemon.ts +71 -0
- package/src/commands/dashboard.ts +74 -0
- package/src/commands/decision.ts +103 -0
- package/src/commands/disarm.ts +21 -0
- package/src/commands/doctor.ts +98 -0
- package/src/commands/event.ts +62 -0
- package/src/commands/extend.ts +64 -0
- package/src/commands/friction.ts +56 -0
- package/src/commands/help.ts +9 -0
- package/src/commands/hold.ts +26 -0
- package/src/commands/intake.ts +134 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +48 -0
- package/src/commands/report.ts +170 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +93 -0
- package/src/commands/start.ts +23 -0
- package/src/commands/stats.ts +131 -0
- package/src/commands/status.ts +48 -0
- package/src/commands/stop.ts +51 -0
- package/src/commands/tail.ts +109 -0
- package/src/commands/unblock.ts +39 -0
- package/src/commands/upgrade-install.ts +31 -0
- package/src/commands/upgrade-rollback.ts +23 -0
- package/src/commands/upgrade.ts +25 -0
- package/src/commands/verb.ts +83 -0
- package/src/commands/version.ts +30 -0
- package/src/commands/worker.ts +100 -0
- package/src/config-schema.ts +38 -1
- package/src/config.ts +10 -3
- package/src/daemon.ts +613 -94
- package/src/dashboard/app.js +120 -0
- package/src/dashboard/index.html +34 -0
- package/src/dashboard/server.ts +267 -0
- package/src/dashboard/style.css +180 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +795 -0
- package/src/escalate.ts +60 -19
- package/src/failure-class.ts +29 -3
- package/src/fleet.ts +58 -1
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/notices.ts +9 -0
- package/src/omp.ts +2 -0
- package/src/orchestrator-tick.ts +315 -17
- package/src/release-policy.ts +135 -23
- package/src/reports.ts +19 -5
- package/src/setup-host.ts +420 -8
- package/src/setup-install.ts +69 -14
- package/src/setup-wizard.ts +199 -61
- package/src/setup.ts +131 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +206 -21
- package/src/tracker/github.ts +27 -4
- package/src/types.ts +144 -31
- package/src/unblock.ts +55 -11
- package/src/upgrade-journal.ts +220 -0
- package/src/upgrade-verify.ts +506 -0
- package/src/upgrade.ts +295 -26
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +7 -0
- 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,49 @@
|
|
|
1
|
+
import type { ProjectConfig } from "../types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a command handler may read about the invocation it was dispatched for.
|
|
5
|
+
*
|
|
6
|
+
* Argument parsing stays in cli.ts — the flag helpers, the shared usage text
|
|
7
|
+
* and the registration table — and each handler receives the parsed essentials
|
|
8
|
+
* plus the lookups it needs through this object. A command module therefore
|
|
9
|
+
* never imports the entry point back: no import cycle, and no module-level
|
|
10
|
+
* mutable state for one verb to change under another.
|
|
11
|
+
*/
|
|
12
|
+
export interface CommandContext {
|
|
13
|
+
/** The full argv including the verb at index 0, read once per process. */
|
|
14
|
+
argv: string[];
|
|
15
|
+
/**
|
|
16
|
+
* The `--project` value, parsed once because nearly every verb takes it.
|
|
17
|
+
* Named `projectFlag` rather than `project` because many handlers bind their
|
|
18
|
+
* own resolved `ProjectConfig` under that name.
|
|
19
|
+
*/
|
|
20
|
+
projectFlag: string | undefined;
|
|
21
|
+
/** Applies both `--flag VALUE` and `--flag=VALUE`; undefined when absent. */
|
|
22
|
+
flag(name: string): string | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* `--port` for the daemon-bound verbs. Exits 2 rather than defaulting,
|
|
25
|
+
* because silently ignoring a typo'd port would leave the operator probing
|
|
26
|
+
* an endpoint the daemon is not on.
|
|
27
|
+
*/
|
|
28
|
+
portFlag(): number | undefined;
|
|
29
|
+
/** Required positive integer for `extend`; no partial parses such as `180x`. */
|
|
30
|
+
turnsFlag(): number;
|
|
31
|
+
/**
|
|
32
|
+
* `report`'s comma-separated `--events` / `--notices` ledger row ids, in the
|
|
33
|
+
* three shapes the runtime actually mints. Exits 2 on anything else.
|
|
34
|
+
*/
|
|
35
|
+
digestIdsFlag(name: "events" | "notices"): string[];
|
|
36
|
+
/**
|
|
37
|
+
* The `<issue>` positional, for the verbs that take one. Exits 2 rather
|
|
38
|
+
* than proceeding with run #NaN; `verb` is named in the message.
|
|
39
|
+
*/
|
|
40
|
+
issueArg(verb: string, raw: string | undefined): number;
|
|
41
|
+
/**
|
|
42
|
+
* The projects a fleet-level verb targets: `--all`, `--project NAME`, or the
|
|
43
|
+
* sole configured project (throwing when the choice would be ambiguous).
|
|
44
|
+
*/
|
|
45
|
+
targetProjects(): ProjectConfig[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One operator subcommand: what the registration table maps a verb to. */
|
|
49
|
+
export type CommandHandler = (ctx: CommandContext) => Promise<void> | void;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `doctor` — read-only deployment health, one finding per past failure mode
|
|
3
|
+
* (#287, legibility 1/5).
|
|
4
|
+
*
|
|
5
|
+
* The probe logic lives in ../doctor.ts with every I/O seam injectable; this
|
|
6
|
+
* module only parses the doctor's own flags, renders the findings the runner
|
|
7
|
+
* produced (the same findings `--json` emits), and derives the exit code:
|
|
8
|
+
* nonzero iff the report contains a failing finding.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { CommandContext } from "./context.ts";
|
|
12
|
+
import { runDoctor, type DoctorReport, type FindingStatus } from "../doctor.ts";
|
|
13
|
+
|
|
14
|
+
const DOCTOR_USAGE = `omp-conductor doctor — check the deployment faults that have already cost
|
|
15
|
+
debugging sessions, mechanically and read-only.
|
|
16
|
+
|
|
17
|
+
usage:
|
|
18
|
+
omp-conductor doctor [--project NAME] [--json] [--probe-telegram]
|
|
19
|
+
|
|
20
|
+
Checks, each one the mechanical version of a past production incident:
|
|
21
|
+
gh auth token alive and scopes cover every configured repo (a token
|
|
22
|
+
that expired under a live daemon failed every tracker call
|
|
23
|
+
silently)
|
|
24
|
+
labels every configured routing/state label exists with exact case
|
|
25
|
+
— label matching is case-sensitive and a mismatch fails
|
|
26
|
+
silently (README Limitations)
|
|
27
|
+
systemd unit the installed units vs the staged render (User=, environment,
|
|
28
|
+
working directory, exec, restart/success status) and the
|
|
29
|
+
ownership/modes of the unit and runtime dirs (the worker
|
|
30
|
+
die-at-turn-0 class)
|
|
31
|
+
config config.json validates, and its pre-write backup exists and
|
|
32
|
+
is fresh
|
|
33
|
+
store PRAGMA integrity_check
|
|
34
|
+
spend telemetry every completed run recording $0.00 means the USD cap cannot
|
|
35
|
+
fire — $0.00 is not proof of no spend
|
|
36
|
+
timezone reporting timezones are known IANA zones
|
|
37
|
+
telegram bot health, and one self-identified probe message
|
|
38
|
+
|
|
39
|
+
flags:
|
|
40
|
+
--json print the stable report shape {project, checkedAt,
|
|
41
|
+
status, findings:[{id,status,summary,fix?}]} — the CI
|
|
42
|
+
contract
|
|
43
|
+
--probe-telegram the ONE opt-in side effect: send one self-identified
|
|
44
|
+
test message through the report transport
|
|
45
|
+
--project NAME the project to check (required only when the config
|
|
46
|
+
has more than one)
|
|
47
|
+
|
|
48
|
+
Exit code is 0 only when nothing failed; warnings do not fail the run.`;
|
|
49
|
+
|
|
50
|
+
const LABEL_BY_STATUS: Record<FindingStatus, string> = { pass: "PASS", warn: "WARN", fail: "FAIL" };
|
|
51
|
+
|
|
52
|
+
/** Renders the same findings `--json` emits, so the two cannot disagree. */
|
|
53
|
+
export function renderDoctorReport(report: DoctorReport): string {
|
|
54
|
+
const width = Math.max(...report.findings.map((f) => f.id.length).concat(0), 8);
|
|
55
|
+
const lines = [
|
|
56
|
+
`omp-conductor doctor — ${report.project}`,
|
|
57
|
+
`status: ${report.status.toUpperCase()} (${report.findings.filter((f) => f.status === "fail").length} failed · ${report.findings.filter((f) => f.status === "warn").length} warnings) · checked ${report.checkedAt}`,
|
|
58
|
+
"",
|
|
59
|
+
];
|
|
60
|
+
for (const f of report.findings) {
|
|
61
|
+
const pad = " ".repeat(Math.max(0, width - f.id.length));
|
|
62
|
+
lines.push(` ${LABEL_BY_STATUS[f.status].padEnd(5)} ${f.id}${pad} ${f.summary}`);
|
|
63
|
+
if (f.fix !== undefined) lines.push(` ${" ".repeat(width)} fix: ${f.fix}`);
|
|
64
|
+
}
|
|
65
|
+
return `${lines.join("\n")}\n`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function doctorCommand(ctx: CommandContext): Promise<void> {
|
|
69
|
+
// Help first: parsing stops before any probe, config read, or side effect.
|
|
70
|
+
if (ctx.argv[1] === "--help" || ctx.argv[1] === "-h") {
|
|
71
|
+
process.stdout.write(`${DOCTOR_USAGE}\n`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Every remaining token after the verb is accounted for: the two flag
|
|
75
|
+
// presence markers and the `--project NAME` pair cli.ts already parsed.
|
|
76
|
+
const DOCTOR_FLAGS: Record<string, true> = {
|
|
77
|
+
"--json": true,
|
|
78
|
+
"--probe-telegram": true,
|
|
79
|
+
"--project": true,
|
|
80
|
+
};
|
|
81
|
+
for (let i = 1; i < ctx.argv.length; i++) {
|
|
82
|
+
const token = ctx.argv[i];
|
|
83
|
+
if (token === undefined) continue;
|
|
84
|
+
if (DOCTOR_FLAGS[token] !== true) {
|
|
85
|
+
process.stderr.write(`omp-conductor: doctor: unexpected argument "${token}"\n`);
|
|
86
|
+
process.exit(2);
|
|
87
|
+
}
|
|
88
|
+
if (token === "--project") i += 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const report = await runDoctor(ctx.projectFlag, {
|
|
92
|
+
probeTelegram: ctx.argv.includes("--probe-telegram"),
|
|
93
|
+
});
|
|
94
|
+
if (ctx.argv.includes("--json")) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
95
|
+
else process.stdout.write(renderDoctorReport(report));
|
|
96
|
+
// The one contract CI reads: nonzero iff something failed. Warnings pass.
|
|
97
|
+
if (report.status === "fail") process.exitCode = 1;
|
|
98
|
+
}
|