omp-conductor 0.15.9 → 0.15.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +273 -2543
- package/REFERENCE.md +2638 -0
- package/package.json +3 -2
- package/schema/config.schema.json +8 -23
- package/src/arm-challenge.ts +112 -0
- package/src/ask.ts +434 -0
- package/src/board.ts +81 -15
- package/src/brief-upgrade.ts +114 -8
- package/src/briefs/orchestrator.md +55 -29
- package/src/briefs/policy.md +14 -5
- package/src/briefs/worker.md +7 -1
- package/src/chain-check.ts +1 -1
- package/src/check-trailing-newlines.ts +82 -0
- package/src/cli.ts +190 -1391
- package/src/commands/arm.ts +21 -0
- package/src/commands/board.ts +23 -0
- package/src/commands/brief-upgrade.ts +186 -0
- package/src/commands/context.ts +49 -0
- package/src/commands/daemon.ts +71 -0
- package/src/commands/dashboard.ts +74 -0
- package/src/commands/decision.ts +103 -0
- package/src/commands/disarm.ts +21 -0
- package/src/commands/doctor.ts +98 -0
- package/src/commands/event.ts +62 -0
- package/src/commands/extend.ts +64 -0
- package/src/commands/friction.ts +56 -0
- package/src/commands/help.ts +9 -0
- package/src/commands/hold.ts +26 -0
- package/src/commands/intake.ts +134 -0
- package/src/commands/ledger.ts +69 -0
- package/src/commands/message.ts +48 -0
- package/src/commands/report.ts +170 -0
- package/src/commands/restart.ts +76 -0
- package/src/commands/resume.ts +58 -0
- package/src/commands/setup.ts +93 -0
- package/src/commands/start.ts +23 -0
- package/src/commands/stats.ts +131 -0
- package/src/commands/status.ts +48 -0
- package/src/commands/stop.ts +51 -0
- package/src/commands/tail.ts +109 -0
- package/src/commands/unblock.ts +39 -0
- package/src/commands/upgrade-install.ts +31 -0
- package/src/commands/upgrade-rollback.ts +23 -0
- package/src/commands/upgrade.ts +25 -0
- package/src/commands/verb.ts +83 -0
- package/src/commands/version.ts +30 -0
- package/src/commands/worker.ts +100 -0
- package/src/config-schema.ts +38 -1
- package/src/config.ts +10 -3
- package/src/daemon.ts +613 -94
- package/src/dashboard/app.js +120 -0
- package/src/dashboard/index.html +34 -0
- package/src/dashboard/server.ts +267 -0
- package/src/dashboard/style.css +180 -0
- package/src/decisions.ts +39 -14
- package/src/diff-flags.ts +131 -241
- package/src/doctor.ts +795 -0
- package/src/escalate.ts +60 -19
- package/src/failure-class.ts +29 -3
- package/src/fleet.ts +58 -1
- package/src/graph-health.ts +1 -1
- package/src/label-projection.ts +1 -1
- package/src/lifecycle.ts +198 -2
- package/src/notices.ts +9 -0
- package/src/omp.ts +2 -0
- package/src/orchestrator-tick.ts +315 -17
- package/src/release-policy.ts +135 -23
- package/src/reports.ts +19 -5
- package/src/setup-host.ts +420 -8
- package/src/setup-install.ts +69 -14
- package/src/setup-wizard.ts +199 -61
- package/src/setup.ts +131 -35
- package/src/stats.ts +331 -0
- package/src/store.ts +206 -21
- package/src/tracker/github.ts +27 -4
- package/src/types.ts +144 -31
- package/src/unblock.ts +55 -11
- package/src/upgrade-journal.ts +220 -0
- package/src/upgrade-verify.ts +506 -0
- package/src/upgrade.ts +295 -26
- package/src/verbs/actions.ts +73 -1
- package/src/verbs/protocol.ts +29 -4
- package/src/verbs/server.ts +183 -20
- package/systemd/omp-conductor-recover.sh +433 -0
- package/systemd/omp-conductor.service.example +7 -0
- package/systemd/recover-unit-test.sh +428 -0
package/src/setup-install.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* call in here; `privileged.ts` stays the primitive underneath.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { existsSync } from "node:fs";
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
19
19
|
import { platform } from "node:os";
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import { stateDir } from "./config.ts";
|
|
@@ -41,7 +41,9 @@ import {
|
|
|
41
41
|
STAGED_SERVICE_NAME,
|
|
42
42
|
SYSTEMD_UNIT_DIR,
|
|
43
43
|
type EscalationDeps,
|
|
44
|
+
type ServiceRuntime,
|
|
44
45
|
} from "./setup-host.ts";
|
|
46
|
+
import { DEFAULT_HERDR_UNIT } from "./fleet.ts";
|
|
45
47
|
import type { WizardUi } from "./wizard-ui.ts";
|
|
46
48
|
import type { Caps, ProjectConfig } from "./types.ts";
|
|
47
49
|
|
|
@@ -62,6 +64,12 @@ export interface InstallDeps {
|
|
|
62
64
|
/** `"linux"` gates the systemd half. Injectable so the refusal is testable. */
|
|
63
65
|
platform?: () => string;
|
|
64
66
|
unitDir?: string;
|
|
67
|
+
/**
|
|
68
|
+
* The runtime the plan derives from. Injectable so tests pin herdr
|
|
69
|
+
* present/absent deterministically; the default ({@link defaultServiceRuntime})
|
|
70
|
+
* detects herdr from the host's own PATH.
|
|
71
|
+
*/
|
|
72
|
+
runtime?: ServiceRuntime;
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
/**
|
|
@@ -75,6 +83,22 @@ function linuxOnly(deps: InstallDeps): string | undefined {
|
|
|
75
83
|
: "systemd install is Linux-only; staged files are at";
|
|
76
84
|
}
|
|
77
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Whether two files hold identical bytes. The "keep" side of setup-host.ts's
|
|
88
|
+
* `actionFor`: a unit systemd already reads that matches what would be written
|
|
89
|
+
* needs no reinstall. Reads can fail (permissions, vanished mid-flight) — a
|
|
90
|
+
* read error is a mismatch, never silently "matches", because the whole point
|
|
91
|
+
* is to make the plan only claim a unit install is redundant when it can see
|
|
92
|
+
* the installed copy.
|
|
93
|
+
*/
|
|
94
|
+
function sameFile(a: string, b: string): boolean {
|
|
95
|
+
try {
|
|
96
|
+
return readFileSync(a, "utf8") === readFileSync(b, "utf8");
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
78
102
|
/**
|
|
79
103
|
* Install the supervised daemon unit: re-render, stage, then run the four steps
|
|
80
104
|
* `planHostRuntime` used to only print.
|
|
@@ -92,10 +116,12 @@ export async function runHostInstall(
|
|
|
92
116
|
return { kind: "refused", reason: verdict.message };
|
|
93
117
|
}
|
|
94
118
|
|
|
95
|
-
const plan = planHostRuntime(project, caps, telegramStateDir,
|
|
96
|
-
const wrote = writeHostRuntime(plan);
|
|
119
|
+
const plan = planHostRuntime(project, caps, telegramStateDir, deps.runtime, hostInstallWorkers(caps));
|
|
120
|
+
const { wrote, warnings } = writeHostRuntime(plan);
|
|
121
|
+
for (const warning of warnings) ui.notify(warning, "warning");
|
|
97
122
|
const unitDir = deps.unitDir ?? SYSTEMD_UNIT_DIR;
|
|
98
123
|
const installed = join(unitDir, STAGED_SERVICE_NAME);
|
|
124
|
+
const installedHerdr = join(unitDir, DEFAULT_HERDR_UNIT);
|
|
99
125
|
|
|
100
126
|
const blocked = linuxOnly(deps);
|
|
101
127
|
if (blocked !== undefined) {
|
|
@@ -111,13 +137,29 @@ export async function runHostInstall(
|
|
|
111
137
|
{ title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
|
|
112
138
|
{ title: `enable ${STAGED_SERVICE_NAME}`, argv: ["systemctl", "enable", STAGED_SERVICE_NAME] },
|
|
113
139
|
{ title: `restart ${STAGED_SERVICE_NAME}`, argv: ["systemctl", "restart", STAGED_SERVICE_NAME] },
|
|
140
|
+
// The herdr session server, provisioned alongside the daemon (#456). Only
|
|
141
|
+
// when herdr is installed and the plan therefore staged a unit.
|
|
142
|
+
...(plan.herdrUnit === undefined
|
|
143
|
+
? []
|
|
144
|
+
: [
|
|
145
|
+
{ title: `install ${DEFAULT_HERDR_UNIT}`, argv: ["install", "-m", "0644", plan.herdrUnit.path, installedHerdr] },
|
|
146
|
+
{ title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
|
|
147
|
+
{ title: `enable ${DEFAULT_HERDR_UNIT}`, argv: ["systemctl", "enable", DEFAULT_HERDR_UNIT] },
|
|
148
|
+
{ title: `restart ${DEFAULT_HERDR_UNIT}`, argv: ["systemctl", "restart", DEFAULT_HERDR_UNIT] },
|
|
149
|
+
]),
|
|
114
150
|
];
|
|
115
151
|
|
|
116
152
|
const outcome = await runPrivileged(steps, ui, {
|
|
117
153
|
...(deps.privileged === undefined ? {} : { deps: deps.privileged }),
|
|
118
|
-
title: "Install and start the supervised
|
|
154
|
+
title: "Install and start the supervised session?",
|
|
119
155
|
preamble: [
|
|
120
156
|
`Installs ${plan.service.path} as ${installed}, then enables and restarts it.`,
|
|
157
|
+
...(plan.herdrUnit === undefined
|
|
158
|
+
? []
|
|
159
|
+
: [
|
|
160
|
+
`Also installs the fleet session unit as ${installedHerdr} with the account's login shell so panes`,
|
|
161
|
+
"run it instead of dash, then enables and restarts it.",
|
|
162
|
+
]),
|
|
121
163
|
"The unit runs as the account that staged it; nothing here changes that.",
|
|
122
164
|
],
|
|
123
165
|
});
|
|
@@ -219,6 +261,15 @@ export async function runGraphInstall(
|
|
|
219
261
|
const blocked = linuxOnly(options);
|
|
220
262
|
const { service, timer } = unitPaths(options.unitDir ?? SYSTEMD_UNIT_DIR);
|
|
221
263
|
const from = unitPaths(stateDir());
|
|
264
|
+
// The units systemd already reads, compared to what staging just wrote — the
|
|
265
|
+
// same distinction setup-host.ts draws between `installedAction` and
|
|
266
|
+
// `service.action` (read the installed file, compare to the fresh content,
|
|
267
|
+
// "keep" vs "update"). "Installed" is not enough: a green timer serving a
|
|
268
|
+
// stale unit is the older bug, so when they differ the install runs. Only
|
|
269
|
+
// when both units already match does the plan omit them — then just the
|
|
270
|
+
// outstanding clone and seed remain.
|
|
271
|
+
const unitsCurrent =
|
|
272
|
+
blocked === undefined && sameFile(service, from.service) && sameFile(timer, from.timer);
|
|
222
273
|
// 3. Install and enable, privileged. 4. Seed, in the SAME batch: the contract is
|
|
223
274
|
// one preview and one confirm, and a second confirm here also invented a
|
|
224
275
|
// third outcome — a declined seed — that neither the caller nor the
|
|
@@ -230,9 +281,13 @@ export async function runGraphInstall(
|
|
|
230
281
|
const install: PrivilegedStep[] =
|
|
231
282
|
blocked === undefined
|
|
232
283
|
? [
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
284
|
+
...(unitsCurrent
|
|
285
|
+
? []
|
|
286
|
+
: [
|
|
287
|
+
{ title: "install the reindex unit and timer", argv: ["install", "-m", "0644", from.service, from.timer, join(options.unitDir ?? SYSTEMD_UNIT_DIR, "")] },
|
|
288
|
+
{ title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
|
|
289
|
+
{ title: `enable ${REINDEX_UNIT}.timer`, argv: ["systemctl", "enable", "--now", `${REINDEX_UNIT}.timer`] },
|
|
290
|
+
]),
|
|
236
291
|
...seed,
|
|
237
292
|
]
|
|
238
293
|
: [];
|
|
@@ -242,19 +297,19 @@ export async function runGraphInstall(
|
|
|
242
297
|
return { kind: "staged", wrote: staged.written, reason: `${blocked} ${service}` };
|
|
243
298
|
}
|
|
244
299
|
|
|
245
|
-
if (blocked !== undefined) {
|
|
246
|
-
ui.notify(`${blocked} ${service} and ${timer}`, "warning");
|
|
247
|
-
return { kind: "staged", wrote: staged.written, reason: `${blocked} ${service}` };
|
|
248
|
-
}
|
|
249
|
-
|
|
250
300
|
const outcome = await runPrivileged([...clones, ...install], ui, {
|
|
251
301
|
...(options.privileged === undefined ? {} : { deps: options.privileged }),
|
|
252
|
-
title: "Clone, install and enable the code-graph timer?",
|
|
302
|
+
title: unitsCurrent ? "Clone the code-graph checkouts and seed them?" : "Clone, install and enable the code-graph timer?",
|
|
253
303
|
preamble: [
|
|
254
304
|
`Staged: ${staged.written.join(", ")}.`,
|
|
255
305
|
...(clones.length === 0
|
|
256
306
|
? ["Every indexed clone already exists."]
|
|
257
|
-
: [
|
|
307
|
+
: [
|
|
308
|
+
`${clones.length} clone(s) run as you; ${
|
|
309
|
+
unitsCurrent ? "only the seed needs root — the reindex units already match." : "the unit install needs root."
|
|
310
|
+
}`,
|
|
311
|
+
]),
|
|
312
|
+
...(unitsCurrent ? ["The reindex units are already installed and current — nothing to install."] : []),
|
|
258
313
|
`Indexer: ${prereqs.indexer ?? "on PATH"}.`,
|
|
259
314
|
],
|
|
260
315
|
});
|
package/src/setup-wizard.ts
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
type QueuePreview,
|
|
31
31
|
} from "./daemon.ts";
|
|
32
32
|
import { armTicks, telegramStateDir } from "./fleet.ts";
|
|
33
|
-
import { restartDaemon } from "./lifecycle.ts";
|
|
33
|
+
import { restartDaemon, type RestartResult } from "./lifecycle.ts";
|
|
34
34
|
import { defaultGraphRoot, graphRepos } from "./graph.ts";
|
|
35
35
|
import {
|
|
36
36
|
formatHostRuntimePlan,
|
|
@@ -40,8 +40,9 @@ import {
|
|
|
40
40
|
SYSTEMD_UNIT_DIR,
|
|
41
41
|
tickCwdForProject,
|
|
42
42
|
writeHostRuntime,
|
|
43
|
+
type SetupSmokeResult,
|
|
43
44
|
} from "./setup-host.ts";
|
|
44
|
-
import { runGraphInstall, runHostInstall } from "./setup-install.ts";
|
|
45
|
+
import { runGraphInstall, runHostInstall, type GraphInstallOptions, type InstallOutcome } from "./setup-install.ts";
|
|
45
46
|
import { probeGates, probeProse, probeRepoMap, type ProbedGate, type ProbeTarget } from "./setup-probe.ts";
|
|
46
47
|
import {
|
|
47
48
|
discoverFacts,
|
|
@@ -74,7 +75,9 @@ import {
|
|
|
74
75
|
summarisePlan,
|
|
75
76
|
writeOrchestratorBrief,
|
|
76
77
|
type AmendAreaId,
|
|
78
|
+
type LabelPlan,
|
|
77
79
|
type OperatorJudgment,
|
|
80
|
+
type ScopeCheck,
|
|
78
81
|
type SetupAnswers,
|
|
79
82
|
type ProbedProse,
|
|
80
83
|
} from "./setup.ts";
|
|
@@ -82,11 +85,13 @@ import {
|
|
|
82
85
|
BASE_FRESHNESS,
|
|
83
86
|
BEHIND_BASE_ACTIONS,
|
|
84
87
|
DEFAULT_CAPS,
|
|
88
|
+
DENIED_RELEASE_GRANTS,
|
|
85
89
|
DRAFT_POLICIES,
|
|
86
90
|
RELEASE_REQUIREMENTS,
|
|
87
91
|
INTERRUPT_CATEGORIES,
|
|
88
92
|
RELEASE_SHAPES,
|
|
89
93
|
WEEKDAYS,
|
|
94
|
+
type AuthorityHolder,
|
|
90
95
|
type Caps,
|
|
91
96
|
type ConductorConfig,
|
|
92
97
|
type OrchestratorMode,
|
|
@@ -374,8 +379,18 @@ async function askReleaseRequirements(
|
|
|
374
379
|
* them out of POLICY.md is that they are config: a condition an operator can
|
|
375
380
|
* only reach by opening `config.json` is one that stays at its default while
|
|
376
381
|
* their prose says something else, which is the drift this key ended.
|
|
382
|
+
*
|
|
383
|
+
* The merge half is always asked — it gates work that lands whatever the
|
|
384
|
+
* release authority is. The release half only matters when the orchestrator
|
|
385
|
+
* actually cuts releases: with `release: human` a release verb is refused
|
|
386
|
+
* before these fields are read (see `verbs/server`), so asking them would be a
|
|
387
|
+
* dead question. They are skipped, keeping the seed's value.
|
|
377
388
|
*/
|
|
378
|
-
async function askPolicyPreconditions(
|
|
389
|
+
async function askPolicyPreconditions(
|
|
390
|
+
ui: WizardUi,
|
|
391
|
+
prior: ProjectPolicy,
|
|
392
|
+
releaseAuthority: AuthorityHolder,
|
|
393
|
+
): Promise<ProjectPolicy> {
|
|
379
394
|
const merge = {
|
|
380
395
|
requiredChecks: await askNameList(
|
|
381
396
|
ui,
|
|
@@ -405,33 +420,37 @@ async function askPolicyPreconditions(ui: WizardUi, prior: ProjectPolicy): Promi
|
|
|
405
420
|
),
|
|
406
421
|
};
|
|
407
422
|
|
|
408
|
-
const release =
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
423
|
+
const release =
|
|
424
|
+
releaseAuthority === "orchestrator"
|
|
425
|
+
? {
|
|
426
|
+
requires: await askReleaseRequirements(ui, prior.release.requires),
|
|
427
|
+
requiredChecks: await askNameList(
|
|
428
|
+
ui,
|
|
429
|
+
`Release — required checks (comma separated, "${EMPTY_LIST}" = every check the branch reports)`,
|
|
430
|
+
prior.release.requiredChecks,
|
|
431
|
+
),
|
|
432
|
+
artefacts: await askNameList(
|
|
433
|
+
ui,
|
|
434
|
+
`Release — artefacts this project ships (comma separated, or "${EMPTY_LIST}")`,
|
|
435
|
+
prior.release.artefacts,
|
|
436
|
+
),
|
|
437
|
+
environments: await askNameList(
|
|
438
|
+
ui,
|
|
439
|
+
`Release — environments a deploy may target (comma separated, or "${EMPTY_LIST}")`,
|
|
440
|
+
prior.release.environments,
|
|
441
|
+
),
|
|
442
|
+
}
|
|
443
|
+
: prior.release;
|
|
426
444
|
|
|
427
445
|
return { merge, release };
|
|
428
446
|
}
|
|
429
447
|
|
|
430
448
|
/**
|
|
431
|
-
* Who merges and who
|
|
432
|
-
* these are independent grants — delegating merges is routine,
|
|
433
|
-
* releases is not — and a menu of
|
|
434
|
-
* ordinary choices, which is exactly the framing a release
|
|
449
|
+
* Who merges, who releases and who promotes. Three confirms rather than one
|
|
450
|
+
* four-way list: these are independent grants — delegating merges is routine,
|
|
451
|
+
* delegating releases and promotion is not — and a menu of combinations frames
|
|
452
|
+
* them as equally ordinary choices, which is exactly the framing a release or
|
|
453
|
+
* promotion grant must not get.
|
|
435
454
|
*
|
|
436
455
|
* Neither confirm can start on "yes", so a re-run that Enters through the
|
|
437
456
|
* wizard revokes rather than renews. That is the safe direction, and the
|
|
@@ -456,7 +475,19 @@ async function askAuthority(
|
|
|
456
475
|
"Default: humans release" +
|
|
457
476
|
`${prior.release === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
|
|
458
477
|
);
|
|
459
|
-
|
|
478
|
+
const promotion = await askYesNo(
|
|
479
|
+
ui,
|
|
480
|
+
"Promotion authority",
|
|
481
|
+
"Delegate promotion to the orchestrator session? Promotion is adding the queue label to an " +
|
|
482
|
+
"issue — the sign-off that lets a worker claim it, and the act that starts spend. " +
|
|
483
|
+
"Default: the operator promotes" +
|
|
484
|
+
`${prior.promotion === "orchestrator" ? " — currently delegated, answer no to take it back" : ""}.`,
|
|
485
|
+
);
|
|
486
|
+
return {
|
|
487
|
+
merge: merge ? "orchestrator" : "human",
|
|
488
|
+
release: release ? "orchestrator" : "human",
|
|
489
|
+
promotion: promotion ? "orchestrator" : "human",
|
|
490
|
+
};
|
|
460
491
|
}
|
|
461
492
|
|
|
462
493
|
/**
|
|
@@ -478,6 +509,14 @@ const RELEASE_SHAPE_QUESTIONS: { readonly [K in (typeof RELEASE_SHAPES)[number]]
|
|
|
478
509
|
deploy:
|
|
479
510
|
"deploy — change what is running: kubectl/helm/terraform, a deploy device call, a rollout. " +
|
|
480
511
|
"This is the one grant that mutates a live environment rather than producing an artifact",
|
|
512
|
+
// #486: the last leg of the loop. Releasing publishes; installing replaces the
|
|
513
|
+
// running conductor itself, detached from the session, and only the first
|
|
514
|
+
// tick after the restart proves it. Fund as deliberate as `deploy` is — the
|
|
515
|
+
// package floor's "nobody patches the running conductor" is this grant's
|
|
516
|
+
// deny default.
|
|
517
|
+
install:
|
|
518
|
+
"install — replace this host's installed conductor: the Bun-global CLI, omp plugin " +
|
|
519
|
+
"and Herdr plugin pinned to one published release, executed detached from the fleet",
|
|
481
520
|
};
|
|
482
521
|
|
|
483
522
|
/**
|
|
@@ -1137,11 +1176,25 @@ const askWorkerModel: AreaAsker = async (ui, a) => {
|
|
|
1137
1176
|
return next;
|
|
1138
1177
|
};
|
|
1139
1178
|
|
|
1140
|
-
/** The
|
|
1179
|
+
/** The three ownership questions, then the mechanical gate one shape at a time:
|
|
1141
1180
|
* together they are what decides what an unattended fleet may do unasked. */
|
|
1142
1181
|
const askAuthorityArea: AreaAsker = async (ui, a, _probes, discovered) => {
|
|
1143
1182
|
const authority = await askAuthority(ui, a.authority);
|
|
1144
|
-
|
|
1183
|
+
// Declining release authority makes the five per-shape grants moot — they
|
|
1184
|
+
// exist to scope an authority that was just refused, and asking them after
|
|
1185
|
+
// "humans release" teaches the operator that answers do not matter (#368).
|
|
1186
|
+
//
|
|
1187
|
+
// Humans-only authority clears any stale per-shape grant to the fail-closed
|
|
1188
|
+
// baseline rather than offering an individual-tool path: `releaseVerb`
|
|
1189
|
+
// refuses before a shape grant is ever read (see `verbs/server`), so a shape
|
|
1190
|
+
// granted under humans-only authority is a dead prompt that says the opposite
|
|
1191
|
+
// of what the operator answered. Clearing keeps the tick tripwire, which
|
|
1192
|
+
// reads the grant map independently of authority, from enforcing a shape the
|
|
1193
|
+
// operator just revoked.
|
|
1194
|
+
const releaseGrants =
|
|
1195
|
+
authority.release === "human"
|
|
1196
|
+
? { ...DENIED_RELEASE_GRANTS }
|
|
1197
|
+
: await askReleaseGrants(ui, a.releaseGrants);
|
|
1145
1198
|
// Asked here because it is the same decision one layer down: the grants say
|
|
1146
1199
|
// who may act, and these say where that permission stops. Amending authority
|
|
1147
1200
|
// therefore re-asks the boundary, which is the point — a grant widened without
|
|
@@ -1151,8 +1204,13 @@ const askAuthorityArea: AreaAsker = async (ui, a, _probes, discovered) => {
|
|
|
1151
1204
|
};
|
|
1152
1205
|
|
|
1153
1206
|
/** What a merge and a release must satisfy. Asked straight after the grants:
|
|
1154
|
-
* who may act, then under what conditions
|
|
1155
|
-
|
|
1207
|
+
* who may act, then under what conditions. The release half is only asked
|
|
1208
|
+
* when the orchestrator cuts releases — with humans releasing it has no gates
|
|
1209
|
+
* to configure, so it would be a dead prompt (#368). */
|
|
1210
|
+
const askPolicy: AreaAsker = async (ui, a) => ({
|
|
1211
|
+
...a,
|
|
1212
|
+
policy: await askPolicyPreconditions(ui, a.policy, a.authority.release),
|
|
1213
|
+
});
|
|
1156
1214
|
|
|
1157
1215
|
/** How a stuck run reaches a human, and who triages it when it does. */
|
|
1158
1216
|
const askEscalation: AreaAsker = async (ui, a) => {
|
|
@@ -1216,7 +1274,12 @@ async function askTelegramTopicId(
|
|
|
1216
1274
|
const options = [
|
|
1217
1275
|
...claimed.map((t) => ({
|
|
1218
1276
|
label: `${t.name} — ${t.threadId}`,
|
|
1219
|
-
|
|
1277
|
+
// Two panes under one state directory claim the same title, so the
|
|
1278
|
+
// herdr space is the only thing that tells the operator which is which.
|
|
1279
|
+
description:
|
|
1280
|
+
t.workspaceLabel === undefined || t.workspaceLabel === t.name
|
|
1281
|
+
? `message_thread_id ${t.threadId}`
|
|
1282
|
+
: `herdr space ${t.workspaceLabel} · message_thread_id ${t.threadId}`,
|
|
1220
1283
|
})),
|
|
1221
1284
|
{ label: manual, description: "type a numeric forum topic id" },
|
|
1222
1285
|
{ label: none, description: "send to the chat root, not a topic" },
|
|
@@ -1439,7 +1502,7 @@ type AmendChoice =
|
|
|
1439
1502
|
* add-a-project path. Only asked when a project is already configured — a first
|
|
1440
1503
|
* run has nothing to amend and is never shown this.
|
|
1441
1504
|
*/
|
|
1442
|
-
async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig): Promise<AmendChoice> {
|
|
1505
|
+
async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig, defaults: Caps = DEFAULT_CAPS): Promise<AmendChoice> {
|
|
1443
1506
|
const mode = await ui.select(
|
|
1444
1507
|
`"${prior.name}" is already configured — what would you like to do?`,
|
|
1445
1508
|
[
|
|
@@ -1468,7 +1531,7 @@ async function chooseAmendArea(ui: WizardUi, prior: ProjectConfig): Promise<Amen
|
|
|
1468
1531
|
return { kind: "reinterview" };
|
|
1469
1532
|
}
|
|
1470
1533
|
|
|
1471
|
-
const choices = amendChoices(prior);
|
|
1534
|
+
const choices = amendChoices(prior, defaults);
|
|
1472
1535
|
const picked = await ui.select(
|
|
1473
1536
|
"Which area? Each row shows what it says now",
|
|
1474
1537
|
choices.map((c) => ({ label: c.label, description: c.description })),
|
|
@@ -1656,7 +1719,7 @@ export async function collectSetup(
|
|
|
1656
1719
|
};
|
|
1657
1720
|
}
|
|
1658
1721
|
|
|
1659
|
-
const choice = await chooseAmendArea(ui, prior);
|
|
1722
|
+
const choice = await chooseAmendArea(ui, prior, existing?.defaults ?? DEFAULT_CAPS);
|
|
1660
1723
|
if (choice.kind === "add-project") {
|
|
1661
1724
|
// Fresh interview, no pre-fill from the neighbour. CLI --project is ignored
|
|
1662
1725
|
// here: the operator just chose to add, so the name comes from the prompt.
|
|
@@ -1738,11 +1801,82 @@ export function formatHerdrHandoff(project: ProjectConfig, cfg: ConductorConfig)
|
|
|
1738
1801
|
|
|
1739
1802
|
|
|
1740
1803
|
|
|
1804
|
+
/**
|
|
1805
|
+
* The side-effecting apply tail of {@link setup}, as one injectable object — the
|
|
1806
|
+
* wizard has `collectSetup` fully covered but none of its apply tail today. The
|
|
1807
|
+
* default is the real implementations; a test stubs whichever of gh/daemon/root
|
|
1808
|
+
* it proves, and drives the real tail (including the real `runGraphInstall`
|
|
1809
|
+
* with injected `unitDir`/`privileged`/probe) through the same seam, so a
|
|
1810
|
+
* regression cannot hide behind a rewritten status line.
|
|
1811
|
+
*/
|
|
1812
|
+
export interface SetupApplyDeps {
|
|
1813
|
+
scopes: () => Promise<ScopeCheck>;
|
|
1814
|
+
labels: (trackerRepo: string, a: SetupAnswers) => Promise<LabelPlan[]>;
|
|
1815
|
+
preview: (project: ProjectConfig) => Promise<QueuePreview>;
|
|
1816
|
+
createLabels: (trackerRepo: string, plan: LabelPlan[]) => Promise<string[]>;
|
|
1817
|
+
smoke: (project: string) => Promise<SetupSmokeResult>;
|
|
1818
|
+
restart: (o: { project?: string }) => Promise<RestartResult>;
|
|
1819
|
+
arm: (project: string) => Promise<string>;
|
|
1820
|
+
resume: (project: string) => void;
|
|
1821
|
+
hostInstall: (
|
|
1822
|
+
project: ProjectConfig,
|
|
1823
|
+
caps: Caps,
|
|
1824
|
+
telegramStateDir: string,
|
|
1825
|
+
ui: WizardUi,
|
|
1826
|
+
) => Promise<InstallOutcome>;
|
|
1827
|
+
graphInstall: (
|
|
1828
|
+
project: ProjectConfig,
|
|
1829
|
+
ui: WizardUi,
|
|
1830
|
+
options?: GraphInstallOptions,
|
|
1831
|
+
) => Promise<InstallOutcome>;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
export const DEFAULT_APPLY: SetupApplyDeps = {
|
|
1835
|
+
scopes: checkTokenScopes,
|
|
1836
|
+
labels: planLabels,
|
|
1837
|
+
preview: previewProject,
|
|
1838
|
+
createLabels: createMissingLabels,
|
|
1839
|
+
smoke: runSetupSmoke,
|
|
1840
|
+
restart: (o) => restartDaemon(o),
|
|
1841
|
+
arm: (project) => ensureSetupArm(project),
|
|
1842
|
+
resume: (project) => setPaused(false, undefined, project),
|
|
1843
|
+
hostInstall: (project, caps, telegramStateDir, ui) =>
|
|
1844
|
+
runHostInstall(project, caps, telegramStateDir, ui),
|
|
1845
|
+
graphInstall: (project, ui, options) => runGraphInstall(project, ui, options),
|
|
1846
|
+
};
|
|
1847
|
+
|
|
1848
|
+
/**
|
|
1849
|
+
* The post-apply code-graph offer. Shared by the tail and the live-worker early
|
|
1850
|
+
* return, so neither path can silently leave the graph install unreferenced.
|
|
1851
|
+
* `graphInstall` is injectable: the tail and the CLI both call the real
|
|
1852
|
+
* {@link runGraphInstall}, and a test drives apply through this same seam.
|
|
1853
|
+
*/
|
|
1854
|
+
async function offerCodeGraph(
|
|
1855
|
+
project: ProjectConfig,
|
|
1856
|
+
ui: WizardUi,
|
|
1857
|
+
graphInstall: SetupApplyDeps["graphInstall"],
|
|
1858
|
+
): Promise<void> {
|
|
1859
|
+
if (graphRepos(project).length === 0) return;
|
|
1860
|
+
const wantsGraph = await ui.confirm(
|
|
1861
|
+
"Install the code-graph indexes now?",
|
|
1862
|
+
"Clones each index-only checkout as you, installs and enables the reindex timer as root, then seeds one " +
|
|
1863
|
+
"indexing run so the first fetch happens while you watch. Minutes per repo. " +
|
|
1864
|
+
"`omp-conductor setup graph` does the same later; `--no-seed` skips the seeding run.",
|
|
1865
|
+
);
|
|
1866
|
+
if (wantsGraph === true) await graphInstall(project, ui);
|
|
1867
|
+
else
|
|
1868
|
+
ui.notify(
|
|
1869
|
+
"Skipped the code graph — workers grep until it exists. When you want it: omp-conductor setup graph",
|
|
1870
|
+
"info",
|
|
1871
|
+
);
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1741
1874
|
export async function setup(
|
|
1742
1875
|
ui: WizardUi,
|
|
1743
1876
|
projectArg: string | undefined,
|
|
1744
1877
|
areaArg?: AmendAreaId,
|
|
1745
1878
|
probes: SetupProbes = DEFAULT_PROBES,
|
|
1879
|
+
apply: SetupApplyDeps = DEFAULT_APPLY,
|
|
1746
1880
|
): Promise<void> {
|
|
1747
1881
|
const path = configPath();
|
|
1748
1882
|
// A config that exists but does not parse is a fault to report, never
|
|
@@ -1763,7 +1897,7 @@ export async function setup(
|
|
|
1763
1897
|
}
|
|
1764
1898
|
const { answers, amend, added } = collected;
|
|
1765
1899
|
|
|
1766
|
-
const scopes = await
|
|
1900
|
+
const scopes = await apply.scopes();
|
|
1767
1901
|
if (!scopes.ok) {
|
|
1768
1902
|
ui.notify(
|
|
1769
1903
|
`Setup stopped before writing anything. The gh token needs repo and project scopes. ` +
|
|
@@ -1778,7 +1912,7 @@ export async function setup(
|
|
|
1778
1912
|
// files will contain. Each draft carries its own preview and confirm; declining
|
|
1779
1913
|
// one keeps the shipped stub, so an operator who wants none reaches the same plan.
|
|
1780
1914
|
const prose = answers.writeOrchestratorBrief ? await probes.prose(ui, answers) : {};
|
|
1781
|
-
const labels = await
|
|
1915
|
+
const labels = await apply.labels(answers.trackerRepo, answers);
|
|
1782
1916
|
const telegram = detectTelegram();
|
|
1783
1917
|
let nextConfig;
|
|
1784
1918
|
try {
|
|
@@ -1815,7 +1949,7 @@ export async function setup(
|
|
|
1815
1949
|
);
|
|
1816
1950
|
let queuePreview: string[];
|
|
1817
1951
|
try {
|
|
1818
|
-
queuePreview = formatPreview(await
|
|
1952
|
+
queuePreview = formatPreview(await apply.preview(project));
|
|
1819
1953
|
} catch (err) {
|
|
1820
1954
|
const message = err instanceof Error ? err.message : String(err);
|
|
1821
1955
|
ui.notify(
|
|
@@ -1829,8 +1963,8 @@ export async function setup(
|
|
|
1829
1963
|
[
|
|
1830
1964
|
// The delta first when there is one, then the whole plan: the confirm has
|
|
1831
1965
|
// to name every mutation it authorises, and a delta names none of them.
|
|
1832
|
-
...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers)]),
|
|
1833
|
-
summarisePlan(answers, scopes, labels, telegram),
|
|
1966
|
+
...(amend === undefined ? [] : [summariseAmend(amend.area, amend.before, answers, nextConfig.defaults)]),
|
|
1967
|
+
summarisePlan(answers, scopes, labels, telegram, nextConfig.defaults),
|
|
1834
1968
|
"",
|
|
1835
1969
|
formatHostRuntimePlan(runtime),
|
|
1836
1970
|
"",
|
|
@@ -1858,11 +1992,21 @@ export async function setup(
|
|
|
1858
1992
|
runtime.service.action === "keep"
|
|
1859
1993
|
? `Keeps the staged systemd unit at ${runtime.service.path}.`
|
|
1860
1994
|
: `${runtime.service.action === "create" ? "Creates" : "Updates"} the staged systemd unit at ${runtime.service.path}.`,
|
|
1995
|
+
runtime.herdrUnit === undefined
|
|
1996
|
+
? ""
|
|
1997
|
+
: runtime.herdrUnit.action === "keep"
|
|
1998
|
+
? `Keeps the staged Herdr session unit at ${runtime.herdrUnit.path}.`
|
|
1999
|
+
: `${runtime.herdrUnit.action === "create" ? "Creates" : "Updates"} the staged Herdr session unit at ${runtime.herdrUnit.path} (account's login shell for panes).`,
|
|
1861
2000
|
runtime.tick === undefined
|
|
1862
2001
|
? ""
|
|
1863
2002
|
: runtime.tick.action === "keep"
|
|
1864
2003
|
? `Keeps the external heartbeat config at ${runtime.tick.path}.`
|
|
1865
2004
|
: `${runtime.tick.action === "create" ? "Creates" : "Updates"} the external heartbeat config at ${runtime.tick.path}.`,
|
|
2005
|
+
runtime.briefLink.action === "skip"
|
|
2006
|
+
? `Leaves the existing file at ${runtime.briefLink.path} alone (an operator's file, not overwritten).`
|
|
2007
|
+
: runtime.briefLink.action === "keep"
|
|
2008
|
+
? `Keeps the brief link at ${runtime.briefLink.path}.`
|
|
2009
|
+
: `${runtime.briefLink.action === "create" ? "Creates" : "Replaces"} the brief link at ${runtime.briefLink.path} -> ${runtime.briefLink.target}.`,
|
|
1866
2010
|
answers.writeOrchestratorBrief
|
|
1867
2011
|
? `Writes ${orchestratorBriefPath(answers)}, which is then yours to edit.`
|
|
1868
2012
|
: "",
|
|
@@ -1882,11 +2026,12 @@ export async function setup(
|
|
|
1882
2026
|
// Hold first. Any later filesystem, tracker, smoke, or channel error leaves a
|
|
1883
2027
|
// partially applied setup unable to claim work.
|
|
1884
2028
|
prepareConductor(project.name);
|
|
1885
|
-
const created = await
|
|
2029
|
+
const created = await apply.createLabels(answers.trackerRepo, labels);
|
|
1886
2030
|
saveConfig(nextConfig);
|
|
1887
2031
|
const briefPath = answers.writeOrchestratorBrief ? writeOrchestratorBrief(answers, prose) : undefined;
|
|
1888
2032
|
const runtimeFiles = writeHostRuntime(runtime);
|
|
1889
|
-
const
|
|
2033
|
+
for (const warning of runtimeFiles.warnings) ui.notify(warning, "warning");
|
|
2034
|
+
const smoke = await apply.smoke(project.name);
|
|
1890
2035
|
let smokeLine =
|
|
1891
2036
|
`paused daemon --once; temporary /healthz on :${smoke.daemon.port}; ` +
|
|
1892
2037
|
`stored status for ${smoke.status.project}`;
|
|
@@ -1906,6 +2051,12 @@ export async function setup(
|
|
|
1906
2051
|
].join("\n"),
|
|
1907
2052
|
"warning",
|
|
1908
2053
|
);
|
|
2054
|
+
// The early return must not swallow the graph offer. This host is the
|
|
2055
|
+
// live reproduction: the shared reindex units were already installed by
|
|
2056
|
+
// the first project, so only the clone+seed are outstanding — and nothing
|
|
2057
|
+
// above names them. Offer regardless of live workers: cloning seeds and
|
|
2058
|
+
// enables the timer, which does not touch the running daemon.
|
|
2059
|
+
await offerCodeGraph(project, ui, apply.graphInstall);
|
|
1909
2060
|
ui.notify(formatHerdrHandoff(project, nextConfig), "info");
|
|
1910
2061
|
return;
|
|
1911
2062
|
}
|
|
@@ -1923,7 +2074,7 @@ export async function setup(
|
|
|
1923
2074
|
"info",
|
|
1924
2075
|
);
|
|
1925
2076
|
} else {
|
|
1926
|
-
const restarted = await
|
|
2077
|
+
const restarted = await apply.restart({ project: project.name });
|
|
1927
2078
|
restartVia = restarted.via;
|
|
1928
2079
|
smokeLine =
|
|
1929
2080
|
`existing /healthz and stored status; restarted through ${restarted.via}; ` +
|
|
@@ -1935,7 +2086,7 @@ export async function setup(
|
|
|
1935
2086
|
if (project.escalation.orchestrator === "external") {
|
|
1936
2087
|
ui.notify("Setup smoke passed. Proving the external heartbeat channel…", "info");
|
|
1937
2088
|
try {
|
|
1938
|
-
armLine = await
|
|
2089
|
+
armLine = await apply.arm(project.name);
|
|
1939
2090
|
} catch (err) {
|
|
1940
2091
|
ui.notify(
|
|
1941
2092
|
[
|
|
@@ -1950,7 +2101,7 @@ export async function setup(
|
|
|
1950
2101
|
return;
|
|
1951
2102
|
}
|
|
1952
2103
|
}
|
|
1953
|
-
|
|
2104
|
+
apply.resume(project.name);
|
|
1954
2105
|
|
|
1955
2106
|
ui.notify(
|
|
1956
2107
|
[
|
|
@@ -1959,9 +2110,9 @@ export async function setup(
|
|
|
1959
2110
|
briefPath === undefined
|
|
1960
2111
|
? "Kept the existing orchestrator brief."
|
|
1961
2112
|
: `Wrote ${briefPath} + POLICY.md. Edit POLICY.md for Releases and Reporting.`,
|
|
1962
|
-
runtimeFiles.length === 0
|
|
2113
|
+
runtimeFiles.wrote.length === 0
|
|
1963
2114
|
? "Host runtime files were already current."
|
|
1964
|
-
: `Wrote host runtime file(s): ${runtimeFiles.join(", ")}`,
|
|
2115
|
+
: `Wrote host runtime file(s): ${runtimeFiles.wrote.join(", ")}`,
|
|
1965
2116
|
`Smoke passed: ${smokeLine}.`,
|
|
1966
2117
|
`Heartbeat: ${armLine}.`,
|
|
1967
2118
|
"",
|
|
@@ -1999,24 +2150,11 @@ export async function setup(
|
|
|
1999
2150
|
"command before it runs. Skipping is fine — `omp-conductor setup host` does exactly this later.",
|
|
2000
2151
|
);
|
|
2001
2152
|
if (install === true)
|
|
2002
|
-
await
|
|
2153
|
+
await apply.hostInstall(project, resolveCaps(project, nextConfig.defaults), telegram.stateDir, ui);
|
|
2003
2154
|
else ui.notify("Left the unit staged. When you want it supervised: omp-conductor setup host", "info");
|
|
2004
2155
|
}
|
|
2005
2156
|
|
|
2006
|
-
|
|
2007
|
-
const wantsGraph = await ui.confirm(
|
|
2008
|
-
"Install the code-graph indexes now?",
|
|
2009
|
-
"Clones each index-only checkout as you, installs and enables the reindex timer as root, then seeds one " +
|
|
2010
|
-
"indexing run so the first fetch happens while you watch. Minutes per repo. " +
|
|
2011
|
-
"`omp-conductor setup graph` does the same later; `--no-seed` skips the seeding run.",
|
|
2012
|
-
);
|
|
2013
|
-
if (wantsGraph === true) await runGraphInstall(project, ui);
|
|
2014
|
-
else
|
|
2015
|
-
ui.notify(
|
|
2016
|
-
"Skipped the code graph — workers grep until it exists. When you want it: omp-conductor setup graph",
|
|
2017
|
-
"info",
|
|
2018
|
-
);
|
|
2019
|
-
}
|
|
2157
|
+
await offerCodeGraph(project, ui, apply.graphInstall);
|
|
2020
2158
|
|
|
2021
2159
|
// Always print: first install, amend, and add-a-project all need the operator
|
|
2022
2160
|
// to create/verify the herdr pane. Added projects especially — the CLI wrote
|