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-host.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, type Stats } from "node:fs";
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import { homedir, userInfo } from "node:os";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
4
|
+
import { dirname, join, relative } from "node:path";
|
|
5
5
|
import { configPath, loadConfig, resolveCaps, stateDir } from "./config.ts";
|
|
6
6
|
import { isPaused, runDaemon, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
|
|
7
|
-
import {
|
|
7
|
+
import { ORCHESTRATOR_BRIEF_NAME } from "./brief-upgrade.ts";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_FLEET_AGENT_NAME,
|
|
10
|
+
DEFAULT_HERDR_SESSION,
|
|
11
|
+
DEFAULT_HERDR_UNIT,
|
|
12
|
+
resolveHerdrSessionWithBridge,
|
|
13
|
+
} from "./fleet.ts";
|
|
8
14
|
import {
|
|
9
15
|
DEFAULT_PORT,
|
|
10
16
|
healthCheck,
|
|
@@ -27,6 +33,34 @@ export const DEFAULT_TICK_INTERVAL_SECONDS = 900;
|
|
|
27
33
|
export const STAGED_SERVICE_NAME = "omp-conductor.service";
|
|
28
34
|
export const SYSTEMD_UNIT_DIR = "/etc/systemd/system";
|
|
29
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The one recovery unit every fleet unit's `OnFailure=` points at (#485).
|
|
38
|
+
*
|
|
39
|
+
* A plain (non-template) unit on purpose: both fleet units name the same
|
|
40
|
+
* oneshot, and the playbook it runs determines which unit failed from live
|
|
41
|
+
* systemd state rather than trusting an instance name it cannot verify. The
|
|
42
|
+
* unit itself never carries `OnFailure=` — the one hard bound on
|
|
43
|
+
* recovery-triggering-recovery.
|
|
44
|
+
*/
|
|
45
|
+
export const RECOVER_SERVICE_NAME = "omp-conductor-recover.service";
|
|
46
|
+
|
|
47
|
+
/** The playbook file shipped in the package's `systemd/` directory. */
|
|
48
|
+
const RECOVER_SCRIPT_FILE = "omp-conductor-recover.sh";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Where `setup host` installs the recovery playbook so the unit's ExecStart is
|
|
52
|
+
* a stable absolute path that survives package upgrades. /usr/local/sbin is
|
|
53
|
+
* root-executed operational tooling, on PATH for root on every target host.
|
|
54
|
+
*/
|
|
55
|
+
export const RECOVER_SCRIPT_INSTALL_PATH = "/usr/local/sbin/omp-conductor-recover";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The symlink the session cwd loads as its brief. omp auto-loads `AGENTS.md`
|
|
59
|
+
* from the session cwd, so the fleet pane's cwd needs a link at this name
|
|
60
|
+
* resolving to the composed {@link ORCHESTRATOR_BRIEF_NAME}.
|
|
61
|
+
*/
|
|
62
|
+
export const AGENTS_BRIEF_NAME = "AGENTS.md";
|
|
63
|
+
|
|
30
64
|
export type PlannedWrite<T> = {
|
|
31
65
|
path: string;
|
|
32
66
|
action: "create" | "update" | "keep";
|
|
@@ -34,9 +68,59 @@ export type PlannedWrite<T> = {
|
|
|
34
68
|
value: T;
|
|
35
69
|
};
|
|
36
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The `AGENTS.md` symlink setup places in a project's fleet cwd so the pane's
|
|
73
|
+
* session auto-loads the composed brief instead of staying brief-less.
|
|
74
|
+
*
|
|
75
|
+
* {@link action} is what the plan will *do*, compared against what sits at
|
|
76
|
+
* {@link path}:
|
|
77
|
+
* - `create` — no entry yet, link it;
|
|
78
|
+
* - `update` — a symlink occupies {@link path} but points elsewhere, replace it;
|
|
79
|
+
* - `keep` — an already-correct symlink, leave untouched (idempotent re-run);
|
|
80
|
+
* - `skip` — a regular file occupies {@link path}, never overwrite an
|
|
81
|
+
* operator's file (see {@link skippedReason}).
|
|
82
|
+
*/
|
|
83
|
+
export interface BriefLinkPlan {
|
|
84
|
+
/** `AGENTS.md` inside the project's fleet cwd ({@link tickCwdForProject}). */
|
|
85
|
+
path: string;
|
|
86
|
+
/** Relative target inside the symlink — what the link resolves to. */
|
|
87
|
+
target: string;
|
|
88
|
+
action: "create" | "update" | "keep" | "skip";
|
|
89
|
+
/** Why {@link action} is `skip`; present only then. */
|
|
90
|
+
skippedReason?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
37
93
|
export interface HostRuntimePlan {
|
|
38
94
|
service: PlannedWrite<string>;
|
|
95
|
+
/**
|
|
96
|
+
* The staged Herdr session-server unit. Absent when Herdr is not installed
|
|
97
|
+
* ({@link ServiceRuntime.herdr} is `undefined`) — a host driving Herdr some
|
|
98
|
+
* other way must stay supported. `upgrade` and `start` treat
|
|
99
|
+
* {@link DEFAULT_HERDR_UNIT} as a first-class dependency, and this is the
|
|
100
|
+
* rendering that provisions it (see {@link renderHerdrUnit}).
|
|
101
|
+
*/
|
|
102
|
+
herdrUnit?: PlannedWrite<string>;
|
|
103
|
+
/**
|
|
104
|
+
* The fleet recovery oneshot (#485): staged always, because the daemon unit
|
|
105
|
+
* (which every fleet has) carries `OnFailure=` to it. Rendered by
|
|
106
|
+
* {@link renderRecoverUnit}.
|
|
107
|
+
*/
|
|
108
|
+
recoverUnit: PlannedWrite<string>;
|
|
109
|
+
/**
|
|
110
|
+
* The recovery playbook body, staged from the shipped package file and
|
|
111
|
+
* installed to {@link RECOVER_SCRIPT_INSTALL_PATH} so the unit's ExecStart
|
|
112
|
+
* is a stable absolute path.
|
|
113
|
+
*/
|
|
114
|
+
recoverScript: PlannedWrite<string>;
|
|
115
|
+
/**
|
|
116
|
+
* `[terminal] default_shell` staged into the session's herdr config, the
|
|
117
|
+
* belt-and-braces behind the unit's `SHELL=` (see {@link renderHerdrConfig}).
|
|
118
|
+
* Present exactly when {@link herdrUnit} is.
|
|
119
|
+
*/
|
|
120
|
+
herdrConfig?: PlannedWrite<string>;
|
|
39
121
|
tick?: PlannedWrite<TickConfig>;
|
|
122
|
+
/** The `AGENTS.md` — composed-brief symlink — placed in the fleet cwd. */
|
|
123
|
+
briefLink: BriefLinkPlan;
|
|
40
124
|
installCommands: readonly string[];
|
|
41
125
|
cliSource: "global" | "plugin";
|
|
42
126
|
/** Absolute path of the unit systemd actually reads. */
|
|
@@ -65,6 +149,21 @@ export interface ServiceRuntime {
|
|
|
65
149
|
packageCli: string;
|
|
66
150
|
conductorHome: string;
|
|
67
151
|
telegramStateDir: string;
|
|
152
|
+
/**
|
|
153
|
+
* The account's login shell. Rendered into the Herdr session unit as
|
|
154
|
+
* `Environment=SHELL=` (and into the herdr config as `[terminal]
|
|
155
|
+
* default_shell`), so every pane in the fleet session runs this shell instead
|
|
156
|
+
* of the `/bin/sh` Herdr falls back to when `$SHELL` is unset (the fleet's
|
|
157
|
+
* dash-pane symptom, #456). A systemd service has no `$SHELL`, so this is
|
|
158
|
+
* load-bearing, not decoration.
|
|
159
|
+
*/
|
|
160
|
+
shell: string;
|
|
161
|
+
/**
|
|
162
|
+
* Absolute path of the `herdr` binary on this host, or `undefined` when Herdr
|
|
163
|
+
* is not installed. When absent, setup stages no Herdr unit and no pane-shell
|
|
164
|
+
* config — a host driving Herdr some other way stays supported (#456).
|
|
165
|
+
*/
|
|
166
|
+
herdr?: string;
|
|
68
167
|
/**
|
|
69
168
|
* Non-default Herdr session to pin into the unit (#394). Omitted when the
|
|
70
169
|
* host uses {@link DEFAULT_HERDR_SESSION}: an always-present line would show
|
|
@@ -252,6 +351,8 @@ function defaultServiceRuntime(
|
|
|
252
351
|
const home = homedir();
|
|
253
352
|
const bun = process.execPath;
|
|
254
353
|
const globalCli = Bun.which("omp-conductor");
|
|
354
|
+
const herdr = Bun.which("herdr");
|
|
355
|
+
const loginShell = userInfo().shell;
|
|
255
356
|
const pathParts = [dirname(bun), ...(process.env["PATH"] ?? "").split(":")].filter(
|
|
256
357
|
(value, index, all) => value.length > 0 && all.indexOf(value) === index,
|
|
257
358
|
);
|
|
@@ -264,6 +365,10 @@ function defaultServiceRuntime(
|
|
|
264
365
|
packageCli: join(import.meta.dir, "cli.ts"),
|
|
265
366
|
conductorHome: dirname(configPath()),
|
|
266
367
|
telegramStateDir,
|
|
368
|
+
shell: loginShell ?? "/bin/sh",
|
|
369
|
+
// Only when Herdr is actually installed: a host running Herdr some other
|
|
370
|
+
// way (or none yet) gets no Herdr unit rendered, ever.
|
|
371
|
+
...(herdr === null ? {} : { herdr }),
|
|
267
372
|
// Only pin a non-default session. Default hosts keep a unit with no
|
|
268
373
|
// HERDR_SESSION line so setup/upgrade do not invent drift (#394).
|
|
269
374
|
...(herdrSession !== DEFAULT_HERDR_SESSION ? { herdrSession } : {}),
|
|
@@ -289,6 +394,10 @@ export function renderDaemonService(runtime: ServiceRuntime, totalWorkers: numbe
|
|
|
289
394
|
"Documentation=https://github.com/TerrifiedBug/conductor",
|
|
290
395
|
"After=network-online.target",
|
|
291
396
|
"Wants=network-online.target",
|
|
397
|
+
// #485: a crash-looped daemon used to sit in `failed` indefinitely, in
|
|
398
|
+
// silence — no unit was watching it. The recovery oneshot runs outside
|
|
399
|
+
// this unit's cgroup, collects evidence durably, and pages tier-2.
|
|
400
|
+
`OnFailure=${RECOVER_SERVICE_NAME}`,
|
|
292
401
|
"",
|
|
293
402
|
"[Service]",
|
|
294
403
|
"Type=simple",
|
|
@@ -315,6 +424,122 @@ export function renderDaemonService(runtime: ServiceRuntime, totalWorkers: numbe
|
|
|
315
424
|
].join("\n");
|
|
316
425
|
}
|
|
317
426
|
|
|
427
|
+
/**
|
|
428
|
+
* The Herdr session-server unit, in the same shape as the daemon unit.
|
|
429
|
+
*
|
|
430
|
+
* The fleet's 24/7 guarantee (`upgrade`'s restart + pane-recovery leg, `start`,
|
|
431
|
+
* the recovery plugin) treats {@link DEFAULT_HERDR_UNIT} as a first-class
|
|
432
|
+
* dependency, yet nothing rendered it — every operator hand-wrote it from a bare
|
|
433
|
+
* `ExecStart=` line. This provisions it, and it is where the dash-pane bug dies:
|
|
434
|
+
* a systemd service has no `$SHELL`, so without `Environment=SHELL=` Herdr
|
|
435
|
+
* resolved every pane as `/bin/sh`→dash with no readline and no `.bashrc`. The
|
|
436
|
+
* account's login shell is pinned here, and again in the herdr config
|
|
437
|
+
* ({@link renderHerdrConfig}) as belt and braces.
|
|
438
|
+
*/
|
|
439
|
+
export function renderHerdrUnit(runtime: ServiceRuntime): string {
|
|
440
|
+
if (runtime.herdr === undefined) throw new Error("cannot render a herdr unit without a herdr binary");
|
|
441
|
+
const session = runtime.herdrSession ?? DEFAULT_HERDR_SESSION;
|
|
442
|
+
const configPath = join(runtime.home, ".config", "herdr", "config.toml");
|
|
443
|
+
return [
|
|
444
|
+
"[Unit]",
|
|
445
|
+
"Description=herdr fleet session server",
|
|
446
|
+
"Documentation=https://github.com/TerrifiedBug/conductor",
|
|
447
|
+
"After=network-online.target",
|
|
448
|
+
"Wants=network-online.target",
|
|
449
|
+
// #485: the fleet's two units are each other's only plausible watcher and
|
|
450
|
+
// neither watched the other. The recovery oneshot is the watcher, and it
|
|
451
|
+
// must fire for the orchestrator host too — a dead herdr means no tick, so
|
|
452
|
+
// nobody else would ever report it.
|
|
453
|
+
`OnFailure=${RECOVER_SERVICE_NAME}`,
|
|
454
|
+
"",
|
|
455
|
+
"[Service]",
|
|
456
|
+
"Type=simple",
|
|
457
|
+
`User=${runtime.username}`,
|
|
458
|
+
`Environment=${systemdQuote(`HOME=${runtime.home}`)}`,
|
|
459
|
+
`Environment=${systemdQuote(`PATH=${runtime.path}`)}`,
|
|
460
|
+
`Environment=${systemdQuote(`HERDR_CONFIG_PATH=${configPath}`)}`,
|
|
461
|
+
`Environment=${systemdQuote(`SHELL=${runtime.shell}`)}`,
|
|
462
|
+
`WorkingDirectory=${systemdPath(runtime.conductorHome)}`,
|
|
463
|
+
`ExecStart=${systemdQuote(runtime.herdr)} --session ${systemdQuote(session)} server`,
|
|
464
|
+
"Restart=on-failure",
|
|
465
|
+
"RestartSec=5",
|
|
466
|
+
"",
|
|
467
|
+
"[Install]",
|
|
468
|
+
"WantedBy=multi-user.target",
|
|
469
|
+
"",
|
|
470
|
+
].join("\n");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* The fleet recovery unit (#485): a oneshot every fleet unit's `OnFailure=`
|
|
475
|
+
* names.
|
|
476
|
+
*
|
|
477
|
+
* It runs as root, outside the fleet units' cgroups and process trees, so the
|
|
478
|
+
* restart it performs can never take it down, and it carries an explicit PATH —
|
|
479
|
+
* on a typical host the non-interactive ssh PATH lacks `omp`, `herdr` and
|
|
480
|
+
* `omp-conductor`, and the playbook needs all three.
|
|
481
|
+
*
|
|
482
|
+
* The bounds are part of the unit text so `doctor` drift sees them:
|
|
483
|
+
* `StartLimitIntervalSec`/`StartLimitBurst` cap failed recovery starts at the
|
|
484
|
+
* systemd layer (the playbook keeps its own durable consecutive-attempt
|
|
485
|
+
* counter, agreeing on the same window), and there is deliberately **no**
|
|
486
|
+
* `OnFailure=` of its own and no `[Install]` — nothing activates recovery
|
|
487
|
+
* except the two fleet units' failure, so recovery can never re-trigger
|
|
488
|
+
* recovery.
|
|
489
|
+
*/
|
|
490
|
+
export function renderRecoverUnit(runtime: ServiceRuntime, projectName: string): string {
|
|
491
|
+
return [
|
|
492
|
+
"[Unit]",
|
|
493
|
+
"Description=omp-conductor fleet recovery (OnFailure handler)",
|
|
494
|
+
"Documentation=https://github.com/TerrifiedBug/conductor",
|
|
495
|
+
// The oneshot bound: a recovery that fails twice in the window stops
|
|
496
|
+
// trying, and the playbook's durable counter uses the same window.
|
|
497
|
+
"StartLimitIntervalSec=1800",
|
|
498
|
+
"StartLimitBurst=2",
|
|
499
|
+
"",
|
|
500
|
+
"[Service]",
|
|
501
|
+
"Type=oneshot",
|
|
502
|
+
`Environment=${systemdQuote(`PATH=${runtime.path}`)}`,
|
|
503
|
+
`Environment=${systemdQuote(`OMP_CONDUCTOR_HOME=${runtime.conductorHome}`)}`,
|
|
504
|
+
`Environment=${systemdQuote(`RECOVER_STATE_DIR=${stateDir()}`)}`,
|
|
505
|
+
`Environment=${systemdQuote(`RECOVER_PROJECT=${projectName}`)}`,
|
|
506
|
+
`ExecStart=${RECOVER_SCRIPT_INSTALL_PATH}`,
|
|
507
|
+
"",
|
|
508
|
+
].join("\n");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Idempotently put `[terminal] default_shell` into a herdr config, preserving
|
|
513
|
+
* everything else verbatim.
|
|
514
|
+
*
|
|
515
|
+
* Belt-and-braces behind the unit's `Environment=SHELL=`: a pane comes up as the
|
|
516
|
+
* account's login shell even if an operator later edits the unit and drops the
|
|
517
|
+
* `SHELL` line. Herdr falls back `$SHELL → /bin/sh` and skips the passwd entry,
|
|
518
|
+
* so without this the only guarantee would be the unit's environment.
|
|
519
|
+
*/
|
|
520
|
+
export function renderHerdrConfig(shell: string, existing?: string): string {
|
|
521
|
+
const quoted = `"${shell.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
522
|
+
const text = existing ?? "";
|
|
523
|
+
const line = `default_shell = ${quoted}`;
|
|
524
|
+
|
|
525
|
+
// Any existing `default_shell = …` gets the new value in place — even when the
|
|
526
|
+
// value already matches, so a re-run stays idempotent instead of duplicating
|
|
527
|
+
// the key.
|
|
528
|
+
if (/\s*default_shell\s*=/.test(text)) {
|
|
529
|
+
return text.replace(/^(\s*default_shell\s*=\s*).*$/m, `$1${quoted}`);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const lines = text.split("\n");
|
|
533
|
+
const table = lines.findIndex((l) => /^\[terminal\]\s*$/.test(l));
|
|
534
|
+
if (table === -1) {
|
|
535
|
+
const body = text.replace(/\s+$/, "");
|
|
536
|
+
return `${body.length > 0 ? body + "\n" : ""}[terminal]\n${line}\n`;
|
|
537
|
+
}
|
|
538
|
+
// The table exists but has no default_shell; drop the key right under it.
|
|
539
|
+
lines.splice(table + 1, 0, line);
|
|
540
|
+
return lines.join("\n");
|
|
541
|
+
}
|
|
542
|
+
|
|
318
543
|
function tickSearchRoots(project: ProjectConfig): string[] {
|
|
319
544
|
const roots = [stateDir(), dirname(project.workspaceRoot), project.workspaceRoot];
|
|
320
545
|
return roots.filter((root, index) => roots.indexOf(root) === index);
|
|
@@ -373,6 +598,47 @@ export function tickCwdForProject(project: ProjectConfig): string {
|
|
|
373
598
|
}
|
|
374
599
|
}
|
|
375
600
|
|
|
601
|
+
/**
|
|
602
|
+
* Where this project's `AGENTS.md` link should point and what setup will do
|
|
603
|
+
* about it. The link lives in the **fleet cwd** — the directory the pane runs
|
|
604
|
+
* in — pointing at the composed brief under `workspaceRoot`, by a relative
|
|
605
|
+
* target so the tree stays movable.
|
|
606
|
+
*
|
|
607
|
+
* A fleet whose tick config predates per-project roots keeps its cwd at the
|
|
608
|
+
* state dir, so the link is `<stateDir>/AGENTS.md -> worktrees/ORCHESTRATOR.md`;
|
|
609
|
+
* a brand-new project links `AGENTS.md -> ORCHESTRATOR.md` in its own cwd.
|
|
610
|
+
*
|
|
611
|
+
* Never a clobber: a symlink already at the brief is left alone, a stale
|
|
612
|
+
* symlink is replaceable, and a *regular file* is an operator's own brief —
|
|
613
|
+
* reported and left untouched.
|
|
614
|
+
*/
|
|
615
|
+
function planBriefLink(project: ProjectConfig): BriefLinkPlan {
|
|
616
|
+
const path = join(tickCwdForProject(project), AGENTS_BRIEF_NAME);
|
|
617
|
+
const target = relative(dirname(path), join(project.workspaceRoot, ORCHESTRATOR_BRIEF_NAME));
|
|
618
|
+
let st;
|
|
619
|
+
try {
|
|
620
|
+
st = lstatSync(path);
|
|
621
|
+
} catch {
|
|
622
|
+
// ENOENT (no link yet) and any unreadable path (no permission on a parent)
|
|
623
|
+
// both collapse to a best-effort create; a refused filesystem surfaces as
|
|
624
|
+
// a warning at write time.
|
|
625
|
+
return { path, target, action: "create" };
|
|
626
|
+
}
|
|
627
|
+
if (st.isSymbolicLink()) {
|
|
628
|
+
return {
|
|
629
|
+
path,
|
|
630
|
+
target,
|
|
631
|
+
action: readlinkSync(path, "utf8") === target ? "keep" : "update",
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
return {
|
|
635
|
+
path,
|
|
636
|
+
target,
|
|
637
|
+
action: "skip",
|
|
638
|
+
skippedReason: `an existing file at ${path} is not ours to overwrite; left untouched`,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
376
642
|
function planTick(project: ProjectConfig, telegramStateDir: string): PlannedWrite<TickConfig> {
|
|
377
643
|
const found = findProjectTick(project);
|
|
378
644
|
const existing = found === undefined ? undefined : { path: found.path, config: found.config };
|
|
@@ -422,17 +688,91 @@ export function planHostRuntime(
|
|
|
422
688
|
content: serviceContent,
|
|
423
689
|
value: serviceContent,
|
|
424
690
|
};
|
|
691
|
+
const herdrUnitPath = join(stateDir(), DEFAULT_HERDR_UNIT);
|
|
692
|
+
const herdrConfigPath = join(runtime.home, ".config", "herdr", "config.toml");
|
|
693
|
+
let herdrUnit: PlannedWrite<string> | undefined;
|
|
694
|
+
let herdrConfig: PlannedWrite<string> | undefined;
|
|
695
|
+
// The recovery playbook ships in this package's systemd/ dir; the same
|
|
696
|
+
// bytes go into the staged copy, so version control is the single source.
|
|
697
|
+
const recoverScriptPath = join(stateDir(), RECOVER_SCRIPT_FILE);
|
|
698
|
+
let recoverScriptContent: string;
|
|
699
|
+
try {
|
|
700
|
+
recoverScriptContent = readFileSync(join(import.meta.dir, "..", "systemd", RECOVER_SCRIPT_FILE), "utf8");
|
|
701
|
+
} catch (err) {
|
|
702
|
+
throw new Error(
|
|
703
|
+
`cannot read the recovery playbook at ${join(import.meta.dir, "..", "systemd", RECOVER_SCRIPT_FILE)} — it ships with this package, and setup cannot stage it without it: ${err instanceof Error ? err.message : String(err)}`,
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
if (runtime.herdr !== undefined) {
|
|
707
|
+
const unitContent = renderHerdrUnit(runtime);
|
|
708
|
+
herdrUnit = {
|
|
709
|
+
path: herdrUnitPath,
|
|
710
|
+
action: actionFor(herdrUnitPath, unitContent),
|
|
711
|
+
content: unitContent,
|
|
712
|
+
value: unitContent,
|
|
713
|
+
};
|
|
714
|
+
// Belt and braces: read the session's herdr config so `[terminal]
|
|
715
|
+
// default_shell` merges into whatever the operator already has, preserving
|
|
716
|
+
// it verbatim. A missing or unreadable config starts from scratch.
|
|
717
|
+
let existing: string | undefined;
|
|
718
|
+
try {
|
|
719
|
+
existing = readFileSync(herdrConfigPath, "utf8");
|
|
720
|
+
} catch {
|
|
721
|
+
existing = undefined;
|
|
722
|
+
}
|
|
723
|
+
const configContent = renderHerdrConfig(runtime.shell, existing);
|
|
724
|
+
herdrConfig = {
|
|
725
|
+
path: herdrConfigPath,
|
|
726
|
+
action: actionFor(herdrConfigPath, configContent),
|
|
727
|
+
content: configContent,
|
|
728
|
+
value: configContent,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
425
731
|
const installedPath = join(SYSTEMD_UNIT_DIR, STAGED_SERVICE_NAME);
|
|
732
|
+
const installedHerdr = join(SYSTEMD_UNIT_DIR, DEFAULT_HERDR_UNIT);
|
|
733
|
+
const recoverUnitPath = join(stateDir(), RECOVER_SERVICE_NAME);
|
|
734
|
+
const recoverUnitContent = renderRecoverUnit(runtime, project.name);
|
|
735
|
+
const recoverUnit: PlannedWrite<string> = {
|
|
736
|
+
path: recoverUnitPath,
|
|
737
|
+
action: actionFor(recoverUnitPath, recoverUnitContent),
|
|
738
|
+
content: recoverUnitContent,
|
|
739
|
+
value: recoverUnitContent,
|
|
740
|
+
};
|
|
741
|
+
const recoverScript: PlannedWrite<string> = {
|
|
742
|
+
path: recoverScriptPath,
|
|
743
|
+
action: actionFor(recoverScriptPath, recoverScriptContent),
|
|
744
|
+
content: recoverScriptContent,
|
|
745
|
+
value: recoverScriptContent,
|
|
746
|
+
};
|
|
426
747
|
return {
|
|
427
748
|
service,
|
|
749
|
+
// The herdr unit and pane-shell config stand and fall together: no herdr, no
|
|
750
|
+
// session to supervise, nothing for a pane shell to belong to.
|
|
751
|
+
...(herdrUnit === undefined ? {} : { herdrUnit, herdrConfig }),
|
|
752
|
+
recoverUnit,
|
|
753
|
+
recoverScript,
|
|
754
|
+
briefLink: planBriefLink(project),
|
|
428
755
|
...(project.escalation.orchestrator === "external"
|
|
429
756
|
? { tick: planTick(project, telegramStateDir) }
|
|
430
757
|
: {}),
|
|
431
758
|
installCommands: [
|
|
759
|
+
// Recovery first: the daemon unit that follows names it in OnFailure=,
|
|
760
|
+
// so the restart below must never point at a unit systemd cannot load.
|
|
761
|
+
`sudo install -m 0755 ${shellQuote(recoverScriptPath)} ${shellQuote(RECOVER_SCRIPT_INSTALL_PATH)}`,
|
|
762
|
+
`sudo install -m 0644 ${shellQuote(recoverUnitPath)} ${shellQuote(join(SYSTEMD_UNIT_DIR, RECOVER_SERVICE_NAME))}`,
|
|
763
|
+
"sudo systemctl daemon-reload",
|
|
432
764
|
`sudo install -m 0644 ${shellQuote(servicePath)} ${shellQuote(installedPath)}`,
|
|
433
765
|
"sudo systemctl daemon-reload",
|
|
434
766
|
`sudo systemctl enable ${STAGED_SERVICE_NAME}`,
|
|
435
767
|
`sudo systemctl restart ${STAGED_SERVICE_NAME}`,
|
|
768
|
+
...(herdrUnit === undefined
|
|
769
|
+
? []
|
|
770
|
+
: [
|
|
771
|
+
`sudo install -m 0644 ${shellQuote(herdrUnitPath)} ${shellQuote(installedHerdr)}`,
|
|
772
|
+
"sudo systemctl daemon-reload",
|
|
773
|
+
`sudo systemctl enable ${DEFAULT_HERDR_UNIT}`,
|
|
774
|
+
`sudo systemctl restart ${DEFAULT_HERDR_UNIT}`,
|
|
775
|
+
]),
|
|
436
776
|
],
|
|
437
777
|
cliSource: runtime.cli === undefined ? "plugin" : "global",
|
|
438
778
|
installedPath,
|
|
@@ -445,6 +785,14 @@ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
|
|
|
445
785
|
"host runtime",
|
|
446
786
|
` service ${plan.service.action} ${plan.service.path}`,
|
|
447
787
|
` daemon entry ${plan.cliSource === "global" ? "installed omp-conductor CLI" : "current installed plugin"}`,
|
|
788
|
+
` recovery ${plan.recoverUnit.action} ${plan.recoverUnit.path}`,
|
|
789
|
+
` recovery exec ${plan.recoverScript.action} ${plan.recoverScript.path} -> ${RECOVER_SCRIPT_INSTALL_PATH}`,
|
|
790
|
+
...(plan.herdrUnit === undefined
|
|
791
|
+
? [" herdr session skipped — herdr not installed on this host"]
|
|
792
|
+
: [
|
|
793
|
+
` herdr session ${plan.herdrUnit.action} ${plan.herdrUnit.path}`,
|
|
794
|
+
` pane shell ${plan.herdrConfig!.action} ${plan.herdrConfig!.path} ([terminal] default_shell)`,
|
|
795
|
+
]),
|
|
448
796
|
];
|
|
449
797
|
if (plan.tick !== undefined) {
|
|
450
798
|
lines.push(
|
|
@@ -456,6 +804,11 @@ export function formatHostRuntimePlan(plan: HostRuntimePlan): string {
|
|
|
456
804
|
} else {
|
|
457
805
|
lines.push(" heartbeat embedded orchestrator — no external tick config");
|
|
458
806
|
}
|
|
807
|
+
lines.push(
|
|
808
|
+
plan.briefLink.action === "skip"
|
|
809
|
+
? ` brief link ${plan.briefLink.path} — ${plan.briefLink.skippedReason}`
|
|
810
|
+
: ` brief link ${plan.briefLink.action} ${plan.briefLink.path} -> ${plan.briefLink.target}`,
|
|
811
|
+
);
|
|
459
812
|
lines.push(" install staged only; the final result prints the systemd install commands");
|
|
460
813
|
return lines.join("\n");
|
|
461
814
|
}
|
|
@@ -474,17 +827,76 @@ function atomicWrite(path: string, content: string, mode: number): void {
|
|
|
474
827
|
}
|
|
475
828
|
}
|
|
476
829
|
|
|
477
|
-
|
|
478
|
-
|
|
830
|
+
/** What {@link writeHostRuntime} wrote, plus anything it could not. */
|
|
831
|
+
export interface HostRuntimeWrite {
|
|
832
|
+
/** Paths that were (re)written. */
|
|
833
|
+
wrote: string[];
|
|
834
|
+
/** Best-effort failures the caller should surface; setup continues regardless. */
|
|
835
|
+
warnings: string[];
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
export function writeHostRuntime(plan: HostRuntimePlan): HostRuntimeWrite {
|
|
839
|
+
const wrote: string[] = [];
|
|
840
|
+
const warnings: string[] = [];
|
|
479
841
|
if (plan.service.action !== "keep") {
|
|
480
842
|
atomicWrite(plan.service.path, plan.service.content, 0o644);
|
|
481
|
-
|
|
843
|
+
wrote.push(plan.service.path);
|
|
844
|
+
}
|
|
845
|
+
if (plan.herdrUnit !== undefined && plan.herdrUnit.action !== "keep") {
|
|
846
|
+
atomicWrite(plan.herdrUnit.path, plan.herdrUnit.content, 0o644);
|
|
847
|
+
wrote.push(plan.herdrUnit.path);
|
|
848
|
+
}
|
|
849
|
+
if (plan.herdrConfig !== undefined && plan.herdrConfig.action !== "keep") {
|
|
850
|
+
atomicWrite(plan.herdrConfig.path, plan.herdrConfig.content, 0o644);
|
|
851
|
+
wrote.push(plan.herdrConfig.path);
|
|
852
|
+
}
|
|
853
|
+
if (plan.recoverUnit.action !== "keep") {
|
|
854
|
+
atomicWrite(plan.recoverUnit.path, plan.recoverUnit.content, 0o644);
|
|
855
|
+
wrote.push(plan.recoverUnit.path);
|
|
856
|
+
}
|
|
857
|
+
if (plan.recoverScript.action !== "keep") {
|
|
858
|
+
atomicWrite(plan.recoverScript.path, plan.recoverScript.content, 0o755);
|
|
859
|
+
wrote.push(plan.recoverScript.path);
|
|
482
860
|
}
|
|
483
861
|
if (plan.tick !== undefined && plan.tick.action !== "keep") {
|
|
484
862
|
atomicWrite(plan.tick.path, plan.tick.content, 0o600);
|
|
485
|
-
|
|
863
|
+
wrote.push(plan.tick.path);
|
|
864
|
+
}
|
|
865
|
+
// `skip` is a regular file the plan already told the operator would be left
|
|
866
|
+
// alone; `keep` is an already-correct link. Only create/update touch disk —
|
|
867
|
+
// and never on the plan's word: the entry can change across the consent gap
|
|
868
|
+
// (an operator may drop a regular file where a stale symlink was planned for
|
|
869
|
+
// replacement). Re-lstat at write time and act on *current* state, so a file
|
|
870
|
+
// that appears mid-flight is preserved, never unlinked by a stale plan.
|
|
871
|
+
if (plan.briefLink.action === "create" || plan.briefLink.action === "update") {
|
|
872
|
+
const { path, target } = plan.briefLink;
|
|
873
|
+
try {
|
|
874
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
875
|
+
let st: Stats | undefined;
|
|
876
|
+
try {
|
|
877
|
+
st = lstatSync(path);
|
|
878
|
+
} catch {
|
|
879
|
+
st = undefined; // ENOENT — nothing there yet; also best-effort if a parent refuses.
|
|
880
|
+
}
|
|
881
|
+
if (st !== undefined && !st.isSymbolicLink()) {
|
|
882
|
+
// A regular file occupies the path (present at plan time, appeared, or
|
|
883
|
+
// swapped in since). Never clobber an operator's file: warn and carry on.
|
|
884
|
+
warnings.push(`${path} is a regular file, not ours to overwrite; AGENTS.md brief link skipped`);
|
|
885
|
+
} else if (st !== undefined && readlinkSync(path, "utf8") === target) {
|
|
886
|
+
// Already the correct link — leave it untouched, however the plan read it.
|
|
887
|
+
} else {
|
|
888
|
+
// Missing, or a stale symlink: (re)link it. Only a *current* symlink is
|
|
889
|
+
// removed; an operator's file that appears mid-flight survives the write.
|
|
890
|
+
if (st !== undefined) rmSync(path, { force: true });
|
|
891
|
+
symlinkSync(target, path);
|
|
892
|
+
wrote.push(path);
|
|
893
|
+
}
|
|
894
|
+
} catch (err) {
|
|
895
|
+
// Best effort: a filesystem that refuses symlinks must not fail setup.
|
|
896
|
+
warnings.push(`could not link ${path} -> ${target}: ${err instanceof Error ? err.message : String(err)}`);
|
|
897
|
+
}
|
|
486
898
|
}
|
|
487
|
-
return
|
|
899
|
+
return { wrote, warnings };
|
|
488
900
|
}
|
|
489
901
|
|
|
490
902
|
export interface SetupSmokeResult {
|