omp-conductor 0.18.2 → 0.19.0
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 +105 -40
- package/REFERENCE.md +865 -30
- package/package.json +1 -1
- package/schema/config.schema.json +26 -0
- package/src/admission.ts +212 -26
- package/src/ask.ts +288 -1
- package/src/briefs/orchestrator.md +6 -5
- package/src/cli.ts +5 -1
- package/src/command-help.ts +9 -1
- package/src/command-manifest.ts +36 -3
- package/src/commands/arm.ts +5 -1
- package/src/commands/context.ts +2 -0
- package/src/commands/message.ts +26 -2
- package/src/commands/reconcile-units.ts +104 -0
- package/src/commands/release-composition.ts +232 -0
- package/src/commands/resume.ts +2 -27
- package/src/commands/setup.ts +101 -16
- package/src/commands/stats.ts +11 -30
- package/src/commands/tail.ts +31 -1
- package/src/commands/upgrade.ts +20 -3
- package/src/commands/verb.ts +2 -1
- package/src/config-schema.ts +19 -0
- package/src/config.ts +80 -0
- package/src/credential-class.ts +366 -0
- package/src/daemon.ts +1218 -288
- package/src/dashboard/app.js +504 -2
- package/src/dashboard/controls.ts +336 -0
- package/src/dashboard/index.html +30 -0
- package/src/dashboard/server.ts +271 -30
- package/src/dashboard/style.css +116 -0
- package/src/dashboard/transcript.ts +173 -0
- package/src/doctor.ts +377 -20
- package/src/failure-class.ts +59 -0
- package/src/fleet.ts +497 -15
- package/src/host.ts +6 -130
- package/src/omp.ts +29 -0
- package/src/orchestrator-tick.ts +343 -88
- package/src/pause.ts +233 -0
- package/src/settlement.ts +159 -2
- package/src/setup-answers.ts +97 -0
- package/src/setup-host.ts +321 -1155
- package/src/setup-install.ts +204 -27
- package/src/setup-wizard.ts +111 -50
- package/src/setup.ts +33 -0
- package/src/spend-telemetry.ts +117 -0
- package/src/stats.ts +35 -0
- package/src/status-render.ts +348 -19
- package/src/store.ts +1229 -55
- package/src/telegram-freshness.ts +269 -0
- package/src/to-spec.ts +27 -0
- package/src/types.ts +697 -4
- package/src/unblock.ts +22 -0
- package/src/unit-reconcile.ts +303 -0
- package/src/upgrade-verify.ts +8 -1
- package/src/upgrade.ts +299 -12
- package/src/verbs/actions.ts +124 -10
- package/src/verbs/protocol.ts +70 -2
- package/src/verbs/server.ts +447 -8
- package/src/wake.ts +48 -0
- package/src/worker.ts +403 -3
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is the installed `omp-telegram` current, and is its daemon running it? (#961)
|
|
3
|
+
*
|
|
4
|
+
* Conductor already proves its *own* three install surfaces carry one identity
|
|
5
|
+
* (#904/#915) and shows divergence without an on-demand run (#919). This is the
|
|
6
|
+
* same check for the one plugin a documented conductor contract depends on.
|
|
7
|
+
*
|
|
8
|
+
* The incident that motivated it: the package floor mandates answering an
|
|
9
|
+
* inbound message with a targetless `telegram_send`, so the reply stays in the
|
|
10
|
+
* topic it arrived in. On 2026-08-21 that call refused three times with
|
|
11
|
+
* `no active telegram chat — pass chat_id`, and each refusal cost an improvised
|
|
12
|
+
* recovery — once by passing route ids by hand, which is precisely the
|
|
13
|
+
* anti-pattern #882 names as its silent fake. The cause was that the installed
|
|
14
|
+
* 0.12.1 predates the target ladder: its send path had two rungs, and the
|
|
15
|
+
* published 0.12.2 has four plus per-rung diagnostics. The fix had been on npm
|
|
16
|
+
* for days. No conductor surface said so, so three sessions did transcript
|
|
17
|
+
* archaeology to reach a conclusion `npm view` answers in one call.
|
|
18
|
+
*
|
|
19
|
+
* Three properties are deliberate:
|
|
20
|
+
*
|
|
21
|
+
* - **Read-only, and never a remedy.** Installing stays operator-owned under the
|
|
22
|
+
* running-conductor boundary. The deliverable is that the operator is told.
|
|
23
|
+
* - **Offline-tolerant.** A registry that cannot be reached is `unknown`, never
|
|
24
|
+
* a finding. A fleet with no outbound npm access must not grow a permanent
|
|
25
|
+
* warning it can do nothing about.
|
|
26
|
+
* - **No baked-in baseline.** A minimum version compiled into conductor goes
|
|
27
|
+
* stale in exactly the way this module exists to catch, and re-creates #904's
|
|
28
|
+
* divergence with one more copy of the number. Every version compared here is
|
|
29
|
+
* read from a surface that exists at runtime.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { readFileSync } from "node:fs";
|
|
33
|
+
import { homedir } from "node:os";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
|
|
36
|
+
/** The plugin this checks. Named once. */
|
|
37
|
+
export const TELEGRAM_PACKAGE = "omp-telegram";
|
|
38
|
+
|
|
39
|
+
/** One surface's answer: a version, or why there isn't one. */
|
|
40
|
+
export type VersionRead =
|
|
41
|
+
| { kind: "version"; version: string }
|
|
42
|
+
| { kind: "absent" }
|
|
43
|
+
| { kind: "unreadable"; problem: string };
|
|
44
|
+
|
|
45
|
+
/** What the three surfaces said. */
|
|
46
|
+
export interface TelegramSurfaces {
|
|
47
|
+
/** The copy on disk, as `omp plugin list --json` reports it. */
|
|
48
|
+
installed: VersionRead;
|
|
49
|
+
/** What the running daemon says it is, from its own record. */
|
|
50
|
+
daemon: VersionRead;
|
|
51
|
+
/** The newest published version, or `unreadable` when the registry is not
|
|
52
|
+
* reachable — which is a non-finding, not a problem. */
|
|
53
|
+
published: VersionRead;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The verdict, in the vocabulary a surface renders.
|
|
58
|
+
*
|
|
59
|
+
* `unknown` is load-bearing: it is what an unreachable registry or an absent
|
|
60
|
+
* daemon produces, and it must never read as "current". A check that cannot run
|
|
61
|
+
* says so rather than certifying.
|
|
62
|
+
*/
|
|
63
|
+
export type TelegramFreshnessState =
|
|
64
|
+
| "current"
|
|
65
|
+
| "installed-behind"
|
|
66
|
+
| "daemon-stale"
|
|
67
|
+
| "not-installed"
|
|
68
|
+
| "unknown";
|
|
69
|
+
|
|
70
|
+
export interface TelegramFreshness {
|
|
71
|
+
state: TelegramFreshnessState;
|
|
72
|
+
surfaces: TelegramSurfaces;
|
|
73
|
+
/** One operator-facing sentence, or undefined when there is nothing to say.
|
|
74
|
+
* Never contains a token, chat id or topic id — only versions and paths. */
|
|
75
|
+
detail?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Numeric-dotted comparison, prerelease-agnostic: -1, 0 or 1.
|
|
79
|
+
*
|
|
80
|
+
* Deliberately not a semver dependency. The only question asked here is
|
|
81
|
+
* "is the installed one older than the published one", and a prerelease
|
|
82
|
+
* suffix is compared as a tiebreak string rather than by semver's precedence
|
|
83
|
+
* rules — an installed prerelease of the newest version is not behind. */
|
|
84
|
+
export function compareVersions(a: string, b: string): number {
|
|
85
|
+
const parse = (v: string): { nums: number[]; rest: string } => {
|
|
86
|
+
const m = /^v?(\d+(?:\.\d+)*)(.*)$/.exec(v.trim());
|
|
87
|
+
if (m === null) return { nums: [], rest: v.trim() };
|
|
88
|
+
return { nums: m[1]!.split(".").map((n) => Number(n)), rest: m[2] ?? "" };
|
|
89
|
+
};
|
|
90
|
+
const pa = parse(a);
|
|
91
|
+
const pb = parse(b);
|
|
92
|
+
const width = Math.max(pa.nums.length, pb.nums.length);
|
|
93
|
+
for (let i = 0; i < width; i++) {
|
|
94
|
+
const x = pa.nums[i] ?? 0;
|
|
95
|
+
const y = pb.nums[i] ?? 0;
|
|
96
|
+
if (x !== y) return x < y ? -1 : 1;
|
|
97
|
+
}
|
|
98
|
+
if (pa.rest === pb.rest) return 0;
|
|
99
|
+
// A release sorts above its own prereleases: "" beats "-rc.1".
|
|
100
|
+
if (pa.rest === "") return 1;
|
|
101
|
+
if (pb.rest === "") return -1;
|
|
102
|
+
return pa.rest < pb.rest ? -1 : 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The plugin's version from an `omp plugin list --json` payload.
|
|
106
|
+
*
|
|
107
|
+
* Tolerant by design, unlike conductor's own surface read: this plugin is a
|
|
108
|
+
* peer the fleet depends on, not the package being upgraded, so an inventory
|
|
109
|
+
* that cannot be parsed is `unreadable` rather than a thrown error that would
|
|
110
|
+
* take a whole `doctor` run down with it. */
|
|
111
|
+
export function installedFromInventory(raw: string): VersionRead {
|
|
112
|
+
let parsed: unknown;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(raw);
|
|
115
|
+
} catch {
|
|
116
|
+
return { kind: "unreadable", problem: "omp plugin list returned invalid JSON" };
|
|
117
|
+
}
|
|
118
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
119
|
+
return { kind: "unreadable", problem: "omp plugin list returned an invalid inventory" };
|
|
120
|
+
}
|
|
121
|
+
const npm = Reflect.get(parsed, "npm");
|
|
122
|
+
if (!Array.isArray(npm)) {
|
|
123
|
+
return { kind: "unreadable", problem: "omp plugin list returned no npm inventory" };
|
|
124
|
+
}
|
|
125
|
+
const entry = npm.find(
|
|
126
|
+
(e) => e !== null && typeof e === "object" && Reflect.get(e, "name") === TELEGRAM_PACKAGE,
|
|
127
|
+
);
|
|
128
|
+
if (entry === undefined) return { kind: "absent" };
|
|
129
|
+
const version = Reflect.get(entry, "version");
|
|
130
|
+
return typeof version === "string" && version.length > 0
|
|
131
|
+
? { kind: "version", version }
|
|
132
|
+
: { kind: "unreadable", problem: `${TELEGRAM_PACKAGE} is installed with no version` };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Where the daemon writes its own identity. Resolved exactly the way every
|
|
136
|
+
* other reader of that directory resolves it, so a test's override works here
|
|
137
|
+
* without a second convention. */
|
|
138
|
+
export function telegramStateDir(stateDir?: string): string {
|
|
139
|
+
const override = stateDir?.trim() ?? process.env.OMP_TELEGRAM_STATE_DIR?.trim();
|
|
140
|
+
return override ? override : join(homedir(), ".omp", "agent", "telegram");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** What the running daemon says it is. `absent` when it is not running — which
|
|
144
|
+
* is not a fault here: a fleet may have no daemon up, and that is `unknown`
|
|
145
|
+
* rather than stale. */
|
|
146
|
+
export function daemonVersion(stateDir?: string): VersionRead {
|
|
147
|
+
const path = join(telegramStateDir(stateDir), "daemon.json");
|
|
148
|
+
let raw: unknown;
|
|
149
|
+
try {
|
|
150
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return { kind: "absent" };
|
|
153
|
+
return {
|
|
154
|
+
kind: "unreadable",
|
|
155
|
+
problem: `cannot read ${path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
159
|
+
return { kind: "unreadable", problem: `${path} is not a daemon record` };
|
|
160
|
+
}
|
|
161
|
+
const version = Reflect.get(raw, "version");
|
|
162
|
+
return typeof version === "string" && version.length > 0
|
|
163
|
+
? { kind: "version", version }
|
|
164
|
+
: { kind: "unreadable", problem: `${path} records no version` };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Decide, from three reads.
|
|
169
|
+
*
|
|
170
|
+
* Order matters, and it is worth stating: `installed-behind` is checked before
|
|
171
|
+
* `daemon-stale` because the remedy differs — a behind install needs an install
|
|
172
|
+
* *and* a restart, and reporting only the restart would leave the operator one
|
|
173
|
+
* step short. A daemon matching a behind install is therefore still reported as
|
|
174
|
+
* behind, not as stale.
|
|
175
|
+
*/
|
|
176
|
+
export function classifyTelegramFreshness(surfaces: TelegramSurfaces): TelegramFreshness {
|
|
177
|
+
const { installed, daemon, published } = surfaces;
|
|
178
|
+
|
|
179
|
+
if (installed.kind === "absent") {
|
|
180
|
+
return {
|
|
181
|
+
state: "not-installed",
|
|
182
|
+
surfaces,
|
|
183
|
+
detail: `${TELEGRAM_PACKAGE} is not installed; the reply-in-topic contract the brief mandates has no transport`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (installed.kind === "unreadable") {
|
|
187
|
+
return { state: "unknown", surfaces, detail: installed.problem };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (published.kind === "version" && compareVersions(installed.version, published.version) < 0) {
|
|
191
|
+
const running =
|
|
192
|
+
daemon.kind === "version" && daemon.version !== installed.version
|
|
193
|
+
? `; the daemon is on ${daemon.version}`
|
|
194
|
+
: "";
|
|
195
|
+
return {
|
|
196
|
+
state: "installed-behind",
|
|
197
|
+
surfaces,
|
|
198
|
+
detail:
|
|
199
|
+
`${TELEGRAM_PACKAGE} ${installed.version} is installed, ${published.version} is published${running}` +
|
|
200
|
+
` — install it and restart the daemon (a publish does not bump an installed copy)`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (daemon.kind === "version" && daemon.version !== installed.version) {
|
|
205
|
+
return {
|
|
206
|
+
state: "daemon-stale",
|
|
207
|
+
surfaces,
|
|
208
|
+
detail:
|
|
209
|
+
`the running ${TELEGRAM_PACKAGE} daemon is ${daemon.version} but ${installed.version} is installed` +
|
|
210
|
+
" — restart it, or it keeps serving the old code",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Nothing to compare against: the registry was unreachable, so "current" is
|
|
215
|
+
// an answer this check has not earned.
|
|
216
|
+
if (published.kind !== "version") {
|
|
217
|
+
return {
|
|
218
|
+
state: "unknown",
|
|
219
|
+
surfaces,
|
|
220
|
+
detail:
|
|
221
|
+
published.kind === "unreadable"
|
|
222
|
+
? `${TELEGRAM_PACKAGE} ${installed.version} is installed; the newest published version could not be read (${published.problem})`
|
|
223
|
+
: `${TELEGRAM_PACKAGE} ${installed.version} is installed; nothing published to compare against`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return { state: "current", surfaces };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** What reading the surfaces needs. Injected so the whole check is testable
|
|
231
|
+
* without npm, a daemon, or a plugin install. */
|
|
232
|
+
export interface TelegramFreshnessDeps {
|
|
233
|
+
/** `omp plugin list --json`, and `npm view omp-telegram version`. */
|
|
234
|
+
run: (cmd: string, args: readonly string[]) => Promise<{ code: number; stdout: string }>;
|
|
235
|
+
/** Override for the daemon record's directory, in tests. */
|
|
236
|
+
stateDir?: string;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Read all three surfaces and classify. Never throws: every failure is a
|
|
240
|
+
* `VersionRead` that the classifier turns into `unknown`. */
|
|
241
|
+
export async function checkTelegramFreshness(
|
|
242
|
+
deps: TelegramFreshnessDeps,
|
|
243
|
+
): Promise<TelegramFreshness> {
|
|
244
|
+
const inventory = await deps
|
|
245
|
+
.run("omp", ["plugin", "list", "--json"])
|
|
246
|
+
.then((r): VersionRead =>
|
|
247
|
+
r.code === 0
|
|
248
|
+
? installedFromInventory(r.stdout)
|
|
249
|
+
: { kind: "unreadable", problem: "omp plugin list failed" },
|
|
250
|
+
)
|
|
251
|
+
.catch((err: unknown): VersionRead => ({
|
|
252
|
+
kind: "unreadable",
|
|
253
|
+
problem: `omp plugin list failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
254
|
+
}));
|
|
255
|
+
const published = await deps
|
|
256
|
+
.run("npm", ["view", TELEGRAM_PACKAGE, "version"])
|
|
257
|
+
.then((r): VersionRead => {
|
|
258
|
+
const version = r.stdout.trim();
|
|
259
|
+
return r.code === 0 && version.length > 0
|
|
260
|
+
? { kind: "version", version }
|
|
261
|
+
: { kind: "unreadable", problem: "npm registry unreachable" };
|
|
262
|
+
})
|
|
263
|
+
.catch((): VersionRead => ({ kind: "unreadable", problem: "npm registry unreachable" }));
|
|
264
|
+
return classifyTelegramFreshness({
|
|
265
|
+
installed: inventory,
|
|
266
|
+
daemon: daemonVersion(deps.stateDir),
|
|
267
|
+
published,
|
|
268
|
+
});
|
|
269
|
+
}
|
package/src/to-spec.ts
CHANGED
|
@@ -406,3 +406,30 @@ export function parseToSpecEvidence(evidence: string): ToSpecResult | undefined
|
|
|
406
406
|
const parsed = ToSpecResultSchema.safeParse(raw.result);
|
|
407
407
|
return parsed.success ? parsed.data : undefined;
|
|
408
408
|
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The mirror of {@link parseToSpecEvidence} for the refusal payload
|
|
412
|
+
* {@link failureEvidence} writes: the recorded failure when a row is a
|
|
413
|
+
* refused pass, `undefined` for anything else (a valid verdict, a mechanical
|
|
414
|
+
* hold, an in-flight marker, a hand-edited row).
|
|
415
|
+
*
|
|
416
|
+
* A refusal is durable evidence too — it says a full delegated batch was
|
|
417
|
+
* already spent on this candidate and produced nothing usable — so the
|
|
418
|
+
* selection side needs to read it back rather than seeing only "not groomed"
|
|
419
|
+
* and re-spending immediately (#887).
|
|
420
|
+
*/
|
|
421
|
+
export function parseToSpecFailureEvidence(evidence: string): ToSpecFailure | undefined {
|
|
422
|
+
let raw: unknown;
|
|
423
|
+
try {
|
|
424
|
+
raw = JSON.parse(evidence);
|
|
425
|
+
} catch {
|
|
426
|
+
return undefined;
|
|
427
|
+
}
|
|
428
|
+
if (!isObject(raw) || raw.kind !== "to-spec-failure") return undefined;
|
|
429
|
+
const failure = raw.failure;
|
|
430
|
+
if (!isObject(failure) || typeof failure.detail !== "string") return undefined;
|
|
431
|
+
if (failure.kind !== "malformed" && failure.kind !== "missing-source" && failure.kind !== "stale-source") {
|
|
432
|
+
return undefined;
|
|
433
|
+
}
|
|
434
|
+
return { kind: failure.kind, detail: failure.detail };
|
|
435
|
+
}
|