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.
- package/README.md +273 -2544
- package/REFERENCE.md +2680 -0
- package/package.json +3 -2
- package/schema/config.schema.json +11 -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 +113 -34
- package/src/briefs/policy.md +33 -8
- package/src/briefs/worker.md +18 -9
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +225 -1406
- 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 +150 -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 +100 -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 +155 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +96 -0
- package/src/commands/report.ts +206 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +143 -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 +32 -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 +47 -1
- package/src/config.ts +45 -4
- package/src/daemon.ts +972 -101
- package/src/dashboard/app.js +459 -0
- package/src/dashboard/index.html +61 -0
- package/src/dashboard/server.ts +481 -0
- package/src/dashboard/style.css +348 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +932 -0
- package/src/escalate.ts +2 -2
- package/src/failure-class.ts +66 -3
- package/src/fleet.ts +58 -1
- package/src/gitops.ts +157 -0
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/model-fallback.ts +177 -0
- package/src/notices.ts +9 -0
- package/src/omp.ts +93 -13
- package/src/orchestrator-tick.ts +414 -20
- package/src/orchestrator.ts +4 -4
- package/src/privileged.ts +10 -0
- package/src/release-policy.ts +342 -30
- package/src/reports.ts +19 -5
- package/src/session-host.ts +11 -5
- package/src/setup-host.ts +663 -25
- package/src/setup-install.ts +292 -28
- package/src/setup-wizard.ts +255 -74
- package/src/setup.ts +156 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +219 -22
- package/src/tracker/github.ts +74 -4
- package/src/types.ts +215 -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 +385 -58
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/src/worker.ts +3 -3
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +14 -3
- 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
|
+
}
|