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,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `report` — hand a rendered report to the daemon's durable outbox; the daemon owns delivery with bounded retries.
|
|
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 { randomUUID } from "node:crypto";
|
|
11
|
+
import { interruptDisposition } from "../availability.ts";
|
|
12
|
+
import { findProject, loadConfig } from "../config.ts";
|
|
13
|
+
import { digestDue } from "../digest-schedule.ts";
|
|
14
|
+
import { dbPath, openStore } from "../store.ts";
|
|
15
|
+
import { digestDedupeKey } from "../reports.ts";
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_REPORT_POLICY,
|
|
18
|
+
INTERRUPT_CATEGORIES,
|
|
19
|
+
REPORT_KINDS,
|
|
20
|
+
SESSION_ROLE_ENV,
|
|
21
|
+
type InterruptCategory,
|
|
22
|
+
type ReportKind,
|
|
23
|
+
} from "../types.ts";
|
|
24
|
+
|
|
25
|
+
export async function reportCommand(ctx: CommandContext): Promise<void> {
|
|
26
|
+
const rawText = ctx.flag("text");
|
|
27
|
+
const body = rawText?.trim();
|
|
28
|
+
if (body === undefined || body.length === 0 || rawText?.startsWith("--") === true) {
|
|
29
|
+
process.stderr.write("omp-conductor: report needs --text with the rendered report\n");
|
|
30
|
+
process.exit(2);
|
|
31
|
+
}
|
|
32
|
+
const rawKind = ctx.flag("kind") ?? "material";
|
|
33
|
+
// Fail closed on an unknown kind rather than defaulting to `material`: a
|
|
34
|
+
// typo'd `--kind diggest` that silently became a material report would
|
|
35
|
+
// bypass the daily-digest guard, which is the one thing the kind is for.
|
|
36
|
+
if (!(REPORT_KINDS as readonly string[]).includes(rawKind)) {
|
|
37
|
+
process.stderr.write(
|
|
38
|
+
`omp-conductor: report --kind must be one of: ${REPORT_KINDS.join(", ")}\n`,
|
|
39
|
+
);
|
|
40
|
+
process.exit(2);
|
|
41
|
+
}
|
|
42
|
+
const kind = rawKind as ReportKind;
|
|
43
|
+
if (kind !== "digest" && (ctx.argv.includes("--events") || ctx.argv.includes("--notices"))) {
|
|
44
|
+
process.stderr.write(
|
|
45
|
+
"omp-conductor: report --events and --notices are valid only with --kind digest\n",
|
|
46
|
+
);
|
|
47
|
+
process.exit(2);
|
|
48
|
+
}
|
|
49
|
+
// Escalation tiers are not a worker session's to claim, like every other
|
|
50
|
+
// privileged surface (#508). The daemon stamps the session role on spawned
|
|
51
|
+
// sessions; a value outside the closed vocabulary is refused, never assumed.
|
|
52
|
+
// A bare CLI run (operator's shell) has the env absent and stays the
|
|
53
|
+
// orchestrator surface, so this refuses precisely the worker who would
|
|
54
|
+
// otherwise page the operator from inside a worktree.
|
|
55
|
+
const sessionRole = process.env[SESSION_ROLE_ENV];
|
|
56
|
+
if (
|
|
57
|
+
sessionRole === "worker" &&
|
|
58
|
+
INTERRUPT_CATEGORIES.some((category) => category !== "material" && category === kind)
|
|
59
|
+
) {
|
|
60
|
+
process.stderr.write(
|
|
61
|
+
`omp-conductor: report: escalation tier is not a worker session's to claim; ` +
|
|
62
|
+
`only the operator or orchestrator may hand off a ${kind} report\n`,
|
|
63
|
+
);
|
|
64
|
+
process.exit(3);
|
|
65
|
+
}
|
|
66
|
+
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
67
|
+
const store = openStore(dbPath());
|
|
68
|
+
try {
|
|
69
|
+
const at = Date.now();
|
|
70
|
+
// #229: the policy decides what may interrupt the phone. A kind the
|
|
71
|
+
// policy defers is refused here rather than silently turning into a
|
|
72
|
+
// page, or a digest going out off-schedule.
|
|
73
|
+
const policy = project.reporting;
|
|
74
|
+
const digestPolicy = policy?.digest ?? { cadence: "per-tick" };
|
|
75
|
+
if (kind === "digest") {
|
|
76
|
+
if (digestPolicy.cadence === "none") {
|
|
77
|
+
process.stderr.write(`omp-conductor: report: digest cadence is "none" for this project\n`);
|
|
78
|
+
process.exit(2);
|
|
79
|
+
}
|
|
80
|
+
if (digestPolicy.cadence === "daily" && digestPolicy.at !== undefined) {
|
|
81
|
+
const lastKey = store.lastDigestDedupeKey(project.name);
|
|
82
|
+
const lastDay = lastKey === undefined ? undefined : lastKey.slice("digest:".length);
|
|
83
|
+
if (!digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at)) {
|
|
84
|
+
process.stderr.write(
|
|
85
|
+
`omp-conductor: report: the daily digest is not due until ${digestPolicy.at}` +
|
|
86
|
+
`${digestPolicy.timezone === undefined ? "" : ` ${digestPolicy.timezone}`}\n`,
|
|
87
|
+
);
|
|
88
|
+
process.exit(2);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Every non-digest kind is an interrupt category, and the caller's kind
|
|
93
|
+
// IS the category: admission runs the shared interruptDisposition with
|
|
94
|
+
// that category, so a `fleet-stopped` report under scope=escalations is
|
|
95
|
+
// admitted while `material` stays digest-only under that same policy.
|
|
96
|
+
if (kind !== "digest") {
|
|
97
|
+
const category: InterruptCategory = kind;
|
|
98
|
+
const disposition = interruptDisposition(policy, category, at);
|
|
99
|
+
if (disposition === "digest") {
|
|
100
|
+
// Name the flags that would have worked rather than only the digest:
|
|
101
|
+
// the categories this policy actually pages are how a handoff like a
|
|
102
|
+
// tier-2 install notice gets through, and there is no other spelling to
|
|
103
|
+
// guess at (#508).
|
|
104
|
+
const interruptable = INTERRUPT_CATEGORIES.filter(
|
|
105
|
+
(candidate) => interruptDisposition(policy, candidate, at) === "interrupt",
|
|
106
|
+
);
|
|
107
|
+
const flags =
|
|
108
|
+
interruptable.length > 0
|
|
109
|
+
? `hand it off as a category this policy pages (--kind ${interruptable.join("|")}) or fold it ` +
|
|
110
|
+
"into the next digest (--kind digest)"
|
|
111
|
+
: "fold it into the next digest (--kind digest)";
|
|
112
|
+
process.stderr.write(
|
|
113
|
+
`omp-conductor: report: ${category} updates are digest-only under this reporting policy; ${flags}\n`,
|
|
114
|
+
);
|
|
115
|
+
process.exit(2);
|
|
116
|
+
}
|
|
117
|
+
// One escalation handoff per event while the first has not landed.
|
|
118
|
+
// Material reports describe one ordinary outcome each time, so they
|
|
119
|
+
// keep their historical no-dedup behaviour. The escalation categories
|
|
120
|
+
// refuse an identical retry only while the earlier handoff is still
|
|
121
|
+
// owed — an outbox report awaiting delivery or a held notice not yet
|
|
122
|
+
// digested (#453). The notifications ledger cannot decide this: its
|
|
123
|
+
// rows are permanent, so a same-text recurrence would be refused
|
|
124
|
+
// forever once the first occurrence was marked; a delivered handoff
|
|
125
|
+
// means the identical text is a new event, not a double-send.
|
|
126
|
+
if (category !== "material") {
|
|
127
|
+
if (store.hasOpenEscalationHandoff(project.name, category, body)) {
|
|
128
|
+
process.stderr.write(
|
|
129
|
+
`omp-conductor: report: an identical ${category} handoff for ${project.name} is still queued undelivered ` +
|
|
130
|
+
"— this retry was refused so the operator is not paged twice; nothing new was queued, " +
|
|
131
|
+
"check `omp-conductor status` for what is still awaiting delivery\n",
|
|
132
|
+
);
|
|
133
|
+
process.exit(2);
|
|
134
|
+
}
|
|
135
|
+
if (disposition === "availability") {
|
|
136
|
+
const noticeId = randomUUID();
|
|
137
|
+
store.addHeldNotice({
|
|
138
|
+
id: noticeId,
|
|
139
|
+
project: project.name,
|
|
140
|
+
category,
|
|
141
|
+
summary: body.split("\n", 1)[0]!.slice(0, 240),
|
|
142
|
+
detail: body,
|
|
143
|
+
createdAt: at,
|
|
144
|
+
releaseOnAvailable: true,
|
|
145
|
+
});
|
|
146
|
+
process.stdout.write(
|
|
147
|
+
`held notice ${noticeId} queued for ${project.name} (${category}; quiet hours)\n` +
|
|
148
|
+
"the daemon will include it in the next digest or working-hours catch-up\n",
|
|
149
|
+
);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const { report } = store.enqueueReport({ project: project.name, kind, body, at });
|
|
153
|
+
process.stdout.write(
|
|
154
|
+
`report ${report.id} queued for ${project.name} (${kind})\n` +
|
|
155
|
+
"the daemon owns delivery from here; omp-conductor status shows it until it lands\n",
|
|
156
|
+
);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (disposition === "availability") {
|
|
160
|
+
const noticeId = randomUUID();
|
|
161
|
+
store.addHeldNotice({
|
|
162
|
+
id: noticeId,
|
|
163
|
+
project: project.name,
|
|
164
|
+
category: "material",
|
|
165
|
+
summary: body.split("\n", 1)[0]!.slice(0, 240),
|
|
166
|
+
detail: body,
|
|
167
|
+
createdAt: at,
|
|
168
|
+
releaseOnAvailable: true,
|
|
169
|
+
});
|
|
170
|
+
process.stdout.write(
|
|
171
|
+
`held notice ${noticeId} queued for ${project.name} (material; quiet hours)\n` +
|
|
172
|
+
"the daemon will include it in the next digest or working-hours catch-up\n",
|
|
173
|
+
);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const draft = {
|
|
178
|
+
project: project.name,
|
|
179
|
+
kind,
|
|
180
|
+
body,
|
|
181
|
+
// Only a daily digest is at-most-once. Per-tick digests and material
|
|
182
|
+
// reports describe new outcomes each time, so they carry no daily key.
|
|
183
|
+
...(kind === "digest" && digestPolicy.cadence === "daily"
|
|
184
|
+
? { dedupeKey: digestDedupeKey(at, digestPolicy.timezone) }
|
|
185
|
+
: {}),
|
|
186
|
+
at,
|
|
187
|
+
};
|
|
188
|
+
const { report, deduped } =
|
|
189
|
+
kind === "digest"
|
|
190
|
+
? store.enqueueDigestReport(
|
|
191
|
+
draft,
|
|
192
|
+
ctx.digestIdsFlag("events"),
|
|
193
|
+
ctx.digestIdsFlag("notices"),
|
|
194
|
+
)
|
|
195
|
+
: store.enqueueReport(draft);
|
|
196
|
+
process.stdout.write(
|
|
197
|
+
deduped
|
|
198
|
+
? `today's digest was already handed over as report ${report.id} (${report.state}) — nothing queued\n` +
|
|
199
|
+
"the ledger decides this, not your memory of the last tick; newly named rows remain owed for a later digest\n"
|
|
200
|
+
: `report ${report.id} queued for ${project.name} (${kind})\n` +
|
|
201
|
+
"the daemon owns delivery from here; omp-conductor status shows it until it lands\n",
|
|
202
|
+
);
|
|
203
|
+
} finally {
|
|
204
|
+
store.close();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `restart` — drain the fleet by default, then restart keeping the running daemon's port and project unless a flag overrides them.
|
|
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 { setPaused } from "../daemon.ts";
|
|
11
|
+
import { restartDaemon, type RestartResult } from "../lifecycle.ts";
|
|
12
|
+
import { DEFAULT_DEPS, drainAndRestart, type UpgradeDeps } from "../upgrade.ts";
|
|
13
|
+
|
|
14
|
+
export async function restartCommand(ctx: CommandContext): Promise<void> {
|
|
15
|
+
if (ctx.argv.includes("--now")) {
|
|
16
|
+
// Inherit the running daemon's port and project: a restart that quietly
|
|
17
|
+
// moved to the default port would leave every existing health check
|
|
18
|
+
// pointing at nothing. When the unit owns the live pid, restartDaemon
|
|
19
|
+
// goes through systemctl so the replacement stays supervised; a failed
|
|
20
|
+
// installed unit is reset and started through systemd the same way and
|
|
21
|
+
// is never replaced by an unmanaged daemon. Success is only reported
|
|
22
|
+
// after the manager is proven to own the reported pid.
|
|
23
|
+
const { previous, record, via } = await restartDaemon({
|
|
24
|
+
port: ctx.portFlag(),
|
|
25
|
+
project: ctx.projectFlag,
|
|
26
|
+
});
|
|
27
|
+
if (previous !== undefined) {
|
|
28
|
+
process.stdout.write(
|
|
29
|
+
`stopped — pid ${previous.pid}${via === "systemctl" ? " (via systemctl)" : ""}\n`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
process.stdout.write(
|
|
33
|
+
`started — pid ${record.pid}, /healthz on :${record.port}` +
|
|
34
|
+
`${record.project === undefined ? "" : `, project ${record.project}`}` +
|
|
35
|
+
`${via === "systemctl" ? " (via systemctl)" : ""}\nlog ${record.logFile}\n`,
|
|
36
|
+
);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Default: drain the fleet before restarting — pause new claims, wait for
|
|
41
|
+
// live workers to reach 0/N (bounded by --timeout), restart, then restore
|
|
42
|
+
// the prior dispatch state. A timed-out drain restarts nothing and leaves
|
|
43
|
+
// dispatch paused, so it is safe to run while the daemon is wedged.
|
|
44
|
+
const timeoutRaw = ctx.flag("timeout");
|
|
45
|
+
const timeoutSeconds =
|
|
46
|
+
timeoutRaw === undefined ? 1800 : Number.parseInt(timeoutRaw, 10);
|
|
47
|
+
const timeoutMs = (Number.isNaN(timeoutSeconds) ? 1800 : timeoutSeconds) * 1000;
|
|
48
|
+
let result!: RestartResult;
|
|
49
|
+
const deps: UpgradeDeps = {
|
|
50
|
+
...DEFAULT_DEPS,
|
|
51
|
+
setPaused: (v, project) =>
|
|
52
|
+
setPaused(v, { source: "restart", reason: "restart, draining" }, project),
|
|
53
|
+
restartDaemon: async () => {
|
|
54
|
+
result = await restartDaemon({
|
|
55
|
+
port: ctx.portFlag(),
|
|
56
|
+
project: ctx.projectFlag,
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
try {
|
|
61
|
+
await drainAndRestart(deps, { project: ctx.projectFlag, timeoutMs });
|
|
62
|
+
} catch (err) {
|
|
63
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
if (result.previous !== undefined) {
|
|
67
|
+
process.stdout.write(
|
|
68
|
+
`stopped — pid ${result.previous.pid}${result.via === "systemctl" ? " (via systemctl)" : ""}\n`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
process.stdout.write(
|
|
72
|
+
`started — pid ${result.record.pid}, /healthz on :${result.record.port}` +
|
|
73
|
+
`${result.record.project === undefined ? "" : `, project ${result.record.project}`}` +
|
|
74
|
+
`${result.via === "systemctl" ? " (via systemctl)" : ""}\nlog ${result.record.logFile}\n`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `resume` — clear pause and any pane-recovery pin. Does NOT re-arm.
|
|
3
|
+
*
|
|
4
|
+
* A running daemon is woken over its loopback control port so the resumed
|
|
5
|
+
* project is dispatched on a pass that starts immediately instead of after
|
|
6
|
+
* the five-minute tick (#380); when no daemon is alive there is nothing to
|
|
7
|
+
* wake and the output says so rather than implying an immediate pass happened.
|
|
8
|
+
*
|
|
9
|
+
* Moved out of cli.ts's switch by the per-verb module split (#462);
|
|
10
|
+
* only the case wrapper, the injected `ctx` lookups and the imports
|
|
11
|
+
* changed from the original bodies.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { CommandContext } from "./context.ts";
|
|
15
|
+
import { armState, clearPaneHaltIfResolvable, releaseHold } from "../fleet.ts";
|
|
16
|
+
import { livingDaemon } from "../lifecycle.ts";
|
|
17
|
+
|
|
18
|
+
/** Wakes a running daemon, returning the human line that states what happened.
|
|
19
|
+
* State-only clients (no live daemon) get an honest fallback rather than an
|
|
20
|
+
* error: the hold is already cleared and the next pass or daemon start
|
|
21
|
+
* claims without this poke. */
|
|
22
|
+
async function wakeDaemon(projectName: string): Promise<string> {
|
|
23
|
+
const daemon = livingDaemon();
|
|
24
|
+
if (daemon === undefined) {
|
|
25
|
+
return "daemon not running — claiming starts when the daemon next starts or ticks";
|
|
26
|
+
}
|
|
27
|
+
let response: Response;
|
|
28
|
+
try {
|
|
29
|
+
response = await fetch(`http://127.0.0.1:${daemon.port}/wake`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { "content-type": "application/json" },
|
|
32
|
+
body: JSON.stringify({ project: projectName }),
|
|
33
|
+
});
|
|
34
|
+
} catch {
|
|
35
|
+
return "daemon wake failed — daemon unreachable; the next scheduled pass will claim";
|
|
36
|
+
}
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
return `daemon wake failed (HTTP ${response.status}); the next scheduled pass will claim`;
|
|
39
|
+
}
|
|
40
|
+
return "daemon dispatch loop woken — claiming starts on an immediate pass";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function resumeCommand(ctx: CommandContext): Promise<void> {
|
|
44
|
+
for (const project of ctx.targetProjects()) {
|
|
45
|
+
releaseHold(project.name);
|
|
46
|
+
const pin = clearPaneHaltIfResolvable(project.name);
|
|
47
|
+
const arm = armState(project.name);
|
|
48
|
+
process.stdout.write(
|
|
49
|
+
`resumed — claiming allowed — ${await wakeDaemon(project.name)}\n` +
|
|
50
|
+
(pin.wasHalted
|
|
51
|
+
? `pane recovery pin cleared — ${pin.path}\n`
|
|
52
|
+
: "no pane recovery pin to clear\n") +
|
|
53
|
+
(arm.armed
|
|
54
|
+
? `ticks remain armed — marker ${arm.path}\n`
|
|
55
|
+
: "ticks stay disarmed — run `omp-conductor arm`\n"),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `setup` — interview, then write config.json, the labels, the briefs and the * staged host files behind one confirm.
|
|
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, resolveCaps } from "../config.ts";
|
|
11
|
+
import { AMEND_AREA_IDS, type AmendAreaId } from "../setup.ts";
|
|
12
|
+
import { runGraphInstall, runHostInstall, type InstallOutcome } from "../setup-install.ts";
|
|
13
|
+
import { DEFAULT_PROBES, NO_PROBES, setup } from "../setup-wizard.ts";
|
|
14
|
+
import { telegramStateDir } from "../fleet.ts";
|
|
15
|
+
import { terminalUi } from "../wizard-ui.ts";
|
|
16
|
+
import type { ConductorConfig, ProjectConfig } from "../types.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The setup verb's own usage, printed for `setup --help` / `setup -h` before
|
|
20
|
+
* any UI, config read, probe or area parsing. Areas come from `AMEND_AREA_IDS`
|
|
21
|
+
* rather than a second copy, so an added amend area appears both here and in
|
|
22
|
+
* the shared `USAGE` with no third spelling to keep in sync.
|
|
23
|
+
*/
|
|
24
|
+
const SETUP_USAGE = `omp-conductor setup — interview, then write config.json, the labels, the briefs
|
|
25
|
+
and the staged host files behind one confirm.
|
|
26
|
+
|
|
27
|
+
usage:
|
|
28
|
+
omp-conductor setup [area] [--no-ai] [--project NAME]
|
|
29
|
+
omp-conductor setup host [NAME] [--project NAME]
|
|
30
|
+
omp-conductor setup graph [--no-seed] [--print] [--project NAME]
|
|
31
|
+
|
|
32
|
+
Bare setup runs the full interview — or, when the project already exists,
|
|
33
|
+
asks which area to amend. Naming an area positionally skips that chooser and
|
|
34
|
+
amends only that area. \`setup host\` and \`setup graph\` are install
|
|
35
|
+
subcommands, not areas: the NAME (or --project NAME) says which project to
|
|
36
|
+
install for.
|
|
37
|
+
|
|
38
|
+
amend areas:
|
|
39
|
+
${AMEND_AREA_IDS.join(", ")}
|
|
40
|
+
|
|
41
|
+
flags:
|
|
42
|
+
--project NAME the project to configure or amend (or NAME positionally
|
|
43
|
+
for \`setup host\`)
|
|
44
|
+
--no-ai ask every question, propose nothing (no AI repo reads)`;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the project an install subcommand (`setup host`, `setup graph`)
|
|
48
|
+
* installs for — the flag, or the positional form an operator naturally
|
|
49
|
+
* types on a multi-project host (`setup host conductor`). The pair may not
|
|
50
|
+
* disagree, and an unknown name fails loudly as "Unknown project …" rather
|
|
51
|
+
* than being silently ignored (#514).
|
|
52
|
+
*/
|
|
53
|
+
export function setupInstallProject(
|
|
54
|
+
cfg: ConductorConfig,
|
|
55
|
+
projectFlag: string | undefined,
|
|
56
|
+
positionalName: string | undefined,
|
|
57
|
+
): ProjectConfig {
|
|
58
|
+
if (projectFlag !== undefined && positionalName !== undefined && projectFlag !== positionalName) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`setup names two different projects (--project ${projectFlag} and ${positionalName}) — pass one`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return findProject(cfg, projectFlag ?? positionalName);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function setupCommand(ctx: CommandContext): Promise<void> {
|
|
67
|
+
// Help first, and only in the first trailing position: a help request
|
|
68
|
+
// must never open a UI, read config, probe GitHub or pause dispatch.
|
|
69
|
+
// `setup bogus --help` still rejects `bogus` as an unknown area, and
|
|
70
|
+
// `setup caps --help` still names `caps` as the governing argument.
|
|
71
|
+
if (ctx.argv[1] === "--help" || ctx.argv[1] === "-h") {
|
|
72
|
+
process.stdout.write(`${SETUP_USAGE}\n`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const sub = ctx.argv[1];
|
|
76
|
+
const positional = sub !== undefined && !sub.startsWith("--") ? sub : undefined;
|
|
77
|
+
// The terminal surface owns stdin for whichever path runs, and is released
|
|
78
|
+
// in a finally: a throw with readline still attached leaves the operator's
|
|
79
|
+
// shell without an echo.
|
|
80
|
+
const ui = terminalUi();
|
|
81
|
+
try {
|
|
82
|
+
// `host` and `graph` are install subcommands, checked BEFORE the amend
|
|
83
|
+
// areas. They are not areas — routing them through the area validation
|
|
84
|
+
// is how `setup host` came to exit 2 as an "unknown setup area".
|
|
85
|
+
if (positional === "host" || positional === "graph") {
|
|
86
|
+
const cfg = loadConfig();
|
|
87
|
+
// `setup host <name>`: the project an operator would naturally type.
|
|
88
|
+
// The positional only ever names a project; anything else that does not
|
|
89
|
+
// look like a flag is refused by findProject below rather than being
|
|
90
|
+
// quietly ignored.
|
|
91
|
+
const positionalName =
|
|
92
|
+
positional === "host" && ctx.argv[2] !== undefined && !ctx.argv[2].startsWith("--")
|
|
93
|
+
? ctx.argv[2]
|
|
94
|
+
: undefined;
|
|
95
|
+
let outcome: InstallOutcome;
|
|
96
|
+
if (positional === "host") {
|
|
97
|
+
// No `--project` and no positional → a host-global install (#530): the
|
|
98
|
+
// units are host-global, so a multi-project host installs them with no
|
|
99
|
+
// name and skips the per-project tail (tick config, brief link) with a
|
|
100
|
+
// named note; a name resolves and wires the tail.
|
|
101
|
+
const project =
|
|
102
|
+
ctx.projectFlag === undefined && positionalName === undefined
|
|
103
|
+
? undefined
|
|
104
|
+
: setupInstallProject(cfg, ctx.projectFlag, positionalName);
|
|
105
|
+
outcome = await runHostInstall(
|
|
106
|
+
project,
|
|
107
|
+
project === undefined ? cfg.defaults : resolveCaps(project, cfg.defaults),
|
|
108
|
+
telegramStateDir(),
|
|
109
|
+
ui,
|
|
110
|
+
);
|
|
111
|
+
} else {
|
|
112
|
+
// `setup graph` is inherently per-project repositories; there is no
|
|
113
|
+
// host-global graph to install, so a project must resolve.
|
|
114
|
+
outcome = await runGraphInstall(findProject(cfg, ctx.projectFlag), ui, {
|
|
115
|
+
noSeed: ctx.argv.includes("--no-seed"),
|
|
116
|
+
print: ctx.argv.includes("--print"),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
// `staged` is a success on a host that has no systemd: the files are
|
|
120
|
+
// real, only the enable step is impossible.
|
|
121
|
+
if (outcome.kind === "refused" || outcome.kind === "failed") process.exit(1);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
let area: AmendAreaId | undefined;
|
|
125
|
+
if (positional !== undefined) {
|
|
126
|
+
if (!(AMEND_AREA_IDS as readonly string[]).includes(positional)) {
|
|
127
|
+
process.stderr.write(
|
|
128
|
+
`omp-conductor: unknown setup argument "${positional}" — ` +
|
|
129
|
+
`install subcommands are host, graph; amend areas are ${AMEND_AREA_IDS.join(", ")}\n`,
|
|
130
|
+
);
|
|
131
|
+
process.exit(2);
|
|
132
|
+
}
|
|
133
|
+
area = positional as AmendAreaId;
|
|
134
|
+
}
|
|
135
|
+
// `--no-ai` is the interview with its reading half removed: every question
|
|
136
|
+
// is still asked, nothing is proposed. For a host with no omp peer, a
|
|
137
|
+
// private repo no probe can clone, or an operator who would rather type the
|
|
138
|
+
// gates than review a model's reading of their CI.
|
|
139
|
+
await setup(ui, ctx.projectFlag, area, ctx.argv.includes("--no-ai") ? NO_PROBES : DEFAULT_PROBES);
|
|
140
|
+
} finally {
|
|
141
|
+
ui.close();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `start` — start the installed herdr-fleet.service when present, then run the dispatch loop in the background and wait for /healthz.
|
|
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 { startHerdrFleet } from "../fleet.ts";
|
|
11
|
+
import { startDaemon } from "../lifecycle.ts";
|
|
12
|
+
|
|
13
|
+
export async function startCommand(ctx: CommandContext): Promise<void> {
|
|
14
|
+
const project = ctx.projectFlag;
|
|
15
|
+
const herdr = startHerdrFleet(project);
|
|
16
|
+
const rec = await startDaemon({ port: ctx.portFlag(), project });
|
|
17
|
+
process.stdout.write(
|
|
18
|
+
`started — pid ${rec.pid}, /healthz on :${rec.port}` +
|
|
19
|
+
`${rec.project === undefined ? "" : `, project ${rec.project}`}\n` +
|
|
20
|
+
`herdr ${herdr.kind === "active" ? `active (${herdr.unit})${herdr.recoveryReleased ? "; recovery pin cleared" : ""}` : `unmanaged (${herdr.reason})`}\n` +
|
|
21
|
+
`log ${rec.logFile}\n`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `stats` — what the fleet accomplished and at what cost over a window (#282,
|
|
3
|
+
* legibility 5/5).
|
|
4
|
+
*
|
|
5
|
+
* The aggregation lives in ../stats.ts, pure over injected store rows; this
|
|
6
|
+
* module only parses the flags, reads the local store read-only, and renders
|
|
7
|
+
* the same report the --json shape carries. Nothing here touches GitHub or any
|
|
8
|
+
* other network surface: the window, runs, spend and gh-call counts all come
|
|
9
|
+
* from SQLite on this host.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { CommandContext } from "./context.ts";
|
|
13
|
+
import { findProject, loadConfig } from "../config.ts";
|
|
14
|
+
import { dbPath, openStore, utcDay } from "../store.ts";
|
|
15
|
+
import { computeStats, renderStatsHuman, type StatsWindow } from "../stats.ts";
|
|
16
|
+
|
|
17
|
+
const DAY_MS = 24 * 60 * 60 * 1_000;
|
|
18
|
+
const DATE_FORM = /^\d{4}-\d{2}-\d{2}$/;
|
|
19
|
+
const DURATION_FORM = /^(\d{1,3})d$/;
|
|
20
|
+
|
|
21
|
+
const STATS_USAGE = `omp-conductor stats — what the fleet accomplished and at what cost.
|
|
22
|
+
|
|
23
|
+
usage:
|
|
24
|
+
omp-conductor stats [--since 7d|30d|YYYY-MM-DD] [--project NAME] [--json]
|
|
25
|
+
|
|
26
|
+
Reads only the local store — zero GitHub calls. Over a bounded window it
|
|
27
|
+
reports, per repo and in total: issues merged, issues settled (merged or
|
|
28
|
+
terminally failed/blocked), merge rate, queue-to-merge lead time (median and
|
|
29
|
+
p90, from the first claim of the issue's continuation chain to the tick's
|
|
30
|
+
merge settlement), attempts per merged issue, metered spend per merged issue,
|
|
31
|
+
the failure-class breakdown of everything that did not merge, and the tracked
|
|
32
|
+
GitHub API calls consumed over the window's days.
|
|
33
|
+
|
|
34
|
+
Continuation chains collapse into one journey: an issue that took six attempts
|
|
35
|
+
is one merged outcome with six runs and one lead time. Runs whose spend reads
|
|
36
|
+
$0.00 — harness telemetry absent (README Limitations) — are counted separately
|
|
37
|
+
as unmetered and never averaged into cost as if they were free.
|
|
38
|
+
|
|
39
|
+
flags:
|
|
40
|
+
--since 7d | 30d | YYYY-MM-DD window start (default 7d)
|
|
41
|
+
--json print the stable report shape {project,
|
|
42
|
+
window, ghCalls, empty, total, repos} — keys
|
|
43
|
+
never move, so a consumer can rely on it
|
|
44
|
+
--project NAME the project to report (required only when
|
|
45
|
+
the config has more than one)
|
|
46
|
+
|
|
47
|
+
When nothing settled in the window the report says so explicitly ("empty:
|
|
48
|
+
true" in --json) rather than printing zeros that could read as measurements.`;
|
|
49
|
+
|
|
50
|
+
/** `--since` to a window. The bare date form means from 00:00:00Z of that day;
|
|
51
|
+
* the duration form (`7d`) is now minus whole days. Anything else exits 2,
|
|
52
|
+
* because silently defaulting a typo'd window would measure the wrong week. */
|
|
53
|
+
function sinceWindow(raw: string, now: number): Pick<StatsWindow, "sinceDay" | "sinceEpochMs"> {
|
|
54
|
+
const duration = DURATION_FORM.exec(raw);
|
|
55
|
+
if (duration !== null) {
|
|
56
|
+
const days = Number(duration[1]);
|
|
57
|
+
if (days >= 1) {
|
|
58
|
+
const sinceEpochMs = now - days * DAY_MS;
|
|
59
|
+
return { sinceEpochMs, sinceDay: utcDay(sinceEpochMs) };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (DATE_FORM.test(raw)) {
|
|
63
|
+
const sinceEpochMs = Date.parse(`${raw}T00:00:00Z`);
|
|
64
|
+
if (Number.isFinite(sinceEpochMs)) {
|
|
65
|
+
return { sinceEpochMs, sinceDay: raw };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
process.stderr.write(
|
|
69
|
+
`omp-conductor: stats --since needs 7d, 30d or YYYY-MM-DD, got "${raw}"\n`,
|
|
70
|
+
);
|
|
71
|
+
process.exit(2);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function statsCommand(ctx: CommandContext): Promise<void> {
|
|
75
|
+
// Help first: parsing stops before any config read or store open.
|
|
76
|
+
if (ctx.argv[1] === "--help" || ctx.argv[1] === "-h") {
|
|
77
|
+
process.stdout.write(`${STATS_USAGE}\n`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Every remaining token after the verb is accounted for: the flag presence
|
|
81
|
+
// markers plus the two value flags cli.ts already parsed.
|
|
82
|
+
const STATS_FLAGS: Record<string, true> = {
|
|
83
|
+
"--json": true,
|
|
84
|
+
"--since": true,
|
|
85
|
+
"--project": true,
|
|
86
|
+
};
|
|
87
|
+
for (let i = 1; i < ctx.argv.length; i++) {
|
|
88
|
+
const token = ctx.argv[i];
|
|
89
|
+
if (token === undefined) continue;
|
|
90
|
+
// `--since 7d` and `--since=7d` are both legal — cli.ts's flag helper
|
|
91
|
+
// accepts the prefixed form, so the scan must too.
|
|
92
|
+
if (
|
|
93
|
+
STATS_FLAGS[token] !== true &&
|
|
94
|
+
!token.startsWith("--since=") &&
|
|
95
|
+
!token.startsWith("--project=")
|
|
96
|
+
) {
|
|
97
|
+
process.stderr.write(`omp-conductor: stats: unexpected argument "${token}"\n`);
|
|
98
|
+
process.exit(2);
|
|
99
|
+
}
|
|
100
|
+
if (token === "--since" || token === "--project") i += 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const project = findProject(loadConfig(), ctx.projectFlag);
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
const rawSince = ctx.flag("since");
|
|
106
|
+
if (rawSince === undefined && ctx.argv.includes("--since")) {
|
|
107
|
+
process.stderr.write(`omp-conductor: stats --since needs 7d, 30d or YYYY-MM-DD\n`);
|
|
108
|
+
process.exit(2);
|
|
109
|
+
}
|
|
110
|
+
const window: StatsWindow = {
|
|
111
|
+
...sinceWindow(rawSince ?? "7d", now),
|
|
112
|
+
untilEpochMs: now,
|
|
113
|
+
untilDay: utcDay(now),
|
|
114
|
+
};
|
|
115
|
+
const store = openStore(dbPath());
|
|
116
|
+
try {
|
|
117
|
+
const report = computeStats({
|
|
118
|
+
project: project.name,
|
|
119
|
+
window,
|
|
120
|
+
ghCalls: store.ghCallsBetween(window.sinceDay, window.untilDay),
|
|
121
|
+
runs: store.statsRuns(project.name, window.sinceEpochMs),
|
|
122
|
+
});
|
|
123
|
+
process.stdout.write(
|
|
124
|
+
ctx.argv.includes("--json")
|
|
125
|
+
? `${JSON.stringify(report, null, 2)}\n`
|
|
126
|
+
: renderStatsHuman(report),
|
|
127
|
+
);
|
|
128
|
+
} finally {
|
|
129
|
+
store.close();
|
|
130
|
+
}
|
|
131
|
+
}
|