omp-conductor 0.16.2 → 0.17.1
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 +38 -4
- package/REFERENCE.md +18 -12
- package/package.json +2 -1
- package/schema/config.schema.json +16 -0
- package/src/admission.ts +159 -43
- package/src/availability.ts +27 -1
- package/src/briefs/worker.md +2 -0
- package/src/clack-ui.ts +83 -0
- package/src/command-manifest.ts +16 -7
- package/src/commands/arm.ts +11 -3
- package/src/commands/decision.ts +17 -7
- package/src/commands/doctor.ts +18 -1
- package/src/commands/hold.ts +9 -7
- package/src/commands/ledger.ts +25 -4
- package/src/commands/message.ts +32 -4
- package/src/commands/setup.ts +61 -10
- package/src/commands/stats.ts +9 -5
- package/src/commands/status.ts +32 -5
- package/src/commands/tail.ts +13 -1
- package/src/commands/watch.ts +16 -7
- package/src/config-schema.ts +20 -0
- package/src/config.ts +37 -0
- package/src/daemon.ts +1240 -18
- package/src/doctor.ts +310 -22
- package/src/escalate.ts +560 -57
- package/src/failure-class.ts +56 -13
- package/src/fleet.ts +224 -47
- package/src/gitops.ts +103 -24
- package/src/lifecycle.ts +7 -2
- package/src/orchestrator-tick.ts +372 -157
- package/src/privileged.ts +3 -0
- package/src/release-policy.ts +177 -5
- package/src/setup-answers.ts +135 -0
- package/src/setup-host.ts +193 -4
- package/src/setup-install.ts +2 -0
- package/src/setup-probe.ts +1 -0
- package/src/setup-wizard.ts +1296 -101
- package/src/setup.ts +60 -3
- package/src/status-render.ts +11 -1
- package/src/store.ts +333 -12
- package/src/tracker/github.ts +562 -13
- package/src/types.ts +204 -2
- package/src/ui/progress.ts +32 -0
- package/src/ui/style.ts +11 -0
- package/src/upgrade.ts +50 -19
- package/src/verbs/actions.ts +66 -18
- package/src/verbs/protocol.ts +45 -0
- package/src/verbs/server.ts +212 -11
- package/src/wizard-ui.ts +14 -5
- package/src/worker.ts +26 -0
- package/systemd/omp-conductor-recover.sh +73 -0
- package/systemd/recover-unit-test.sh +61 -0
package/src/clack-ui.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// @clack/prompts is ESM-only and currently imports node:process in a shape
|
|
2
|
+
// that blocks a future `bun build`; this CLI intentionally executes TS in Bun.
|
|
3
|
+
import * as p from "@clack/prompts";
|
|
4
|
+
import { stdin, stdout } from "node:process";
|
|
5
|
+
import type { Readable, Writable } from "node:stream";
|
|
6
|
+
|
|
7
|
+
import type { TerminalUi } from "./wizard-ui.ts";
|
|
8
|
+
|
|
9
|
+
interface ClackIo {
|
|
10
|
+
input?: Readable;
|
|
11
|
+
output?: Writable;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Interactive WizardUi driver. Callers gate this on TTY input and output. */
|
|
15
|
+
export function clackUi(io: ClackIo = {}): TerminalUi {
|
|
16
|
+
const input = io.input ?? stdin;
|
|
17
|
+
const output = io.output ?? stdout;
|
|
18
|
+
const promptIo = { input, output };
|
|
19
|
+
let closed = false;
|
|
20
|
+
let started = false;
|
|
21
|
+
const start = (): void => {
|
|
22
|
+
if (started) return;
|
|
23
|
+
started = true;
|
|
24
|
+
p.intro("omp-conductor setup", { output });
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
close: (message) => {
|
|
29
|
+
if (closed) return;
|
|
30
|
+
closed = true;
|
|
31
|
+
if (started) p.outro(message ?? "Setup finished.", { output });
|
|
32
|
+
},
|
|
33
|
+
notify: (message, type = "info") => {
|
|
34
|
+
start();
|
|
35
|
+
if (message.includes("\n")) {
|
|
36
|
+
const title = type === "warning" ? "Warning" : type === "error" ? "Error" : undefined;
|
|
37
|
+
p.note(message, title, { output });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (type === "warning") p.log.warning(message, { output });
|
|
41
|
+
else if (type === "error") p.log.error(message, { output });
|
|
42
|
+
else p.log.info(message, { output });
|
|
43
|
+
},
|
|
44
|
+
confirm: async (title, message) => {
|
|
45
|
+
start();
|
|
46
|
+
const result = await p.confirm({
|
|
47
|
+
...promptIo,
|
|
48
|
+
message: `${title}\n${message}`,
|
|
49
|
+
initialValue: false,
|
|
50
|
+
});
|
|
51
|
+
return p.isCancel(result) ? undefined : result;
|
|
52
|
+
},
|
|
53
|
+
input: async (title, placeholder) => {
|
|
54
|
+
start();
|
|
55
|
+
const result = await p.text({
|
|
56
|
+
...promptIo,
|
|
57
|
+
message: title,
|
|
58
|
+
placeholder,
|
|
59
|
+
defaultValue: placeholder,
|
|
60
|
+
});
|
|
61
|
+
return p.isCancel(result) ? undefined : result;
|
|
62
|
+
},
|
|
63
|
+
select: async (title, options, dialogOptions) => {
|
|
64
|
+
start();
|
|
65
|
+
const index = Math.max(
|
|
66
|
+
0,
|
|
67
|
+
Math.min(dialogOptions?.initialIndex ?? 0, options.length - 1),
|
|
68
|
+
);
|
|
69
|
+
const initialValue = options[index]?.label;
|
|
70
|
+
const result = await p.select({
|
|
71
|
+
...promptIo,
|
|
72
|
+
message: title,
|
|
73
|
+
options: options.map((option) => ({
|
|
74
|
+
value: option.label,
|
|
75
|
+
label: option.label,
|
|
76
|
+
hint: option.description,
|
|
77
|
+
})),
|
|
78
|
+
initialValue,
|
|
79
|
+
});
|
|
80
|
+
return p.isCancel(result) ? undefined : result;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
package/src/command-manifest.ts
CHANGED
|
@@ -59,7 +59,7 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
59
59
|
description: "interview, configure the fleet, or install host and graph surfaces",
|
|
60
60
|
scope: "project",
|
|
61
61
|
usage: [
|
|
62
|
-
"setup [area] [--no-ai] [--project NAME]",
|
|
62
|
+
"setup [area] [--no-ai] [--answers FILE] [--save-answers FILE] [--project NAME]",
|
|
63
63
|
"setup host [NAME] [--project NAME]",
|
|
64
64
|
"setup graph [--no-seed] [--print] [--project NAME]",
|
|
65
65
|
],
|
|
@@ -71,6 +71,8 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
71
71
|
flags: [
|
|
72
72
|
project(),
|
|
73
73
|
toggle("--no-ai", "ask every question without AI repository probes"),
|
|
74
|
+
value("--answers", "answer every prompt from a JSON file"),
|
|
75
|
+
value("--save-answers", "save successful prompt answers as JSON"),
|
|
74
76
|
toggle("--no-seed", "enable graph indexing without the initial seed"),
|
|
75
77
|
toggle("--print", "print the graph install plan without changing anything"),
|
|
76
78
|
],
|
|
@@ -151,8 +153,8 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
151
153
|
name: "status",
|
|
152
154
|
description: "print layered fleet and deployment status",
|
|
153
155
|
scope: "fleet",
|
|
154
|
-
usage: ["status [--project NAME]"],
|
|
155
|
-
flags: [project()],
|
|
156
|
+
usage: ["status [--project NAME] [--json]"],
|
|
157
|
+
flags: [project(), toggle("--json", "print the stable status JSON shape")],
|
|
156
158
|
},
|
|
157
159
|
{
|
|
158
160
|
name: "stats",
|
|
@@ -188,8 +190,13 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
188
190
|
name: "ledger",
|
|
189
191
|
description: "print conductor verb and daemon decision history",
|
|
190
192
|
scope: "project",
|
|
191
|
-
usage: ["ledger [--issue N] [--limit N] [--project NAME]"],
|
|
192
|
-
flags: [
|
|
193
|
+
usage: ["ledger [--issue N] [--limit N] [--project NAME] [--json]"],
|
|
194
|
+
flags: [
|
|
195
|
+
value("--issue", "filter by issue number"),
|
|
196
|
+
value("--limit", "maximum rows"),
|
|
197
|
+
project(),
|
|
198
|
+
toggle("--json", "print the stable ledger JSON shape"),
|
|
199
|
+
],
|
|
193
200
|
},
|
|
194
201
|
{
|
|
195
202
|
name: "hold",
|
|
@@ -362,7 +369,7 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
362
369
|
"decision open --question TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]",
|
|
363
370
|
"decision resolve <id> --answer TEXT [--project NAME]",
|
|
364
371
|
"decision withdraw <id> [--reason TEXT] [--project NAME]",
|
|
365
|
-
"decision list [--project NAME]",
|
|
372
|
+
"decision list [--project NAME] [--json]",
|
|
366
373
|
],
|
|
367
374
|
subcommands: [
|
|
368
375
|
{ name: "open", description: "record a question for the operator" },
|
|
@@ -376,6 +383,7 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
376
383
|
value("--resolves-when", "automatic resolution condition"),
|
|
377
384
|
value("--answer", "operator answer"),
|
|
378
385
|
value("--reason", "withdrawal reason"),
|
|
386
|
+
toggle("--json", "print decision list as stable JSON"),
|
|
379
387
|
project(),
|
|
380
388
|
],
|
|
381
389
|
positionals: [{ name: "action" }, { name: "id" }],
|
|
@@ -386,7 +394,7 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
386
394
|
scope: "project",
|
|
387
395
|
usage: [
|
|
388
396
|
"watch add --note TEXT [--blocks TEXT] [--resolves-when COND] [--project NAME]",
|
|
389
|
-
"watch list [--project NAME]",
|
|
397
|
+
"watch list [--project NAME] [--json]",
|
|
390
398
|
],
|
|
391
399
|
subcommands: [
|
|
392
400
|
{ name: "add", description: "record a watch" },
|
|
@@ -396,6 +404,7 @@ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = [
|
|
|
396
404
|
value("--note", "note carried when the watch resolves"),
|
|
397
405
|
value("--blocks", "what the watch blocks"),
|
|
398
406
|
value("--resolves-when", "automatic resolution condition"),
|
|
407
|
+
toggle("--json", "print watch list as stable JSON"),
|
|
399
408
|
project(),
|
|
400
409
|
],
|
|
401
410
|
positionals: [{ name: "action" }],
|
package/src/commands/arm.ts
CHANGED
|
@@ -8,13 +8,21 @@
|
|
|
8
8
|
|
|
9
9
|
import type { CommandContext } from "./context.ts";
|
|
10
10
|
import { armTicks } from "../fleet.ts";
|
|
11
|
+
import { withProgress } from "../ui/progress.ts";
|
|
11
12
|
|
|
12
13
|
export async function armCommand(ctx: CommandContext): Promise<void> {
|
|
13
14
|
for (const project of ctx.targetProjects()) {
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
// Proof-neutral wording: `claim-only` performs no Telegram send, so the
|
|
16
|
+
// progress line cannot promise a challenge that never goes out (#613). The
|
|
17
|
+
// result line names the proof that actually armed it.
|
|
18
|
+
const r = await withProgress(
|
|
19
|
+
"arm: verifying the arming proof…",
|
|
20
|
+
"Arming proof verified",
|
|
21
|
+
() => armTicks(project.name),
|
|
22
|
+
{ plainMessage: true },
|
|
23
|
+
);
|
|
16
24
|
process.stdout.write(
|
|
17
|
-
`ARMED — inbound round-trip proved with owner ${r.owner}; ticks are now live.\n` +
|
|
25
|
+
`ARMED — ${r.proof === "claim-only" ? "claim-only plumbing verdict proved" : `inbound round-trip proved with owner ${r.owner}`}; ticks are now live.\n` +
|
|
18
26
|
`marker ${r.path}${r.alreadyArmed ? " (replaced previous marker)" : ""}\n`,
|
|
19
27
|
);
|
|
20
28
|
}
|
package/src/commands/decision.ts
CHANGED
|
@@ -86,17 +86,27 @@ try {
|
|
|
86
86
|
// were the whole reason an operator read the orchestrator's own reminder as
|
|
87
87
|
// a question aimed at them (#459) — `watch list` shows those.
|
|
88
88
|
const open = store.openDecisions(project.name).filter((d) => d.kind !== "watch");
|
|
89
|
-
|
|
89
|
+
const json = ctx.argv.includes("--json");
|
|
90
|
+
const now = Date.now();
|
|
91
|
+
const decisions = open.map((d) => ({
|
|
92
|
+
id: d.id,
|
|
93
|
+
ageHours: Math.max(0, Math.round((now - d.askedAt) / 3_600_000)),
|
|
94
|
+
blocks: d.blocks ?? null,
|
|
95
|
+
condition: d.condition === undefined ? null : d.conditionMetAt === undefined ? "pending" : "met",
|
|
96
|
+
question: d.question,
|
|
97
|
+
}));
|
|
98
|
+
if (json) {
|
|
99
|
+
process.stdout.write(`${JSON.stringify({ project: project.name, decisions }, null, 2)}\n`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (decisions.length === 0) {
|
|
90
103
|
process.stdout.write("no open decisions\n");
|
|
91
104
|
return;
|
|
92
105
|
}
|
|
93
|
-
const
|
|
94
|
-
for (const d of open) {
|
|
95
|
-
const condition =
|
|
96
|
-
d.condition === undefined ? "-" : d.conditionMetAt === undefined ? "pending" : "met";
|
|
97
|
-
const hours = Math.max(0, Math.round((now - d.askedAt) / 3_600_000));
|
|
106
|
+
for (const decision of decisions) {
|
|
98
107
|
process.stdout.write(
|
|
99
|
-
`${
|
|
108
|
+
`${decision.id} ${decision.ageHours}h blocks:${decision.blocks ?? "-"} ` +
|
|
109
|
+
`condition:${decision.condition ?? "-"} ${decision.question}\n`,
|
|
100
110
|
);
|
|
101
111
|
}
|
|
102
112
|
return;
|
package/src/commands/doctor.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { CommandContext } from "./context.ts";
|
|
12
12
|
import { runDoctor, type DoctorReport, type FindingStatus } from "../doctor.ts";
|
|
13
|
+
import { dim, fail, heading, ok, warn } from "../ui/style.ts";
|
|
13
14
|
|
|
14
15
|
const DOCTOR_USAGE = `omp-conductor doctor — check the deployment faults that have already cost
|
|
15
16
|
debugging sessions, mechanically and read-only.
|
|
@@ -69,6 +70,22 @@ export function renderDoctorReport(report: DoctorReport): string {
|
|
|
69
70
|
return `${lines.join("\n")}\n`;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
function styleDoctorReport(report: DoctorReport, text: string): string {
|
|
74
|
+
return text
|
|
75
|
+
.split("\n")
|
|
76
|
+
.map((line, index) => {
|
|
77
|
+
if (index === 0) return heading(line);
|
|
78
|
+
if (index === 1)
|
|
79
|
+
return report.status === "fail" ? fail(line) : report.status === "warn" ? warn(line) : ok(line);
|
|
80
|
+
if (line.startsWith(" FAIL ")) return fail(line);
|
|
81
|
+
if (line.startsWith(" WARN ")) return warn(line);
|
|
82
|
+
if (line.startsWith(" PASS ")) return ok(line);
|
|
83
|
+
if (line.includes(" fix: ")) return dim(line);
|
|
84
|
+
return line;
|
|
85
|
+
})
|
|
86
|
+
.join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
72
89
|
export async function doctorCommand(ctx: CommandContext): Promise<void> {
|
|
73
90
|
// Help first: parsing stops before any probe, config read, or side effect.
|
|
74
91
|
if (ctx.argv[1] === "--help" || ctx.argv[1] === "-h") {
|
|
@@ -96,7 +113,7 @@ export async function doctorCommand(ctx: CommandContext): Promise<void> {
|
|
|
96
113
|
probeTelegram: ctx.argv.includes("--probe-telegram"),
|
|
97
114
|
});
|
|
98
115
|
if (ctx.argv.includes("--json")) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
99
|
-
else process.stdout.write(renderDoctorReport(report));
|
|
116
|
+
else process.stdout.write(styleDoctorReport(report, renderDoctorReport(report)));
|
|
100
117
|
// The one contract CI reads: nonzero iff something failed. Warnings pass.
|
|
101
118
|
if (report.status === "fail") process.exitCode = 1;
|
|
102
119
|
}
|
package/src/commands/hold.ts
CHANGED
|
@@ -8,19 +8,21 @@
|
|
|
8
8
|
|
|
9
9
|
import type { CommandContext } from "./context.ts";
|
|
10
10
|
import { hold } from "../fleet.ts";
|
|
11
|
+
import { dim, ok } from "../ui/style.ts";
|
|
11
12
|
|
|
12
13
|
export async function holdCommand(ctx: CommandContext): Promise<void> {
|
|
13
14
|
const keepTicks = ctx.argv.includes("--keep-ticks");
|
|
14
15
|
for (const project of ctx.targetProjects()) {
|
|
15
16
|
const r = hold(project.name, "hold", keepTicks ? { keepTicks: true } : {});
|
|
16
17
|
process.stdout.write(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
ok(
|
|
19
|
+
`held — claiming paused` +
|
|
20
|
+
`${r.wasPaused ? " (already paused)" : ""}` +
|
|
21
|
+
(r.disarmed === undefined
|
|
22
|
+
? "; ticks left armed — resume restores the fleet with no new arm challenge"
|
|
23
|
+
: `; ticks disarmed at ${r.disarmed.path}` +
|
|
24
|
+
`${r.disarmed.wasArmed ? "" : " (was already disarmed)"}`),
|
|
25
|
+
) + `\n${dim("daemon and pane left running; stop to stop the daemon too")}\n`,
|
|
24
26
|
);
|
|
25
27
|
}
|
|
26
28
|
}
|
package/src/commands/ledger.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { CommandContext } from "./context.ts";
|
|
|
10
10
|
import { findProject, loadConfig } from "../config.ts";
|
|
11
11
|
import { dbPath, openStore } from "../store.ts";
|
|
12
12
|
import { formatVerbLedgerEntry } from "../verbs/ledger.ts";
|
|
13
|
+
import { dim, heading } from "../ui/style.ts";
|
|
13
14
|
|
|
14
15
|
export async function ledgerCommand(ctx: CommandContext): Promise<void> {
|
|
15
16
|
const cfg = loadConfig();
|
|
@@ -35,10 +36,28 @@ try {
|
|
|
35
36
|
...(issue === undefined ? {} : { issue }),
|
|
36
37
|
limit,
|
|
37
38
|
});
|
|
39
|
+
if (ctx.argv.includes("--json")) {
|
|
40
|
+
process.stdout.write(
|
|
41
|
+
`${JSON.stringify(
|
|
42
|
+
{
|
|
43
|
+
project: p.name,
|
|
44
|
+
...(issue === undefined ? {} : { issue }),
|
|
45
|
+
entries,
|
|
46
|
+
refused: entries.filter((entry) => entry.decision === "refused").length,
|
|
47
|
+
turnOverrides: overrides,
|
|
48
|
+
},
|
|
49
|
+
null,
|
|
50
|
+
2,
|
|
51
|
+
)}\n`,
|
|
52
|
+
);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
38
55
|
if (entries.length === 0 && overrides.length === 0) {
|
|
39
56
|
process.stdout.write(
|
|
40
|
-
|
|
41
|
-
|
|
57
|
+
dim(
|
|
58
|
+
`no conductor actions recorded for ${p.name}` +
|
|
59
|
+
`${issue === undefined ? "" : ` on #${String(issue)}`}`,
|
|
60
|
+
) + "\n",
|
|
42
61
|
);
|
|
43
62
|
return;
|
|
44
63
|
}
|
|
@@ -46,13 +65,15 @@ try {
|
|
|
46
65
|
if (entries.length > 0) {
|
|
47
66
|
const refused = entries.filter((e) => e.decision === "refused").length;
|
|
48
67
|
blocks.push(
|
|
49
|
-
`${p.name} — ${entries.length} verb call(s), ${refused} refused (newest first)
|
|
68
|
+
heading(`${p.name} — ${entries.length} verb call(s), ${refused} refused (newest first)`) +
|
|
69
|
+
"\n" +
|
|
50
70
|
entries.flatMap(formatVerbLedgerEntry).join("\n"),
|
|
51
71
|
);
|
|
52
72
|
}
|
|
53
73
|
if (overrides.length > 0) {
|
|
54
74
|
blocks.push(
|
|
55
|
-
`${p.name} — ${overrides.length} turn override(s) (newest first)
|
|
75
|
+
heading(`${p.name} — ${overrides.length} turn override(s) (newest first)`) +
|
|
76
|
+
"\n" +
|
|
56
77
|
overrides
|
|
57
78
|
.map(
|
|
58
79
|
(entry) =>
|
package/src/commands/message.ts
CHANGED
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
|
|
16
16
|
import type { CommandContext } from "./context.ts";
|
|
17
17
|
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { availabilityState, formatNextWindowOpening } from "../availability.ts";
|
|
18
19
|
import { findProject, loadConfig } from "../config.ts";
|
|
19
20
|
import { deliverOperatorMessage, operatorMessageCategory, type OperatorMessageOutcome } from "../reports.ts";
|
|
20
21
|
import { dbPath, openStore } from "../store.ts";
|
|
21
|
-
import { INTERRUPT_CATEGORIES, type DecisionRecord, type InterruptCategory } from "../types.ts";
|
|
22
|
+
import { INTERRUPT_CATEGORIES, type DecisionRecord, type InterruptCategory, type ProjectConfig } from "../types.ts";
|
|
22
23
|
import { validateQuestionShape } from "../ask.ts";
|
|
23
24
|
|
|
24
25
|
/** The floor's "this needs an answer" marker, as every other classifier reads it. */
|
|
@@ -96,8 +97,35 @@ export async function messageCommand(ctx: CommandContext): Promise<void> {
|
|
|
96
97
|
? // Not "into the topic": a stale topic degrades to the flat chat with
|
|
97
98
|
// its own warning on stderr, and this line must not contradict it.
|
|
98
99
|
`message delivered to ${project.name}'s configured Telegram target (${outcome.category})\n`
|
|
99
|
-
:
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
: heldLine(project, outcome),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The held-notice line names the future that releases it, in words an
|
|
105
|
+
* operator can act on: the working-hours window opening, or the daily digest.
|
|
106
|
+
* The earlier spelling — "the next digest or working-hours catch-up" — read
|
|
107
|
+
* the same for both and hid exactly the 23-hour hold that #596 is about. */
|
|
108
|
+
function heldLine(
|
|
109
|
+
project: ProjectConfig,
|
|
110
|
+
outcome: Extract<OperatorMessageOutcome, { kind: "held" }>,
|
|
111
|
+
): string {
|
|
112
|
+
if (outcome.reason === "availability") {
|
|
113
|
+
const state = availabilityState(project.reporting, Date.now());
|
|
114
|
+
const opening =
|
|
115
|
+
state.mode === "quiet" && state.nextTransitionAt !== undefined && state.timezone !== undefined
|
|
116
|
+
? ` at ${formatNextWindowOpening(state.nextTransitionAt, state.timezone)}`
|
|
117
|
+
: "";
|
|
118
|
+
return (
|
|
119
|
+
`held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
|
|
120
|
+
`held until the working-hours window opens${opening})\n` +
|
|
121
|
+
"nothing was sent; the daemon releases it with the working-hours catch-up when your window opens\n"
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const digestAt = project.reporting?.digest.at;
|
|
125
|
+
const when = digestAt === undefined ? "the next digest" : `the ${digestAt} digest`;
|
|
126
|
+
return (
|
|
127
|
+
`held notice ${outcome.noticeId} queued for ${project.name} (${outcome.category}; ` +
|
|
128
|
+
`digest-only — held until ${when})\n` +
|
|
129
|
+
`nothing was sent; the daemon releases it with ${when}\n`
|
|
102
130
|
);
|
|
103
131
|
}
|
package/src/commands/setup.ts
CHANGED
|
@@ -17,7 +17,16 @@ import { AMEND_AREA_IDS, type AmendAreaId } from "../setup.ts";
|
|
|
17
17
|
import { runGraphInstall, runHostInstall, type InstallOutcome } from "../setup-install.ts";
|
|
18
18
|
import { DEFAULT_PROBES, NO_PROBES, setup } from "../setup-wizard.ts";
|
|
19
19
|
import { telegramStateDir } from "../fleet.ts";
|
|
20
|
+
import {
|
|
21
|
+
answersUi,
|
|
22
|
+
guardNonInteractiveUi,
|
|
23
|
+
loadAnswersFile,
|
|
24
|
+
recordingAnswersUi,
|
|
25
|
+
saveAnswersFile,
|
|
26
|
+
type RecordedAnswersUi,
|
|
27
|
+
} from "../setup-answers.ts";
|
|
20
28
|
import { terminalUi, type WizardUi } from "../wizard-ui.ts";
|
|
29
|
+
import { interactiveUi } from "../ui/progress.ts";
|
|
21
30
|
import type { ConductorConfig, ProjectConfig } from "../types.ts";
|
|
22
31
|
|
|
23
32
|
/**
|
|
@@ -30,7 +39,7 @@ const SETUP_USAGE = `omp-conductor setup — interview, then write config.json,
|
|
|
30
39
|
and the staged host files behind one confirm.
|
|
31
40
|
|
|
32
41
|
usage:
|
|
33
|
-
omp-conductor setup [area] [--no-ai] [--project NAME]
|
|
42
|
+
omp-conductor setup [area] [--no-ai] [--answers FILE] [--save-answers FILE] [--project NAME]
|
|
34
43
|
omp-conductor setup host [NAME] [--project NAME]
|
|
35
44
|
omp-conductor setup graph [--no-seed] [--print] [--project NAME]
|
|
36
45
|
|
|
@@ -44,9 +53,11 @@ amend areas:
|
|
|
44
53
|
${AMEND_AREA_IDS.join(", ")}
|
|
45
54
|
|
|
46
55
|
flags:
|
|
47
|
-
--project NAME
|
|
48
|
-
|
|
49
|
-
--no-ai
|
|
56
|
+
--project NAME the project to configure or amend (or NAME positionally
|
|
57
|
+
for \`setup host\`)
|
|
58
|
+
--no-ai ask every question, propose nothing (no AI repo reads)
|
|
59
|
+
--answers FILE answer every prompt from JSON; never opens a prompt
|
|
60
|
+
--save-answers FILE save successful prompt answers as replayable JSON`;
|
|
50
61
|
|
|
51
62
|
/**
|
|
52
63
|
* Resolve the project an install subcommand (`setup host`, `setup graph`)
|
|
@@ -97,6 +108,7 @@ async function offerCompletionInstall(ui: WizardUi): Promise<void> {
|
|
|
97
108
|
const install = await ui.confirm(
|
|
98
109
|
"Install shell completions?",
|
|
99
110
|
"Writes a generated completion script and one idempotent source line to your shell rc file.",
|
|
111
|
+
{ key: "install-shell-completions" },
|
|
100
112
|
);
|
|
101
113
|
if (install !== true) return;
|
|
102
114
|
|
|
@@ -107,13 +119,26 @@ async function offerCompletionInstall(ui: WizardUi): Promise<void> {
|
|
|
107
119
|
{ label: "zsh", description: "Install through ~/.zshrc" },
|
|
108
120
|
{ label: "bash", description: "Install through ~/.bashrc" },
|
|
109
121
|
],
|
|
110
|
-
{ initialIndex: detected === "bash" ? 1 : 0 },
|
|
122
|
+
{ key: "completion-shell", initialIndex: detected === "bash" ? 1 : 0 },
|
|
111
123
|
);
|
|
112
124
|
if (selected !== "zsh" && selected !== "bash") return;
|
|
113
125
|
const installed = await installShellCompletions(selected);
|
|
114
126
|
ui.notify(`Installed completions at ${installed.scriptPath}; sourced from ${installed.rcPath}.`);
|
|
115
127
|
}
|
|
116
128
|
|
|
129
|
+
function setupValueFlag(argv: readonly string[], name: "--answers" | "--save-answers"): string | undefined {
|
|
130
|
+
const inline = argv.find((arg) => arg.startsWith(`${name}=`));
|
|
131
|
+
const at = argv.indexOf(name);
|
|
132
|
+
if (inline !== undefined && at !== -1) throw new Error(`${name} may be passed only once`);
|
|
133
|
+
const value = inline?.slice(name.length + 1) ?? (at === -1 ? undefined : argv[at + 1]);
|
|
134
|
+
if (value === undefined) {
|
|
135
|
+
if (at !== -1) throw new Error(`${name} requires a file path`);
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
if (value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a file path`);
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
|
|
117
142
|
export async function setupCommand(ctx: CommandContext): Promise<void> {
|
|
118
143
|
// Help first, and only in the first trailing position: a help request
|
|
119
144
|
// must never open a UI, read config, probe GitHub or pause dispatch.
|
|
@@ -125,10 +150,22 @@ if (ctx.argv[1] === "--help" || ctx.argv[1] === "-h") {
|
|
|
125
150
|
}
|
|
126
151
|
const sub = ctx.argv[1];
|
|
127
152
|
const positional = sub !== undefined && !sub.startsWith("--") ? sub : undefined;
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
const
|
|
153
|
+
// Clack owns an interactive terminal. Pipes retain the byte-pinned readline
|
|
154
|
+
// protocol, while --answers installs a prompt-free driver.
|
|
155
|
+
const answersPath = setupValueFlag(ctx.argv, "--answers");
|
|
156
|
+
const saveAnswersPath = setupValueFlag(ctx.argv, "--save-answers");
|
|
157
|
+
const useClack = answersPath === undefined && interactiveUi();
|
|
158
|
+
const baseUi = answersPath
|
|
159
|
+
? answersUi(loadAnswersFile(answersPath))
|
|
160
|
+
: useClack
|
|
161
|
+
? (await import("../clack-ui.ts")).clackUi()
|
|
162
|
+
: terminalUi();
|
|
163
|
+
const guardedUi = answersPath === undefined && !process.stdin.isTTY ? guardNonInteractiveUi(baseUi) : baseUi;
|
|
164
|
+
const recording: RecordedAnswersUi | undefined =
|
|
165
|
+
saveAnswersPath === undefined ? undefined : recordingAnswersUi(guardedUi);
|
|
166
|
+
const ui = recording?.ui ?? guardedUi;
|
|
167
|
+
let saveCompletedAnswers = false;
|
|
168
|
+
let closeMessage = "Setup failed.";
|
|
132
169
|
try {
|
|
133
170
|
// `host` and `graph` are install subcommands, checked BEFORE the amend
|
|
134
171
|
// areas. They are not areas — routing them through the area validation
|
|
@@ -170,6 +207,8 @@ try {
|
|
|
170
207
|
// `staged` is a success on a host that has no systemd: the files are
|
|
171
208
|
// real, only the enable step is impossible.
|
|
172
209
|
if (outcome.kind === "refused" || outcome.kind === "failed") process.exit(1);
|
|
210
|
+
saveCompletedAnswers = outcome.kind !== "declined";
|
|
211
|
+
closeMessage = outcome.kind === "declined" ? "Setup incomplete." : "Setup complete.";
|
|
173
212
|
return;
|
|
174
213
|
}
|
|
175
214
|
let area: AmendAreaId | undefined;
|
|
@@ -203,7 +242,19 @@ try {
|
|
|
203
242
|
);
|
|
204
243
|
}
|
|
205
244
|
}
|
|
245
|
+
saveCompletedAnswers = completed;
|
|
246
|
+
closeMessage = completed ? "Setup complete." : "Setup incomplete.";
|
|
206
247
|
} finally {
|
|
207
|
-
|
|
248
|
+
try {
|
|
249
|
+
if (saveCompletedAnswers && saveAnswersPath !== undefined && recording !== undefined) {
|
|
250
|
+
saveAnswersFile(saveAnswersPath, recording.answers);
|
|
251
|
+
ui.notify(`Saved setup answers to ${saveAnswersPath}.`);
|
|
252
|
+
}
|
|
253
|
+
} catch (err) {
|
|
254
|
+
closeMessage = "Setup failed.";
|
|
255
|
+
throw err;
|
|
256
|
+
} finally {
|
|
257
|
+
ui.close(closeMessage);
|
|
258
|
+
}
|
|
208
259
|
}
|
|
209
260
|
}
|
package/src/commands/stats.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { CommandContext } from "./context.ts";
|
|
|
13
13
|
import { findProject, loadConfig } from "../config.ts";
|
|
14
14
|
import { dbPath, openStore, utcDay } from "../store.ts";
|
|
15
15
|
import { computeStats, renderStatsHuman, type StatsWindow } from "../stats.ts";
|
|
16
|
+
import { heading } from "../ui/style.ts";
|
|
16
17
|
|
|
17
18
|
const DAY_MS = 24 * 60 * 60 * 1_000;
|
|
18
19
|
const DATE_FORM = /^\d{4}-\d{2}-\d{2}$/;
|
|
@@ -120,11 +121,14 @@ export async function statsCommand(ctx: CommandContext): Promise<void> {
|
|
|
120
121
|
ghCalls: store.ghCallsBetween(window.sinceDay, window.untilDay),
|
|
121
122
|
runs: store.statsRuns(project.name, window.sinceEpochMs),
|
|
122
123
|
});
|
|
123
|
-
process.stdout.write(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
124
|
+
if (ctx.argv.includes("--json")) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
125
|
+
else {
|
|
126
|
+
const human = renderStatsHuman(report);
|
|
127
|
+
const newline = human.indexOf("\n");
|
|
128
|
+
process.stdout.write(
|
|
129
|
+
newline < 0 ? heading(human) : `${heading(human.slice(0, newline))}${human.slice(newline)}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
128
132
|
} finally {
|
|
129
133
|
store.close();
|
|
130
134
|
}
|
package/src/commands/status.ts
CHANGED
|
@@ -10,8 +10,9 @@ import type { CommandContext } from "./context.ts";
|
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { stateDir } from "../config.ts";
|
|
13
|
-
import {
|
|
13
|
+
import { collectFleetStatus, renderFleetStatusReport } from "../fleet.ts";
|
|
14
14
|
import { STALL_MARKER_FILE } from "../orchestrator-tick.ts";
|
|
15
|
+
import { fail, heading, ok, warn } from "../ui/style.ts";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* The orchestrator half, and the one thing `status` has ever known about the
|
|
@@ -40,9 +41,35 @@ function stallLine(): string | undefined {
|
|
|
40
41
|
return `orchestrator STALLED since ${since === "" ? "an unrecorded time" : since}${diagnosis}`;
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
function styleStatus(text: string): string {
|
|
45
|
+
return text
|
|
46
|
+
.split("\n")
|
|
47
|
+
.map((line) => {
|
|
48
|
+
if (
|
|
49
|
+
line === "caps" ||
|
|
50
|
+
line === "daemon" ||
|
|
51
|
+
line === "active runs" ||
|
|
52
|
+
line === "active runs (none)" ||
|
|
53
|
+
line.startsWith("project ")
|
|
54
|
+
)
|
|
55
|
+
return heading(line);
|
|
56
|
+
if (line.includes("STALLED")) return fail(line);
|
|
57
|
+
if (/^(dispatch|ticks|pane|herdr|telegram| healthz)\s+.*\b(running|healthy|ok)\b/.test(line))
|
|
58
|
+
return ok(line);
|
|
59
|
+
if (/^(dispatch|ticks|daemon)\s+.*\b(paused|stopped|not running|overdue)\b/.test(line))
|
|
60
|
+
return warn(line);
|
|
61
|
+
return line;
|
|
62
|
+
})
|
|
63
|
+
.join("\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
43
66
|
export async function statusCommand(ctx: CommandContext): Promise<void> {
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
process.stdout.write(`${
|
|
67
|
+
const report = await collectFleetStatus(ctx.projectFlag);
|
|
68
|
+
const stalled = stallLine();
|
|
69
|
+
if (ctx.argv.includes("--json")) {
|
|
70
|
+
process.stdout.write(`${JSON.stringify({ ...report, orchestratorStall: stalled }, null, 2)}\n`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const text = styleStatus(renderFleetStatusReport(report));
|
|
74
|
+
process.stdout.write(`${text}${stalled === undefined ? "\n" : `\n\n${fail(stalled)}\n`}`);
|
|
48
75
|
}
|
package/src/commands/tail.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { findProject, loadConfig } from "../config.ts";
|
|
|
13
13
|
import { dbPath, LIVE_STATES, openStore } from "../store.ts";
|
|
14
14
|
import { formatTranscriptLine, prop } from "../transcript.ts";
|
|
15
15
|
import type { Store } from "../types.ts";
|
|
16
|
+
import { dim, fail, ok, warn } from "../ui/style.ts";
|
|
16
17
|
|
|
17
18
|
/** How often `tail` re-stats the transcripts it is following. */
|
|
18
19
|
const TAIL_POLL_MS = 1_000;
|
|
@@ -142,6 +143,16 @@ export function renderAdvisorLine(line: string): string | undefined {
|
|
|
142
143
|
return out.length === 0 ? undefined : `[advisor] ${out.join("\n")}`;
|
|
143
144
|
}
|
|
144
145
|
|
|
146
|
+
function styleTailLine(line: string): string {
|
|
147
|
+
if (line.startsWith("[advisor] blocker:")) return fail(line);
|
|
148
|
+
if (line.startsWith("[advisor] warning:")) return warn(line);
|
|
149
|
+
if (line.startsWith("[advisor] nit:")) return dim(line);
|
|
150
|
+
if (line === "run ended: merged" || line === "run ended: pushed-green") return ok(line);
|
|
151
|
+
if (/^run ended: (?:failed|killed|orphaned)$/.test(line)) return fail(line);
|
|
152
|
+
if (line.startsWith("run ended:")) return warn(line);
|
|
153
|
+
return line;
|
|
154
|
+
}
|
|
155
|
+
|
|
145
156
|
/**
|
|
146
157
|
* Follow one run's transcripts the way `tail -f` follows a log: the worker's
|
|
147
158
|
* own transcript plus any `__advisor*.jsonl` sitting beside it (the mid-run
|
|
@@ -173,7 +184,8 @@ export async function tailRun(
|
|
|
173
184
|
} = {},
|
|
174
185
|
): Promise<void> {
|
|
175
186
|
const store = deps.store ?? openStore(dbPath());
|
|
176
|
-
const write =
|
|
187
|
+
const write =
|
|
188
|
+
deps.write ?? ((line: string) => process.stdout.write(`${styleTailLine(line)}\n`));
|
|
177
189
|
const pollMs = deps.pollMs ?? TAIL_POLL_MS;
|
|
178
190
|
const quietMs = deps.quietMs ?? TAIL_QUIET_MS;
|
|
179
191
|
try {
|