@compr/opscontext-mcp 2.7.1 → 2.8.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/CHANGELOG.md +54 -0
- package/dist/ci-status.d.ts +16 -0
- package/dist/ci-status.js +55 -0
- package/dist/cli.js +17 -2
- package/dist/firewall.js +1 -1
- package/dist/fleet-health.d.ts +57 -0
- package/dist/fleet-health.js +172 -0
- package/dist/index.js +32 -3
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,60 @@ All notable changes to OpsContext for AI Agents (previously ContextEngine — MC
|
|
|
4
4
|
|
|
5
5
|
> Entries for 2.2.0 through 2.4.0 were not backfilled here; see `docs/sessions/SESSION_19` through `SESSION_21` for those releases.
|
|
6
6
|
|
|
7
|
+
## [2.8.1] 2026-09-07: a push is not done until its CI is read
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`end-session` check 3c, CI on HEAD** (`[PUSHED-MEANS-CI-READ]`, `src/ci-status.ts`): every
|
|
12
|
+
workflow run for the exact HEAD sha through `gh run list`; a failed run is a FAIL item (exit 1).
|
|
13
|
+
No gh, no remote, or no runs yet is "not checked", never a pass. Why: main's CI had been red
|
|
14
|
+
on every commit since 2026-09-04 and the Telegram alert fired each time; thirty commits and
|
|
15
|
+
five releases went by with nobody reading it.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- CI installs the activation server's own packages too: the first time the Test step actually
|
|
20
|
+
ran, `server/src/community-rules-server.test.ts` could not load (`ERR_MODULE_NOT_FOUND`).
|
|
21
|
+
- The Doc Freshness gate fails only when 20 or more source lines change without a doc change; a
|
|
22
|
+
one-line lint fix is not a documented change (its first run paged Telegram for a dash).
|
|
23
|
+
- CI runs on Node 20 and 22 with `fail-fast: false`; Node 18 is EOL and eslint 10 needs 20.19+,
|
|
24
|
+
and the 18 job's failure was cancelling the others before tests ran. `engines.node` is now
|
|
25
|
+
`>=20.19.0`, which is what is tested.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- CI was red on every pull request for one `prefer-const` lint error in `src/firewall.ts`.
|
|
30
|
+
- The Doc Freshness workflow failed any push or PR made more than 8 hours after the last
|
|
31
|
+
SKILLS.md commit, whatever the change (two Dependabot bumps on 2026-09-07). It now measures
|
|
32
|
+
the change itself: source touched without a doc touched fails, anything else passes
|
|
33
|
+
(`[DOC-GATE-MEASURES-THE-DIFF]`).
|
|
34
|
+
|
|
35
|
+
## [2.8.0] 2026-09-06: health is measured, never estimated
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **Fleet health** (`[HEALTH-IS-MEASURED-NEVER-ESTIMATED]`, `src/fleet-health.ts`): the indexing
|
|
40
|
+
server writes `~/.contextengine/fleet-health.json` once a minute: servers on an old build
|
|
41
|
+
(version drift), shared-index writes in the last hour (reindex rate, ceiling 30), today's
|
|
42
|
+
pre-commit blocks with file and reason, learnings-store refusals, learnings saved (creates,
|
|
43
|
+
not a sweep's updates), and the last release for which `verify-release` passed. Every number
|
|
44
|
+
is counted from the audit log, the server registry or a file on disk. Warnings list measured
|
|
45
|
+
problems only. Shown by `contextengine servers` (exit 1 on any warning) and by `end-session`
|
|
46
|
+
§ 3b; each server logs "N server(s) on an old build" when that number changes.
|
|
47
|
+
- `index.write` audit events carry the writer's pid.
|
|
48
|
+
|
|
49
|
+
### VS Code extension 0.12.0
|
|
50
|
+
|
|
51
|
+
- **Status bar redesign** (Session 25 item 4): warning colour only on a measured problem (a
|
|
52
|
+
store refusal, servers on an old build, an index-write storm, commits made since the last
|
|
53
|
+
saved CE session, no MCP session, too many uncommitted files). Never on a timer. Default text
|
|
54
|
+
`CE` plus blocks prevented and recalls surfaced. Tooltip: measured problems, today's counts,
|
|
55
|
+
the last blocks with file and reason, servers and roles, version and last verified release,
|
|
56
|
+
the repo's CE session and commits since it was saved. "Time saved" is gone from the bar and
|
|
57
|
+
the info panel: it was a multiplier nobody could check.
|
|
58
|
+
- `fleetHealthPoller.ts` reads the health file every 15 s and treats a file older than 5 min as
|
|
59
|
+
absent.
|
|
60
|
+
|
|
7
61
|
## [2.7.1] 2026-09-06: the autostart agent is the standing indexer; servers --cost
|
|
8
62
|
|
|
9
63
|
### Changed
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface CiRun {
|
|
2
|
+
name: string;
|
|
3
|
+
status: string;
|
|
4
|
+
conclusion: string | null;
|
|
5
|
+
url: string;
|
|
6
|
+
}
|
|
7
|
+
export interface CiStatus {
|
|
8
|
+
sha: string;
|
|
9
|
+
state: "ok" | "failed" | "pending" | "no-runs" | "unavailable";
|
|
10
|
+
runs: CiRun[];
|
|
11
|
+
note?: string;
|
|
12
|
+
}
|
|
13
|
+
export type Runner = (cmd: string, args: string[], cwd: string) => string;
|
|
14
|
+
export declare function ciStatusForHead(cwd: string, run?: Runner): CiStatus;
|
|
15
|
+
export declare function formatCiStatus(s: CiStatus): string[];
|
|
16
|
+
//# sourceMappingURL=ci-status.d.ts.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// [LOCKED] [PUSHED-MEANS-CI-READ] 2026-09-07
|
|
2
|
+
// [NEVER] let end-session pass while a workflow run for HEAD has failed, and [NEVER] count
|
|
3
|
+
// "no runs found" as green.
|
|
4
|
+
// WHY: main's CI had been red on every commit since 2026-09-04 (one lint error, then a Node 18
|
|
5
|
+
// job that eslint 10 cannot run on) and the Telegram alert fired each time. Thirty commits,
|
|
6
|
+
// five releases, nobody read it: the post-commit hook pushes, end-session ran after every
|
|
7
|
+
// push, and nothing in that loop looked at the result. A push is not done until its CI is.
|
|
8
|
+
// FIX: end-session check 3c lists every workflow run for the exact HEAD sha through `gh run
|
|
9
|
+
// list` (hardcoded argv, no shell) and counts a failure as a FAIL item. gh missing, no
|
|
10
|
+
// GitHub remote, or no runs yet is reported as "not checked", never as pass.
|
|
11
|
+
import { execFileSync } from "child_process";
|
|
12
|
+
const defaultRunner = (cmd, args, cwd) => execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15_000 }).trim();
|
|
13
|
+
export function ciStatusForHead(cwd, run = defaultRunner) {
|
|
14
|
+
let sha = "";
|
|
15
|
+
try {
|
|
16
|
+
sha = run("git", ["rev-parse", "HEAD"], cwd);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { sha, state: "unavailable", runs: [], note: "not a git repository" };
|
|
20
|
+
}
|
|
21
|
+
let raw = "";
|
|
22
|
+
try {
|
|
23
|
+
raw = run("gh", ["run", "list", "--limit", "40", "--json", "name,status,conclusion,url,headSha"], cwd);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return { sha, state: "unavailable", runs: [], note: "gh not available, not logged in, or no GitHub remote" };
|
|
27
|
+
}
|
|
28
|
+
let all = [];
|
|
29
|
+
try {
|
|
30
|
+
all = JSON.parse(raw);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return { sha, state: "unavailable", runs: [], note: "gh returned no JSON" };
|
|
34
|
+
}
|
|
35
|
+
const runs = all.filter((r) => r.headSha === sha).map(({ name, status, conclusion, url }) => ({ name, status, conclusion, url }));
|
|
36
|
+
if (runs.length === 0)
|
|
37
|
+
return { sha, state: "no-runs", runs, note: "no workflow run for HEAD yet: pushed seconds ago, or CI not wired" };
|
|
38
|
+
const failed = runs.some((r) => r.conclusion === "failure" || r.conclusion === "timed_out" || r.conclusion === "startup_failure");
|
|
39
|
+
const pending = runs.some((r) => r.status !== "completed");
|
|
40
|
+
return { sha, state: failed ? "failed" : pending ? "pending" : "ok", runs };
|
|
41
|
+
}
|
|
42
|
+
export function formatCiStatus(s) {
|
|
43
|
+
const lines = [];
|
|
44
|
+
if (s.state === "unavailable" || s.state === "no-runs") {
|
|
45
|
+
lines.push(`- ⚠️ CI on HEAD${s.sha ? ` ${s.sha.slice(0, 7)}` : ""} not checked: ${s.note}`);
|
|
46
|
+
return lines;
|
|
47
|
+
}
|
|
48
|
+
for (const r of s.runs) {
|
|
49
|
+
const bad = r.conclusion === "failure" || r.conclusion === "timed_out" || r.conclusion === "startup_failure";
|
|
50
|
+
const icon = bad ? "❌ FAIL" : r.status !== "completed" ? "⏳" : r.conclusion === "success" ? "✅" : "▫️";
|
|
51
|
+
lines.push(`- ${icon} ${r.name}: ${r.conclusion ?? r.status}${bad ? ` ${r.url}` : ""}`);
|
|
52
|
+
}
|
|
53
|
+
return lines;
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=ci-status.js.map
|
package/dist/cli.js
CHANGED
|
@@ -656,6 +656,8 @@ import { buildCostReport } from "./cost-report.js";
|
|
|
656
656
|
import { getStagedFiles, runSecretScan, runDocCoverage, runCommitMessageRequired, runRuleParity, formatSecretViolations, formatDocCoverageViolations, formatSecretViolationsJson, formatDocCoverageViolationsJson, formatCommitMessageViolations, formatCommitMessageViolationsJson, formatRuleParityViolations, formatRuleParityViolationsJson, } from "./hooks.js";
|
|
657
657
|
import { safeAppend } from "./audit.js";
|
|
658
658
|
import { listServers, formatServers } from "./server-registry.js";
|
|
659
|
+
import { computeFleetHealth, formatFleetHealth } from "./fleet-health.js";
|
|
660
|
+
import { ciStatusForHead, formatCiStatus } from "./ci-status.js";
|
|
659
661
|
import { installSkill, locateBundledSkill, buildManagedBlock, syncClaudeMd, } from "./claude-integration.js";
|
|
660
662
|
import { fileURLToPath } from "url";
|
|
661
663
|
// ---------------------------------------------------------------------------
|
|
@@ -2233,10 +2235,21 @@ async function cliEndSession() {
|
|
|
2233
2235
|
// --- Check 3b: running servers ([LOCK] [SERVERS-ARE-INVENTORIED]) ---
|
|
2234
2236
|
checks.push("## 3b. Running servers\n");
|
|
2235
2237
|
const fleet = listServers();
|
|
2236
|
-
checks.push("```\n" + formatServers(fleet) + "\n```");
|
|
2238
|
+
checks.push("```\n" + formatServers(fleet) + "\n" + formatFleetHealth(computeFleetHealth({ version: readPackageVersion(), report: fleet })) + "\n```");
|
|
2237
2239
|
if (fleet.warnings.length > 0)
|
|
2238
2240
|
failCount += fleet.warnings.length;
|
|
2239
2241
|
checks.push("");
|
|
2242
|
+
// --- Check 3c: CI on HEAD ([LOCK] [PUSHED-MEANS-CI-READ]) ---
|
|
2243
|
+
checks.push("## 3c. CI on HEAD\n");
|
|
2244
|
+
const ci = ciStatusForHead(process.cwd());
|
|
2245
|
+
checks.push(...formatCiStatus(ci));
|
|
2246
|
+
if (ci.state === "failed") {
|
|
2247
|
+
failCount++;
|
|
2248
|
+
checks.push("- ❌ FAIL: a workflow run for HEAD failed; a push is not done until its CI is");
|
|
2249
|
+
}
|
|
2250
|
+
else if (ci.state === "ok")
|
|
2251
|
+
passCount++;
|
|
2252
|
+
checks.push("");
|
|
2240
2253
|
checks.push("## 4. Sessions\n");
|
|
2241
2254
|
const sessions = listSessions();
|
|
2242
2255
|
if (sessions.length > 0) {
|
|
@@ -2742,8 +2755,10 @@ else if (command === "audit-rotate") {
|
|
|
2742
2755
|
}
|
|
2743
2756
|
else if (command === "servers") {
|
|
2744
2757
|
const fleet = listServers();
|
|
2758
|
+
const health = computeFleetHealth({ version: readPackageVersion(), report: fleet });
|
|
2745
2759
|
console.log(formatServers(fleet, undefined, { cost: process.argv.includes("--cost") }));
|
|
2746
|
-
|
|
2760
|
+
console.log(formatFleetHealth(health));
|
|
2761
|
+
process.exit(fleet.warnings.length > 0 || health.warnings.length > 0 ? 1 : 0);
|
|
2747
2762
|
}
|
|
2748
2763
|
else if (command === "audit-verify") {
|
|
2749
2764
|
cliAuditVerify().catch((err) => {
|
package/dist/firewall.js
CHANGED
|
@@ -185,7 +185,7 @@ export class ProtocolFirewall {
|
|
|
185
185
|
if (sessionUrgent && level === "footer")
|
|
186
186
|
level = "header";
|
|
187
187
|
// Prepend learning injection to response (always, if available)
|
|
188
|
-
|
|
188
|
+
const text = injection ? injection + "\n\n" + responseText : responseText;
|
|
189
189
|
// Build session urgency block (always prepended when overdue)
|
|
190
190
|
const urgentBlock = sessionUrgent ? this.buildSessionUrgentBlock() : null;
|
|
191
191
|
if (level === "silent" && !urgentBlock)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type ServerReport } from "./server-registry.js";
|
|
2
|
+
export interface FleetHealth {
|
|
3
|
+
generatedAt: string;
|
|
4
|
+
version: string;
|
|
5
|
+
writerPid: number;
|
|
6
|
+
servers: {
|
|
7
|
+
total: number;
|
|
8
|
+
indexers: number;
|
|
9
|
+
readers: number;
|
|
10
|
+
/** Servers whose loaded build differs from the file on disk. */
|
|
11
|
+
stale: Array<{
|
|
12
|
+
pid: number;
|
|
13
|
+
version: string;
|
|
14
|
+
build: string;
|
|
15
|
+
cwd: string;
|
|
16
|
+
}>;
|
|
17
|
+
diskBuild: string | null;
|
|
18
|
+
};
|
|
19
|
+
reindex: {
|
|
20
|
+
/** Shared-index writes in the last hour, all corpora. */
|
|
21
|
+
lastHourWrites: number;
|
|
22
|
+
perCorpus: Record<string, number>;
|
|
23
|
+
/** Above this many writes per hour a warning is raised. */
|
|
24
|
+
threshold: number;
|
|
25
|
+
};
|
|
26
|
+
today: {
|
|
27
|
+
/** Pre-commit blocks (hook.block) since local midnight. */
|
|
28
|
+
blocks: number;
|
|
29
|
+
/** Store refusals (unreadable, shrink refused, growth refused) since local midnight. */
|
|
30
|
+
refusals: number;
|
|
31
|
+
learningsSaved: number;
|
|
32
|
+
/** Newest last: time, kind, one-line detail. */
|
|
33
|
+
lastBlocks: Array<{
|
|
34
|
+
ts: string;
|
|
35
|
+
kind: string;
|
|
36
|
+
detail: string;
|
|
37
|
+
}>;
|
|
38
|
+
};
|
|
39
|
+
/** The newest release for which verify-release passed on this machine, or null. */
|
|
40
|
+
lastVerifiedRelease: string | null;
|
|
41
|
+
/** Measured problems only. Empty means green. */
|
|
42
|
+
warnings: string[];
|
|
43
|
+
}
|
|
44
|
+
export declare const REINDEX_PER_HOUR_WARN = 30;
|
|
45
|
+
export declare function fleetHealthPath(): string;
|
|
46
|
+
/** The highest version with a verify-release marker; version order, not file time (ties). */
|
|
47
|
+
export declare function lastVerifiedRelease(dir?: string): string | null;
|
|
48
|
+
export declare function computeFleetHealth(opts?: {
|
|
49
|
+
now?: Date;
|
|
50
|
+
version?: string;
|
|
51
|
+
auditPath?: string;
|
|
52
|
+
report?: ServerReport;
|
|
53
|
+
}): FleetHealth;
|
|
54
|
+
/** Temp file + rename: a reader never sees half a file. */
|
|
55
|
+
export declare function writeFleetHealth(h: FleetHealth): string;
|
|
56
|
+
export declare function formatFleetHealth(h: FleetHealth): string;
|
|
57
|
+
//# sourceMappingURL=fleet-health.d.ts.map
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// [LOCKED] [HEALTH-IS-MEASURED-NEVER-ESTIMATED] 2026-09-06
|
|
2
|
+
// [NEVER] put a number in this file that comes from a timer, a multiplier or a guess; every
|
|
3
|
+
// field is counted from the audit log, the server registry, git, or a file on disk.
|
|
4
|
+
// WHY: the VS Code status bar showed "CE SAVE SESSION" in yellow on a wall-clock timer and
|
|
5
|
+
// "~N min saved" from a multiplier nobody could check (Session 25 item 4). Meanwhile the
|
|
6
|
+
// real problems of 2026-09-05 (eleven servers re-embedding, load average 230; two servers
|
|
7
|
+
// on a stale build re-importing 1,766 records; a store growth of 1,766 in one minute) had no
|
|
8
|
+
// surface at all. A nag without evidence trains the user to ignore the bar; a fact does not.
|
|
9
|
+
// FIX: one function computes the fleet's health from evidence, one writer (the indexer) drops
|
|
10
|
+
// it into ~/.contextengine/fleet-health.json every minute, and every surface (servers CLI,
|
|
11
|
+
// end-session, the status bar) reads the same file. Warnings list measured problems only.
|
|
12
|
+
import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, readdirSync, renameSync, writeFileSync } from "fs";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import { homedir } from "os";
|
|
15
|
+
import { listServers } from "./server-registry.js";
|
|
16
|
+
export const REINDEX_PER_HOUR_WARN = 30;
|
|
17
|
+
const TAIL_BYTES = 8 * 1024 * 1024;
|
|
18
|
+
function ceHome() {
|
|
19
|
+
return process.env.CONTEXTENGINE_HOME || join(homedir(), ".contextengine");
|
|
20
|
+
}
|
|
21
|
+
export function fleetHealthPath() {
|
|
22
|
+
return join(ceHome(), "fleet-health.json");
|
|
23
|
+
}
|
|
24
|
+
/** The last `bytes` of a file as complete lines (the first partial line is dropped). */
|
|
25
|
+
function tailLines(path, bytes) {
|
|
26
|
+
if (!existsSync(path))
|
|
27
|
+
return [];
|
|
28
|
+
let fd = null;
|
|
29
|
+
try {
|
|
30
|
+
fd = openSync(path, "r");
|
|
31
|
+
const size = fstatSync(fd).size;
|
|
32
|
+
const start = Math.max(0, size - bytes);
|
|
33
|
+
const buf = Buffer.alloc(size - start);
|
|
34
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
35
|
+
let text = buf.toString("utf8");
|
|
36
|
+
if (start > 0) {
|
|
37
|
+
const nl = text.indexOf("\n");
|
|
38
|
+
text = nl === -1 ? "" : text.slice(nl + 1);
|
|
39
|
+
}
|
|
40
|
+
return text.split("\n").filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
if (fd !== null)
|
|
47
|
+
closeSync(fd);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function parseRecords(lines) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const l of lines) {
|
|
53
|
+
try {
|
|
54
|
+
const r = JSON.parse(l);
|
|
55
|
+
if (r && typeof r.ts === "string" && typeof r.event === "string")
|
|
56
|
+
out.push(r);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* a torn line, ignored */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
function localMidnight(now) {
|
|
65
|
+
const d = new Date(now);
|
|
66
|
+
d.setHours(0, 0, 0, 0);
|
|
67
|
+
return d;
|
|
68
|
+
}
|
|
69
|
+
function blockDetail(p = {}) {
|
|
70
|
+
const check = String(p.check ?? "block");
|
|
71
|
+
if (p.file)
|
|
72
|
+
return `${check}: ${String(p.file)}${p.line ? `:${String(p.line)}` : ""}${p.pattern_id ? ` (${String(p.pattern_id)})` : ""}`;
|
|
73
|
+
if (p.requires_section)
|
|
74
|
+
return `${check}: ${String(p.requires_section)}`;
|
|
75
|
+
if (p.reason)
|
|
76
|
+
return `${check}: ${String(p.reason)}`;
|
|
77
|
+
return check;
|
|
78
|
+
}
|
|
79
|
+
/** The highest version with a verify-release marker; version order, not file time (ties). */
|
|
80
|
+
export function lastVerifiedRelease(dir = ceHome()) {
|
|
81
|
+
let files = [];
|
|
82
|
+
try {
|
|
83
|
+
files = readdirSync(dir);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const versions = files.map((f) => /^verified-(\d+\.\d+\.\d+)$/.exec(f)?.[1]).filter((v) => !!v);
|
|
89
|
+
if (versions.length === 0)
|
|
90
|
+
return null;
|
|
91
|
+
const key = (v) => v.split(".").map(Number);
|
|
92
|
+
versions.sort((a, b) => { const x = key(a), y = key(b); return (x[0] - y[0]) || (x[1] - y[1]) || (x[2] - y[2]); });
|
|
93
|
+
return versions[versions.length - 1];
|
|
94
|
+
}
|
|
95
|
+
export function computeFleetHealth(opts = {}) {
|
|
96
|
+
const now = opts.now ?? new Date();
|
|
97
|
+
const report = opts.report ?? listServers();
|
|
98
|
+
const audit = opts.auditPath ?? join(ceHome(), "audit.log");
|
|
99
|
+
const records = parseRecords(tailLines(audit, TAIL_BYTES));
|
|
100
|
+
const midnight = localMidnight(now).getTime();
|
|
101
|
+
const hourAgo = now.getTime() - 3_600_000;
|
|
102
|
+
const perCorpus = {};
|
|
103
|
+
let lastHourWrites = 0, blocks = 0, refusals = 0, learningsSaved = 0;
|
|
104
|
+
const lastBlocks = [];
|
|
105
|
+
for (const r of records) {
|
|
106
|
+
const t = Date.parse(r.ts);
|
|
107
|
+
if (Number.isNaN(t))
|
|
108
|
+
continue;
|
|
109
|
+
if (r.event === "index.write" && t >= hourAgo) {
|
|
110
|
+
lastHourWrites++;
|
|
111
|
+
const c = String(r.payload?.corpus ?? "?");
|
|
112
|
+
perCorpus[c] = (perCorpus[c] ?? 0) + 1;
|
|
113
|
+
}
|
|
114
|
+
if (t < midnight)
|
|
115
|
+
continue;
|
|
116
|
+
if (r.event === "hook.block") {
|
|
117
|
+
blocks++;
|
|
118
|
+
lastBlocks.push({ ts: r.ts, kind: "pre-commit", detail: blockDetail(r.payload) });
|
|
119
|
+
}
|
|
120
|
+
else if (r.event === "learning.store_unreadable" || r.event === "learning.store_shrink_refused" || r.event === "learning.store_growth_refused") {
|
|
121
|
+
refusals++;
|
|
122
|
+
lastBlocks.push({ ts: r.ts, kind: "store", detail: r.event.replace("learning.store_", "").replace(/_/g, " ") });
|
|
123
|
+
}
|
|
124
|
+
else if (r.event === "learning.save" && r.payload?.mode !== "update")
|
|
125
|
+
learningsSaved++; // a sweep's updates are not saves
|
|
126
|
+
}
|
|
127
|
+
const stale = report.servers.filter((s) => s.staleBuild).map((s) => ({ pid: s.pid, version: s.version, build: s.build, cwd: s.cwd }));
|
|
128
|
+
const diskBuild = report.servers.find((s) => s.currentBuild)?.currentBuild ?? null;
|
|
129
|
+
const indexers = report.servers.filter((s) => s.role !== "reader").length;
|
|
130
|
+
const warnings = [];
|
|
131
|
+
if (stale.length > 0)
|
|
132
|
+
warnings.push(`${stale.length} server(s) on an old build (pid ${stale.map((s) => s.pid).join(", ")}): reload their windows`);
|
|
133
|
+
if (lastHourWrites > REINDEX_PER_HOUR_WARN)
|
|
134
|
+
warnings.push(`${lastHourWrites} shared-index writes in the last hour (ceiling ${REINDEX_PER_HOUR_WARN}): something saves in a loop`);
|
|
135
|
+
if (refusals > 0)
|
|
136
|
+
warnings.push(`${refusals} learnings-store refusal(s) today: a write looked like a wipe or a runaway import`);
|
|
137
|
+
for (const w of report.warnings)
|
|
138
|
+
if (/index on their own/.test(w))
|
|
139
|
+
warnings.push(w);
|
|
140
|
+
return {
|
|
141
|
+
generatedAt: now.toISOString(),
|
|
142
|
+
version: opts.version ?? "unknown",
|
|
143
|
+
writerPid: process.pid,
|
|
144
|
+
servers: { total: report.servers.length, indexers, readers: report.servers.length - indexers, stale, diskBuild },
|
|
145
|
+
reindex: { lastHourWrites, perCorpus, threshold: REINDEX_PER_HOUR_WARN },
|
|
146
|
+
today: { blocks, refusals, learningsSaved, lastBlocks: lastBlocks.slice(-3) },
|
|
147
|
+
lastVerifiedRelease: lastVerifiedRelease(),
|
|
148
|
+
warnings,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** Temp file + rename: a reader never sees half a file. */
|
|
152
|
+
export function writeFleetHealth(h) {
|
|
153
|
+
const path = fleetHealthPath();
|
|
154
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
155
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
156
|
+
writeFileSync(tmp, JSON.stringify(h, null, 2) + "\n");
|
|
157
|
+
renameSync(tmp, path);
|
|
158
|
+
return path;
|
|
159
|
+
}
|
|
160
|
+
export function formatFleetHealth(h) {
|
|
161
|
+
const lines = [];
|
|
162
|
+
lines.push(`health: ${h.warnings.length === 0 ? "green" : `${h.warnings.length} measured problem(s)`} (v${h.version}, last verified release ${h.lastVerifiedRelease ?? "none"}, ${h.generatedAt.slice(11, 19)}Z)`);
|
|
163
|
+
lines.push(` servers ${h.servers.total}: ${h.servers.indexers} indexing, ${h.servers.readers} reading, ${h.servers.stale.length} on an old build`);
|
|
164
|
+
lines.push(` shared-index writes last hour: ${h.reindex.lastHourWrites} (ceiling ${h.reindex.threshold})`);
|
|
165
|
+
lines.push(` today: ${h.today.blocks} block(s) prevented, ${h.today.refusals} store refusal(s), ${h.today.learningsSaved} learning(s) saved`);
|
|
166
|
+
for (const b of h.today.lastBlocks)
|
|
167
|
+
lines.push(` ${b.ts.slice(11, 19)}Z ${b.kind}: ${b.detail}`);
|
|
168
|
+
for (const w of h.warnings)
|
|
169
|
+
lines.push(` ⚠ ${w}`);
|
|
170
|
+
return lines.join("\n");
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=fleet-health.js.map
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { listProjects, checkPorts, runComplianceAudit, formatProjectList, format
|
|
|
13
13
|
import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
|
|
14
14
|
import { verifyChain, readAuditLog, filterByRange, autoRotateAuditLog, safeAppend } from "./audit.js";
|
|
15
15
|
import { registerServer, listServers, formatServers } from "./server-registry.js";
|
|
16
|
+
import { computeFleetHealth, writeFleetHealth } from "./fleet-health.js";
|
|
16
17
|
import { startEventIngestServer } from "./http-server.js";
|
|
17
18
|
import { detect } from "./detector.js";
|
|
18
19
|
import { buildCostReport } from "./cost-report.js";
|
|
@@ -210,7 +211,7 @@ function publishIndex() {
|
|
|
210
211
|
keys: chunks.map(embedKeyOf),
|
|
211
212
|
});
|
|
212
213
|
lastIndexMtime = sharedIndexMtime(corpus);
|
|
213
|
-
safeAppend("index.write", { corpus, seq: indexSeq, chunks: chunks.length, vectors: embeddedChunks.length, bytes: r.bytes, ms: r.ms });
|
|
214
|
+
safeAppend("index.write", { pid: process.pid, corpus, seq: indexSeq, chunks: chunks.length, vectors: embeddedChunks.length, bytes: r.bytes, ms: r.ms });
|
|
214
215
|
console.error(`[ContextEngine] 📤 Shared index written: seq ${indexSeq}, ${chunks.length} chunks, ${Math.round(r.bytes / 1024)} KB, ${r.ms} ms`);
|
|
215
216
|
}
|
|
216
217
|
catch (err) {
|
|
@@ -422,11 +423,39 @@ function evaluateRole(reason) {
|
|
|
422
423
|
startIndexPolling();
|
|
423
424
|
}
|
|
424
425
|
}
|
|
426
|
+
let healthTick = 0;
|
|
427
|
+
let lastStaleCount = -1;
|
|
428
|
+
/**
|
|
429
|
+
* The indexer writes ~/.contextengine/fleet-health.json once a minute: version drift, reindex
|
|
430
|
+
* rate, today's blocks and refusals, the last verified release. Every surface reads that file.
|
|
431
|
+
* [LOCK] [HEALTH-IS-MEASURED-NEVER-ESTIMATED]
|
|
432
|
+
*/
|
|
433
|
+
function publishHealth() {
|
|
434
|
+
try {
|
|
435
|
+
const h = computeFleetHealth({ version: PKG_VERSION });
|
|
436
|
+
if (h.servers.stale.length !== lastStaleCount) {
|
|
437
|
+
lastStaleCount = h.servers.stale.length;
|
|
438
|
+
if (lastStaleCount > 0)
|
|
439
|
+
console.error(`[ContextEngine] 🧭 ${lastStaleCount} server(s) on an old build: pid ${h.servers.stale.map((s) => s.pid).join(", ")}`);
|
|
440
|
+
}
|
|
441
|
+
if (role === "indexer")
|
|
442
|
+
writeFleetHealth(h);
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
console.error(`[ContextEngine] ⚠ fleet health failed: ${err.message}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
425
448
|
function startRolePolling() {
|
|
426
|
-
if (rolePoll
|
|
449
|
+
if (rolePoll)
|
|
427
450
|
return;
|
|
428
|
-
rolePoll = setInterval(() =>
|
|
451
|
+
rolePoll = setInterval(() => {
|
|
452
|
+
if (corpus)
|
|
453
|
+
evaluateRole("periodic");
|
|
454
|
+
if (++healthTick % 4 === 0)
|
|
455
|
+
publishHealth(); // every 60 s
|
|
456
|
+
}, ROLE_POLL_MS);
|
|
429
457
|
rolePoll.unref();
|
|
458
|
+
setTimeout(publishHealth, 5_000).unref();
|
|
430
459
|
}
|
|
431
460
|
// ---------------------------------------------------------------------------
|
|
432
461
|
// MCP Server
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compr/opscontext-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.1",
|
|
4
4
|
"description": "OpsContext for AI Agents — read-only fleet visibility (PM2/nginx/Docker/git/cron) + tamper-evident audit log + policy-as-code hooks. The ops + compliance layer Claude Code can't grow natively.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"email": "yannick@compr.ch"
|
|
60
60
|
},
|
|
61
61
|
"engines": {
|
|
62
|
-
"node": ">=
|
|
62
|
+
"node": ">=20.19.0"
|
|
63
63
|
},
|
|
64
64
|
"files": [
|
|
65
65
|
"dist/",
|