omp-conductor 0.17.1 → 0.18.1
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 +34 -0
- package/REFERENCE.md +71 -17
- package/agents/to-spec.md +90 -0
- package/package.json +2 -1
- package/schema/config.schema.json +53 -1
- package/src/admission.ts +308 -76
- package/src/ask.ts +307 -10
- package/src/backups.ts +2 -2
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +43 -14
- package/src/briefs/to-spec.md +84 -0
- package/src/briefs/worker.md +37 -19
- package/src/cli.ts +2 -0
- package/src/command-help.ts +19 -1
- package/src/command-manifest.ts +27 -2
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +110 -3
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +57 -0
- package/src/config.ts +102 -2
- package/src/daemon.ts +1220 -1517
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +279 -16
- package/src/depends-on.ts +261 -1
- package/src/diff-flags.ts +425 -1
- package/src/digest-schedule.ts +37 -0
- package/src/doctor.ts +52 -0
- package/src/escalate.ts +9 -3
- package/src/failure-class.ts +43 -4
- package/src/fleet.ts +166 -24
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +55 -8
- package/src/graph.ts +379 -69
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +567 -2
- package/src/lifecycle.ts +158 -6
- package/src/omp.ts +269 -20
- package/src/orchestrator-tick.ts +1489 -26
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/routing.ts +11 -3
- package/src/session-host.ts +115 -5
- package/src/settlement.ts +1780 -0
- package/src/setup-host.ts +1205 -6
- package/src/setup-install.ts +119 -30
- package/src/setup-wizard.ts +88 -2
- package/src/setup.ts +119 -13
- package/src/shell.ts +15 -0
- package/src/status-render.ts +100 -11
- package/src/store.ts +519 -45
- package/src/to-spec.ts +387 -0
- package/src/tracker/github.ts +150 -14
- package/src/types.ts +470 -16
- package/src/upgrade-verify.ts +209 -2
- package/src/upgrade.ts +175 -1
- package/src/verbs/protocol.ts +39 -0
- package/src/verbs/server.ts +770 -40
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +239 -9
- package/src/worktree.ts +142 -18
package/src/gitops.ts
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* branch cannot disagree.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { existsSync } from "node:fs";
|
|
20
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { homedir, tmpdir } from "node:os";
|
|
21
22
|
import { join } from "node:path";
|
|
22
23
|
|
|
23
24
|
import { parseChainSource, type ChainEntry } from "./chain-check.ts";
|
|
@@ -75,6 +76,98 @@ export function credentialedEnv(
|
|
|
75
76
|
return { ...env, ...extra };
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The exact per-run dubious-ownership exemption for a daemon git call against
|
|
81
|
+
* a worker-owned run repository (#816).
|
|
82
|
+
*
|
|
83
|
+
* Every daemon-side invocation whose cwd (or opening) is the run repository
|
|
84
|
+
* carries the run's own path as a command-line `-c safe.directory=<path>` —
|
|
85
|
+
* never a wildcard, and never an entry in the daemon's global config, which
|
|
86
|
+
* would grow one line per run forever. Git 2.35+ refuses to open a repository
|
|
87
|
+
* owned by another uid ("dubious ownership"); the run repository was handed
|
|
88
|
+
* to the worker identity at dispatch, so the daemon's own `rev-parse`,
|
|
89
|
+
* salvage, lane probe and cleanup calls are exactly that shape.
|
|
90
|
+
*/
|
|
91
|
+
export function runRepoSafeDirectoryExemption(runRepoPath: string): [string, string] {
|
|
92
|
+
return ["-c", `safe.directory=${runRepoPath}`];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A daemon-owned, per-call global git config granting the *source-side*
|
|
97
|
+
* ownership exemption for a local-path fetch out of one worker-owned run
|
|
98
|
+
* repository.
|
|
99
|
+
*
|
|
100
|
+
* `git fetch <runRepo>` from the mirror is the one daemon git call whose
|
|
101
|
+
* *source* is worker-owned, and the one shape command-line config cannot
|
|
102
|
+
* reach: git spawns `upload-pack` against the source — `.git` dubious-ownership
|
|
103
|
+
* check included — and that subprocess sees none of the destination process's
|
|
104
|
+
* `-c` options. The only real config file the subprocess reads that this side
|
|
105
|
+
* controls is the global one, so the run's exemption is materialised there,
|
|
106
|
+
* for exactly this call: a temp file (0700, daemon's own) naming precisely the
|
|
107
|
+
* run's repo path and its gitdir, never a wildcard, with the daemon's existing
|
|
108
|
+
* global config replayed through `include.path` so nothing else about the
|
|
109
|
+
* caller's git behavior changes. The file is removed when the call is done.
|
|
110
|
+
*/
|
|
111
|
+
export interface ScopedSafeDirectory {
|
|
112
|
+
/** The env additions for one git call; the caller's own use of it is what
|
|
113
|
+
* scopes the exemption to that call. */
|
|
114
|
+
env: Record<string, string>;
|
|
115
|
+
/** Remove the daemon-owned temp file. The call must not outlive it. */
|
|
116
|
+
close: () => void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A config value: git's quoting accepts C escapes in double quotes. */
|
|
120
|
+
function quoteGitConfigValue(value: string): string {
|
|
121
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function scopedSafeDirectoryEnv(...runRepos: readonly string[]): ScopedSafeDirectory {
|
|
125
|
+
const dir = mkdtempSync(join(tmpdir(), "omp-conductor-safe-"));
|
|
126
|
+
try {
|
|
127
|
+
// The global-config chain the daemon normally reads, replayed: setting
|
|
128
|
+
// GIT_CONFIG_GLOBAL would otherwise replace those files rather than
|
|
129
|
+
// augmenting them, dropping e.g. credential helpers and `insteadOf` rules
|
|
130
|
+
// for the duration of this one call.
|
|
131
|
+
const globals: string[] = [];
|
|
132
|
+
const globalOverride = process.env["GIT_CONFIG_GLOBAL"];
|
|
133
|
+
if (globalOverride !== undefined && globalOverride !== "") {
|
|
134
|
+
globals.push(globalOverride);
|
|
135
|
+
} else {
|
|
136
|
+
const xdgHome = process.env["XDG_CONFIG_HOME"];
|
|
137
|
+
const xdgConfig = xdgHome === undefined || xdgHome === ""
|
|
138
|
+
? join(homedir(), ".config", "git", "config")
|
|
139
|
+
: join(xdgHome, "git", "config");
|
|
140
|
+
if (existsSync(xdgConfig)) globals.push(xdgConfig);
|
|
141
|
+
const home = homedir();
|
|
142
|
+
if (home !== "") {
|
|
143
|
+
const gitconfig = join(home, ".gitconfig");
|
|
144
|
+
if (existsSync(gitconfig)) globals.push(gitconfig);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const lines = [
|
|
148
|
+
...globals.flatMap((file) => ["[include]", `\tpath = ${quoteGitConfigValue(file)}`]),
|
|
149
|
+
"[safe]",
|
|
150
|
+
// Both spellings git actually checks (2.43 and later use the gitdir
|
|
151
|
+
// path for a local-path source; a direct open names the worktree path).
|
|
152
|
+
// Both are exact, never a wildcard.
|
|
153
|
+
...runRepos.flatMap((repo) => [
|
|
154
|
+
`\tdirectory = ${quoteGitConfigValue(repo)}`,
|
|
155
|
+
`\tdirectory = ${quoteGitConfigValue(join(repo, ".git"))}`,
|
|
156
|
+
]),
|
|
157
|
+
"",
|
|
158
|
+
];
|
|
159
|
+
const file = join(dir, "gitconfig");
|
|
160
|
+
writeFileSync(file, lines.join("\n"));
|
|
161
|
+
return {
|
|
162
|
+
env: { ...credentialedEnv(), GIT_CONFIG_GLOBAL: file },
|
|
163
|
+
close: () => rmSync(dir, { recursive: true, force: true }),
|
|
164
|
+
};
|
|
165
|
+
} catch (err) {
|
|
166
|
+
rmSync(dir, { recursive: true, force: true });
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
78
171
|
// ------------------------------------------------- the privileged publish path
|
|
79
172
|
|
|
80
173
|
/** One run's own repository, as the daemon addresses it. */
|
|
@@ -163,7 +256,11 @@ export async function probeRunLane(
|
|
|
163
256
|
for (const line of stdout.split("\n")) add(line.trim(), source);
|
|
164
257
|
};
|
|
165
258
|
if (input.worktree !== "") {
|
|
166
|
-
|
|
259
|
+
// The live run's worktree is owned by the worker identity (#798), so
|
|
260
|
+
// every daemon-side read of it carries the exact per-run exemption — no
|
|
261
|
+
// wildcard, no global safe.directory (#816).
|
|
262
|
+
const exemption = runRepoSafeDirectoryExemption(input.worktree);
|
|
263
|
+
const status = await exec(["git", ...exemption, "-C", input.worktree, "status", "--porcelain"], {});
|
|
167
264
|
if (status.code === 0) {
|
|
168
265
|
const { untracked, tracked } = parsePorcelain(status.stdout);
|
|
169
266
|
// Untracked files are authored by construction: a merge stages the files
|
|
@@ -183,12 +280,12 @@ export async function probeRunLane(
|
|
|
183
280
|
// resolution. An unreadable divergence read fails open — no tracked
|
|
184
281
|
// occupancy is claimed while the merge makes the read ambiguous.
|
|
185
282
|
const merge = await exec(
|
|
186
|
-
["git", "-C", input.worktree, "rev-parse", "-q", "--verify", "MERGE_HEAD"],
|
|
283
|
+
["git", ...exemption, "-C", input.worktree, "rev-parse", "-q", "--verify", "MERGE_HEAD"],
|
|
187
284
|
{},
|
|
188
285
|
);
|
|
189
286
|
if (merge.code === 0) {
|
|
190
287
|
const diverged = await exec(
|
|
191
|
-
["git", "-C", input.worktree, "diff", "--name-only", input.baseRef],
|
|
288
|
+
["git", ...exemption, "-C", input.worktree, "diff", "--name-only", input.baseRef],
|
|
192
289
|
{},
|
|
193
290
|
);
|
|
194
291
|
if (diverged.code === 0) {
|
|
@@ -203,7 +300,7 @@ export async function probeRunLane(
|
|
|
203
300
|
}
|
|
204
301
|
}
|
|
205
302
|
const diff = await exec(
|
|
206
|
-
["git", "-C", input.worktree, "diff", "--name-only", `${input.baseRef}...HEAD`],
|
|
303
|
+
["git", ...exemption, "-C", input.worktree, "diff", "--name-only", `${input.baseRef}...HEAD`],
|
|
207
304
|
{},
|
|
208
305
|
);
|
|
209
306
|
if (diff.code === 0) addDiff(diff.stdout, "branch");
|
|
@@ -311,91 +408,101 @@ export async function pushRunBranch(
|
|
|
311
408
|
const ref = `refs/heads/${run.branch}`;
|
|
312
409
|
const tracked = `refs/remotes/origin/${run.branch}`;
|
|
313
410
|
const env = credentialedEnv();
|
|
411
|
+
// The run repository is worker-owned (#798): the direct read below carries
|
|
412
|
+
// the exact-path exemption on the command line, and the local-path fetch's
|
|
413
|
+
// source (upload-pack) needs it as a scoped global config for the one call
|
|
414
|
+
// (#816). Both are exact per-run entries, never a wildcard, and the scoped
|
|
415
|
+
// file dies with the call.
|
|
416
|
+
const scoped = scopedSafeDirectoryEnv(run.runRepoPath);
|
|
314
417
|
|
|
315
|
-
|
|
316
|
-
|
|
418
|
+
try {
|
|
419
|
+
// Copy the run's branch into the mirror, fast-forward only.
|
|
420
|
+
const fetched = await exec(["git", ...runRepoSafeDirectoryExemption(run.runRepoPath), "-C", mirror, "fetch", "--no-tags", run.runRepoPath, `${ref}:${ref}`], { env: scoped.env });
|
|
421
|
+
|
|
422
|
+
// The proposed head is read from the run repo itself: when the copy above
|
|
423
|
+
// was refused, the mirror's copy of the branch is exactly the stale one.
|
|
424
|
+
const proposedRun = await exec(["git", ...runRepoSafeDirectoryExemption(run.runRepoPath), "-C", run.runRepoPath, "rev-parse", ref], { env: scoped.env });
|
|
425
|
+
if (proposedRun.code !== 0) {
|
|
426
|
+
return { ok: false, stderr: scrubUserinfo(proposedRun.stderr.trim() || `git rev-parse ${ref} exited ${String(proposedRun.code)}`) };
|
|
427
|
+
}
|
|
428
|
+
const proposed = proposedRun.stdout.trim();
|
|
429
|
+
|
|
430
|
+
// Reconcile with the live remote before enforcing anything: the ancestry
|
|
431
|
+
// that decides a fast-forward is the live remote's, not the mirror's copy's
|
|
432
|
+
// (see the docstring above). ls-remote answers "is the branch published at
|
|
433
|
+
// all" and "what does GitHub hold" in one call.
|
|
434
|
+
const liveListed = await exec(["git", "-C", mirror, "ls-remote", "origin", ref], { env });
|
|
435
|
+
if (liveListed.code !== 0) {
|
|
436
|
+
return { ok: false, stderr: scrubUserinfo(liveListed.stderr.trim() || `git ls-remote origin ${ref} exited ${String(liveListed.code)}`) };
|
|
437
|
+
}
|
|
438
|
+
const live = liveListed.stdout
|
|
439
|
+
.split("\n")
|
|
440
|
+
.map((line) => line.trimEnd())
|
|
441
|
+
.find((line) => line.endsWith(`\t${ref}`))
|
|
442
|
+
?.split(/\s+/, 1)[0];
|
|
443
|
+
|
|
444
|
+
if (live !== undefined && live !== proposed) {
|
|
445
|
+
// The branch is published and the run proposes something different. Pull
|
|
446
|
+
// the live branch's history into the mirror once (which also keeps the
|
|
447
|
+
// tracked ref a reattach reads fresh), then prove the fast-forward.
|
|
448
|
+
const reconciled = await exec(["git", "-C", mirror, "fetch", "--no-tags", "origin", `+${ref}:${tracked}`], { env });
|
|
449
|
+
if (reconciled.code !== 0) {
|
|
450
|
+
return { ok: false, stderr: scrubUserinfo(reconciled.stderr.trim() || reconciled.stdout.trim() || `git fetch origin exited ${String(reconciled.code)}`) };
|
|
451
|
+
}
|
|
317
452
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const reconciled = await exec(["git", "-C", mirror, "fetch", "--no-tags", "origin", `+${ref}:${tracked}`], { env });
|
|
345
|
-
if (reconciled.code !== 0) {
|
|
346
|
-
return { ok: false, stderr: scrubUserinfo(reconciled.stderr.trim() || reconciled.stdout.trim() || `git fetch origin exited ${String(reconciled.code)}`) };
|
|
453
|
+
const isAncestor = await exec(["git", "-C", mirror, "merge-base", "--is-ancestor", live, proposed], { env });
|
|
454
|
+
if (isAncestor.code === 128) {
|
|
455
|
+
// git could not perform the check at all (an object it was asked to
|
|
456
|
+
// resolve is missing, not merely unrelated). That is a failed
|
|
457
|
+
// verification, not a divergence verdict: refuse with git's own words,
|
|
458
|
+
// still naming both SHAs so the report carries the mismatch.
|
|
459
|
+
return {
|
|
460
|
+
ok: false,
|
|
461
|
+
stderr: scrubUserinfo(
|
|
462
|
+
isAncestor.stderr.trim() ||
|
|
463
|
+
isAncestor.stdout.trim() ||
|
|
464
|
+
`git merge-base --is-ancestor ${live} ${proposed} exited ${String(isAncestor.code)}`,
|
|
465
|
+
),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
if (isAncestor.code !== 0) {
|
|
469
|
+
// A real divergence: no fast-forward exists, and the mirror is left
|
|
470
|
+
// exactly where it was. Both SHAs are named so the mismatch is
|
|
471
|
+
// diagnosable instead of a bare "non-fast-forward".
|
|
472
|
+
return {
|
|
473
|
+
ok: false,
|
|
474
|
+
stderr:
|
|
475
|
+
`refusing non-fast-forward push of ${run.branch}: the live remote tip ${live} is not an ancestor of ` +
|
|
476
|
+
`the proposed head ${proposed}. Fetch the live branch and rebase or merge it before pushing again.`,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
347
479
|
}
|
|
348
480
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
// still naming both SHAs so the report carries the mismatch.
|
|
355
|
-
return {
|
|
356
|
-
ok: false,
|
|
357
|
-
stderr: scrubUserinfo(
|
|
358
|
-
isAncestor.stderr.trim() ||
|
|
359
|
-
isAncestor.stdout.trim() ||
|
|
360
|
-
`git merge-base --is-ancestor ${live} ${proposed} exited ${String(isAncestor.code)}`,
|
|
361
|
-
),
|
|
362
|
-
};
|
|
481
|
+
if (fetched.code !== 0 && live === undefined) {
|
|
482
|
+
// The mirror refused the plain copy and there is no published branch to
|
|
483
|
+
// validate the run's head against: a rewritten branch that was never
|
|
484
|
+
// published must not rewrite the mirror either. Refuse with git's words.
|
|
485
|
+
return { ok: false, stderr: scrubUserinfo(fetched.stderr.trim() || fetched.stdout.trim() || `git fetch exited ${String(fetched.code)}`) };
|
|
363
486
|
}
|
|
364
|
-
if (
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
};
|
|
487
|
+
if (fetched.code !== 0) {
|
|
488
|
+
// The proposed head is a legitimate fast-forward over the live remote (it
|
|
489
|
+
// equals the live head or the ancestor test above passed), so the only
|
|
490
|
+
// thing the plain copy refused on was the mirror's own stale copy.
|
|
491
|
+
// Refresh it; the push below still re-checks against the live remote.
|
|
492
|
+
const refreshed = await exec(["git", ...runRepoSafeDirectoryExemption(run.runRepoPath), "-C", mirror, "fetch", "--no-tags", run.runRepoPath, `+${ref}:${ref}`], { env: scoped.env });
|
|
493
|
+
if (refreshed.code !== 0) {
|
|
494
|
+
return { ok: false, stderr: scrubUserinfo(refreshed.stderr.trim() || refreshed.stdout.trim() || `git fetch exited ${String(refreshed.code)}`) };
|
|
495
|
+
}
|
|
374
496
|
}
|
|
375
|
-
}
|
|
376
497
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
// published must not rewrite the mirror either. Refuse with git's words.
|
|
381
|
-
return { ok: false, stderr: scrubUserinfo(fetched.stderr.trim() || fetched.stdout.trim() || `git fetch exited ${String(fetched.code)}`) };
|
|
382
|
-
}
|
|
383
|
-
if (fetched.code !== 0) {
|
|
384
|
-
// The proposed head is a legitimate fast-forward over the live remote (it
|
|
385
|
-
// equals the live head or the ancestor test above passed), so the only
|
|
386
|
-
// thing the plain copy refused on was the mirror's own stale copy.
|
|
387
|
-
// Refresh it; the push below still re-checks against the live remote.
|
|
388
|
-
const refreshed = await exec(["git", "-C", mirror, "fetch", "--no-tags", run.runRepoPath, `+${ref}:${ref}`], { env });
|
|
389
|
-
if (refreshed.code !== 0) {
|
|
390
|
-
return { ok: false, stderr: scrubUserinfo(refreshed.stderr.trim() || refreshed.stdout.trim() || `git fetch exited ${String(refreshed.code)}`) };
|
|
498
|
+
const pushed = await exec(["git", "-C", mirror, "push", "origin", `${ref}:${ref}`], { env });
|
|
499
|
+
if (pushed.code !== 0) {
|
|
500
|
+
return { ok: false, stderr: scrubUserinfo(pushed.stderr.trim() || pushed.stdout.trim() || `git push exited ${String(pushed.code)}`) };
|
|
391
501
|
}
|
|
502
|
+
return { ok: true, sha: proposed };
|
|
503
|
+
} finally {
|
|
504
|
+
scoped.close();
|
|
392
505
|
}
|
|
393
|
-
|
|
394
|
-
const pushed = await exec(["git", "-C", mirror, "push", "origin", `${ref}:${ref}`], { env });
|
|
395
|
-
if (pushed.code !== 0) {
|
|
396
|
-
return { ok: false, stderr: scrubUserinfo(pushed.stderr.trim() || pushed.stdout.trim() || `git push exited ${String(pushed.code)}`) };
|
|
397
|
-
}
|
|
398
|
-
return { ok: true, sha: proposed };
|
|
399
506
|
}
|
|
400
507
|
|
|
401
508
|
/**
|
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,20 @@ export type CodeGraphHealth =
|
|
|
22
31
|
checkedAt: string;
|
|
23
32
|
prerequisites: {
|
|
24
33
|
indexer: CheckState;
|
|
25
|
-
mcpMount:
|
|
34
|
+
mcpMount: McpMountState;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Session-recorded graph-tools observations over this project's runs
|
|
38
|
+
* (#726): what dispatched sessions' registries actually held at start.
|
|
39
|
+
* `recorded: 0` means no run has ever recorded the fact — "observed" is
|
|
40
|
+
* never claimed without one, which is the whole honesty contract of the
|
|
41
|
+
* state. This is the runtime counterpart to `prerequisites.mcpMount`,
|
|
42
|
+
* which is deliberately a *config* finding.
|
|
43
|
+
*/
|
|
44
|
+
session: {
|
|
45
|
+
recorded: number;
|
|
46
|
+
present: number;
|
|
47
|
+
absent: number;
|
|
26
48
|
};
|
|
27
49
|
repos: CodeGraphRepoHealth[];
|
|
28
50
|
timer: {
|
|
@@ -52,6 +74,7 @@ export function pendingCodeGraph(project: ProjectConfig, now = Date.now()): Code
|
|
|
52
74
|
status: "unknown",
|
|
53
75
|
checkedAt: new Date(now).toISOString(),
|
|
54
76
|
prerequisites: { indexer: "unknown", mcpMount: "unknown" },
|
|
77
|
+
session: { recorded: 0, present: 0, absent: 0 },
|
|
55
78
|
repos: repos.map((repo) => ({
|
|
56
79
|
name: repo.name,
|
|
57
80
|
path: repo.graphProject,
|
|
@@ -69,6 +92,12 @@ export interface CodeGraphProbeDeps {
|
|
|
69
92
|
exists(path: string): boolean;
|
|
70
93
|
run(command: string, args: readonly string[]): Promise<ReadOnlyCommandResult>;
|
|
71
94
|
now(): number;
|
|
95
|
+
/**
|
|
96
|
+
* Session-recorded graph-tools observations for the probed project (#726).
|
|
97
|
+
* Absent, the probe reports no observation — which is exactly what a caller
|
|
98
|
+
* that has no store must report, and never a claim that tools were absent.
|
|
99
|
+
*/
|
|
100
|
+
graphToolsObservations?(): { recorded: number; present: number; absent: number };
|
|
72
101
|
}
|
|
73
102
|
|
|
74
103
|
async function runReadOnly(command: string, args: readonly string[]): Promise<ReadOnlyCommandResult> {
|
|
@@ -95,7 +124,7 @@ async function runReadOnly(command: string, args: readonly string[]): Promise<Re
|
|
|
95
124
|
}
|
|
96
125
|
}
|
|
97
126
|
|
|
98
|
-
const DEFAULT_DEPS: CodeGraphProbeDeps = {
|
|
127
|
+
export const DEFAULT_DEPS: CodeGraphProbeDeps = {
|
|
99
128
|
prereqs: resolvePrereqs,
|
|
100
129
|
exists: existsSync,
|
|
101
130
|
run: runReadOnly,
|
|
@@ -228,19 +257,36 @@ export async function probeCodeGraph(
|
|
|
228
257
|
let uncertain = false;
|
|
229
258
|
|
|
230
259
|
const indexer: CheckState = prereqs.indexer === null ? "missing" : "present";
|
|
231
|
-
const mcpMount:
|
|
260
|
+
const mcpMount: McpMountState = prereqs.mounted ? "configured" : "unconfigured";
|
|
232
261
|
if (indexer === "missing") reasons.push("indexer is not present on PATH");
|
|
233
|
-
if (mcpMount === "
|
|
262
|
+
if (mcpMount === "unconfigured") reasons.push("worker MCP configuration does not mount the indexer");
|
|
263
|
+
|
|
264
|
+
// The runtime half (#726): what dispatched sessions actually had in their
|
|
265
|
+
// registries. `recorded: 0` (no run ever recorded an observation, or no
|
|
266
|
+
// store-backed reader) contributes nothing — "observed" is only ever claimed
|
|
267
|
+
// from a run that recorded it. An observed absence is the signal this
|
|
268
|
+
// feature exists to surface: config says "mounted" while sessions ran
|
|
269
|
+
// without the tools, which is exactly the blind spot `mcp.json` alone had.
|
|
270
|
+
const session = deps.graphToolsObservations?.() ?? { recorded: 0, present: 0, absent: 0 };
|
|
271
|
+
if (session.recorded > 0 && session.absent > 0) {
|
|
272
|
+
reasons.push(
|
|
273
|
+
`graph tools observed absent from the session registry in ${session.absent} of ${session.recorded} runs`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
234
276
|
|
|
277
|
+
// The units are per project (`cbm-reindex-<project>.*`): probing the shared
|
|
278
|
+
// stem would report project B's health against project A's timer, and with
|
|
279
|
+
// both installed the answer would be whichever the probe guessed (#720).
|
|
280
|
+
const stem = reindexUnitName(project);
|
|
235
281
|
const [projectsResult, enabledResult, activeResult, serviceResult] = await Promise.all([
|
|
236
282
|
prereqs.indexer === null
|
|
237
283
|
? Promise.resolve<ReadOnlyCommandResult>({ kind: "unavailable" })
|
|
238
284
|
: deps.run(prereqs.indexer, ["cli", "list_projects", "{}"]),
|
|
239
|
-
deps.run("systemctl", ["is-enabled", `${
|
|
240
|
-
deps.run("systemctl", ["is-active", `${
|
|
285
|
+
deps.run("systemctl", ["is-enabled", `${stem}.timer`]),
|
|
286
|
+
deps.run("systemctl", ["is-active", `${stem}.timer`]),
|
|
241
287
|
deps.run("systemctl", [
|
|
242
288
|
"show",
|
|
243
|
-
`${
|
|
289
|
+
`${stem}.service`,
|
|
244
290
|
"--property=Result",
|
|
245
291
|
"--property=ExecMainStatus",
|
|
246
292
|
"--property=ExecMainExitTimestamp",
|
|
@@ -288,6 +334,7 @@ export async function probeCodeGraph(
|
|
|
288
334
|
status: uncertain ? "unknown" : reasons.length === 0 ? "healthy" : "degraded",
|
|
289
335
|
checkedAt,
|
|
290
336
|
prerequisites: { indexer, mcpMount },
|
|
337
|
+
session,
|
|
291
338
|
repos,
|
|
292
339
|
timer: { enabled, active },
|
|
293
340
|
refresh: refresh.health,
|