crewhaus 0.1.8 → 0.2.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 +10 -3
- package/dist/advice-apply.d.ts +182 -0
- package/dist/advice-apply.js +286 -0
- package/dist/advise-rules.d.ts +348 -0
- package/dist/advise-rules.js +905 -0
- package/dist/alert-sink.d.ts +48 -0
- package/dist/alert-sink.js +86 -0
- package/dist/approval-gate.d.ts +127 -0
- package/dist/approval-gate.js +254 -0
- package/dist/audit-verify.d.ts +69 -0
- package/dist/audit-verify.js +97 -0
- package/dist/autodistill.d.ts +113 -0
- package/dist/autodistill.js +256 -0
- package/dist/channel-provision.d.ts +360 -0
- package/dist/channel-provision.js +881 -0
- package/dist/ci-scaffold.d.ts +31 -0
- package/dist/ci-scaffold.js +343 -0
- package/dist/compile-check.d.ts +90 -0
- package/dist/compile-check.js +285 -0
- package/dist/compliance-schedule.d.ts +35 -0
- package/dist/compliance-schedule.js +36 -0
- package/dist/context-pressure.d.ts +80 -0
- package/dist/context-pressure.js +166 -0
- package/dist/dataset-mine.d.ts +173 -0
- package/dist/dataset-mine.js +404 -0
- package/dist/datasets.d.ts +124 -0
- package/dist/datasets.js +260 -0
- package/dist/deploy-canary.d.ts +83 -0
- package/dist/deploy-canary.js +87 -0
- package/dist/doctor-checks.d.ts +33 -0
- package/dist/doctor-checks.js +92 -0
- package/dist/doctor-detect.d.ts +108 -0
- package/dist/doctor-detect.js +214 -0
- package/dist/doctor-fix.d.ts +81 -0
- package/dist/doctor-fix.js +164 -0
- package/dist/egress-triage.d.ts +121 -0
- package/dist/egress-triage.js +261 -0
- package/dist/eval-bridge.d.ts +114 -0
- package/dist/eval-bridge.js +158 -0
- package/dist/eval-coverage.d.ts +140 -0
- package/dist/eval-coverage.js +428 -0
- package/dist/eval-history.d.ts +48 -0
- package/dist/eval-history.js +157 -0
- package/dist/eval-matrix.d.ts +80 -0
- package/dist/eval-matrix.js +182 -0
- package/dist/eval-sentinel.d.ts +65 -0
- package/dist/eval-sentinel.js +132 -0
- package/dist/faq.d.ts +68 -0
- package/dist/faq.js +168 -0
- package/dist/feedback.d.ts +9 -2
- package/dist/feedback.js +17 -7
- package/dist/fewshot.d.ts +83 -0
- package/dist/fewshot.js +158 -0
- package/dist/fleet.d.ts +207 -0
- package/dist/fleet.js +488 -0
- package/dist/flywheel.d.ts +193 -0
- package/dist/flywheel.js +519 -0
- package/dist/graders-suggest.d.ts +186 -0
- package/dist/graders-suggest.js +658 -0
- package/dist/incident.d.ts +99 -0
- package/dist/incident.js +217 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +11664 -963
- package/dist/init-interactive.d.ts +105 -0
- package/dist/init-interactive.js +208 -0
- package/dist/intents.d.ts +105 -0
- package/dist/intents.js +292 -0
- package/dist/judge-calibrate.d.ts +137 -0
- package/dist/judge-calibrate.js +247 -0
- package/dist/justification-calibrate.d.ts +150 -0
- package/dist/justification-calibrate.js +262 -0
- package/dist/justification-gate.d.ts +27 -6
- package/dist/justification-gate.js +30 -6
- package/dist/knowledge-sync.d.ts +179 -0
- package/dist/knowledge-sync.js +551 -0
- package/dist/lessons.d.ts +87 -0
- package/dist/lessons.js +207 -0
- package/dist/lint.d.ts +127 -0
- package/dist/lint.js +226 -0
- package/dist/loadtest.d.ts +114 -0
- package/dist/loadtest.js +196 -0
- package/dist/marketplace-cli.d.ts +110 -0
- package/dist/marketplace-cli.js +250 -0
- package/dist/mcp-doctor.d.ts +121 -0
- package/dist/mcp-doctor.js +249 -0
- package/dist/model-scan.d.ts +116 -0
- package/dist/model-scan.js +226 -0
- package/dist/onchain-tune.d.ts +164 -0
- package/dist/onchain-tune.js +346 -0
- package/dist/permissions-suggest.d.ts +126 -0
- package/dist/permissions-suggest.js +333 -0
- package/dist/pii-tune.d.ts +107 -0
- package/dist/pii-tune.js +122 -0
- package/dist/propose.d.ts +117 -0
- package/dist/propose.js +184 -0
- package/dist/refresh-goldens.d.ts +82 -0
- package/dist/refresh-goldens.js +221 -0
- package/dist/regression-pin.d.ts +160 -0
- package/dist/regression-pin.js +281 -0
- package/dist/retention.d.ts +193 -0
- package/dist/retention.js +607 -0
- package/dist/retire.d.ts +118 -0
- package/dist/retire.js +291 -0
- package/dist/right-size.d.ts +100 -0
- package/dist/right-size.js +123 -0
- package/dist/route.d.ts +19 -0
- package/dist/route.js +90 -0
- package/dist/scaffold-evals.d.ts +138 -0
- package/dist/scaffold-evals.js +410 -0
- package/dist/scope-audit-drift.d.ts +139 -0
- package/dist/scope-audit-drift.js +260 -0
- package/dist/security-corpus.d.ts +237 -0
- package/dist/security-corpus.js +516 -0
- package/dist/security-digest.d.ts +174 -0
- package/dist/security-digest.js +656 -0
- package/dist/sessions-index.d.ts +27 -0
- package/dist/sessions-index.js +51 -0
- package/dist/slo-doctor.d.ts +67 -0
- package/dist/slo-doctor.js +119 -0
- package/dist/slo-sink.d.ts +96 -0
- package/dist/slo-sink.js +107 -0
- package/dist/spec-changelog.d.ts +102 -0
- package/dist/spec-changelog.js +237 -0
- package/dist/state-backup.d.ts +223 -0
- package/dist/state-backup.js +648 -0
- package/dist/tools-cli.d.ts +170 -0
- package/dist/tools-cli.js +300 -0
- package/dist/triage.d.ts +202 -0
- package/dist/triage.js +403 -0
- package/dist/upgrade.d.ts +57 -0
- package/dist/upgrade.js +113 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.js +27 -0
- package/dist/voice-eval.d.ts +138 -0
- package/dist/voice-eval.js +309 -0
- package/dist/watch.d.ts +58 -0
- package/dist/watch.js +97 -0
- package/package.json +90 -65
package/dist/loadtest.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item 68 — `crewhaus loadtest <spec>`: drive a daemon-shape harness (managed
|
|
3
|
+
* gateway / channel-bot / batch) under concurrent load and measure throughput,
|
|
4
|
+
* p50/p95/p99 latency, error rate, and cost/req — with a `--gate` mode that
|
|
5
|
+
* fails (exit 1) when p95 latency or error rate exceed declared thresholds (a
|
|
6
|
+
* pre-deploy gate).
|
|
7
|
+
*
|
|
8
|
+
* Load is driven through an INJECTED `LoadDriver` — one async call per request
|
|
9
|
+
* that returns a `RequestOutcome`. That seam makes the runner deterministic +
|
|
10
|
+
* credential-free in tests (a stub driver returns canned latencies), while the
|
|
11
|
+
* CLI wires a real driver: the managed gateway's in-process `handle()` behind a
|
|
12
|
+
* stub/echo model, or the batch queue's consumer. No live server or provider
|
|
13
|
+
* key is needed for the runner itself.
|
|
14
|
+
*
|
|
15
|
+
* Everything here is side-effect-free (the CLI entry file constructs the driver
|
|
16
|
+
* + writes the report). The aggregation is pure: same outcomes → same report.
|
|
17
|
+
*/
|
|
18
|
+
/** Thrown on bad flags / driver misuse. The CLI routes it through `die()`. */
|
|
19
|
+
export class LoadtestError extends Error {
|
|
20
|
+
name = "LoadtestError";
|
|
21
|
+
}
|
|
22
|
+
const DEFAULT_CONCURRENCY = 10;
|
|
23
|
+
const DEFAULT_REQUESTS = 100;
|
|
24
|
+
function percentile(sorted, p) {
|
|
25
|
+
if (sorted.length === 0)
|
|
26
|
+
return 0;
|
|
27
|
+
// Nearest-rank on a pre-sorted array.
|
|
28
|
+
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
|
|
29
|
+
return sorted[idx];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Run a concurrent load test against the injected driver. Dispatches up to
|
|
33
|
+
* `concurrency` requests in flight until the request budget (or duration) is
|
|
34
|
+
* exhausted, then drains. Deterministic given a deterministic driver + clock.
|
|
35
|
+
*/
|
|
36
|
+
export async function runLoadtest(driver, config = {}) {
|
|
37
|
+
const concurrency = Math.max(1, config.concurrency ?? DEFAULT_CONCURRENCY);
|
|
38
|
+
const maxRequests = config.requests ?? DEFAULT_REQUESTS;
|
|
39
|
+
if (maxRequests < 1)
|
|
40
|
+
throw new LoadtestError("requests must be ≥ 1");
|
|
41
|
+
const now = config.now ?? (() => performance.now());
|
|
42
|
+
const sleep = config.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
43
|
+
const durationMs = config.durationMs;
|
|
44
|
+
const rps = config.rps;
|
|
45
|
+
const outcomes = [];
|
|
46
|
+
let dispatched = 0;
|
|
47
|
+
const startedAt = now();
|
|
48
|
+
const shouldStop = () => {
|
|
49
|
+
if (dispatched >= maxRequests)
|
|
50
|
+
return true;
|
|
51
|
+
if (durationMs !== undefined && now() - startedAt >= durationMs)
|
|
52
|
+
return true;
|
|
53
|
+
return false;
|
|
54
|
+
};
|
|
55
|
+
// Open-loop pacing: the interval between dispatches for the target rps.
|
|
56
|
+
const dispatchIntervalMs = rps !== undefined && rps > 0 ? 1000 / rps : 0;
|
|
57
|
+
async function worker() {
|
|
58
|
+
while (!shouldStop()) {
|
|
59
|
+
const idx = dispatched;
|
|
60
|
+
dispatched += 1;
|
|
61
|
+
if (dispatchIntervalMs > 0) {
|
|
62
|
+
// Pace dispatch toward the target rps. Each of the `concurrency` workers
|
|
63
|
+
// waits `concurrency / rps` seconds between its own dispatches, so the
|
|
64
|
+
// aggregate dispatch rate across all workers approximates `rps`.
|
|
65
|
+
await sleep(dispatchIntervalMs * concurrency);
|
|
66
|
+
}
|
|
67
|
+
let outcome;
|
|
68
|
+
try {
|
|
69
|
+
outcome = await driver(idx);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
outcome = {
|
|
73
|
+
ok: false,
|
|
74
|
+
latencyMs: 0,
|
|
75
|
+
errorKind: err instanceof Error ? err.name || "threw" : "threw",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
outcomes.push(outcome);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const workers = [];
|
|
82
|
+
for (let i = 0; i < concurrency; i++)
|
|
83
|
+
workers.push(worker());
|
|
84
|
+
await Promise.all(workers);
|
|
85
|
+
const wallClockMs = Math.max(0, now() - startedAt);
|
|
86
|
+
return aggregateLoadtest(outcomes, {
|
|
87
|
+
wallClockMs,
|
|
88
|
+
...(config.pricePerMInput !== undefined ? { pricePerMInput: config.pricePerMInput } : {}),
|
|
89
|
+
...(config.pricePerMOutput !== undefined ? { pricePerMOutput: config.pricePerMOutput } : {}),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Aggregate raw outcomes into a report. Pure — exported so tests can assert on
|
|
94
|
+
* percentile math without driving a run, and so the CLI can re-aggregate a
|
|
95
|
+
* persisted outcome set.
|
|
96
|
+
*/
|
|
97
|
+
export function aggregateLoadtest(outcomes, opts) {
|
|
98
|
+
const requests = outcomes.length;
|
|
99
|
+
const succeeded = outcomes.filter((o) => o.ok).length;
|
|
100
|
+
const failed = requests - succeeded;
|
|
101
|
+
// Latency percentiles over SUCCESSFUL requests (a failed 429 at 1ms would
|
|
102
|
+
// otherwise flatter the tail).
|
|
103
|
+
const latencies = outcomes
|
|
104
|
+
.filter((o) => o.ok)
|
|
105
|
+
.map((o) => o.latencyMs)
|
|
106
|
+
.sort((a, b) => a - b);
|
|
107
|
+
const sumLatency = latencies.reduce((acc, l) => acc + l, 0);
|
|
108
|
+
const tokens = outcomes.reduce((acc, o) => ({
|
|
109
|
+
input: acc.input + (o.tokens?.input ?? 0),
|
|
110
|
+
output: acc.output + (o.tokens?.output ?? 0),
|
|
111
|
+
}), { input: 0, output: 0 });
|
|
112
|
+
const errorKinds = {};
|
|
113
|
+
for (const o of outcomes) {
|
|
114
|
+
if (!o.ok) {
|
|
115
|
+
const kind = o.errorKind ?? "error";
|
|
116
|
+
errorKinds[kind] = (errorKinds[kind] ?? 0) + 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const priceIn = opts.pricePerMInput ?? 0;
|
|
120
|
+
const priceOut = opts.pricePerMOutput ?? 0;
|
|
121
|
+
const totalCostUsd = (tokens.input / 1_000_000) * priceIn + (tokens.output / 1_000_000) * priceOut;
|
|
122
|
+
return {
|
|
123
|
+
requests,
|
|
124
|
+
succeeded,
|
|
125
|
+
failed,
|
|
126
|
+
errorRate: requests === 0 ? 0 : failed / requests,
|
|
127
|
+
wallClockMs: opts.wallClockMs,
|
|
128
|
+
throughputRps: opts.wallClockMs > 0 ? (requests / opts.wallClockMs) * 1000 : 0,
|
|
129
|
+
latency: {
|
|
130
|
+
p50: percentile(latencies, 50),
|
|
131
|
+
p95: percentile(latencies, 95),
|
|
132
|
+
p99: percentile(latencies, 99),
|
|
133
|
+
min: latencies[0] ?? 0,
|
|
134
|
+
max: latencies[latencies.length - 1] ?? 0,
|
|
135
|
+
mean: latencies.length === 0 ? 0 : sumLatency / latencies.length,
|
|
136
|
+
},
|
|
137
|
+
tokens,
|
|
138
|
+
costPerReqUsd: requests === 0 ? 0 : totalCostUsd / requests,
|
|
139
|
+
totalCostUsd,
|
|
140
|
+
errorKinds,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/** Evaluate a report against gate thresholds. Empty thresholds → always passes. */
|
|
144
|
+
export function evaluateGate(report, thresholds) {
|
|
145
|
+
const breaches = [];
|
|
146
|
+
if (thresholds.maxP95LatencyMs !== undefined && report.latency.p95 > thresholds.maxP95LatencyMs) {
|
|
147
|
+
breaches.push(`p95 latency ${report.latency.p95.toFixed(1)}ms > ${thresholds.maxP95LatencyMs}ms`);
|
|
148
|
+
}
|
|
149
|
+
if (thresholds.maxErrorRate !== undefined && report.errorRate > thresholds.maxErrorRate) {
|
|
150
|
+
breaches.push(`error rate ${(report.errorRate * 100).toFixed(1)}% > ${(thresholds.maxErrorRate * 100).toFixed(1)}%`);
|
|
151
|
+
}
|
|
152
|
+
return { passed: breaches.length === 0, breaches };
|
|
153
|
+
}
|
|
154
|
+
function fmtUsd(n) {
|
|
155
|
+
return `$${n.toFixed(6)}`;
|
|
156
|
+
}
|
|
157
|
+
/** Render a plain-text loadtest report. */
|
|
158
|
+
export function renderLoadtestText(report, verdict) {
|
|
159
|
+
const lines = [];
|
|
160
|
+
lines.push("loadtest:");
|
|
161
|
+
lines.push(` requests: ${report.requests} (${report.succeeded} ok / ${report.failed} failed, ${(report.errorRate * 100).toFixed(1)}% error rate)`);
|
|
162
|
+
lines.push(` throughput: ${report.throughputRps.toFixed(1)} req/s over ${report.wallClockMs.toFixed(0)}ms`);
|
|
163
|
+
lines.push(` latency: p50 ${report.latency.p50.toFixed(1)}ms · p95 ${report.latency.p95.toFixed(1)}ms · p99 ${report.latency.p99.toFixed(1)}ms (min ${report.latency.min.toFixed(1)} / max ${report.latency.max.toFixed(1)})`);
|
|
164
|
+
lines.push(` tokens: ${report.tokens.input} in / ${report.tokens.output} out; cost/req ${fmtUsd(report.costPerReqUsd)}; total ${fmtUsd(report.totalCostUsd)}`);
|
|
165
|
+
const kinds = Object.entries(report.errorKinds);
|
|
166
|
+
if (kinds.length > 0) {
|
|
167
|
+
lines.push(` errors: ${kinds.map(([k, v]) => `${k}×${v}`).join(", ")}`);
|
|
168
|
+
}
|
|
169
|
+
if (verdict !== undefined) {
|
|
170
|
+
lines.push(verdict.passed ? " gate: PASS" : " gate: FAIL");
|
|
171
|
+
for (const b of verdict.breaches)
|
|
172
|
+
lines.push(` ! ${b}`);
|
|
173
|
+
}
|
|
174
|
+
return `${lines.join("\n")}\n`;
|
|
175
|
+
}
|
|
176
|
+
/** Render an HTML loadtest report (self-contained). */
|
|
177
|
+
export function renderLoadtestHtml(report, verdict) {
|
|
178
|
+
const gateRow = verdict === undefined
|
|
179
|
+
? ""
|
|
180
|
+
: `<tr><th>Gate</th><td>${verdict.passed ? "PASS" : `FAIL — ${verdict.breaches.map((b) => b.replace(/</g, "<")).join("; ")}`}</td></tr>`;
|
|
181
|
+
return `<!doctype html>
|
|
182
|
+
<html><head><meta charset="utf-8"><title>Loadtest report</title>
|
|
183
|
+
<style>body{font:14px system-ui,sans-serif;margin:2rem}table{border-collapse:collapse}th,td{border:1px solid #ddd;padding:.4rem .7rem;text-align:left}th{background:#f5f5f5}</style>
|
|
184
|
+
</head><body>
|
|
185
|
+
<h1>Loadtest report</h1>
|
|
186
|
+
<table>
|
|
187
|
+
<tr><th>Requests</th><td>${report.requests} (${report.succeeded} ok / ${report.failed} failed)</td></tr>
|
|
188
|
+
<tr><th>Error rate</th><td>${(report.errorRate * 100).toFixed(1)}%</td></tr>
|
|
189
|
+
<tr><th>Throughput</th><td>${report.throughputRps.toFixed(1)} req/s</td></tr>
|
|
190
|
+
<tr><th>Latency p50 / p95 / p99</th><td>${report.latency.p50.toFixed(1)} / ${report.latency.p95.toFixed(1)} / ${report.latency.p99.toFixed(1)} ms</td></tr>
|
|
191
|
+
<tr><th>Cost / req</th><td>${fmtUsd(report.costPerReqUsd)}</td></tr>
|
|
192
|
+
${gateRow}
|
|
193
|
+
</table>
|
|
194
|
+
</body></html>
|
|
195
|
+
`;
|
|
196
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { ModuleRegistrySource, PluginMetadata } from "@crewhaus/module-marketplace-client";
|
|
2
|
+
import type { PluginRegistryEntry } from "@crewhaus/plugin-registry";
|
|
3
|
+
import type { PluginManifest } from "@crewhaus/plugin-sdk";
|
|
4
|
+
/** Thrown for operational failures (no registry configured, bad ref, driver
|
|
5
|
+
* failure). The CLI routes the message through `die()`; tests assert on
|
|
6
|
+
* `.message`. */
|
|
7
|
+
export declare class MarketplaceCliError extends Error {
|
|
8
|
+
readonly name = "MarketplaceCliError";
|
|
9
|
+
}
|
|
10
|
+
/** Where installed plugins live (mirrors §41 trustedRoots). */
|
|
11
|
+
export declare function defaultPluginsDir(): string;
|
|
12
|
+
/** The plugin-registry install-record file. */
|
|
13
|
+
export declare function defaultPluginRegistryPath(): string;
|
|
14
|
+
/** Where `templates use` writes the fetched template. Defaults to the cwd
|
|
15
|
+
* (a template is scaffolded into the working directory). */
|
|
16
|
+
export declare function defaultTemplateWorkspaceDir(): string;
|
|
17
|
+
export type RegistryRefKind = {
|
|
18
|
+
readonly kind: "local";
|
|
19
|
+
readonly dir: string;
|
|
20
|
+
} | {
|
|
21
|
+
readonly kind: "http";
|
|
22
|
+
readonly baseUrl: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a registry ref string into a backend descriptor. `http(s)://…` →
|
|
26
|
+
* HTTP; anything else is a filesystem path (a leading `file:` is stripped).
|
|
27
|
+
* `undefined` (nothing configured) throws so a read verb fails loudly instead
|
|
28
|
+
* of silently hitting nothing.
|
|
29
|
+
*/
|
|
30
|
+
export declare function resolveRegistryRef(ref: string | undefined, kindLabel: string): RegistryRefKind;
|
|
31
|
+
/**
|
|
32
|
+
* A `ModuleRegistrySource` over an HTTP JSON index. The catalog is
|
|
33
|
+
* `GET <baseUrl>` returning `{ plugins: PluginMetadata[] }`; a manifest is
|
|
34
|
+
* `GET <baseUrl>/<name>.json` (optionally `<name>@<version>.json`). `fetch`
|
|
35
|
+
* is injected so tests drive it without a network.
|
|
36
|
+
*/
|
|
37
|
+
export declare function createHttpModuleRegistrySource(opts: {
|
|
38
|
+
readonly id: string;
|
|
39
|
+
readonly baseUrl: string;
|
|
40
|
+
readonly fetchImpl?: typeof fetch;
|
|
41
|
+
}): ModuleRegistrySource;
|
|
42
|
+
/**
|
|
43
|
+
* A LOCAL `ModuleRegistrySource` over a directory of plugin manifest JSONs
|
|
44
|
+
* (`<name>.json`). Mirrors template-registry's `LocalRegistrySource`; used
|
|
45
|
+
* for `--registry <dir>` and by tests. Reads go through injected fs seams so
|
|
46
|
+
* this stays unit-testable.
|
|
47
|
+
*/
|
|
48
|
+
export declare function createLocalModuleRegistrySource(opts: {
|
|
49
|
+
readonly dir: string;
|
|
50
|
+
readonly readdirImpl: (dir: string) => ReadonlyArray<string>;
|
|
51
|
+
readonly readFileImpl: (path: string) => string;
|
|
52
|
+
readonly existsImpl: (path: string) => boolean;
|
|
53
|
+
}): ModuleRegistrySource;
|
|
54
|
+
/** Project a full manifest down to the search metadata shape. */
|
|
55
|
+
export declare function toMetadata(m: PluginManifest): PluginMetadata;
|
|
56
|
+
/** Compare two `major.minor.patch` cores; pre-release/build ignored (matches
|
|
57
|
+
* module-marketplace-client's own `compareSemver`). */
|
|
58
|
+
export declare function compareSemverCore(a: string, b: string): number;
|
|
59
|
+
export type OutdatedRow = {
|
|
60
|
+
readonly name: string;
|
|
61
|
+
readonly installed: string;
|
|
62
|
+
readonly latest?: string;
|
|
63
|
+
/** "outdated" | "current" | "unknown" (not in the remote registry). */
|
|
64
|
+
readonly status: "outdated" | "current" | "unknown";
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Compute the freshness report: for each installed plugin, its installed
|
|
68
|
+
* version vs the remote latest. `unknown` when the plugin is not in the
|
|
69
|
+
* remote catalog (a private/local install). Pure over the two lists.
|
|
70
|
+
*/
|
|
71
|
+
export declare function computeOutdated(installed: ReadonlyArray<{
|
|
72
|
+
readonly name: string;
|
|
73
|
+
readonly version: string;
|
|
74
|
+
}>, remote: ReadonlyArray<PluginMetadata>): OutdatedRow[];
|
|
75
|
+
/** The installed (name, version) pairs from plugin-registry entries. */
|
|
76
|
+
export declare function installedVersions(entries: ReadonlyArray<PluginRegistryEntry>): Array<{
|
|
77
|
+
name: string;
|
|
78
|
+
version: string;
|
|
79
|
+
}>;
|
|
80
|
+
export declare function formatPluginList(rows: ReadonlyArray<PluginMetadata>): ReadonlyArray<string>;
|
|
81
|
+
export declare function formatOutdated(rows: ReadonlyArray<OutdatedRow>): ReadonlyArray<string>;
|
|
82
|
+
/** The minimal PublishDraft fields the publish loop needs (structurally
|
|
83
|
+
* satisfied by BOTH marketplace clients' PublishDraft shapes). */
|
|
84
|
+
export type PublishDraftLike = {
|
|
85
|
+
readonly prTitle: string;
|
|
86
|
+
readonly prBody: string;
|
|
87
|
+
/** repo-relative path the manifest is written to on the branch. */
|
|
88
|
+
readonly manifestRelPath: string;
|
|
89
|
+
/** the manifest bytes to write. */
|
|
90
|
+
readonly manifestContents: string;
|
|
91
|
+
readonly name: string;
|
|
92
|
+
readonly version: string;
|
|
93
|
+
};
|
|
94
|
+
/** The write-and-open plan the driver executes for a publish. */
|
|
95
|
+
export type PublishPrPlan = {
|
|
96
|
+
readonly branch: string;
|
|
97
|
+
readonly title: string;
|
|
98
|
+
readonly body: string;
|
|
99
|
+
readonly files: Readonly<Record<string, string>>;
|
|
100
|
+
readonly commitMessage: string;
|
|
101
|
+
};
|
|
102
|
+
/** Assemble the publish PR plan from a draft. Pure. */
|
|
103
|
+
export declare function buildPublishPrPlan(draft: PublishDraftLike, now: Date): PublishPrPlan;
|
|
104
|
+
/** Driver seam: execute a `PublishPrPlan` (branch → write → commit → push →
|
|
105
|
+
* `gh pr create`). CLI wires git/gh; tests inject a stub. NEVER auto-merges. */
|
|
106
|
+
export type PublishPrDriver = (plan: PublishPrPlan) => Promise<{
|
|
107
|
+
readonly prNumber?: number;
|
|
108
|
+
readonly url: string;
|
|
109
|
+
readonly branch: string;
|
|
110
|
+
}>;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item 60 — the marketplace CLI: `crewhaus plugins {list,search,install,
|
|
3
|
+
* uninstall,publish,outdated}` and `crewhaus templates {list,search,use}`.
|
|
4
|
+
* These verbs were the explicitly-deferred CLI surface on top of the §42
|
|
5
|
+
* `module-marketplace-client` / §40 `template-marketplace-client` (their
|
|
6
|
+
* docstrings name "the CLI follow-up" as the secondary consumer); this
|
|
7
|
+
* closes them, plus the publish loop and a dependency-freshness watch.
|
|
8
|
+
*
|
|
9
|
+
* Factored out of the entry file `index.ts` (which runs a top-level argv
|
|
10
|
+
* switch and so cannot be imported by a test without executing the CLI).
|
|
11
|
+
* Side-effect-free on import, mirroring `datasets.ts` / `retention.ts`:
|
|
12
|
+
* - the registry-source construction + the marketplace clients are the only
|
|
13
|
+
* I/O, and they route network through an injectable `fetch` seam so
|
|
14
|
+
* `list`/`search`/`install`/`outdated` are tested against a seeded LOCAL
|
|
15
|
+
* registry or a mocked HTTP source with no real network;
|
|
16
|
+
* - the `outdated` comparison and the report formatters are pure;
|
|
17
|
+
* - the publish loop drives `gh` behind an injected driver seam.
|
|
18
|
+
*
|
|
19
|
+
* REGISTRY RESOLUTION — a `--registry <ref>` flag, else
|
|
20
|
+
* `CREWHAUS_PLUGIN_REGISTRY` / `CREWHAUS_TEMPLATE_REGISTRY`, selects the
|
|
21
|
+
* backend:
|
|
22
|
+
* - a filesystem path (or `file:<path>`) → a LOCAL source (a directory of
|
|
23
|
+
* manifest JSONs — the same on-disk shape the registries write);
|
|
24
|
+
* - an `http(s)://` URL → an HTTP source (a `{plugins:[]}` / `{templates:[]}`
|
|
25
|
+
* JSON index at `<url>` + per-name manifests at `<url>/<name>.json`).
|
|
26
|
+
* With nothing configured, the read verbs report "no registry configured"
|
|
27
|
+
* rather than reaching for a hardcoded endpoint.
|
|
28
|
+
*
|
|
29
|
+
* SIGNING — install respects `plugin-registry`'s fail-closed verification:
|
|
30
|
+
* when trust anchors are configured (via `--trust-anchor` / env), an unsigned
|
|
31
|
+
* or badly-signed plugin is refused; `--allow-unsigned` is the explicit dev
|
|
32
|
+
* opt-out. Templates carry their own `verifyingRegistry` wrapper (out of scope
|
|
33
|
+
* here — install fetches the manifest verbatim).
|
|
34
|
+
*/
|
|
35
|
+
import { homedir } from "node:os";
|
|
36
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
37
|
+
/** Thrown for operational failures (no registry configured, bad ref, driver
|
|
38
|
+
* failure). The CLI routes the message through `die()`; tests assert on
|
|
39
|
+
* `.message`. */
|
|
40
|
+
export class MarketplaceCliError extends Error {
|
|
41
|
+
name = "MarketplaceCliError";
|
|
42
|
+
}
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// default locations
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
/** Where installed plugins live (mirrors §41 trustedRoots). */
|
|
47
|
+
export function defaultPluginsDir() {
|
|
48
|
+
return join(homedir(), ".crewhaus", "plugins");
|
|
49
|
+
}
|
|
50
|
+
/** The plugin-registry install-record file. */
|
|
51
|
+
export function defaultPluginRegistryPath() {
|
|
52
|
+
return join(homedir(), ".crewhaus", "plugin-registry.json");
|
|
53
|
+
}
|
|
54
|
+
/** Where `templates use` writes the fetched template. Defaults to the cwd
|
|
55
|
+
* (a template is scaffolded into the working directory). */
|
|
56
|
+
export function defaultTemplateWorkspaceDir() {
|
|
57
|
+
return process.cwd();
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Resolve a registry ref string into a backend descriptor. `http(s)://…` →
|
|
61
|
+
* HTTP; anything else is a filesystem path (a leading `file:` is stripped).
|
|
62
|
+
* `undefined` (nothing configured) throws so a read verb fails loudly instead
|
|
63
|
+
* of silently hitting nothing.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveRegistryRef(ref, kindLabel) {
|
|
66
|
+
if (ref === undefined || ref === "") {
|
|
67
|
+
throw new MarketplaceCliError(`no ${kindLabel} registry configured — pass --registry <dir|url> or set the CREWHAUS_${kindLabel.toUpperCase()}_REGISTRY env var (a directory of manifest JSONs, or an http(s):// index URL)`);
|
|
68
|
+
}
|
|
69
|
+
if (/^https?:\/\//.test(ref))
|
|
70
|
+
return { kind: "http", baseUrl: ref.replace(/\/$/, "") };
|
|
71
|
+
const path = ref.startsWith("file:") ? ref.slice("file:".length) : ref;
|
|
72
|
+
return { kind: "local", dir: isAbsolute(path) ? path : resolve(process.cwd(), path) };
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// HTTP-backed ModuleRegistrySource (plugins)
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
/**
|
|
78
|
+
* A `ModuleRegistrySource` over an HTTP JSON index. The catalog is
|
|
79
|
+
* `GET <baseUrl>` returning `{ plugins: PluginMetadata[] }`; a manifest is
|
|
80
|
+
* `GET <baseUrl>/<name>.json` (optionally `<name>@<version>.json`). `fetch`
|
|
81
|
+
* is injected so tests drive it without a network.
|
|
82
|
+
*/
|
|
83
|
+
export function createHttpModuleRegistrySource(opts) {
|
|
84
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
85
|
+
const base = opts.baseUrl.replace(/\/$/, "");
|
|
86
|
+
return {
|
|
87
|
+
id: opts.id,
|
|
88
|
+
async listPlugins() {
|
|
89
|
+
const res = await fetchImpl(base);
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
throw new MarketplaceCliError(`plugin registry list ${res.status}: ${(await res.text()).slice(0, 256)}`);
|
|
92
|
+
}
|
|
93
|
+
const data = (await res.json());
|
|
94
|
+
if (!Array.isArray(data?.plugins)) {
|
|
95
|
+
throw new MarketplaceCliError("plugin registry list payload missing plugins[]");
|
|
96
|
+
}
|
|
97
|
+
return data.plugins;
|
|
98
|
+
},
|
|
99
|
+
async getManifest(name, version) {
|
|
100
|
+
const suffix = version !== undefined ? `${name}@${version}` : name;
|
|
101
|
+
const res = await fetchImpl(`${base}/${suffix}.json`);
|
|
102
|
+
if (!res.ok) {
|
|
103
|
+
throw new MarketplaceCliError(`plugin registry fetch "${suffix}" ${res.status}: ${(await res.text()).slice(0, 256)}`);
|
|
104
|
+
}
|
|
105
|
+
return (await res.json());
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* A LOCAL `ModuleRegistrySource` over a directory of plugin manifest JSONs
|
|
111
|
+
* (`<name>.json`). Mirrors template-registry's `LocalRegistrySource`; used
|
|
112
|
+
* for `--registry <dir>` and by tests. Reads go through injected fs seams so
|
|
113
|
+
* this stays unit-testable.
|
|
114
|
+
*/
|
|
115
|
+
export function createLocalModuleRegistrySource(opts) {
|
|
116
|
+
const manifestPath = (name, version) => join(opts.dir, `${version !== undefined ? `${name}@${version}` : name}.json`);
|
|
117
|
+
return {
|
|
118
|
+
id: `local:${opts.dir}`,
|
|
119
|
+
async listPlugins() {
|
|
120
|
+
if (!opts.existsImpl(opts.dir))
|
|
121
|
+
return [];
|
|
122
|
+
const out = [];
|
|
123
|
+
for (const f of opts.readdirImpl(opts.dir)) {
|
|
124
|
+
if (!f.endsWith(".json") || f.includes("@"))
|
|
125
|
+
continue;
|
|
126
|
+
try {
|
|
127
|
+
const m = JSON.parse(opts.readFileImpl(join(opts.dir, f)));
|
|
128
|
+
out.push(toMetadata(m));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// skip malformed
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
135
|
+
},
|
|
136
|
+
async getManifest(name, version) {
|
|
137
|
+
const versioned = manifestPath(name, version);
|
|
138
|
+
const path = version !== undefined && opts.existsImpl(versioned) ? versioned : manifestPath(name);
|
|
139
|
+
if (!opts.existsImpl(path)) {
|
|
140
|
+
throw new MarketplaceCliError(`plugin "${name}" not found in ${opts.dir}`);
|
|
141
|
+
}
|
|
142
|
+
return JSON.parse(opts.readFileImpl(path));
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** Project a full manifest down to the search metadata shape. */
|
|
147
|
+
export function toMetadata(m) {
|
|
148
|
+
const rec = m;
|
|
149
|
+
return {
|
|
150
|
+
name: m.name,
|
|
151
|
+
version: m.version,
|
|
152
|
+
...(typeof rec["description"] === "string" ? { description: rec["description"] } : {}),
|
|
153
|
+
...(typeof rec["author"] === "string" ? { author: rec["author"] } : {}),
|
|
154
|
+
...(typeof rec["homepage"] === "string" ? { homepage: rec["homepage"] } : {}),
|
|
155
|
+
...(typeof rec["license"] === "string" ? { license: rec["license"] } : {}),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// outdated (dependency-freshness watch)
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
/** Compare two `major.minor.patch` cores; pre-release/build ignored (matches
|
|
162
|
+
* module-marketplace-client's own `compareSemver`). */
|
|
163
|
+
export function compareSemverCore(a, b) {
|
|
164
|
+
const core = (v) => (v.split("-")[0] ?? "").split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
165
|
+
const aN = core(a);
|
|
166
|
+
const bN = core(b);
|
|
167
|
+
for (let i = 0; i < 3; i++) {
|
|
168
|
+
const ai = aN[i] ?? 0;
|
|
169
|
+
const bi = bN[i] ?? 0;
|
|
170
|
+
if (ai !== bi)
|
|
171
|
+
return ai - bi;
|
|
172
|
+
}
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Compute the freshness report: for each installed plugin, its installed
|
|
177
|
+
* version vs the remote latest. `unknown` when the plugin is not in the
|
|
178
|
+
* remote catalog (a private/local install). Pure over the two lists.
|
|
179
|
+
*/
|
|
180
|
+
export function computeOutdated(installed, remote) {
|
|
181
|
+
const latestByName = new Map();
|
|
182
|
+
for (const p of remote) {
|
|
183
|
+
const prev = latestByName.get(p.name);
|
|
184
|
+
if (prev === undefined || compareSemverCore(p.version, prev) > 0) {
|
|
185
|
+
latestByName.set(p.name, p.version);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const rows = [];
|
|
189
|
+
for (const inst of installed) {
|
|
190
|
+
const latest = latestByName.get(inst.name);
|
|
191
|
+
if (latest === undefined) {
|
|
192
|
+
rows.push({ name: inst.name, installed: inst.version, status: "unknown" });
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const status = compareSemverCore(latest, inst.version) > 0 ? "outdated" : "current";
|
|
196
|
+
rows.push({ name: inst.name, installed: inst.version, latest, status });
|
|
197
|
+
}
|
|
198
|
+
return rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
199
|
+
}
|
|
200
|
+
/** The installed (name, version) pairs from plugin-registry entries. */
|
|
201
|
+
export function installedVersions(entries) {
|
|
202
|
+
return entries.map((e) => ({
|
|
203
|
+
name: e.manifest.name,
|
|
204
|
+
version: e.pinnedVersion ?? e.manifest.version,
|
|
205
|
+
}));
|
|
206
|
+
}
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// formatters (pure)
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
export function formatPluginList(rows) {
|
|
211
|
+
if (rows.length === 0)
|
|
212
|
+
return ["no plugins in the registry"];
|
|
213
|
+
return rows.map((p) => `${p.name}@${p.version}${p.author !== undefined ? ` — ${p.author}` : ""}${p.description !== undefined ? `\n ${p.description}` : ""}`);
|
|
214
|
+
}
|
|
215
|
+
export function formatOutdated(rows) {
|
|
216
|
+
if (rows.length === 0)
|
|
217
|
+
return ["no plugins installed"];
|
|
218
|
+
const lines = [];
|
|
219
|
+
let outdated = 0;
|
|
220
|
+
for (const r of rows) {
|
|
221
|
+
if (r.status === "outdated") {
|
|
222
|
+
outdated += 1;
|
|
223
|
+
lines.push(`⤴ ${r.name}: ${r.installed} → ${r.latest} (update available)`);
|
|
224
|
+
}
|
|
225
|
+
else if (r.status === "current") {
|
|
226
|
+
lines.push(`✓ ${r.name}: ${r.installed} (current)`);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
lines.push(`? ${r.name}: ${r.installed} (not in the configured registry)`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
lines.push("");
|
|
233
|
+
lines.push(outdated === 0
|
|
234
|
+
? "summary: all installed plugins current"
|
|
235
|
+
: `summary: ${outdated} update(s) available`);
|
|
236
|
+
return lines;
|
|
237
|
+
}
|
|
238
|
+
/** Assemble the publish PR plan from a draft. Pure. */
|
|
239
|
+
export function buildPublishPrPlan(draft, now) {
|
|
240
|
+
const slug = draft.name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
241
|
+
const stamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
242
|
+
const branch = `publish/${slug || "plugin"}-${draft.version}-${stamp}`;
|
|
243
|
+
return {
|
|
244
|
+
branch,
|
|
245
|
+
title: draft.prTitle,
|
|
246
|
+
body: draft.prBody,
|
|
247
|
+
files: { [draft.manifestRelPath]: draft.manifestContents },
|
|
248
|
+
commitMessage: `publish(${draft.name}): ${draft.version}`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F5 — sanitise an mcp_servers KEY into a single safe filename segment before it
|
|
3
|
+
* is joined onto `.crewhaus/mcp/` (the drift-snapshot write). The mcp_servers
|
|
4
|
+
* key schema is `z.string().min(1)` (NOT `safeName`), so a server named
|
|
5
|
+
* `../../evil` — or one carrying a slash / NUL — would let
|
|
6
|
+
* `join(mcpDir, `${name}.json`)` escape the directory. We `basename` to strip
|
|
7
|
+
* path components, then replace every character outside the conservative
|
|
8
|
+
* `[A-Za-z0-9._-]` set with `_` (the same class the eval-runner + graders use),
|
|
9
|
+
* and floor an empty / dot-only result to a stable placeholder. The result is
|
|
10
|
+
* always a bare filename that stays inside the snapshot dir.
|
|
11
|
+
*/
|
|
12
|
+
export declare function safeMcpFileName(name: string): string;
|
|
13
|
+
/** One durable `mcp_stats` record's payload (what runtime-core persists). */
|
|
14
|
+
export type McpStatsPayload = {
|
|
15
|
+
readonly server: string;
|
|
16
|
+
readonly toolName: string;
|
|
17
|
+
readonly durationMs: number;
|
|
18
|
+
readonly isError: boolean;
|
|
19
|
+
};
|
|
20
|
+
/** Per-server rolled-up health over the scored session window. */
|
|
21
|
+
export type McpServerHealth = {
|
|
22
|
+
readonly server: string;
|
|
23
|
+
readonly calls: number;
|
|
24
|
+
readonly errors: number;
|
|
25
|
+
readonly errorRate: number;
|
|
26
|
+
/** p50 / p95 call latency, ms (nearest-rank). */
|
|
27
|
+
readonly p50Ms: number;
|
|
28
|
+
readonly p95Ms: number;
|
|
29
|
+
/** Longest run of CONSECUTIVE erroring calls — a reconnect-churn proxy, since
|
|
30
|
+
* the mcp client's reconnect events are Logger-only (not durable). */
|
|
31
|
+
readonly maxErrorStreak: number;
|
|
32
|
+
/** True when the server is chronically failing (see {@link QUARANTINE_POLICY}). */
|
|
33
|
+
readonly chronic: boolean;
|
|
34
|
+
};
|
|
35
|
+
/** Thresholds a server must cross to be judged chronically failing → quarantine
|
|
36
|
+
* candidate. Kept together so the report and the quarantine decision agree. */
|
|
37
|
+
export declare const QUARANTINE_POLICY: {
|
|
38
|
+
/** Minimum calls before an error rate is trustworthy (cold-start guard). */
|
|
39
|
+
readonly minCalls: 5;
|
|
40
|
+
/** Error-rate ceiling; above it (with enough calls) the server is chronic. */
|
|
41
|
+
readonly errorRate: 0.5;
|
|
42
|
+
/** OR: this many consecutive errors is chronic regardless of overall rate
|
|
43
|
+
* (a server that just went hard-down mid-session). */
|
|
44
|
+
readonly errorStreak: 4;
|
|
45
|
+
};
|
|
46
|
+
/** Nearest-rank percentile (0-based floor). Empty ⇒ 0. */
|
|
47
|
+
export declare function percentile(values: ReadonlyArray<number>, p: number): number;
|
|
48
|
+
/**
|
|
49
|
+
* Fold a flat stream of `mcp_stats` payloads (already read from session logs,
|
|
50
|
+
* oldest-first) into per-server health. The `maxErrorStreak` is computed over
|
|
51
|
+
* the input order, so callers should pass records in chronological order (the
|
|
52
|
+
* session-log reader yields insertion order — which is chronological).
|
|
53
|
+
*/
|
|
54
|
+
export declare function scoreMcpHealth(records: ReadonlyArray<McpStatsPayload>): McpServerHealth[];
|
|
55
|
+
/** Chronic-failure verdict shared by scoring + quarantine decision. */
|
|
56
|
+
export declare function isChronic(calls: number, errorRate: number, maxErrorStreak: number): boolean;
|
|
57
|
+
/** A single tool as advertised by a server, for drift snapshotting. */
|
|
58
|
+
export type McpToolSnapshot = {
|
|
59
|
+
readonly name: string;
|
|
60
|
+
/** Stable hash of the tool's JSON-schema (order-insensitive). */
|
|
61
|
+
readonly schemaHash: string;
|
|
62
|
+
};
|
|
63
|
+
/** The on-disk drift snapshot for one server (`.crewhaus/mcp/<server>.json`). */
|
|
64
|
+
export type McpServerSnapshot = {
|
|
65
|
+
readonly version: 1;
|
|
66
|
+
readonly server: string;
|
|
67
|
+
readonly ts: string;
|
|
68
|
+
readonly tools: ReadonlyArray<McpToolSnapshot>;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Stable hash of a JSON-schema value: recursively sort object keys so
|
|
72
|
+
* `{a,b}` and `{b,a}` hash identically, then sha256 the canonical form. A
|
|
73
|
+
* server that reorders its schema keys between connects is NOT drift; a server
|
|
74
|
+
* that changes a field's type IS. Returns the first 16 hex chars (collision-
|
|
75
|
+
* safe enough for a per-tool change signal).
|
|
76
|
+
*/
|
|
77
|
+
export declare function schemaHash(schema: unknown): string;
|
|
78
|
+
/** Build a fresh snapshot from a server's live `listTools` result. */
|
|
79
|
+
export declare function buildSnapshot(server: string, tools: ReadonlyArray<{
|
|
80
|
+
readonly name: string;
|
|
81
|
+
readonly inputSchema: unknown;
|
|
82
|
+
}>, ts: string): McpServerSnapshot;
|
|
83
|
+
export type McpDrift = {
|
|
84
|
+
readonly added: ReadonlyArray<string>;
|
|
85
|
+
readonly removed: ReadonlyArray<string>;
|
|
86
|
+
/** Tools present in both but whose schema hash changed. */
|
|
87
|
+
readonly schemaChanged: ReadonlyArray<string>;
|
|
88
|
+
};
|
|
89
|
+
/** True when nothing changed. */
|
|
90
|
+
export declare function driftIsEmpty(drift: McpDrift): boolean;
|
|
91
|
+
/**
|
|
92
|
+
* Diff a previous snapshot against the current one. `previous` undefined (no
|
|
93
|
+
* snapshot on disk yet) ⇒ empty drift (the current becomes the new baseline;
|
|
94
|
+
* a first-ever connect is not "drift"). Added = in current not previous;
|
|
95
|
+
* removed = in previous not current; schemaChanged = in both, different hash.
|
|
96
|
+
*/
|
|
97
|
+
export declare function diffSnapshots(previous: McpServerSnapshot | undefined, current: McpServerSnapshot): McpDrift;
|
|
98
|
+
export type QuarantineDecision = {
|
|
99
|
+
/** Servers whose tools should be WITHDRAWN (chronic + not already out). */
|
|
100
|
+
readonly quarantine: ReadonlyArray<string>;
|
|
101
|
+
/** Servers whose tools should be RESTORED (were out, now healthy). */
|
|
102
|
+
readonly restore: ReadonlyArray<string>;
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Decide catalog mutations from the current health + the set of servers whose
|
|
106
|
+
* tools are ALREADY quarantined. A chronic server not yet quarantined →
|
|
107
|
+
* quarantine; a quarantined server that is no longer chronic (its recent
|
|
108
|
+
* window is healthy — e.g. a probe succeeded) → restore. A server absent from
|
|
109
|
+
* `health` entirely (no recent calls) is left as-is: no signal either way.
|
|
110
|
+
*/
|
|
111
|
+
export declare function decideQuarantine(health: ReadonlyArray<McpServerHealth>, alreadyQuarantined: ReadonlyArray<string>): QuarantineDecision;
|
|
112
|
+
/**
|
|
113
|
+
* The synthetic notice injected in place of a quarantined server's tools so the
|
|
114
|
+
* model routes around them — mirrors loop-detection's warning injection. Kept
|
|
115
|
+
* here so the report and the runtime injection render the identical text.
|
|
116
|
+
*/
|
|
117
|
+
export declare function quarantineNotice(server: string, reason: string): string;
|
|
118
|
+
/** Render the per-server health table lines for `crewhaus mcp doctor`. */
|
|
119
|
+
export declare function formatHealthReport(health: ReadonlyArray<McpServerHealth>): string[];
|
|
120
|
+
/** Render the drift lines for one server. Empty array when no drift. */
|
|
121
|
+
export declare function formatDriftReport(server: string, drift: McpDrift): string[];
|