omp-conductor 0.17.0 → 0.18.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/REFERENCE.md +12 -8
- package/package.json +1 -1
- package/schema/config.schema.json +40 -1
- package/src/admission.ts +263 -44
- package/src/ask.ts +39 -3
- package/src/availability.ts +27 -1
- package/src/backups.ts +2 -2
- package/src/briefs/orchestrator.md +1 -0
- package/src/briefs/worker.md +38 -19
- package/src/command-help.ts +8 -1
- package/src/command-manifest.ts +5 -2
- package/src/commands/arm.ts +6 -3
- package/src/commands/message.ts +32 -4
- package/src/commands/watch.ts +62 -3
- package/src/config-schema.ts +53 -0
- package/src/config.ts +97 -1
- package/src/daemon.ts +1479 -1483
- package/src/decisions.ts +51 -6
- package/src/depends-on.ts +261 -1
- package/src/diff-flags.ts +350 -0
- package/src/digest-schedule.ts +37 -0
- package/src/doctor.ts +310 -22
- package/src/escalate.ts +560 -57
- package/src/failure-class.ts +71 -15
- package/src/fleet.ts +189 -34
- package/src/gitops.ts +103 -24
- package/src/graph-health.ts +20 -7
- package/src/graph.ts +313 -68
- package/src/lifecycle.ts +43 -7
- package/src/omp.ts +42 -0
- package/src/orchestrator-tick.ts +430 -162
- package/src/release-policy.ts +177 -5
- package/src/routing.ts +11 -3
- package/src/session-host.ts +16 -0
- package/src/settlement.ts +1728 -0
- package/src/setup-host.ts +193 -4
- package/src/setup-install.ts +91 -30
- package/src/setup-wizard.ts +1257 -78
- package/src/setup.ts +153 -6
- package/src/status-render.ts +36 -4
- package/src/store.ts +411 -17
- package/src/tracker/github.ts +607 -12
- package/src/types.ts +331 -5
- 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 +270 -13
- package/src/worker.ts +239 -6
- package/src/worktree.ts +115 -8
- package/systemd/omp-conductor-recover.sh +73 -0
- package/systemd/recover-unit-test.sh +61 -0
package/src/setup-host.ts
CHANGED
|
@@ -15,10 +15,13 @@ import {
|
|
|
15
15
|
DEFAULT_PORT,
|
|
16
16
|
healthCheck,
|
|
17
17
|
livingDaemon,
|
|
18
|
+
probeUnit,
|
|
18
19
|
startDaemon,
|
|
19
20
|
stopDaemon,
|
|
21
|
+
SYSTEMD_UNIT,
|
|
20
22
|
type DaemonRecord,
|
|
21
23
|
type StopResult,
|
|
24
|
+
type UnitOwnership,
|
|
22
25
|
} from "./lifecycle.ts";
|
|
23
26
|
import {
|
|
24
27
|
legacyArmedMarkerPath,
|
|
@@ -48,7 +51,7 @@ export const SYSTEMD_UNIT_DIR = "/etc/systemd/system";
|
|
|
48
51
|
export const RECOVER_SERVICE_NAME = "omp-conductor-recover.service";
|
|
49
52
|
|
|
50
53
|
/** The playbook file shipped in the package's `systemd/` directory. */
|
|
51
|
-
const RECOVER_SCRIPT_FILE = "omp-conductor-recover.sh";
|
|
54
|
+
export const RECOVER_SCRIPT_FILE = "omp-conductor-recover.sh";
|
|
52
55
|
|
|
53
56
|
/**
|
|
54
57
|
* Where `setup host` installs the recovery playbook so the unit's ExecStart is
|
|
@@ -1512,8 +1515,125 @@ export function writeHostRuntime(plan: HostRuntimePlan): HostRuntimeWrite {
|
|
|
1512
1515
|
return { wrote, warnings };
|
|
1513
1516
|
}
|
|
1514
1517
|
|
|
1518
|
+
// ---------------------------------------------------------- rollback (#652) --
|
|
1519
|
+
//
|
|
1520
|
+
// The inverse of the writes above, for the setup apply's mutation inventory:
|
|
1521
|
+
// every path the apply can write is captured byte-for-byte (or as absence, or
|
|
1522
|
+
// as unreadable — never conflated) before the first mutation, and restored on
|
|
1523
|
+
// any later failure. `capturePathState` is the read half, `restorePathState`
|
|
1524
|
+
// the write half; the transaction that owns them lives in `setup-wizard.ts`.
|
|
1525
|
+
|
|
1526
|
+
/** One path's pre-entry state, three-valued so absence is never confused with
|
|
1527
|
+
* unreadability:
|
|
1528
|
+
* - `absent` — nothing at the path; rollback removes what the apply created.
|
|
1529
|
+
* - `bytes` — readable on entry (a regular file with its mode, or a symlink
|
|
1530
|
+
* with its target); rollback writes those exact bytes/mode or re-links it.
|
|
1531
|
+
* - `unreadable` — something exists but cannot be read. The transaction must
|
|
1532
|
+
* fail closed before changing anything: collapsing this to absence and
|
|
1533
|
+
* later deleting the path would remove state the apply never saw. */
|
|
1534
|
+
export type CapturedPathState =
|
|
1535
|
+
| { path: string; kind: "absent" }
|
|
1536
|
+
| { path: string; kind: "bytes"; bytes: string; mode: number; symlink: boolean }
|
|
1537
|
+
| { path: string; kind: "unreadable" };
|
|
1538
|
+
|
|
1539
|
+
/** Reads {@link CapturedPathState} for one path. Symlinks are captured as
|
|
1540
|
+
* their target (readlink), never followed — the brief link is a symlink by
|
|
1541
|
+
* contract, and a dangling one is still state worth restoring. */
|
|
1542
|
+
export function capturePathState(path: string): CapturedPathState {
|
|
1543
|
+
let st: Stats;
|
|
1544
|
+
try {
|
|
1545
|
+
st = lstatSync(path);
|
|
1546
|
+
} catch {
|
|
1547
|
+
return { path, kind: "absent" };
|
|
1548
|
+
}
|
|
1549
|
+
try {
|
|
1550
|
+
if (st.isSymbolicLink()) {
|
|
1551
|
+
return { path, kind: "bytes", bytes: readlinkSync(path, "utf8"), mode: st.mode & 0o7777, symlink: true };
|
|
1552
|
+
}
|
|
1553
|
+
return { path, kind: "bytes", bytes: readFileSync(path, "utf8"), mode: st.mode & 0o7777, symlink: false };
|
|
1554
|
+
} catch {
|
|
1555
|
+
return { path, kind: "unreadable" };
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
/** Options for {@link restorePathState}. */
|
|
1560
|
+
export interface RestorePathOptions {
|
|
1561
|
+
/**
|
|
1562
|
+
* Never remove or replace a regular file: only a symlink (or absence) is
|
|
1563
|
+
* touched, so an operator's file that appeared mid-transaction survives a
|
|
1564
|
+
* rollback that cannot clobber it — the same guard the brief-link write
|
|
1565
|
+
* itself applies (#652). A regular file where the entry state said
|
|
1566
|
+
* "absent"/"symlink" therefore reports a restoration failure: the prior
|
|
1567
|
+
* state is not coherent and the operator must be told.
|
|
1568
|
+
*/
|
|
1569
|
+
preserveRegularFile?: boolean;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/** Restores {@link CapturedPathState} captured before the first mutation:
|
|
1573
|
+
* writes the entry bytes back (with their mode), re-links a symlink's entry
|
|
1574
|
+
* target, or removes what the apply created when nothing was there. Returns
|
|
1575
|
+
* `undefined` on a complete restore, else the first failure's message — a
|
|
1576
|
+
* rollback that could not restore a path must be reported, never implied
|
|
1577
|
+
* complete. */
|
|
1578
|
+
export function restorePathState(state: CapturedPathState, opts: RestorePathOptions = {}): string | undefined {
|
|
1579
|
+
const { path } = state;
|
|
1580
|
+
try {
|
|
1581
|
+
if (state.kind === "bytes" && state.symlink) {
|
|
1582
|
+
// Re-link the entry target. Under `preserveRegularFile` a regular file
|
|
1583
|
+
// that replaced the link mid-flight is left alone — and reported — so a
|
|
1584
|
+
// rollback never clobbers an operator's file it did not create.
|
|
1585
|
+
let current: Stats | undefined;
|
|
1586
|
+
try {
|
|
1587
|
+
current = lstatSync(path);
|
|
1588
|
+
} catch {
|
|
1589
|
+
current = undefined;
|
|
1590
|
+
}
|
|
1591
|
+
if (current !== undefined && !current.isSymbolicLink() && opts.preserveRegularFile === true) {
|
|
1592
|
+
return `could not restore the symlink at ${path}: a regular file now occupies it`;
|
|
1593
|
+
}
|
|
1594
|
+
rmSync(path, { force: true });
|
|
1595
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1596
|
+
symlinkSync(state.bytes, path);
|
|
1597
|
+
return undefined;
|
|
1598
|
+
}
|
|
1599
|
+
if (state.kind === "bytes") {
|
|
1600
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1601
|
+
writeFileSync(path, state.bytes);
|
|
1602
|
+
// Mode is part of the pre-entry state (a staged unit 0644, the tick
|
|
1603
|
+
// config 0600, the recovery playbook 0755): writeFileSync preserves an
|
|
1604
|
+
// existing file's mode, so chmod explicitly restores the captured one.
|
|
1605
|
+
chmodSync(path, state.mode);
|
|
1606
|
+
return undefined;
|
|
1607
|
+
}
|
|
1608
|
+
// Absent at entry: remove what the apply created. A regular file that
|
|
1609
|
+
// appeared where the entry was absent is the operator's own unless the
|
|
1610
|
+
// caller says otherwise.
|
|
1611
|
+
let current: Stats | undefined;
|
|
1612
|
+
try {
|
|
1613
|
+
current = lstatSync(path);
|
|
1614
|
+
} catch {
|
|
1615
|
+
return undefined; // already gone — a complete restore.
|
|
1616
|
+
}
|
|
1617
|
+
if (current.isSymbolicLink() || opts.preserveRegularFile !== true) {
|
|
1618
|
+
rmSync(path, { force: true });
|
|
1619
|
+
return undefined;
|
|
1620
|
+
}
|
|
1621
|
+
return `could not restore ${path}: a regular file appeared where nothing was at entry`;
|
|
1622
|
+
} catch (err) {
|
|
1623
|
+
return `could not restore ${path}: ${err instanceof Error ? err.message : String(err)}`;
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1515
1627
|
export interface SetupSmokeResult {
|
|
1516
1628
|
mode: "temporary" | "existing";
|
|
1629
|
+
/**
|
|
1630
|
+
* Whether the smoke itself proved the daemon answers `/healthz` on
|
|
1631
|
+
* {@link daemon}.port. A record-less active unit has no port to probe, so
|
|
1632
|
+
* its "existing" smoke is unproven: it cannot count as passed until the
|
|
1633
|
+
* caller restarts the daemon through the lifecycle seam, whose
|
|
1634
|
+
* `waitForOwnedDaemon` proves MainPID + `/healthz` (#651, review #2).
|
|
1635
|
+
*/
|
|
1636
|
+
healthProven: boolean;
|
|
1517
1637
|
status: StatusSnapshot;
|
|
1518
1638
|
daemon: DaemonRecord;
|
|
1519
1639
|
}
|
|
@@ -1522,6 +1642,15 @@ export interface SetupSmokeDeps {
|
|
|
1522
1642
|
paused(project: string): boolean;
|
|
1523
1643
|
runOnce(project: string): Promise<void>;
|
|
1524
1644
|
living(): DaemonRecord | undefined;
|
|
1645
|
+
/**
|
|
1646
|
+
* systemd ownership of the `omp-conductor.service` supervisor unit, probed
|
|
1647
|
+
* independently of the pidfile/runtime record. An active unit is a live
|
|
1648
|
+
* supervised daemon even when its record is missing or unreadable (#618):
|
|
1649
|
+
* it must never be run beside a `--once` tick, and `start()` must never be
|
|
1650
|
+
* aimed at it. `unknown` is preserved — never collapsed into `inactive` —
|
|
1651
|
+
* so a possibly-live supervised daemon is not run beside (#651, review #2).
|
|
1652
|
+
*/
|
|
1653
|
+
unitOwnership(): UnitOwnership;
|
|
1525
1654
|
health(port: number): Promise<{ ok: boolean; body?: string }>;
|
|
1526
1655
|
start(project: string): Promise<DaemonRecord>;
|
|
1527
1656
|
stop(): Promise<StopResult>;
|
|
@@ -1532,30 +1661,90 @@ const DEFAULT_SMOKE_DEPS: SetupSmokeDeps = {
|
|
|
1532
1661
|
paused: isPaused,
|
|
1533
1662
|
runOnce: async (project) => await runDaemon({ once: true, project }),
|
|
1534
1663
|
living: livingDaemon,
|
|
1664
|
+
unitOwnership: () => probeUnit(SYSTEMD_UNIT),
|
|
1535
1665
|
health: healthCheck,
|
|
1536
1666
|
start: async (project) => await startDaemon({ project }),
|
|
1537
1667
|
stop: stopDaemon,
|
|
1538
1668
|
status: statusSnapshot,
|
|
1539
1669
|
};
|
|
1540
1670
|
|
|
1671
|
+
/** The daemon record a smoke attributes to a live but record-less supervisor.
|
|
1672
|
+
* The port is unknown (the record would have named it); the "existing" mode
|
|
1673
|
+
* caller restarts the daemon through the lifecycle seam and recomputes the
|
|
1674
|
+
* real /healthz line from that restart, so this satisfies the result shape
|
|
1675
|
+
* without pretending to know a port. `healthProven: false` marks that this
|
|
1676
|
+
* is a placeholder until that restart proves the unit's MainPID answers. */
|
|
1677
|
+
function unrecordedDaemon(mainPid: number): DaemonRecord {
|
|
1678
|
+
return {
|
|
1679
|
+
pid: 0,
|
|
1680
|
+
port: 0,
|
|
1681
|
+
startedAt: 0,
|
|
1682
|
+
logFile: `<record missing — supervised daemon MainPID ${mainPid} via systemd>`,
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1541
1686
|
export async function runSetupSmoke(
|
|
1542
1687
|
project: string,
|
|
1543
1688
|
deps: SetupSmokeDeps = DEFAULT_SMOKE_DEPS,
|
|
1544
1689
|
): Promise<SetupSmokeResult> {
|
|
1545
1690
|
if (!deps.paused(project)) throw new Error("setup smoke requires paused dispatch");
|
|
1546
|
-
|
|
1691
|
+
// Detect a live daemon BEFORE the mutating `--once` tick (#618). A paused
|
|
1692
|
+
// `--once` run is itself a dispatcher: it settles rows, projects labels,
|
|
1693
|
+
// admits and salvages workers. Running one beside a live daemon orphans and
|
|
1694
|
+
// salvages the runs the live process owns — exactly what the incident did to
|
|
1695
|
+
// three workers before failing its restart check. An already-active daemon
|
|
1696
|
+
// needs only its health proving (the caller restarts it through the lifecycle
|
|
1697
|
+
// seam so the new config takes effect) — no `--once` process ever runs beside
|
|
1698
|
+
// it, and no `systemctl start` is aimed at a unit that is already active.
|
|
1547
1699
|
const existing = deps.living();
|
|
1548
1700
|
if (existing !== undefined) {
|
|
1549
1701
|
const health = await deps.health(existing.port);
|
|
1550
1702
|
if (!health.ok) throw new Error(`existing daemon on :${existing.port} did not answer /healthz`);
|
|
1551
|
-
return { mode: "existing", status: deps.status(project), daemon: existing };
|
|
1703
|
+
return { mode: "existing", healthProven: true, status: deps.status(project), daemon: existing };
|
|
1552
1704
|
}
|
|
1553
1705
|
|
|
1706
|
+
// The pidfile record is absent, but that is not "no daemon": an active
|
|
1707
|
+
// systemd-owned unit is a live supervised dispatcher even when its record
|
|
1708
|
+
// went missing (a crash raced a re-record, or an unmanaged start never wrote
|
|
1709
|
+
// one). Without this independent probe the smoke runs a second `--once`
|
|
1710
|
+
// beside it, and `start()` targets the already-active unit — two dispatchers
|
|
1711
|
+
// owning the same state, the #618 incident shape. Treat it as an existing
|
|
1712
|
+
// daemon: no competing tick, no activation; the caller restarts the unit so
|
|
1713
|
+
// the new config takes effect (#651, review #1).
|
|
1714
|
+
const ownership = deps.unitOwnership();
|
|
1715
|
+
if (ownership.kind === "unknown") {
|
|
1716
|
+
// A probe failure is not the confirmed negative `inactive` claims to be:
|
|
1717
|
+
// collapsing `unknown` here is how a unit-owned daemon gets a second one
|
|
1718
|
+
// started beside it (#651, review #2). The mutating `--once` tick refuses
|
|
1719
|
+
// instead.
|
|
1720
|
+
throw new Error(
|
|
1721
|
+
`cannot prove whether a daemon is running: systemd ownership probe failed (${ownership.reason}) — ` +
|
|
1722
|
+
"refusing to run the setup smoke beside a possibly-live daemon",
|
|
1723
|
+
);
|
|
1724
|
+
}
|
|
1725
|
+
if (ownership.kind === "active") {
|
|
1726
|
+
// No record to read a port from, so no /healthz proof is possible here:
|
|
1727
|
+
// the smoke reports `existing` honestly as *unproven*, and the caller
|
|
1728
|
+
// must restart the unit through the lifecycle seam — whose
|
|
1729
|
+
// `waitForOwnedDaemon` proves MainPID + `/healthz` — before the smoke
|
|
1730
|
+
// counts as passed (#651, review #2).
|
|
1731
|
+
return {
|
|
1732
|
+
mode: "existing",
|
|
1733
|
+
healthProven: false,
|
|
1734
|
+
status: deps.status(project),
|
|
1735
|
+
daemon: unrecordedDaemon(ownership.pid),
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// Confirmed inactive (or failed, which owns no process): a paused `--once`
|
|
1740
|
+
// smoke can run by itself, then a temporary daemon proves the new runtime,
|
|
1741
|
+
// then it is stopped again.
|
|
1742
|
+
await deps.runOnce(project);
|
|
1554
1743
|
const daemon = await deps.start(project);
|
|
1555
1744
|
try {
|
|
1556
1745
|
const health = await deps.health(daemon.port);
|
|
1557
1746
|
if (!health.ok) throw new Error(`temporary daemon on :${daemon.port} did not answer /healthz`);
|
|
1558
|
-
return { mode: "temporary", status: deps.status(project), daemon };
|
|
1747
|
+
return { mode: "temporary", healthProven: true, status: deps.status(project), daemon };
|
|
1559
1748
|
} finally {
|
|
1560
1749
|
await deps.stop();
|
|
1561
1750
|
}
|
package/src/setup-install.ts
CHANGED
|
@@ -30,12 +30,17 @@ import {
|
|
|
30
30
|
type UpgradeScope,
|
|
31
31
|
} from "./upgrade.ts";
|
|
32
32
|
import {
|
|
33
|
+
graphConflictMessage,
|
|
33
34
|
graphRepos,
|
|
34
35
|
formatGraphSetup,
|
|
36
|
+
legacyReindexFiles,
|
|
37
|
+
legacyReindexNote,
|
|
35
38
|
mcpEntry,
|
|
39
|
+
planGraphSetup,
|
|
36
40
|
reindexScriptPath,
|
|
41
|
+
reindexUnitName,
|
|
37
42
|
resolvePrereqs,
|
|
38
|
-
|
|
43
|
+
stagingConflict,
|
|
39
44
|
unitPaths,
|
|
40
45
|
writeGraphSetup,
|
|
41
46
|
type GraphPrereqs,
|
|
@@ -131,16 +136,18 @@ function linuxOnly(deps: InstallDeps): string | undefined {
|
|
|
131
136
|
}
|
|
132
137
|
|
|
133
138
|
/**
|
|
134
|
-
* Whether
|
|
135
|
-
* `actionFor`: a unit systemd already reads that matches what
|
|
136
|
-
* needs no reinstall. Reads can fail (permissions, vanished
|
|
137
|
-
* read error is a mismatch, never silently "matches", because
|
|
138
|
-
* is to make the plan only claim a unit install is redundant
|
|
139
|
-
* the installed copy.
|
|
139
|
+
* Whether a file holds exactly the rendered bytes. The "keep" side of
|
|
140
|
+
* setup-host.ts's `actionFor`: a unit systemd already reads that matches what
|
|
141
|
+
* would be written needs no reinstall. Reads can fail (permissions, vanished
|
|
142
|
+
* mid-flight) — a read error is a mismatch, never silently "matches", because
|
|
143
|
+
* the whole point is to make the plan only claim a unit install is redundant
|
|
144
|
+
* when it can see the installed copy. Comparing against the rendered content
|
|
145
|
+
* rather than the staged file keeps the comparison read-only: staging now
|
|
146
|
+
* happens only after consent (#720), so nothing may be written to compare.
|
|
140
147
|
*/
|
|
141
|
-
function
|
|
148
|
+
function sameContent(path: string, content: string): boolean {
|
|
142
149
|
try {
|
|
143
|
-
return readFileSync(
|
|
150
|
+
return readFileSync(path, "utf8") === content;
|
|
144
151
|
} catch {
|
|
145
152
|
return false;
|
|
146
153
|
}
|
|
@@ -450,9 +457,10 @@ export interface GraphInstallOptions extends InstallDeps {
|
|
|
450
457
|
* Staging and enabling alone installs a service that fails on every run: the
|
|
451
458
|
* generated script runs under `set -euo pipefail` and `cd "<graphProject>"` as
|
|
452
459
|
* its first act per repo, so a missing clone is a `cd` failure at 03:00 rather
|
|
453
|
-
* than a graph. That is why
|
|
454
|
-
*
|
|
455
|
-
* seed and verify — one preview, one confirm
|
|
460
|
+
* than a graph. That is why staging was never allowed to call the install a
|
|
461
|
+
* finished job. So: prerequisites, then a conflict check, then clones, then
|
|
462
|
+
* install, then seed and verify — one preview, one confirm, and nothing
|
|
463
|
+
* staged before the confirm (#720).
|
|
456
464
|
*/
|
|
457
465
|
export async function runGraphInstall(
|
|
458
466
|
project: ProjectConfig,
|
|
@@ -494,7 +502,38 @@ export async function runGraphInstall(
|
|
|
494
502
|
return { kind: "refused", reason: missing };
|
|
495
503
|
}
|
|
496
504
|
|
|
497
|
-
const
|
|
505
|
+
const unitDir = options.unitDir ?? SYSTEMD_UNIT_DIR;
|
|
506
|
+
// The plan is computed eagerly — `planGraphSetup` renders every byte, so the
|
|
507
|
+
// prompt can print exactly what would be written and the unitsCurrent gate
|
|
508
|
+
// below can decide a re-run installs nothing. But rendering is read-only: no
|
|
509
|
+
// file is written here. Staging happens only after consent (#720), so the
|
|
510
|
+
// prompt's "stages once you confirm" is true of the staged tree when it is
|
|
511
|
+
// printed, and a declined run leaves every staged file byte-identical.
|
|
512
|
+
const plan = planGraphSetup(project, unitDir);
|
|
513
|
+
// Never overwrite another project's staged artefact: the stems derive from
|
|
514
|
+
// the project name, and two names can fold to one stem ("My Project" vs
|
|
515
|
+
// "my_project"), so an existing file's generated-for marker is checked
|
|
516
|
+
// before the consent prompt — a collision is refused before anything is
|
|
517
|
+
// asked, let alone written (#720).
|
|
518
|
+
const conflict = stagingConflict(project, [
|
|
519
|
+
plan.script.path,
|
|
520
|
+
plan.service.path,
|
|
521
|
+
plan.timer.path,
|
|
522
|
+
...Object.values(unitPaths(project, unitDir)),
|
|
523
|
+
]);
|
|
524
|
+
if (conflict !== undefined) {
|
|
525
|
+
const message = graphConflictMessage(conflict);
|
|
526
|
+
ui.notify(message, "error");
|
|
527
|
+
return { kind: "refused", reason: message };
|
|
528
|
+
}
|
|
529
|
+
// A host that ran the pre-#720 project-less version keeps its old files:
|
|
530
|
+
// their timer refreshes whichever project generated it last, forever. Name
|
|
531
|
+
// the remediation before the consent prompt — deleting files outside this
|
|
532
|
+
// command's own names is exactly how the overwrite happened in the first
|
|
533
|
+
// place, so it is host action, not install action.
|
|
534
|
+
const legacy = legacyReindexFiles();
|
|
535
|
+
if (legacy.length > 0) ui.notify(legacyReindexNote(legacy), "warning");
|
|
536
|
+
|
|
498
537
|
const absent = repos.filter((r) => !existsSync(r.graphProject));
|
|
499
538
|
|
|
500
539
|
// 2. Missing clones, as the operator. A root-owned index-only clone under the
|
|
@@ -507,17 +546,18 @@ export async function runGraphInstall(
|
|
|
507
546
|
}));
|
|
508
547
|
|
|
509
548
|
const blocked = linuxOnly(options);
|
|
510
|
-
const
|
|
511
|
-
const
|
|
512
|
-
// The units systemd already reads, compared to
|
|
549
|
+
const stem = reindexUnitName(project);
|
|
550
|
+
const { service, timer } = unitPaths(project, unitDir);
|
|
551
|
+
// The units systemd already reads, compared to the rendered content — the
|
|
513
552
|
// same distinction setup-host.ts draws between `installedAction` and
|
|
514
553
|
// `service.action` (read the installed file, compare to the fresh content,
|
|
515
554
|
// "keep" vs "update"). "Installed" is not enough: a green timer serving a
|
|
516
555
|
// stale unit is the older bug, so when they differ the install runs. Only
|
|
517
556
|
// when both units already match does the plan omit them — then just the
|
|
518
|
-
// outstanding clone and seed remain.
|
|
557
|
+
// outstanding clone and seed remain. Nothing is written to compare: staging
|
|
558
|
+
// is post-consent now, so the comparison is against the rendered bytes.
|
|
519
559
|
const unitsCurrent =
|
|
520
|
-
blocked === undefined &&
|
|
560
|
+
blocked === undefined && sameContent(service, plan.service.content) && sameContent(timer, plan.timer.content);
|
|
521
561
|
// 3. Install and enable, privileged. 4. Seed, in the SAME batch: the contract is
|
|
522
562
|
// one preview and one confirm, and a second confirm here also invented a
|
|
523
563
|
// third outcome — a declined seed — that neither the caller nor the
|
|
@@ -525,32 +565,52 @@ export async function runGraphInstall(
|
|
|
525
565
|
const seed: PrivilegedStep[] =
|
|
526
566
|
options.noSeed === true
|
|
527
567
|
? []
|
|
528
|
-
: [{ title: `seed the indexes (runs ${
|
|
568
|
+
: [{ title: `seed the indexes (runs ${stem}.service once, minutes per repo)`, argv: ["systemctl", "start", `${stem}.service`] }];
|
|
529
569
|
const install: PrivilegedStep[] =
|
|
530
570
|
blocked === undefined
|
|
531
571
|
? [
|
|
532
572
|
...(unitsCurrent
|
|
533
573
|
? []
|
|
534
574
|
: [
|
|
535
|
-
{ title: "install the reindex unit and timer", argv: ["install", "-m", "0644",
|
|
575
|
+
{ title: "install the reindex unit and timer", argv: ["install", "-m", "0644", plan.service.path, plan.timer.path, join(unitDir, "")] },
|
|
536
576
|
{ title: "reload systemd", argv: ["systemctl", "daemon-reload"] },
|
|
537
|
-
{ title: `enable ${
|
|
577
|
+
{ title: `enable ${stem}.timer`, argv: ["systemctl", "enable", "--now", `${stem}.timer`] },
|
|
538
578
|
]),
|
|
539
579
|
...seed,
|
|
540
580
|
]
|
|
541
581
|
: [];
|
|
542
582
|
|
|
543
583
|
if (blocked !== undefined) {
|
|
584
|
+
// A non-Linux host cannot run systemd, so staging IS the deliverable: the
|
|
585
|
+
// rendered files are written for the operator to copy to the box that will
|
|
586
|
+
// run them, and only the systemctl half is refused. There is no consent
|
|
587
|
+
// prompt on this path — nothing privileged to approve — so the write lands
|
|
588
|
+
// here, not in the (never reached) post-consent hook (#720, as #510).
|
|
589
|
+
const staged = writeGraphSetup(project, unitDir);
|
|
544
590
|
ui.notify(`${blocked} ${service} and ${timer}`, "warning");
|
|
545
591
|
return { kind: "staged", wrote: staged.written, reason: `${blocked} ${service}` };
|
|
546
592
|
}
|
|
547
593
|
|
|
594
|
+
// What the post-consent staging actually wrote, surfaced by the outcomes
|
|
595
|
+
// that report it. Empty on a decline — the hook never ran.
|
|
596
|
+
let wrote: readonly string[] = [];
|
|
597
|
+
const beforeRun = async (): Promise<void> => {
|
|
598
|
+
// Consent has been given; this project's files may now land. Staging here —
|
|
599
|
+
// post-confirm, pre-step, under the same confirm that authorised the batch
|
|
600
|
+
// — is what makes the prompt's plan true of the staged tree when it is
|
|
601
|
+
// printed: a declined prompt never reaches this hook, so nothing is staged
|
|
602
|
+
// and a re-run still plans an install (#720). `writeGraphSetup`'s ownership
|
|
603
|
+
// backstop throws here with nothing written if a collision raced the
|
|
604
|
+
// pre-consent check.
|
|
605
|
+
wrote = writeGraphSetup(project, unitDir).written;
|
|
606
|
+
};
|
|
607
|
+
|
|
548
608
|
const outcome = await runPrivileged([...clones, ...install], ui, {
|
|
549
609
|
...(options.privileged === undefined ? {} : { deps: options.privileged }),
|
|
550
610
|
title: unitsCurrent ? "Clone the code-graph checkouts and seed them?" : "Clone, install and enable the code-graph timer?",
|
|
551
611
|
answerKey: `install-code-graph.${project.name}`,
|
|
552
612
|
preamble: [
|
|
553
|
-
`
|
|
613
|
+
`Stages ${[plan.script.path, plan.service.path, plan.timer.path].join(", ")} once you confirm — nothing is written before that.`,
|
|
554
614
|
...(clones.length === 0
|
|
555
615
|
? ["Every indexed clone already exists."]
|
|
556
616
|
: [
|
|
@@ -561,8 +621,9 @@ export async function runGraphInstall(
|
|
|
561
621
|
...(unitsCurrent ? ["The reindex units are already installed and current — nothing to install."] : []),
|
|
562
622
|
`Indexer: ${prereqs.indexer ?? "on PATH"}.`,
|
|
563
623
|
],
|
|
624
|
+
beforeRun,
|
|
564
625
|
});
|
|
565
|
-
if (outcome.kind === "declined") return { kind: "declined", wrote
|
|
626
|
+
if (outcome.kind === "declined") return { kind: "declined", wrote };
|
|
566
627
|
if (outcome.kind === "failed") {
|
|
567
628
|
return { kind: "failed", reason: `${outcome.step.title} exited ${outcome.exitCode}` };
|
|
568
629
|
}
|
|
@@ -571,10 +632,10 @@ export async function runGraphInstall(
|
|
|
571
632
|
// answer: an unseeded graph is not usable until the timer first fires.
|
|
572
633
|
if (options.noSeed === true) {
|
|
573
634
|
ui.notify(
|
|
574
|
-
`Timer enabled; skipped the seeding run. The graph is NOT usable until ${
|
|
635
|
+
`Timer enabled; skipped the seeding run. The graph is NOT usable until ${stem}.timer first fires.`,
|
|
575
636
|
"warning",
|
|
576
637
|
);
|
|
577
|
-
return { kind: "installed", wrote
|
|
638
|
+
return { kind: "installed", wrote };
|
|
578
639
|
}
|
|
579
640
|
|
|
580
641
|
const health = await (options.probe ?? probeCodeGraph)(project);
|
|
@@ -583,13 +644,13 @@ export async function runGraphInstall(
|
|
|
583
644
|
// Staged but not trusted. Reporting success here is how an operator learns
|
|
584
645
|
// months later that no worker ever read an index.
|
|
585
646
|
ui.notify(
|
|
586
|
-
[`Installed, but ${unhealthy.length} repo(s) did not verify: ${unhealthy.join(", ")}.`, "",
|
|
647
|
+
[`Installed, but ${unhealthy.length} repo(s) did not verify: ${unhealthy.join(", ")}.`, "", plan.next].join("\n"),
|
|
587
648
|
"error",
|
|
588
649
|
);
|
|
589
650
|
return { kind: "failed", reason: `unverified: ${unhealthy.join(", ")}` };
|
|
590
651
|
}
|
|
591
652
|
ui.notify(`Code graph installed and verified for ${repos.map((r) => r.name).join(", ")}.`, "info");
|
|
592
|
-
return { kind: "installed", wrote
|
|
653
|
+
return { kind: "installed", wrote };
|
|
593
654
|
}
|
|
594
655
|
|
|
595
656
|
/** The two prerequisites that make an enabled timer meaningful, or `undefined`. */
|
|
@@ -618,7 +679,7 @@ export function graphInstallable(project: ProjectConfig): GraphRepo[] {
|
|
|
618
679
|
return graphRepos(project);
|
|
619
680
|
}
|
|
620
681
|
|
|
621
|
-
/** Where
|
|
622
|
-
export function reindexScriptLocation(): string {
|
|
623
|
-
return reindexScriptPath();
|
|
682
|
+
/** Where this project's reindex script lands, for the wizard's tail to name. */
|
|
683
|
+
export function reindexScriptLocation(project: ProjectConfig): string {
|
|
684
|
+
return reindexScriptPath(project);
|
|
624
685
|
}
|