sparkforensics-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/sparkforensics-analyze.mjs +288 -0
- package/package.json +29 -0
- package/vendor-core/analyzer.js +167 -0
- package/vendor-core/assert-never.js +3 -0
- package/vendor-core/cli/budgets.js +203 -0
- package/vendor-core/cli/collect-run.js +107 -0
- package/vendor-core/core-count.js +68 -0
- package/vendor-core/core-locality-ratio.js +54 -0
- package/vendor-core/core-time-series.js +92 -0
- package/vendor-core/core-usage-locality.js +70 -0
- package/vendor-core/detectors.js +1989 -0
- package/vendor-core/docs-config.js +72 -0
- package/vendor-core/docs-site-config.js +23 -0
- package/vendor-core/efficiency-model.js +62 -0
- package/vendor-core/etl-phases.js +28 -0
- package/vendor-core/event-handlers.js +906 -0
- package/vendor-core/event-schemas.js +405 -0
- package/vendor-core/evidence-availability.js +121 -0
- package/vendor-core/evidence-report.js +459 -0
- package/vendor-core/finding-action-label.js +97 -0
- package/vendor-core/finding-filter-predicate.js +38 -0
- package/vendor-core/format-utils.js +167 -0
- package/vendor-core/impact-band.js +50 -0
- package/vendor-core/impact-estimator.js +428 -0
- package/vendor-core/ingest.js +139 -0
- package/vendor-core/job-groups.js +30 -0
- package/vendor-core/load-vendored.js +24 -0
- package/vendor-core/lz4-block.js +135 -0
- package/vendor-core/mcp-error.js +3 -0
- package/vendor-core/mcp-server-factory.js +115 -0
- package/vendor-core/mcp-tools.js +331 -0
- package/vendor-core/model-assembler.js +76 -0
- package/vendor-core/occupancy.js +202 -0
- package/vendor-core/parser-worker.js +249 -0
- package/vendor-core/plan-dot.js +25 -0
- package/vendor-core/plan-duration-attribution.js +185 -0
- package/vendor-core/plan-graph-model.js +171 -0
- package/vendor-core/plan-node-detail.js +159 -0
- package/vendor-core/plan-summary.js +233 -0
- package/vendor-core/plan-tree-walk.js +29 -0
- package/vendor-core/proxy.js +157 -0
- package/vendor-core/recommendation-rollup.js +197 -0
- package/vendor-core/redact.js +175 -0
- package/vendor-core/rolling-log-reassembly.js +52 -0
- package/vendor-core/run-aggregates.js +44 -0
- package/vendor-core/run-comparison.js +458 -0
- package/vendor-core/scaling-sim.js +73 -0
- package/vendor-core/session-snapshot.js +79 -0
- package/vendor-core/shs-fetch.js +196 -0
- package/vendor-core/shs-load.js +121 -0
- package/vendor-core/shs-request.js +101 -0
- package/vendor-core/shs-schemas.js +13 -0
- package/vendor-core/snappy-block.js +140 -0
- package/vendor-core/stage-quantiles.js +199 -0
- package/vendor-core/threshold-summary.js +35 -0
- package/vendor-core/types.js +286 -0
- package/vendor-core/vendor/fflate.js +2695 -0
- package/vendor-core/vendor/fzstd.js +768 -0
- package/vendor-core/wall-clock.js +36 -0
- package/vendor-core/wasted-core-hours.js +68 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Worker/CLI-shared message router: maps a worker (or Node-synchronous,
|
|
2
|
+
// per src/cli/collect-run.js's dispatch()) message to its handler callback.
|
|
3
|
+
// Exported so both contexts run one switch instead of hand-kept-in-sync
|
|
4
|
+
// copies. `pendingTaskRequests` is optional: the CLI path never sends a
|
|
5
|
+
// `getTaskData` request, so it never receives a `taskData` message back.
|
|
6
|
+
// Handler dispatch is optional-chained (`handlers.onXxx?.(...)`), so a
|
|
7
|
+
// caller that adds a new message type here without wiring its handler
|
|
8
|
+
// fails silently (a no-op) rather than throwing.
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
export function routeMessage(
|
|
30
|
+
data ,
|
|
31
|
+
handlers ,
|
|
32
|
+
pendingTaskRequests
|
|
33
|
+
) {
|
|
34
|
+
switch (data.type) {
|
|
35
|
+
case 'progress': handlers.onProgress?.(data); break;
|
|
36
|
+
case 'app': handlers.onApp?.(data.data); break;
|
|
37
|
+
case 'stage': handlers.onStage?.(data.data); break;
|
|
38
|
+
case 'sql': handlers.onSql?.(data.data); break;
|
|
39
|
+
case 'sqlPlan': handlers.onSqlPlan?.(data.data); break;
|
|
40
|
+
case 'executor': handlers.onExecutor?.(data.data); break;
|
|
41
|
+
case 'job': handlers.onJob?.(data.data); break;
|
|
42
|
+
case 'runAggregates': handlers.onRunAggregates?.(data.data); break;
|
|
43
|
+
case 'stageExecutorMetrics': handlers.onStageExecutorMetrics?.(data.data); break;
|
|
44
|
+
case 'done': {
|
|
45
|
+
const { skippedLines } = data;
|
|
46
|
+
handlers.onDone?.({ skippedLines });
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
case 'error': handlers.onError?.(data); break;
|
|
50
|
+
case 'taskData': {
|
|
51
|
+
const pending = pendingTaskRequests?.get(data.reqId );
|
|
52
|
+
if (pending) {
|
|
53
|
+
pending.resolve({ metrics: data.metrics, fieldNames: data.fieldNames });
|
|
54
|
+
pendingTaskRequests .delete(data.reqId );
|
|
55
|
+
}
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
export function createIngestClient()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
let worker = null;
|
|
75
|
+
const pendingTaskRequests = new Map ();
|
|
76
|
+
let reqCounter = 0;
|
|
77
|
+
|
|
78
|
+
let handlers = {};
|
|
79
|
+
|
|
80
|
+
// The `new Worker(new URL(...), ...)` expression must appear inline, exactly
|
|
81
|
+
// like this, for Vite's worker plugin to statically detect and bundle it
|
|
82
|
+
// (including its own imports, e.g. vendor/fflate.js): routing the URL
|
|
83
|
+
// through an intermediate variable defeats that detection and leaves the
|
|
84
|
+
// worker's dependencies unbundled, 404ing at runtime in a production build.
|
|
85
|
+
function makeWorker() {
|
|
86
|
+
worker = new Worker(new URL('./parser-worker.js', import.meta.url), { type: 'module' });
|
|
87
|
+
worker.onmessage = ({ data }) => routeMessage(data, handlers, pendingTaskRequests);
|
|
88
|
+
worker.onerror = (err) => {
|
|
89
|
+
handlers.onError?.(`Worker crashed: ${err.message}`);
|
|
90
|
+
terminate();
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function startParse(file , h ) {
|
|
95
|
+
handlers = h;
|
|
96
|
+
makeWorker();
|
|
97
|
+
worker .postMessage({ type: 'parse', file });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function startParseFromUrl(request , h ) {
|
|
101
|
+
handlers = h;
|
|
102
|
+
makeWorker();
|
|
103
|
+
worker .postMessage({ type: 'parseFromUrl', request });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function startParseFiles(files , h ) {
|
|
107
|
+
handlers = h;
|
|
108
|
+
makeWorker();
|
|
109
|
+
worker .postMessage({ type: 'parseFiles', files });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function requestTaskData(stageId ) {
|
|
113
|
+
return new Promise ((resolve, reject) => {
|
|
114
|
+
const reqId = String(reqCounter++);
|
|
115
|
+
pendingTaskRequests.set(reqId, { resolve, reject });
|
|
116
|
+
worker .postMessage({ type: 'getTaskData', stageId, reqId });
|
|
117
|
+
}) ;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function prefetchFlaggedStages(flaggedStageIds ) {
|
|
121
|
+
const results = await Promise.all(
|
|
122
|
+
flaggedStageIds.map(stageId =>
|
|
123
|
+
requestTaskData(stageId).then(data => ({ stageId, ...data }))
|
|
124
|
+
)
|
|
125
|
+
);
|
|
126
|
+
return results;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function terminate() {
|
|
130
|
+
worker?.terminate();
|
|
131
|
+
worker = null;
|
|
132
|
+
for (const { reject } of pendingTaskRequests.values()) {
|
|
133
|
+
reject(new Error('Worker terminated'));
|
|
134
|
+
}
|
|
135
|
+
pendingTaskRequests.clear();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { startParse, startParseFromUrl, startParseFiles, requestTaskData, prefetchFlaggedStages, terminate };
|
|
139
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// §8 Concurrent-job-group reliability guard (SparkLens JobOverlapAnalyzer).
|
|
2
|
+
// Groups jobs by SQL execution id; jobs with none form their own singleton
|
|
3
|
+
// group. If two DIFFERENT groups' [submissionTime, completionTime] intervals
|
|
4
|
+
// overlap, wall-clock-based estimates (scaling simulator, efficiency model)
|
|
5
|
+
// become unreliable. Jobs WITHIN a group overlapping (AQE/multi-stage) is
|
|
6
|
+
// normal and not the signal.
|
|
7
|
+
export function checkConcurrentJobGroups(jobs ) {
|
|
8
|
+
const list = jobs ? [...jobs.values()] : [];
|
|
9
|
+
// Build one interval per group: [min submission, max completion].
|
|
10
|
+
const groups = new Map(); // key -> { start, end }
|
|
11
|
+
let singletonSeq = 0;
|
|
12
|
+
for (const j of list) {
|
|
13
|
+
if (j.submissionTime == null || j.completionTime == null) continue;
|
|
14
|
+
const key = j.sqlExecutionId != null ? `sql:${j.sqlExecutionId}` : `job:${j.id ?? singletonSeq++}`;
|
|
15
|
+
const g = groups.get(key);
|
|
16
|
+
if (!g) groups.set(key, { start: j.submissionTime, end: j.completionTime });
|
|
17
|
+
else { g.start = Math.min(g.start, j.submissionTime); g.end = Math.max(g.end, j.completionTime); }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const entries = [...groups.entries()].sort((a, b) => a[1].start - b[1].start);
|
|
21
|
+
const overlappingGroupIds = [];
|
|
22
|
+
for (let i = 1; i < entries.length; i++) {
|
|
23
|
+
for (let k = 0; k < i; k++) {
|
|
24
|
+
const [aKey, a] = entries[k];
|
|
25
|
+
const [bKey, b] = entries[i];
|
|
26
|
+
if (b.start < a.end && a.start < b.end) overlappingGroupIds.push([aKey, bKey] );
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { wallClockReliable: overlappingGroupIds.length === 0, overlappingGroupIds };
|
|
30
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Plain JS (like proxy.js): scripts/vendor-core.mjs copies it byte-for-byte
|
|
2
|
+
// into vendor-core/, so this exact file exists at both
|
|
3
|
+
// vendor-core/load-vendored.js (published install) and
|
|
4
|
+
// core/src/load-vendored.js (monorepo dev). Each published package's bin
|
|
5
|
+
// entry point bootstraps by locating *this* file first (a tiny fixed-name
|
|
6
|
+
// existsSync/join check, unavoidably duplicated per entry point since
|
|
7
|
+
// nothing can resolve it for them), then uses the exports below for every
|
|
8
|
+
// other core module, so the actual resolution logic lives in exactly one
|
|
9
|
+
// place instead of being re-implemented per entry point.
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { pathToFileURL } from 'node:url';
|
|
13
|
+
|
|
14
|
+
// moduleName is a path relative to core/src without extension, e.g.
|
|
15
|
+
// 'cli/collect-run' or 'shs-load'. Pass srcExt: 'js' for modules that are
|
|
16
|
+
// already plain JS in core/src (e.g. 'proxy') rather than TypeScript.
|
|
17
|
+
export function resolveVendored(pkgDir, moduleName, { srcExt = 'ts' } = {}) {
|
|
18
|
+
const vendored = join(pkgDir, 'vendor-core', `${moduleName}.js`);
|
|
19
|
+
return existsSync(vendored) ? vendored : join(pkgDir, '..', 'core', 'src', `${moduleName}.${srcExt}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function loadVendored(pkgDir, moduleName, opts) {
|
|
23
|
+
return import(pathToFileURL(resolveVendored(pkgDir, moduleName, opts)).href);
|
|
24
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
const MAGIC = [76, 90, 52, 66, 108, 111, 99, 107]; // "LZ4Block"
|
|
2
|
+
|
|
3
|
+
function readInt32LE(bytes , offset ) {
|
|
4
|
+
return (bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24)) >>> 0;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// Standard LZ4 block decompression (the same algorithm used by every LZ4
|
|
8
|
+
// implementation: only the outer per-block framing above is Spark-specific).
|
|
9
|
+
function decompressLz4Sequence(input , outSize ) {
|
|
10
|
+
const out = new Uint8Array(outSize);
|
|
11
|
+
let ip = 0, op = 0;
|
|
12
|
+
const n = input.length;
|
|
13
|
+
while (ip < n) {
|
|
14
|
+
const token = input[ip++];
|
|
15
|
+
let literalLength = token >> 4;
|
|
16
|
+
if (literalLength === 15) {
|
|
17
|
+
let b;
|
|
18
|
+
do { b = input[ip++]; literalLength += b; } while (b === 255);
|
|
19
|
+
}
|
|
20
|
+
out.set(input.subarray(ip, ip + literalLength), op);
|
|
21
|
+
ip += literalLength;
|
|
22
|
+
op += literalLength;
|
|
23
|
+
if (ip >= n) break; // final sequence has no match part
|
|
24
|
+
const offset = input[ip] | (input[ip + 1] << 8);
|
|
25
|
+
ip += 2;
|
|
26
|
+
let matchLength = token & 0x0f;
|
|
27
|
+
if (matchLength === 15) {
|
|
28
|
+
let b;
|
|
29
|
+
do { b = input[ip++]; matchLength += b; } while (b === 255);
|
|
30
|
+
}
|
|
31
|
+
matchLength += 4; // minimum match length
|
|
32
|
+
let matchPos = op - offset;
|
|
33
|
+
for (let i = 0; i < matchLength; i++) out[op++] = out[matchPos++];
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Streaming counterpart to decodeLz4Block: push arbitrary byte slices, and each
|
|
39
|
+
// fully-received LZ4Block block is decompressed and handed to `onChunk` as it
|
|
40
|
+
// completes, so the decompressed output is never fully buffered (bounded to one
|
|
41
|
+
// block at a time). Partial trailing bytes are retained until the next push.
|
|
42
|
+
// `onChunk` must consume its argument synchronously (it aliases the internal
|
|
43
|
+
// buffer for RAW blocks and is not retained across the next push).
|
|
44
|
+
export function createLz4BlockDecoder(
|
|
45
|
+
onChunk ,
|
|
46
|
+
) {
|
|
47
|
+
let buf = new Uint8Array(0);
|
|
48
|
+
return {
|
|
49
|
+
push(chunk ) {
|
|
50
|
+
if (buf.length === 0) {
|
|
51
|
+
buf = chunk;
|
|
52
|
+
} else if (chunk.length) {
|
|
53
|
+
const merged = new Uint8Array(buf.length + chunk.length);
|
|
54
|
+
merged.set(buf); merged.set(chunk, buf.length);
|
|
55
|
+
buf = merged;
|
|
56
|
+
}
|
|
57
|
+
let pos = 0;
|
|
58
|
+
while (buf.length - pos >= 21) {
|
|
59
|
+
for (let i = 0; i < 8; i++) {
|
|
60
|
+
if (buf[pos + i] !== MAGIC[i]) {
|
|
61
|
+
throw new Error(`Not a Spark LZ4Block stream: bad magic at offset ${pos}.`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const method = buf[pos + 8] & 0xf0;
|
|
65
|
+
const compressedLength = readInt32LE(buf, pos + 9);
|
|
66
|
+
const decompressedLength = readInt32LE(buf, pos + 13);
|
|
67
|
+
const bodyStart = pos + 21;
|
|
68
|
+
if (buf.length - bodyStart < compressedLength) break; // block not fully arrived yet
|
|
69
|
+
const body = buf.subarray(bodyStart, bodyStart + compressedLength);
|
|
70
|
+
|
|
71
|
+
let out;
|
|
72
|
+
if (method === 0x10) {
|
|
73
|
+
out = body;
|
|
74
|
+
} else if (method === 0x20) {
|
|
75
|
+
out = decompressLz4Sequence(body, decompressedLength);
|
|
76
|
+
} else {
|
|
77
|
+
throw new Error(`Unknown LZ4Block method 0x${method.toString(16)} at offset ${pos}.`);
|
|
78
|
+
}
|
|
79
|
+
if (out.length !== decompressedLength) {
|
|
80
|
+
throw new Error(`LZ4Block length mismatch at offset ${pos}: expected ${decompressedLength}, got ${out.length}.`);
|
|
81
|
+
}
|
|
82
|
+
onChunk(out);
|
|
83
|
+
pos = bodyStart + compressedLength;
|
|
84
|
+
}
|
|
85
|
+
// Copy (not subarray) the unconsumed tail so the large read-slice buffer
|
|
86
|
+
// can be garbage-collected instead of being pinned by a view.
|
|
87
|
+
buf = pos > 0 ? buf.slice(pos) : buf;
|
|
88
|
+
},
|
|
89
|
+
end() {
|
|
90
|
+
if (buf.length !== 0) {
|
|
91
|
+
throw new Error(`Trailing ${buf.length} undecoded bytes in LZ4Block stream.`);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function decodeLz4Block(bytes ) {
|
|
98
|
+
const chunks = [];
|
|
99
|
+
let pos = 0;
|
|
100
|
+
const n = bytes.length;
|
|
101
|
+
while (pos < n) {
|
|
102
|
+
for (let i = 0; i < 8; i++) {
|
|
103
|
+
if (bytes[pos + i] !== MAGIC[i]) {
|
|
104
|
+
throw new Error(`Not a Spark LZ4Block stream: bad magic at offset ${pos}.`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const token = bytes[pos + 8];
|
|
108
|
+
const method = token & 0xf0;
|
|
109
|
+
const compressedLength = readInt32LE(bytes, pos + 9);
|
|
110
|
+
const decompressedLength = readInt32LE(bytes, pos + 13);
|
|
111
|
+
// checksum at pos+17..pos+20 (xxhash32 of decompressed data), not verified
|
|
112
|
+
const bodyStart = pos + 21;
|
|
113
|
+
const body = bytes.subarray(bodyStart, bodyStart + compressedLength);
|
|
114
|
+
|
|
115
|
+
let chunk;
|
|
116
|
+
if (method === 0x10) {
|
|
117
|
+
chunk = body;
|
|
118
|
+
} else if (method === 0x20) {
|
|
119
|
+
chunk = decompressLz4Sequence(body, decompressedLength);
|
|
120
|
+
} else {
|
|
121
|
+
throw new Error(`Unknown LZ4Block method 0x${method.toString(16)} at offset ${pos}.`);
|
|
122
|
+
}
|
|
123
|
+
if (chunk.length !== decompressedLength) {
|
|
124
|
+
throw new Error(`LZ4Block length mismatch at offset ${pos}: expected ${decompressedLength}, got ${chunk.length}.`);
|
|
125
|
+
}
|
|
126
|
+
chunks.push(chunk);
|
|
127
|
+
pos = bodyStart + compressedLength;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const total = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
131
|
+
const out = new Uint8Array(total);
|
|
132
|
+
let offset = 0;
|
|
133
|
+
for (const c of chunks) { out.set(c, offset); offset += c.length; }
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
resolveOrCreateRun, diagnoseRun, getRunSummary, compareRuns, getFindingEvidence, evaluateBudgetsForRun,
|
|
6
|
+
} from './mcp-tools.js';
|
|
7
|
+
|
|
8
|
+
const sourceSchema = z.union([
|
|
9
|
+
z.object({ path: z.string() }),
|
|
10
|
+
z.object({ shsBaseUrl: z.string(), appId: z.string(), attemptId: z.string().optional() }),
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const runRefSchema = { source: sourceSchema.optional(), runId: z.string().optional() };
|
|
14
|
+
const secondRunRefSchema = { sourceB: sourceSchema.optional(), runIdB: z.string().optional() };
|
|
15
|
+
const formatSchema = { format: z.enum(['json', 'md']).optional() };
|
|
16
|
+
|
|
17
|
+
function toCallToolResult(text , structuredContent ) {
|
|
18
|
+
return { content: [{ type: 'text' , text }], structuredContent };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toolErrorResult(error ) {
|
|
22
|
+
return {
|
|
23
|
+
isError: true,
|
|
24
|
+
content: [{ type: 'text' , text: error.message }],
|
|
25
|
+
structuredContent: { code: error.code ?? 'access-or-upstream-failure' },
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// diagnoseRun/compareRuns may additionally carry a `markdown` field (only
|
|
30
|
+
// when the caller asked for `format: 'md'`); that field never belongs in
|
|
31
|
+
// structuredContent (its shape must stay stable regardless of `format`), so
|
|
32
|
+
// it's stripped here, and — when present — becomes content[0].text instead
|
|
33
|
+
// of the usual JSON.stringify.
|
|
34
|
+
function toolResultWithMarkdown (promise ) {
|
|
35
|
+
return promise.then(
|
|
36
|
+
(value) => {
|
|
37
|
+
const { markdown, ...structuredContent } = value;
|
|
38
|
+
return toCallToolResult(markdown !== undefined ? markdown : JSON.stringify(structuredContent), structuredContent );
|
|
39
|
+
},
|
|
40
|
+
toolErrorResult,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Counterpart for the 3 tools that never produce a markdown field: its own
|
|
45
|
+
// mapping (not a delegation through toolResultWithMarkdown), so there's no
|
|
46
|
+
// need to cast T to pretend it might carry a `markdown` field it never does.
|
|
47
|
+
function toolResult (promise ) {
|
|
48
|
+
return promise.then((value) => toCallToolResult(JSON.stringify(value), value ), toolErrorResult);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createMcpServer() {
|
|
52
|
+
const server = new McpServer({ name: 'sparkforensics', version: '1.0.0' });
|
|
53
|
+
|
|
54
|
+
server.registerTool('diagnose_run', {
|
|
55
|
+
description: 'Diagnose a Spark run: thresholded findings with remediation text, an impact-ranked fix recommendation rollup, and clean-check status.',
|
|
56
|
+
inputSchema: {
|
|
57
|
+
...runRefSchema, redact: z.boolean().optional(),
|
|
58
|
+
include: z.array(z.enum(['summary', 'evidenceAvailability', 'detectors'])).optional(),
|
|
59
|
+
impactBand: z.array(z.string()).optional(), type: z.array(z.string()).optional(), stageId: z.number().int().optional(),
|
|
60
|
+
...formatSchema,
|
|
61
|
+
},
|
|
62
|
+
}, ({ source, runId, redact, include, format, impactBand, type, stageId }) => toolResultWithMarkdown(
|
|
63
|
+
resolveOrCreateRun({ source, runId }).then(({ runId: id }) =>
|
|
64
|
+
diagnoseRun(id, { redact, include, markdown: format === 'md', impactBand, type, stageId })),
|
|
65
|
+
));
|
|
66
|
+
|
|
67
|
+
server.registerTool('get_run_summary', {
|
|
68
|
+
description: 'App/stage/job/sql counts and duration for a run, no findings.',
|
|
69
|
+
inputSchema: { ...runRefSchema, redact: z.boolean().optional() },
|
|
70
|
+
}, ({ source, runId, redact }) => toolResult(
|
|
71
|
+
resolveOrCreateRun({ source, runId }).then(({ runId: id }) => getRunSummary(id, { redact })),
|
|
72
|
+
));
|
|
73
|
+
|
|
74
|
+
server.registerTool('compare_runs', {
|
|
75
|
+
description: 'Compare two runs: categorized findings delta and metric deltas.',
|
|
76
|
+
inputSchema: {
|
|
77
|
+
runIdA: z.string().optional(), sourceA: sourceSchema.optional(),
|
|
78
|
+
...secondRunRefSchema,
|
|
79
|
+
redact: z.boolean().optional(),
|
|
80
|
+
...formatSchema,
|
|
81
|
+
},
|
|
82
|
+
}, ({ runIdA, sourceA, runIdB, sourceB, redact, format }) => toolResultWithMarkdown(
|
|
83
|
+
compareRuns({ runId: runIdA, source: sourceA }, { runId: runIdB, source: sourceB }, { redact, markdown: format === 'md' }),
|
|
84
|
+
));
|
|
85
|
+
|
|
86
|
+
server.registerTool('evaluate_budgets', {
|
|
87
|
+
description: 'Evaluate a run (optionally against a second run for regression budgets) against pass/fail thresholds.',
|
|
88
|
+
inputSchema: {
|
|
89
|
+
source: sourceSchema.optional().describe('The run to evaluate. Also the regression baseline when sourceB/runIdB is given.'),
|
|
90
|
+
runId: runRefSchema.runId.describe('Same as `source`, referencing an already-resolved run by id.'),
|
|
91
|
+
maxRuntimeMs: z.number().optional(), maxSpillGb: z.number().optional(), maxSkewRatio: z.number().optional(),
|
|
92
|
+
maxFailedTaskRatePct: z.number().optional(), minEfficiencyPct: z.number().optional(),
|
|
93
|
+
sourceB: sourceSchema.optional().describe('Optional candidate run, compared against source/runId as the regression baseline (maxRegressionPct/failOnIntroduced).'),
|
|
94
|
+
runIdB: secondRunRefSchema.runIdB.describe('Same as `sourceB`, referencing an already-resolved run by id.'),
|
|
95
|
+
maxRegressionPct: z.number().optional(), regressionMetric: z.string().optional(), failOnIntroduced: z.string().optional(),
|
|
96
|
+
},
|
|
97
|
+
}, ({
|
|
98
|
+
source, runId, runIdB, sourceB,
|
|
99
|
+
maxRuntimeMs, maxSpillGb, maxSkewRatio, maxFailedTaskRatePct, minEfficiencyPct,
|
|
100
|
+
maxRegressionPct, regressionMetric, failOnIntroduced,
|
|
101
|
+
}) => toolResult(evaluateBudgetsForRun(
|
|
102
|
+
{ source, runId },
|
|
103
|
+
{ maxRuntimeMs, maxSpillGb, maxSkewRatio, maxFailedTaskRatePct, minEfficiencyPct, maxRegressionPct, regressionMetric, failOnIntroduced },
|
|
104
|
+
(runIdB || sourceB) ? { runId: runIdB, source: sourceB } : undefined,
|
|
105
|
+
)));
|
|
106
|
+
|
|
107
|
+
server.registerTool('get_finding_evidence', {
|
|
108
|
+
description: 'Raw evidence bundle backing one finding, for drill-down after diagnose_run.',
|
|
109
|
+
inputSchema: { runId: z.string(), findingId: z.string(), redact: z.boolean().optional() },
|
|
110
|
+
}, ({ runId, findingId, redact }) => toolResult(
|
|
111
|
+
Promise.resolve().then(() => getFindingEvidence(runId, findingId, { redact })),
|
|
112
|
+
));
|
|
113
|
+
|
|
114
|
+
return server;
|
|
115
|
+
}
|