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/graph-health.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { graphRepos,
|
|
2
|
+
import { graphRepos, reindexUnitName, resolvePrereqs, type GraphPrereqs } from "./graph.ts";
|
|
3
3
|
import type { ProjectConfig } from "./types.ts";
|
|
4
4
|
|
|
5
5
|
export const GRAPH_PROBE_TIMEOUT_MS = 1_000;
|
|
6
6
|
export const GRAPH_FRESHNESS_MS = 45 * 60_000;
|
|
7
7
|
|
|
8
|
+
/** Existence findings: the check observed the host (PATH, a directory, an index). */
|
|
8
9
|
type CheckState = "present" | "missing" | "unknown";
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* The MCP-mount finding. Its only evidence is the agent's `mcp.json` on disk,
|
|
13
|
+
* never a live session, so it speaks of configuration rather than presence — a
|
|
14
|
+
* runtime observation of the mounted toolset must be a *different* state, not
|
|
15
|
+
* this one relabelled (#738).
|
|
16
|
+
*/
|
|
17
|
+
type McpMountState = "configured" | "unconfigured" | "unknown";
|
|
18
|
+
|
|
10
19
|
export interface CodeGraphRepoHealth {
|
|
11
20
|
name: string;
|
|
12
21
|
path: string;
|
|
@@ -22,7 +31,7 @@ export type CodeGraphHealth =
|
|
|
22
31
|
checkedAt: string;
|
|
23
32
|
prerequisites: {
|
|
24
33
|
indexer: CheckState;
|
|
25
|
-
mcpMount:
|
|
34
|
+
mcpMount: McpMountState;
|
|
26
35
|
};
|
|
27
36
|
repos: CodeGraphRepoHealth[];
|
|
28
37
|
timer: {
|
|
@@ -228,19 +237,23 @@ export async function probeCodeGraph(
|
|
|
228
237
|
let uncertain = false;
|
|
229
238
|
|
|
230
239
|
const indexer: CheckState = prereqs.indexer === null ? "missing" : "present";
|
|
231
|
-
const mcpMount:
|
|
240
|
+
const mcpMount: McpMountState = prereqs.mounted ? "configured" : "unconfigured";
|
|
232
241
|
if (indexer === "missing") reasons.push("indexer is not present on PATH");
|
|
233
|
-
if (mcpMount === "
|
|
242
|
+
if (mcpMount === "unconfigured") reasons.push("worker MCP configuration does not mount the indexer");
|
|
234
243
|
|
|
244
|
+
// The units are per project (`cbm-reindex-<project>.*`): probing the shared
|
|
245
|
+
// stem would report project B's health against project A's timer, and with
|
|
246
|
+
// both installed the answer would be whichever the probe guessed (#720).
|
|
247
|
+
const stem = reindexUnitName(project);
|
|
235
248
|
const [projectsResult, enabledResult, activeResult, serviceResult] = await Promise.all([
|
|
236
249
|
prereqs.indexer === null
|
|
237
250
|
? Promise.resolve<ReadOnlyCommandResult>({ kind: "unavailable" })
|
|
238
251
|
: deps.run(prereqs.indexer, ["cli", "list_projects", "{}"]),
|
|
239
|
-
deps.run("systemctl", ["is-enabled", `${
|
|
240
|
-
deps.run("systemctl", ["is-active", `${
|
|
252
|
+
deps.run("systemctl", ["is-enabled", `${stem}.timer`]),
|
|
253
|
+
deps.run("systemctl", ["is-active", `${stem}.timer`]),
|
|
241
254
|
deps.run("systemctl", [
|
|
242
255
|
"show",
|
|
243
|
-
`${
|
|
256
|
+
`${stem}.service`,
|
|
244
257
|
"--property=Result",
|
|
245
258
|
"--property=ExecMainStatus",
|
|
246
259
|
"--property=ExecMainExitTimestamp",
|
package/src/graph.ts
CHANGED
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
* - **This package never builds or mutates an index, and never depends on the
|
|
15
15
|
* indexer for dispatch.** The optional health surface runs the indexer's
|
|
16
16
|
* read-only `list_projects` query; nothing spawns the graph server or imports
|
|
17
|
-
* it. `graph
|
|
18
|
-
*
|
|
19
|
-
* root-level state is not one you can
|
|
17
|
+
* it. `setup graph` renders a script and two systemd units per project and
|
|
18
|
+
* stages them only after its consent prompt — it does not enable them,
|
|
19
|
+
* because a package that silently writes root-level state is not one you can
|
|
20
|
+
* trust with a fleet.
|
|
20
21
|
* - **A worker never queries its own worktree.** An index is keyed by the
|
|
21
22
|
* realpath of the directory it was built from, with no git-worktree awareness,
|
|
22
23
|
* so a run's `worktrees/<issue>` path is always an empty project. Workers are
|
|
@@ -37,12 +38,135 @@ import type { ProjectConfig, RepoTarget } from "./types.ts";
|
|
|
37
38
|
*/
|
|
38
39
|
const INDEXER = "codebase-memory-mcp";
|
|
39
40
|
|
|
40
|
-
/**
|
|
41
|
+
/** The shared prefix of every reindex artefact; `cbm` is the indexer's own prefix. */
|
|
41
42
|
export const REINDEX_UNIT = "cbm-reindex";
|
|
42
43
|
|
|
43
44
|
/** Where a system timer has to live to be enabled by `systemctl`. */
|
|
44
45
|
export const SYSTEMD_UNIT_DIR = "/etc/systemd/system";
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The filename-safe stem one project's reindex artefacts share:
|
|
49
|
+
* `cbm-reindex-<slug>`. The slug is the project name folded to `[a-z0-9-]` —
|
|
50
|
+
* the alphabet systemd unit names tolerate — with a deterministic hash
|
|
51
|
+
* fallback for a name that folds to nothing, so every configured project gets
|
|
52
|
+
* its own script, service and timer on the same host and one project's
|
|
53
|
+
* `setup graph` can never replace another's files. The #720 incident was
|
|
54
|
+
* exactly one project-less set of names, silently overwritten by the second
|
|
55
|
+
* project's run.
|
|
56
|
+
*
|
|
57
|
+
* Two distinct names can fold to one stem ("My Project" vs "my_project"), so
|
|
58
|
+
* the write path checks each target's generated-for marker before replacing
|
|
59
|
+
* it rather than trusting the stem alone.
|
|
60
|
+
*/
|
|
61
|
+
export function reindexUnitName(p: ProjectConfig): string {
|
|
62
|
+
const name = p.name;
|
|
63
|
+
const slug = name
|
|
64
|
+
.normalize("NFKD")
|
|
65
|
+
.replace(/\p{Diacritic}/gu, "")
|
|
66
|
+
.toLowerCase()
|
|
67
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
68
|
+
.replace(/^-+|-+$/g, "");
|
|
69
|
+
if (slug !== "") return `${REINDEX_UNIT}-${slug}`;
|
|
70
|
+
let hash = 2166136261;
|
|
71
|
+
for (const byte of new TextEncoder().encode(name)) {
|
|
72
|
+
hash ^= byte;
|
|
73
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
74
|
+
}
|
|
75
|
+
return `${REINDEX_UNIT}-project-${hash.toString(16).padStart(8, "0")}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Where this project's generated refresh script lands: conductor state, not a
|
|
80
|
+
* unit directory, because it is ours to regenerate and needs no root to write.
|
|
81
|
+
*/
|
|
82
|
+
export function reindexScriptPath(p: ProjectConfig): string {
|
|
83
|
+
return join(stateDir(), `${reindexUnitName(p)}.sh`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Both unit files, from the stem `systemctl enable` will be given. */
|
|
87
|
+
export function unitPaths(p: ProjectConfig, unitDir = SYSTEMD_UNIT_DIR): { service: string; timer: string } {
|
|
88
|
+
const stem = reindexUnitName(p);
|
|
89
|
+
return {
|
|
90
|
+
service: join(unitDir, `${stem}.service`),
|
|
91
|
+
timer: join(unitDir, `${stem}.timer`),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The `project "<name>"` marker every generated file carries: the script's
|
|
96
|
+
* first header line, and both units' Description. */
|
|
97
|
+
const GENERATED_FOR = /omp-conductor project "([^"]+)"/;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The project a generated file at `path` was rendered for, or `undefined`
|
|
101
|
+
* when the file is absent, unreadable, or does not carry our marker. Any of
|
|
102
|
+
* the three artefacts can identify its stem's owner, which is what lets a
|
|
103
|
+
* write refuse to replace another project's files instead of silently doing
|
|
104
|
+
* it (#720).
|
|
105
|
+
*/
|
|
106
|
+
export function generatedFor(path: string): string | undefined {
|
|
107
|
+
try {
|
|
108
|
+
return GENERATED_FOR.exec(readFileSync(path, "utf8"))?.[1];
|
|
109
|
+
} catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The first of `paths` that already exists and is not this project's own
|
|
116
|
+
* artefact — another project's generated file, or something `setup graph`
|
|
117
|
+
* never wrote — or `undefined` when every path is clear. The stem derives
|
|
118
|
+
* from the project name, and two names can fold to one stem, so an existing
|
|
119
|
+
* file's marker is checked before it is ever overwritten (#720).
|
|
120
|
+
*/
|
|
121
|
+
export function stagingConflict(
|
|
122
|
+
p: ProjectConfig,
|
|
123
|
+
paths: readonly string[],
|
|
124
|
+
): { path: string; owner: string | undefined } | undefined {
|
|
125
|
+
for (const path of paths) {
|
|
126
|
+
if (!existsSync(path)) continue;
|
|
127
|
+
const owner = generatedFor(path);
|
|
128
|
+
if (owner !== p.name) return { path, owner };
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The refusal reason for a conflicting artefact, shared by the pre-consent
|
|
134
|
+
* check and the write backstop so both say the same thing. */
|
|
135
|
+
export function graphConflictMessage(conflict: { path: string; owner: string | undefined }): string {
|
|
136
|
+
return (
|
|
137
|
+
`${conflict.path} already exists and was ` +
|
|
138
|
+
(conflict.owner === undefined
|
|
139
|
+
? "not generated by `setup graph` — refusing to overwrite it. Delete the file and re-run."
|
|
140
|
+
: `generated for project "${conflict.owner}" — refusing to overwrite another project's reindex artefacts. Delete it and re-run.`)
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The pre-#720 project-less artefact paths that still exist, in the order a
|
|
146
|
+
* host that ran the old version has them. `setup graph` no longer writes or
|
|
147
|
+
* installs these, so a host that upgrades keeps them — and their timer keeps
|
|
148
|
+
* refreshing whichever project generated it last, forever.
|
|
149
|
+
*/
|
|
150
|
+
export function legacyReindexFiles(): string[] {
|
|
151
|
+
return [
|
|
152
|
+
join(stateDir(), `${REINDEX_UNIT}.sh`),
|
|
153
|
+
join(stateDir(), `${REINDEX_UNIT}.service`),
|
|
154
|
+
join(stateDir(), `${REINDEX_UNIT}.timer`),
|
|
155
|
+
].filter((f) => existsSync(f));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** What to do with the legacy files, shared by the plan and the install
|
|
159
|
+
* warning so both say the same thing. */
|
|
160
|
+
export function legacyReindexNote(files: readonly string[]): string {
|
|
161
|
+
return [
|
|
162
|
+
`Legacy project-less reindex files still exist: ${files.join(", ")}.`,
|
|
163
|
+
"`setup graph` no longer writes or installs them, so their timer refreshes",
|
|
164
|
+
"whichever project generated it last, forever. Disable and delete them:",
|
|
165
|
+
" sudo systemctl disable --now cbm-reindex.timer",
|
|
166
|
+
` rm ${files.join(" ")}`,
|
|
167
|
+
].join("\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
46
170
|
/** Where the upstream indexer lives, for an operator who has to go install it. */
|
|
47
171
|
const INDEXER_SOURCE = "https://github.com/DeusData/codebase-memory-mcp";
|
|
48
172
|
|
|
@@ -122,35 +246,111 @@ export function graphRepos(p: ProjectConfig): GraphRepo[] {
|
|
|
122
246
|
}
|
|
123
247
|
|
|
124
248
|
/**
|
|
125
|
-
* The
|
|
126
|
-
*
|
|
127
|
-
*
|
|
249
|
+
* The project key the indexer derives from a path — the `project` argument
|
|
250
|
+
* every code-graph tool takes, and the `<name>` in `<name>.db` under the
|
|
251
|
+
* indexer's store. This is the mirror of the indexer's own
|
|
252
|
+
* `cbm_project_name_from_path` (upstream `src/pipeline/fqn.c`):
|
|
253
|
+
* validator-safe bytes survive, every other byte maps to `-` (non-ASCII
|
|
254
|
+
* bytes to their two lowercase hex digits), runs of `-` and `.` collapse,
|
|
255
|
+
* leading `-`/`.` and trailing `-` trim, an all-separator path falls back to
|
|
256
|
+
* `root`, and a name over 200 bytes is bound with an FNV-1a suffix.
|
|
257
|
+
*
|
|
258
|
+
* A brief that named a wrong key would send a worker to a tool that answers
|
|
259
|
+
* empty and reads as "no graph" — indistinguishable from silence. So this
|
|
260
|
+
* matches the indexer byte for byte, and graph.test.ts pins it against names
|
|
261
|
+
* taken from the live indexer store rather than against this function's own
|
|
262
|
+
* output.
|
|
263
|
+
*/
|
|
264
|
+
export function graphProjectKey(graphProject: string): string {
|
|
265
|
+
const bytes = new TextEncoder().encode(graphProject);
|
|
266
|
+
let mapped = "";
|
|
267
|
+
for (const b of bytes) {
|
|
268
|
+
const safe =
|
|
269
|
+
(b >= 0x61 && b <= 0x7a) || (b >= 0x41 && b <= 0x5a) || (b >= 0x30 && b <= 0x39) || b === 0x2e || b === 0x5f || b === 0x2d;
|
|
270
|
+
if (safe) mapped += String.fromCharCode(b);
|
|
271
|
+
else if (b >= 0x80) mapped += ((b >> 4) & 0xf).toString(16) + (b & 0xf).toString(16);
|
|
272
|
+
else mapped += "-";
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Collapse consecutive dashes and dots (the validator also rejects "..").
|
|
276
|
+
let collapsed = "";
|
|
277
|
+
for (const ch of mapped) {
|
|
278
|
+
const prev = collapsed[collapsed.length - 1];
|
|
279
|
+
if ((ch === "-" && prev === "-") || (ch === "." && prev === ".")) continue;
|
|
280
|
+
collapsed += ch;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Trim leading dashes and dots (the validator rejects a leading dot) and
|
|
284
|
+
// trailing dashes. A path that maps to nothing but separators is "root".
|
|
285
|
+
let start = 0;
|
|
286
|
+
while (start < collapsed.length && (collapsed[start] === "-" || collapsed[start] === ".")) start++;
|
|
287
|
+
let end = collapsed.length;
|
|
288
|
+
while (end > start && collapsed[end - 1] === "-") end--;
|
|
289
|
+
const key = collapsed.slice(start, end);
|
|
290
|
+
if (key === "") return "root";
|
|
291
|
+
|
|
292
|
+
// Bound long names the way the indexer does (#624): first 191 bytes plus an
|
|
293
|
+
// 8-hex FNV-1a of the full name, so two long paths that share a prefix but
|
|
294
|
+
// differ later still map to distinct names. The mapped key is pure ASCII, so
|
|
295
|
+
// per-character iteration is per-byte iteration.
|
|
296
|
+
if (key.length > 200) {
|
|
297
|
+
let hash = 2166136261;
|
|
298
|
+
for (const ch of key) {
|
|
299
|
+
hash ^= ch.charCodeAt(0);
|
|
300
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
301
|
+
}
|
|
302
|
+
return key.slice(0, 191) + "-" + hash.toString(16).padStart(8, "0");
|
|
303
|
+
}
|
|
304
|
+
return key;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The paragraph a worker's brief carries about its repo's graph: the exact
|
|
309
|
+
* `project` key for a configured repo, or an explicit "no graph" statement for
|
|
310
|
+
* an unconfigured one — never silence, because a worker that knows there is no
|
|
311
|
+
* graph stops looking for one.
|
|
128
312
|
*
|
|
129
313
|
* The leading newline and the three-space indent are load-bearing: the
|
|
130
314
|
* placeholder sits immediately before the next numbered item in
|
|
131
|
-
* `briefs/worker.md`, so
|
|
132
|
-
*
|
|
315
|
+
* `briefs/worker.md`, so the hint reads as a continuation of the item above it
|
|
316
|
+
* and never leaves a blank line behind.
|
|
133
317
|
*
|
|
134
318
|
* Every sentence here is defending against one specific failure. A worker that
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
319
|
+
* derives the project name from its cwd gets an empty answer and concludes
|
|
320
|
+
* there is no graph. A worker that indexes its own throwaway worktree writes a
|
|
321
|
+
* dead index nobody reads — the 9.3 GB of stale worktree-keyed indexes this
|
|
322
|
+
* fleet pruned. A worker that trusts the graph as current edits against a
|
|
323
|
+
* snapshot that predates its own branch. All three end the same way — a
|
|
324
|
+
* confident diff in the wrong place — so the wording says the quiet part out
|
|
325
|
+
* loud rather than describing the tool.
|
|
139
326
|
*/
|
|
140
327
|
export function graphHint(repo: RepoTarget): string {
|
|
141
328
|
const path = repo.graphProject;
|
|
142
|
-
if (path === undefined)
|
|
329
|
+
if (path === undefined) {
|
|
330
|
+
return (
|
|
331
|
+
"\n" +
|
|
332
|
+
" **This repo has no code graph configured.** Do not go looking for one:\n" +
|
|
333
|
+
" no index exists for it, so the graph tools answer empty whatever you\n" +
|
|
334
|
+
" pass — and indexing your own worktree would build a dead index nobody\n" +
|
|
335
|
+
" reads, not a shortcut. Grep is the tool for this run.\n"
|
|
336
|
+
);
|
|
337
|
+
}
|
|
143
338
|
|
|
144
339
|
return (
|
|
145
340
|
"\n" +
|
|
146
341
|
" **This repo has a code graph, and it was not built from your worktree.**\n" +
|
|
147
|
-
"
|
|
148
|
-
|
|
149
|
-
`
|
|
150
|
-
"
|
|
151
|
-
|
|
152
|
-
"
|
|
153
|
-
"
|
|
342
|
+
" Pass `project: \"" +
|
|
343
|
+
graphProjectKey(path) +
|
|
344
|
+
"\"` to every code-graph tool — the index the reindex timer\n" +
|
|
345
|
+
" builds for root_path `" +
|
|
346
|
+
path +
|
|
347
|
+
"`, shown under that name by\n" +
|
|
348
|
+
" `list_projects`. Never pass a path, and never pass your own cwd: that\n" +
|
|
349
|
+
" clone is what was indexed, your worktree has no index and never will,\n" +
|
|
350
|
+
" so a cwd-based lookup answers nothing and you lose the run to grep.\n" +
|
|
351
|
+
" Never run `index_repository` yourself — the refresh that builds this\n" +
|
|
352
|
+
" index is conductor's, and indexing your throwaway worktree writes a\n" +
|
|
353
|
+
" dead index nobody reads.\n" +
|
|
154
354
|
"\n" +
|
|
155
355
|
" Read what it tells you as a snapshot of that clone's default branch at\n" +
|
|
156
356
|
" the last reindex: it does not contain your edits, and it can be hours\n" +
|
|
@@ -160,20 +360,6 @@ export function graphHint(repo: RepoTarget): string {
|
|
|
160
360
|
);
|
|
161
361
|
}
|
|
162
362
|
|
|
163
|
-
/** Where the generated refresh script lands: conductor state, not a unit
|
|
164
|
-
* directory, because it is ours to regenerate and needs no root to write. */
|
|
165
|
-
export function reindexScriptPath(): string {
|
|
166
|
-
return join(stateDir(), `${REINDEX_UNIT}.sh`);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** Both unit files, from the one stem `systemctl enable` will be given. */
|
|
170
|
-
export function unitPaths(unitDir = SYSTEMD_UNIT_DIR): { service: string; timer: string } {
|
|
171
|
-
return {
|
|
172
|
-
service: join(unitDir, `${REINDEX_UNIT}.service`),
|
|
173
|
-
timer: join(unitDir, `${REINDEX_UNIT}.timer`),
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
|
|
177
363
|
/**
|
|
178
364
|
* `git clone` for one repo's index-only clone.
|
|
179
365
|
*
|
|
@@ -247,7 +433,7 @@ export function reindexScript(p: ProjectConfig): string {
|
|
|
247
433
|
* its timer, and a service enabled on its own would run once at boot and never
|
|
248
434
|
* again, which looks exactly like a working install.
|
|
249
435
|
*/
|
|
250
|
-
export function reindexService(p: ProjectConfig, scriptPath = reindexScriptPath()): string {
|
|
436
|
+
export function reindexService(p: ProjectConfig, scriptPath = reindexScriptPath(p)): string {
|
|
251
437
|
const home = homedir();
|
|
252
438
|
const user = userInfo().username;
|
|
253
439
|
return [
|
|
@@ -315,7 +501,7 @@ export function reindexTimer(p: ProjectConfig): string {
|
|
|
315
501
|
"Persistent=true",
|
|
316
502
|
"RandomizedDelaySec=2m",
|
|
317
503
|
"AccuracySec=1min",
|
|
318
|
-
`Unit=${
|
|
504
|
+
`Unit=${reindexUnitName(p)}.service`,
|
|
319
505
|
"",
|
|
320
506
|
"[Install]",
|
|
321
507
|
"WantedBy=timers.target",
|
|
@@ -334,11 +520,11 @@ export function reindexTimer(p: ProjectConfig): string {
|
|
|
334
520
|
* So generation runs unprivileged as the fleet user, and only the copy into
|
|
335
521
|
* the unit directory is elevated.
|
|
336
522
|
*/
|
|
337
|
-
export function installCommands(unitDir = SYSTEMD_UNIT_DIR, from = stateDir()): string[] {
|
|
338
|
-
const { service, timer } = unitPaths(from);
|
|
523
|
+
export function installCommands(p: ProjectConfig, unitDir = SYSTEMD_UNIT_DIR, from = stateDir()): string[] {
|
|
524
|
+
const { service, timer } = unitPaths(p, from);
|
|
339
525
|
return [
|
|
340
526
|
`sudo install -m 0644 ${service} ${timer} ${unitDir}/`,
|
|
341
|
-
`sudo systemctl daemon-reload && sudo systemctl enable --now ${
|
|
527
|
+
`sudo systemctl daemon-reload && sudo systemctl enable --now ${reindexUnitName(p)}.timer`,
|
|
342
528
|
];
|
|
343
529
|
}
|
|
344
530
|
|
|
@@ -347,9 +533,9 @@ function block(title: string, body: string): string[] {
|
|
|
347
533
|
}
|
|
348
534
|
|
|
349
535
|
/**
|
|
350
|
-
* The whole plan as text, with nothing done. This is the
|
|
351
|
-
* `graph
|
|
352
|
-
* including on a host where they are not root and
|
|
536
|
+
* The whole plan as text, with nothing done. This is the `--print` mode of
|
|
537
|
+
* `setup graph`, and it is a plan an operator can read, paste, or ignore —
|
|
538
|
+
* including on a host where they are not root and the install would fail.
|
|
353
539
|
*/
|
|
354
540
|
export function formatGraphSetup(
|
|
355
541
|
p: ProjectConfig,
|
|
@@ -434,25 +620,63 @@ export function formatGraphSetup(
|
|
|
434
620
|
"",
|
|
435
621
|
);
|
|
436
622
|
|
|
437
|
-
const script = reindexScriptPath();
|
|
438
|
-
const { service, timer } = unitPaths(stateDir());
|
|
623
|
+
const script = reindexScriptPath(p);
|
|
624
|
+
const { service, timer } = unitPaths(p, stateDir());
|
|
439
625
|
lines.push(
|
|
440
626
|
` \`omp-conductor setup graph\` writes these three files for you, all under`,
|
|
441
|
-
` ${stateDir()}
|
|
442
|
-
"
|
|
443
|
-
"
|
|
627
|
+
` ${stateDir()} — named for this project, so another project's files on the`,
|
|
628
|
+
" same host are never touched. Run it as the account the fleet runs as —",
|
|
629
|
+
" never under sudo, which would resolve the config, the state directory and",
|
|
630
|
+
" the unit's own User= as root and quietly build indexes no worker can read.",
|
|
444
631
|
"",
|
|
445
632
|
...block(script, reindexScript(p)),
|
|
446
633
|
...block(service, reindexService(p, script)),
|
|
447
634
|
...block(timer, reindexTimer(p)),
|
|
448
635
|
` then install them, which is the only step that needs root:`,
|
|
449
636
|
"",
|
|
450
|
-
...installCommands(unitDir).map((c) => ` ${c}`),
|
|
637
|
+
...installCommands(p, unitDir).map((c) => ` ${c}`),
|
|
451
638
|
);
|
|
452
639
|
|
|
640
|
+
// An artefact another project (or nothing of ours) already owns at this
|
|
641
|
+
// project's stem: the run would refuse rather than overwrite it, and the
|
|
642
|
+
// plan must say so before the operator answers a consent prompt they will
|
|
643
|
+
// not be asked (#720).
|
|
644
|
+
const conflict = stagingConflict(p, [script, service, timer]);
|
|
645
|
+
if (conflict !== undefined) {
|
|
646
|
+
lines.push(
|
|
647
|
+
"",
|
|
648
|
+
` NOTE: ${conflict.path} already exists and was ${
|
|
649
|
+
conflict.owner === undefined ? "not generated by `setup graph`" : `generated for project "${conflict.owner}"`
|
|
650
|
+
} —`,
|
|
651
|
+
" this run refuses to overwrite it. Delete the file and re-run.",
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// A host that ran the pre-#720 project-less version keeps its old files:
|
|
656
|
+
// their timer refreshes whichever project generated it last, forever. The
|
|
657
|
+
// plan names the remediation because it is host action this command must
|
|
658
|
+
// not take for the operator.
|
|
659
|
+
const legacy = legacyReindexFiles();
|
|
660
|
+
if (legacy.length > 0) {
|
|
661
|
+
lines.push("", " NOTE: " + legacyReindexNote(legacy).replace(/\n/g, "\n "));
|
|
662
|
+
}
|
|
663
|
+
|
|
453
664
|
return lines.join("\n");
|
|
454
665
|
}
|
|
455
666
|
|
|
667
|
+
/** One rendered artefact: where it lands and exactly what would be written. */
|
|
668
|
+
export interface GraphSetupFile {
|
|
669
|
+
path: string;
|
|
670
|
+
content: string;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** The plan's three artefacts. */
|
|
674
|
+
export interface GraphSetupFiles {
|
|
675
|
+
script: GraphSetupFile;
|
|
676
|
+
service: GraphSetupFile;
|
|
677
|
+
timer: GraphSetupFile;
|
|
678
|
+
}
|
|
679
|
+
|
|
456
680
|
/** What staging wrote, and the root-only steps it deliberately left. */
|
|
457
681
|
export interface GraphSetupWrite {
|
|
458
682
|
written: string[];
|
|
@@ -460,26 +684,20 @@ export interface GraphSetupWrite {
|
|
|
460
684
|
}
|
|
461
685
|
|
|
462
686
|
/**
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
687
|
+
* Everything `setup graph` would stage, rendered and nothing written: the
|
|
688
|
+
* three files' paths and bytes, plus the `next` text naming the root-only
|
|
689
|
+
* steps. The consent prompt prints this, and only the consent-gated step
|
|
690
|
+
* turns it into files — so the prompt's "stages on confirm" is true of the
|
|
691
|
+
* staged tree when it is printed, and a declined run leaves every staged
|
|
692
|
+
* file byte-identical (#720, mirroring #510's deferral for `setup host`).
|
|
468
693
|
*/
|
|
469
|
-
export function
|
|
470
|
-
const script = reindexScriptPath();
|
|
694
|
+
export function planGraphSetup(p: ProjectConfig, unitDir = SYSTEMD_UNIT_DIR): GraphSetupFiles & { next: string } {
|
|
695
|
+
const script = { path: reindexScriptPath(p), content: reindexScript(p) };
|
|
471
696
|
// All three land in the state directory, which this account owns — so the
|
|
472
697
|
// whole command runs unprivileged and there is no sudo path that could
|
|
473
698
|
// resolve HOME, the config or the unit's User= as the wrong account.
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
mkdirSync(dirname(script), { recursive: true });
|
|
477
|
-
writeFileSync(script, reindexScript(p));
|
|
478
|
-
// Executable so an operator can run the refresh by hand before trusting a
|
|
479
|
-
// timer with it; the unit calls bash explicitly either way.
|
|
480
|
-
chmodSync(script, 0o755);
|
|
481
|
-
writeFileSync(service, reindexService(p, script));
|
|
482
|
-
writeFileSync(timer, reindexTimer(p));
|
|
699
|
+
const service = { path: unitPaths(p, stateDir()).service, content: reindexService(p, script.path) };
|
|
700
|
+
const timer = { path: unitPaths(p, stateDir()).timer, content: reindexTimer(p) };
|
|
483
701
|
|
|
484
702
|
const missing = graphRepos(p).filter((r) => !existsSync(r.graphProject));
|
|
485
703
|
const next = [
|
|
@@ -488,11 +706,11 @@ export function writeGraphSetup(p: ProjectConfig, unitDir = SYSTEMD_UNIT_DIR): G
|
|
|
488
706
|
"",
|
|
489
707
|
"to install them, which is the only privileged step:",
|
|
490
708
|
"",
|
|
491
|
-
...installCommands(unitDir).map((c) => ` ${c}`),
|
|
709
|
+
...installCommands(p, unitDir).map((c) => ` ${c}`),
|
|
492
710
|
"",
|
|
493
711
|
"then watch one real run before trusting the schedule (it takes minutes per repo):",
|
|
494
712
|
"",
|
|
495
|
-
` sudo systemctl start ${
|
|
713
|
+
` sudo systemctl start ${reindexUnitName(p)}.service && systemctl status ${reindexUnitName(p)}.service`,
|
|
496
714
|
...(missing.length === 0
|
|
497
715
|
? []
|
|
498
716
|
: [
|
|
@@ -504,5 +722,32 @@ export function writeGraphSetup(p: ProjectConfig, unitDir = SYSTEMD_UNIT_DIR): G
|
|
|
504
722
|
]),
|
|
505
723
|
].join("\n");
|
|
506
724
|
|
|
507
|
-
return {
|
|
725
|
+
return { script, service, timer, next };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Writes the script and both units, and returns what to do next.
|
|
730
|
+
*
|
|
731
|
+
* Deliberately stops there. Running `systemctl` would need root the wizard and
|
|
732
|
+
* the CLI may not have, and a package that enables system timers behind an
|
|
733
|
+
* operator's back is one you cannot audit by reading its output.
|
|
734
|
+
*
|
|
735
|
+
* Refuses an artefact that already exists and is not this project's own — a
|
|
736
|
+
* collision between two names that fold to one stem is refused rather than
|
|
737
|
+
* silently overwritten (#720).
|
|
738
|
+
*/
|
|
739
|
+
export function writeGraphSetup(p: ProjectConfig, unitDir = SYSTEMD_UNIT_DIR): GraphSetupWrite {
|
|
740
|
+
const plan = planGraphSetup(p, unitDir);
|
|
741
|
+
const conflict = stagingConflict(p, [plan.script.path, plan.service.path, plan.timer.path]);
|
|
742
|
+
if (conflict !== undefined) throw new Error(graphConflictMessage(conflict));
|
|
743
|
+
|
|
744
|
+
mkdirSync(dirname(plan.script.path), { recursive: true });
|
|
745
|
+
writeFileSync(plan.script.path, plan.script.content);
|
|
746
|
+
// Executable so an operator can run the refresh by hand before trusting a
|
|
747
|
+
// timer with it; the unit calls bash explicitly either way.
|
|
748
|
+
chmodSync(plan.script.path, 0o755);
|
|
749
|
+
writeFileSync(plan.service.path, plan.service.content);
|
|
750
|
+
writeFileSync(plan.timer.path, plan.timer.content);
|
|
751
|
+
|
|
752
|
+
return { written: [plan.script.path, plan.service.path, plan.timer.path], next: plan.next };
|
|
508
753
|
}
|
package/src/lifecycle.ts
CHANGED
|
@@ -94,7 +94,7 @@ export interface DaemonRecord {
|
|
|
94
94
|
logFile: string;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
function recordPath(): string {
|
|
97
|
+
export function recordPath(): string {
|
|
98
98
|
return join(runtimeDir(), "daemon.json");
|
|
99
99
|
}
|
|
100
100
|
|
|
@@ -340,24 +340,60 @@ export function acquireOnceLease(
|
|
|
340
340
|
throw new Error("could not acquire the daemon lease — a stale holder kept reappearing");
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Why a health probe came back negative. A timeout means the process is up
|
|
345
|
+
* but not answering (wedged or overloaded); a refusal means nothing is
|
|
346
|
+
* listening on the port at all. Everything else — a 500, a torn response —
|
|
347
|
+
* is `other`, a health answer rather than a probe verdict.
|
|
348
|
+
*/
|
|
349
|
+
export type HealthCheckFailure = "timeout" | "refused" | "other";
|
|
350
|
+
|
|
351
|
+
export type HealthCheckResult =
|
|
352
|
+
| { ok: true; body?: string }
|
|
353
|
+
| { ok: false; failure: HealthCheckFailure; body?: string };
|
|
354
|
+
|
|
343
355
|
/**
|
|
344
356
|
* Probes the daemon's own health endpoint. Never throws: a refused connection,
|
|
345
357
|
* a DNS-less host, a hung socket and a 500 are all just "not healthy", and the
|
|
346
|
-
* callers of this are the ones responsible for saying so nicely.
|
|
358
|
+
* callers of this are the ones responsible for saying so nicely. The `failure`
|
|
359
|
+
* kind lets a caller tell "up but slow" (timeout) from "nothing listening"
|
|
360
|
+
* (refused) instead of collapsing both into `{ ok: false }` (#716).
|
|
347
361
|
*/
|
|
348
|
-
export async function healthCheck(port: number): Promise<
|
|
362
|
+
export async function healthCheck(port: number): Promise<HealthCheckResult> {
|
|
349
363
|
try {
|
|
350
364
|
const res = await fetch(`http://127.0.0.1:${port}/healthz`, {
|
|
351
365
|
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
|
|
352
366
|
});
|
|
353
367
|
const body = (await res.text()).trim();
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
368
|
+
if (!res.ok) {
|
|
369
|
+
return { ok: false, failure: "other", ...(body.length > 0 ? { body } : {}) };
|
|
370
|
+
}
|
|
371
|
+
return { ok: true, ...(body.length > 0 ? { body } : {}) };
|
|
372
|
+
} catch (err) {
|
|
373
|
+
return { ok: false, failure: healthFailureKind(err) };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Classify a fetch rejection: the timeout signal raises `TimeoutError`; a
|
|
379
|
+
* refused connection is `ConnectionRefused` under Bun and `ECONNREFUSED`
|
|
380
|
+
* under undici. Anything else is an unclassified probe failure.
|
|
381
|
+
*/
|
|
382
|
+
function healthFailureKind(err: unknown): HealthCheckFailure {
|
|
383
|
+
if (err instanceof Error && err.name === "TimeoutError") return "timeout";
|
|
384
|
+
const code = (err as { code?: unknown } | null)?.code;
|
|
385
|
+
if (code === "ConnectionRefused" || code === "ECONNREFUSED" || code === "ECONNRESET") {
|
|
386
|
+
return "refused";
|
|
357
387
|
}
|
|
388
|
+
return "other";
|
|
358
389
|
}
|
|
359
390
|
|
|
360
|
-
|
|
391
|
+
/** Whether a `/healthz` body names `project` as served: the daemon's health
|
|
392
|
+
* endpoint lists every project it loaded, as bare strings or `{ project }`
|
|
393
|
+
* entries, so callers can prove a runtime generation serves a project —
|
|
394
|
+
* the setup rollback's prior-set proof and the restart's owned proof both
|
|
395
|
+
* build on it (#650). An unparseable body is a served-nothing answer. */
|
|
396
|
+
export function healthServesProject(body: string | undefined, project: string | undefined): boolean {
|
|
361
397
|
if (project === undefined) return true;
|
|
362
398
|
if (body === undefined) return false;
|
|
363
399
|
try {
|