ralph-hero-mcp-server 2.5.140 → 2.5.149
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/dist/index.js +3 -0
- package/dist/lib/debug-issue-shape.js +202 -0
- package/dist/lib/delegation-log.js +199 -0
- package/dist/tools/debug-tools.js +254 -27
- package/dist/tools/delegation-tools.js +44 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -31,6 +31,7 @@ import { registerDecomposeTools } from "./tools/decompose-tools.js";
|
|
|
31
31
|
import { registerViewTools } from "./tools/view-tools.js";
|
|
32
32
|
import { registerPlanGraphTools } from "./tools/plan-graph-tools.js";
|
|
33
33
|
import { registerActivityTools } from "./tools/activity-tools.js";
|
|
34
|
+
import { registerDelegationTools } from "./tools/delegation-tools.js";
|
|
34
35
|
import { registerTrendsTools } from "./tools/trends-tools.js";
|
|
35
36
|
/**
|
|
36
37
|
* Initialize the GitHub client from environment variables.
|
|
@@ -445,6 +446,8 @@ async function main() {
|
|
|
445
446
|
registerPlanGraphTools(server, client);
|
|
446
447
|
// Activity log reader (recent_activity tool — pure filesystem, no GitHub client)
|
|
447
448
|
registerActivityTools(server);
|
|
449
|
+
// Delegation telemetry reader (delegation_stats tool — pure filesystem, no GitHub client)
|
|
450
|
+
registerDelegationTools(server);
|
|
448
451
|
// Trends tools (capture_snapshot — JSONL persistence under ~/.ralph-hero/snapshots/)
|
|
449
452
|
registerTrendsTools(server, client, fieldCache);
|
|
450
453
|
// Debug tools (only when RALPH_DEBUG=true)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue body + comment body builders for `ralph_hero__collate_debug` Phase 3b.
|
|
3
|
+
*
|
|
4
|
+
* Each `SignatureGroup` returned by `groupSpansBySignature` becomes either:
|
|
5
|
+
* - a fresh GitHub issue (when no existing `debug-auto` issue carries the
|
|
6
|
+
* same hash within the dedup window), or
|
|
7
|
+
* - a comment on an existing issue (occurrence-update).
|
|
8
|
+
*
|
|
9
|
+
* The body MUST include a machine-parseable hash marker on its own line —
|
|
10
|
+
* `**Hash**: \`<8-char-hash>\`` — because dedup in Phase 3b matches on this
|
|
11
|
+
* exact line via GitHub's code-search index.
|
|
12
|
+
*
|
|
13
|
+
* Token-shaped values are scrubbed from every emitted field. The regex set
|
|
14
|
+
* mirrors `redactTokenAttributes` in `telemetry.ts`: GitHub tokens
|
|
15
|
+
* (`^gh[ps]_`), basic-auth headers, and any attribute key ending in
|
|
16
|
+
* `_TOKEN` are replaced with `[REDACTED]`.
|
|
17
|
+
*/
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Token redaction (kept local to avoid cross-module coupling at runtime; the
|
|
20
|
+
// shape mirrors telemetry.ts:redactTokenAttributes for consistency)
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
const GH_TOKEN_VALUE_RE = /\bgh[psour]_[A-Za-z0-9_]{16,}\b/g;
|
|
23
|
+
const TOKEN_KEY_RE = /(_TOKEN|authorization)$/i;
|
|
24
|
+
const BASIC_AUTH_RE = /\bBasic\s+[A-Za-z0-9+/=]{8,}\b/g;
|
|
25
|
+
/**
|
|
26
|
+
* Scrub token-shaped substrings from a free-form string. Used for error
|
|
27
|
+
* messages and serialised metadata before they land in an issue body.
|
|
28
|
+
*/
|
|
29
|
+
export function scrubTokensFromString(input) {
|
|
30
|
+
if (!input)
|
|
31
|
+
return input;
|
|
32
|
+
return input.replace(GH_TOKEN_VALUE_RE, "[REDACTED]").replace(BASIC_AUTH_RE, "Basic [REDACTED]");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Scrub token-shaped values from a plain attribute bag. Keys matching
|
|
36
|
+
* `_TOKEN` or `authorization` (case-insensitive) are replaced with
|
|
37
|
+
* `[REDACTED]`; values matching the GitHub token regex are scrubbed too.
|
|
38
|
+
*
|
|
39
|
+
* The result is a shallow copy — callers can serialise it without mutating
|
|
40
|
+
* the input. Nested objects are stringified before scrubbing to keep the
|
|
41
|
+
* function flat and predictable.
|
|
42
|
+
*/
|
|
43
|
+
export function scrubTokensFromAttrs(attrs) {
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
46
|
+
if (TOKEN_KEY_RE.test(key)) {
|
|
47
|
+
out[key] = "[REDACTED]";
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (typeof value === "string") {
|
|
51
|
+
out[key] = scrubTokensFromString(value);
|
|
52
|
+
}
|
|
53
|
+
else if (value !== null && typeof value === "object") {
|
|
54
|
+
out[key] = scrubTokensFromString(JSON.stringify(value));
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
out[key] = value;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Helpers
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
const TITLE_MAX = 100;
|
|
66
|
+
const NORMALIZED_MAX = 60;
|
|
67
|
+
function truncate(s, max) {
|
|
68
|
+
if (s.length <= max)
|
|
69
|
+
return s;
|
|
70
|
+
return s.slice(0, max - 1) + "…"; // ellipsis
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Pull a short, human-readable error blurb out of a span. Falls back through
|
|
74
|
+
* statusMessage -> exception.message -> metadata.error -> the span name.
|
|
75
|
+
*/
|
|
76
|
+
function extractMessage(span) {
|
|
77
|
+
if (!span)
|
|
78
|
+
return "";
|
|
79
|
+
if (span.message)
|
|
80
|
+
return span.message;
|
|
81
|
+
const meta = span.metadata ?? {};
|
|
82
|
+
const exception = meta.exception;
|
|
83
|
+
if (exception &&
|
|
84
|
+
typeof exception === "object" &&
|
|
85
|
+
"message" in exception &&
|
|
86
|
+
typeof exception.message === "string") {
|
|
87
|
+
return exception.message;
|
|
88
|
+
}
|
|
89
|
+
if (typeof meta.error === "string")
|
|
90
|
+
return meta.error;
|
|
91
|
+
if (typeof meta.message === "string")
|
|
92
|
+
return meta.message;
|
|
93
|
+
return "";
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Take the third segment of a `${spanName}:${errorType}:${normalized}`
|
|
97
|
+
* signature string. Used to populate the title when no sample span message
|
|
98
|
+
* is available.
|
|
99
|
+
*/
|
|
100
|
+
function normalizedFromSignature(signature) {
|
|
101
|
+
const parts = signature.split(":");
|
|
102
|
+
if (parts.length < 3)
|
|
103
|
+
return signature;
|
|
104
|
+
return parts.slice(2).join(":");
|
|
105
|
+
}
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// buildIssueBody
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
/**
|
|
110
|
+
* Build the title + body for a freshly-filed `debug-auto` issue.
|
|
111
|
+
*
|
|
112
|
+
* The body layout is deliberately stable so Phase 3b's dedup regex
|
|
113
|
+
* (`/^\*\*Hash\*\*: `([0-9a-f]{8})`/m`) keeps matching across versions.
|
|
114
|
+
*/
|
|
115
|
+
export function buildIssueBody(group, env) {
|
|
116
|
+
const sample = group.sampleSpans[0];
|
|
117
|
+
const rawMessage = extractMessage(sample) || normalizedFromSignature(group.signature);
|
|
118
|
+
const message = scrubTokensFromString(rawMessage);
|
|
119
|
+
const spanName = sample?.name ?? "ralph_hero.error";
|
|
120
|
+
const title = truncate(`[Debug] ${spanName}: ${truncate(message, NORMALIZED_MAX)}`, TITLE_MAX);
|
|
121
|
+
const occurrenceRows = [
|
|
122
|
+
`| Count | First seen | Last seen |`,
|
|
123
|
+
`|---|---|---|`,
|
|
124
|
+
`| ${group.count} | ${group.firstSeen} | ${group.lastSeen} |`,
|
|
125
|
+
].join("\n");
|
|
126
|
+
const sampleAttrs = sample?.metadata
|
|
127
|
+
? scrubTokensFromAttrs(sample.metadata)
|
|
128
|
+
: {};
|
|
129
|
+
const errorDetails = Object.keys(sampleAttrs).length
|
|
130
|
+
? "```json\n" + JSON.stringify(sampleAttrs, null, 2) + "\n```"
|
|
131
|
+
: "_(no attributes captured on sample span)_";
|
|
132
|
+
const reproduction = sample
|
|
133
|
+
? "```json\n" +
|
|
134
|
+
JSON.stringify({
|
|
135
|
+
spanName: sample.name,
|
|
136
|
+
traceId: sample.traceId,
|
|
137
|
+
startTime: sample.startTime,
|
|
138
|
+
errorType: sample.errorType,
|
|
139
|
+
message: scrubTokensFromString(extractMessage(sample)),
|
|
140
|
+
}, null, 2) +
|
|
141
|
+
"\n```"
|
|
142
|
+
: "_(no sample span available)_";
|
|
143
|
+
const body = [
|
|
144
|
+
`**Hash**: \`${group.hash}\``,
|
|
145
|
+
``,
|
|
146
|
+
`**Signature**: \`${scrubTokensFromString(group.signature)}\``,
|
|
147
|
+
``,
|
|
148
|
+
`## First seen`,
|
|
149
|
+
``,
|
|
150
|
+
`- mcp-server version: \`${env.mcpVersion}\``,
|
|
151
|
+
`- node: \`${env.nodeVersion}\``,
|
|
152
|
+
`- os: \`${env.os}\``,
|
|
153
|
+
``,
|
|
154
|
+
`## Error details`,
|
|
155
|
+
``,
|
|
156
|
+
errorDetails,
|
|
157
|
+
``,
|
|
158
|
+
`## Reproduction (sample span)`,
|
|
159
|
+
``,
|
|
160
|
+
reproduction,
|
|
161
|
+
``,
|
|
162
|
+
`## Occurrences`,
|
|
163
|
+
``,
|
|
164
|
+
occurrenceRows,
|
|
165
|
+
``,
|
|
166
|
+
`## Langfuse trace`,
|
|
167
|
+
``,
|
|
168
|
+
`[Open latest example trace](${group.exampleTraceUrl})`,
|
|
169
|
+
``,
|
|
170
|
+
`---`,
|
|
171
|
+
``,
|
|
172
|
+
`_Filed automatically by \`ralph_hero__collate_debug\` — Phase 3b (GH-1100). ` +
|
|
173
|
+
`Re-running collation over the same window will append occurrence ` +
|
|
174
|
+
`comments here instead of creating a duplicate issue._`,
|
|
175
|
+
].join("\n");
|
|
176
|
+
return { title, body };
|
|
177
|
+
}
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// buildCommentBody
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
/**
|
|
182
|
+
* Build the occurrence-update comment body posted when an existing
|
|
183
|
+
* `debug-auto` issue is matched by hash. `newCount` is the count returned by
|
|
184
|
+
* the current `groupSpansBySignature` run — i.e., occurrences in the *new*
|
|
185
|
+
* window, not the cumulative total (we don't have read access to historical
|
|
186
|
+
* comment counts without extra queries).
|
|
187
|
+
*/
|
|
188
|
+
export function buildCommentBody(group, newCount, latestTraceUrl) {
|
|
189
|
+
return [
|
|
190
|
+
`## Recurring occurrence`,
|
|
191
|
+
``,
|
|
192
|
+
`Detected **${newCount}** new occurrence${newCount === 1 ? "" : "s"} of this signature in the latest collation window.`,
|
|
193
|
+
``,
|
|
194
|
+
`- Hash: \`${group.hash}\``,
|
|
195
|
+
`- First seen (this window): ${group.firstSeen}`,
|
|
196
|
+
`- Last seen (this window): ${group.lastSeen}`,
|
|
197
|
+
`- [Latest example trace](${scrubTokensFromString(latestTraceUrl)})`,
|
|
198
|
+
``,
|
|
199
|
+
`_Posted automatically by \`ralph_hero__collate_debug\` (Phase 3b)._`,
|
|
200
|
+
].join("\n");
|
|
201
|
+
}
|
|
202
|
+
//# sourceMappingURL=debug-issue-shape.js.map
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure read library for the local ralph-delegate JSONL audit log.
|
|
3
|
+
*
|
|
4
|
+
* The log lives at `~/.ralph-hero/delegate.log` (overridable via
|
|
5
|
+
* `RALPH_DELEGATE_LOG_PATH`). One JSON object per line, append-only,
|
|
6
|
+
* written by `plugin/ralph-hero/scripts/ralph-delegate.sh`. This library
|
|
7
|
+
* only reads — it never writes.
|
|
8
|
+
*
|
|
9
|
+
* Schema versioning: F1 (issue #1185) does NOT emit an explicit
|
|
10
|
+
* `schemaVersion` field on each line. F5 treats lines containing the
|
|
11
|
+
* required fields `{ts, task, status, ms}` as **implicit v1**. A future
|
|
12
|
+
* issue MAY add an explicit `schemaVersion >= 2` — when that happens, the
|
|
13
|
+
* shape-check should be expanded to honor it. TODO: revisit when the
|
|
14
|
+
* producer side emits `schemaVersion`.
|
|
15
|
+
*
|
|
16
|
+
* Determinism: pure functions. Filesystem reads are the only side effect.
|
|
17
|
+
* Missing log file resolves to a zero-state result with no throw, matching
|
|
18
|
+
* the activity.ts precedent (the steady state for opt-out users).
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from "node:fs/promises";
|
|
21
|
+
import * as os from "node:os";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Test hook + default path resolution
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
/**
|
|
27
|
+
* Optional override used only by tests. Production code never sets this.
|
|
28
|
+
* Mirrors `__setSnapshotRoot` in `snapshots.ts`.
|
|
29
|
+
*/
|
|
30
|
+
let delegateLogPathOverride = null;
|
|
31
|
+
/** Test hook: override the default log path. Pass `null` to restore. */
|
|
32
|
+
export function __setDelegateLogPath(path) {
|
|
33
|
+
delegateLogPathOverride = path;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the default log path: env var override, then `~/.ralph-hero/delegate.log`.
|
|
37
|
+
* Expands a leading `~/` (mirrors `ralph-delegate.sh:208-211`).
|
|
38
|
+
*/
|
|
39
|
+
export function defaultDelegationLogPath() {
|
|
40
|
+
if (delegateLogPathOverride !== null)
|
|
41
|
+
return delegateLogPathOverride;
|
|
42
|
+
const fromEnv = process.env.RALPH_DELEGATE_LOG_PATH;
|
|
43
|
+
if (fromEnv && fromEnv.length > 0) {
|
|
44
|
+
return expandHome(fromEnv);
|
|
45
|
+
}
|
|
46
|
+
return path.join(os.homedir(), ".ralph-hero", "delegate.log");
|
|
47
|
+
}
|
|
48
|
+
function expandHome(p) {
|
|
49
|
+
if (p.startsWith("~/")) {
|
|
50
|
+
return path.join(os.homedir(), p.slice(2));
|
|
51
|
+
}
|
|
52
|
+
return p;
|
|
53
|
+
}
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Reader
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
/**
|
|
58
|
+
* Read the delegation audit log. Returns a zero-state result with
|
|
59
|
+
* `fileExists: false` when the file is missing — never throws on ENOENT.
|
|
60
|
+
* Lines that fail JSON.parse OR lack required fields are skipped and
|
|
61
|
+
* counted in `skippedLines`; each skip emits a `console.warn` with a
|
|
62
|
+
* truncated prefix of the offending line.
|
|
63
|
+
*/
|
|
64
|
+
export async function readDelegationLog(config) {
|
|
65
|
+
const logPath = config.logPath;
|
|
66
|
+
let content;
|
|
67
|
+
try {
|
|
68
|
+
content = await fs.readFile(logPath, "utf8");
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
if (e &&
|
|
72
|
+
typeof e === "object" &&
|
|
73
|
+
"code" in e &&
|
|
74
|
+
e.code === "ENOENT") {
|
|
75
|
+
return { events: [], skippedLines: 0, fileExists: false, logPath };
|
|
76
|
+
}
|
|
77
|
+
throw e;
|
|
78
|
+
}
|
|
79
|
+
const events = [];
|
|
80
|
+
let skipped = 0;
|
|
81
|
+
for (const raw of content.split("\n")) {
|
|
82
|
+
const line = raw.trim();
|
|
83
|
+
if (line.length === 0)
|
|
84
|
+
continue;
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(line);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
skipped++;
|
|
91
|
+
console.warn(`[delegation-log] Skipping malformed line in ${logPath}: ${line.slice(0, 80)}`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (!isDelegationEventShape(parsed)) {
|
|
95
|
+
skipped++;
|
|
96
|
+
console.warn(`[delegation-log] Skipping line with missing required fields in ${logPath}: ${line.slice(0, 80)}`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
events.push(parsed);
|
|
100
|
+
}
|
|
101
|
+
return { events, skippedLines: skipped, fileExists: true, logPath };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Implicit-v1 shape check: presence of `{ts, task, status, ms}` with
|
|
105
|
+
* correct primitive types. A future explicit `schemaVersion >= 2` may
|
|
106
|
+
* extend this gate.
|
|
107
|
+
*/
|
|
108
|
+
function isDelegationEventShape(v) {
|
|
109
|
+
if (!v || typeof v !== "object")
|
|
110
|
+
return false;
|
|
111
|
+
const o = v;
|
|
112
|
+
return (typeof o.ts === "string" &&
|
|
113
|
+
typeof o.task === "string" &&
|
|
114
|
+
typeof o.status === "string" &&
|
|
115
|
+
typeof o.ms === "number");
|
|
116
|
+
}
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Aggregator
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
/**
|
|
121
|
+
* Aggregate parsed events into per-task + totals. Pure function — does
|
|
122
|
+
* no I/O. Percentiles use the nearest-rank method against successful
|
|
123
|
+
* (status=ok) calls only.
|
|
124
|
+
*/
|
|
125
|
+
export function aggregateDelegationStats(events) {
|
|
126
|
+
const byTask = {};
|
|
127
|
+
const okMsByTask = {};
|
|
128
|
+
let totalCalls = 0;
|
|
129
|
+
let totalFallbacks = 0;
|
|
130
|
+
let totalBytesIn = 0;
|
|
131
|
+
let totalBytesOut = 0;
|
|
132
|
+
for (const ev of events) {
|
|
133
|
+
const task = ev.task;
|
|
134
|
+
if (!byTask[task]) {
|
|
135
|
+
byTask[task] = {
|
|
136
|
+
calls: 0,
|
|
137
|
+
fallbacks: 0,
|
|
138
|
+
p50Ms: null,
|
|
139
|
+
p99Ms: null,
|
|
140
|
+
bytesIn: 0,
|
|
141
|
+
bytesOut: 0,
|
|
142
|
+
tokens: null,
|
|
143
|
+
};
|
|
144
|
+
okMsByTask[task] = [];
|
|
145
|
+
}
|
|
146
|
+
byTask[task].calls += 1;
|
|
147
|
+
totalCalls += 1;
|
|
148
|
+
if (isFallbackStatus(ev.status)) {
|
|
149
|
+
byTask[task].fallbacks += 1;
|
|
150
|
+
totalFallbacks += 1;
|
|
151
|
+
}
|
|
152
|
+
const bIn = typeof ev.bytes_in === "number" ? ev.bytes_in : 0;
|
|
153
|
+
const bOut = typeof ev.bytes_out === "number" ? ev.bytes_out : 0;
|
|
154
|
+
byTask[task].bytesIn += bIn;
|
|
155
|
+
byTask[task].bytesOut += bOut;
|
|
156
|
+
totalBytesIn += bIn;
|
|
157
|
+
totalBytesOut += bOut;
|
|
158
|
+
if (ev.status === "ok") {
|
|
159
|
+
okMsByTask[task].push(ev.ms);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Compute percentiles
|
|
163
|
+
for (const task of Object.keys(byTask)) {
|
|
164
|
+
const samples = okMsByTask[task];
|
|
165
|
+
byTask[task].p50Ms = percentile(samples, 0.5);
|
|
166
|
+
byTask[task].p99Ms = percentile(samples, 0.99);
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
totals: {
|
|
170
|
+
calls: totalCalls,
|
|
171
|
+
fallbacks: totalFallbacks,
|
|
172
|
+
bytesIn: totalBytesIn,
|
|
173
|
+
bytesOut: totalBytesOut,
|
|
174
|
+
},
|
|
175
|
+
byTask,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* A fallback is any non-`ok`, non-`dry_run` status: timeout, unreachable,
|
|
180
|
+
* parse_error, http_*. `dry_run` is operator-driven and not a real
|
|
181
|
+
* delegation failure, so it does not count.
|
|
182
|
+
*/
|
|
183
|
+
function isFallbackStatus(status) {
|
|
184
|
+
return status !== "ok" && status !== "dry_run";
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Nearest-rank percentile: `sorted[ceil(q * n) - 1]`. Returns `null`
|
|
188
|
+
* when the sample is empty.
|
|
189
|
+
*/
|
|
190
|
+
function percentile(samples, q) {
|
|
191
|
+
if (samples.length === 0)
|
|
192
|
+
return null;
|
|
193
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
194
|
+
const rank = Math.ceil(q * sorted.length);
|
|
195
|
+
// Guard the edge case q=0 (would index -1)
|
|
196
|
+
const idx = Math.max(0, rank - 1);
|
|
197
|
+
return sorted[idx];
|
|
198
|
+
}
|
|
199
|
+
//# sourceMappingURL=delegation-log.js.map
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Provides:
|
|
5
5
|
* - `ralph_hero__collate_debug` (v2 — queries Langfuse for error spans,
|
|
6
|
-
* groups by normalized signature,
|
|
7
|
-
*
|
|
6
|
+
* groups by normalized signature, dedupes against open `debug-auto`
|
|
7
|
+
* issues, and either creates new issues or appends occurrence comments
|
|
8
|
+
* when `dryRun=false`)
|
|
8
9
|
* - `ralph_hero__debug_stats` (v1 — aggregates JSONL logs; preserved for
|
|
9
10
|
* backward compat, not extended)
|
|
10
11
|
*
|
|
@@ -12,14 +13,18 @@
|
|
|
12
13
|
* `debug_stats`; the new Langfuse path is fully separate.
|
|
13
14
|
*/
|
|
14
15
|
import { readdir, readFile } from "node:fs/promises";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { join, resolve, dirname } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { homedir, platform, release } from "node:os";
|
|
17
20
|
import { createHash } from "node:crypto";
|
|
18
21
|
import { z } from "zod";
|
|
19
22
|
import { toolSuccess, toolError } from "../types.js";
|
|
20
23
|
import { zBoolish } from "../lib/zod-helpers.js";
|
|
21
24
|
import { createLangfuseClient, } from "../lib/langfuse-client.js";
|
|
22
25
|
import { groupSpansBySignature, observationToSpan, } from "../lib/error-signature.js";
|
|
26
|
+
import { buildIssueBody, buildCommentBody, } from "../lib/debug-issue-shape.js";
|
|
27
|
+
import { resolveConfig } from "../lib/helpers.js";
|
|
23
28
|
// ---------------------------------------------------------------------------
|
|
24
29
|
// JSONL Parsing
|
|
25
30
|
// ---------------------------------------------------------------------------
|
|
@@ -181,6 +186,208 @@ export function aggregateStats(events, groupBy) {
|
|
|
181
186
|
groups,
|
|
182
187
|
};
|
|
183
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Look for an open `debug-auto` issue whose body carries the given 8-char
|
|
191
|
+
* hash on a `**Hash**: \`<hash>\`` line. Only issues updated within the last
|
|
192
|
+
* `withinDays` are considered (default 7), matching the spec's "dedup window".
|
|
193
|
+
*
|
|
194
|
+
* Returns the first matching issue `{ number, id }` or `null` if no match.
|
|
195
|
+
* Search rate-limit errors are swallowed (caller will create a duplicate;
|
|
196
|
+
* the next run will collapse it via comment).
|
|
197
|
+
*/
|
|
198
|
+
export async function findExistingDebugIssue(client, owner, repo, hash, withinDays = 7) {
|
|
199
|
+
const sinceIso = new Date(Date.now() - withinDays * 24 * 60 * 60 * 1000)
|
|
200
|
+
.toISOString()
|
|
201
|
+
.slice(0, 10); // YYYY-MM-DD
|
|
202
|
+
// The hash marker `**Hash**: ` is too punctuation-heavy for GitHub's text
|
|
203
|
+
// search index — search on the bare 8-char hex; the marker line is verified
|
|
204
|
+
// by inspecting the issue body below.
|
|
205
|
+
const q = `repo:${owner}/${repo} is:issue is:open label:debug-auto ${hash} in:body updated:>=${sinceIso}`;
|
|
206
|
+
try {
|
|
207
|
+
const data = await client.query(`query DebugIssueSearch($q: String!) {
|
|
208
|
+
search(query: $q, type: ISSUE, first: 10) {
|
|
209
|
+
nodes {
|
|
210
|
+
... on Issue {
|
|
211
|
+
number
|
|
212
|
+
id
|
|
213
|
+
body
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}`, { q });
|
|
218
|
+
const marker = new RegExp(`^\\*\\*Hash\\*\\*: \`${hash}\``, "m");
|
|
219
|
+
for (const node of data.search.nodes ?? []) {
|
|
220
|
+
if (typeof node.number === "number" &&
|
|
221
|
+
typeof node.id === "string" &&
|
|
222
|
+
typeof node.body === "string" &&
|
|
223
|
+
marker.test(node.body)) {
|
|
224
|
+
return { number: node.number, id: node.id };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
console.error(`[debug-tools] findExistingDebugIssue search failed (treating as no-match): ${error instanceof Error ? error.message : String(error)}`);
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Resolve the repository's GraphQL node ID, with SessionCache memoization.
|
|
236
|
+
*/
|
|
237
|
+
async function resolveRepoNodeId(client, owner, repo) {
|
|
238
|
+
const cacheKey = `repo-node-id:${owner}/${repo}`;
|
|
239
|
+
const cached = client.getCache().get(cacheKey);
|
|
240
|
+
if (cached)
|
|
241
|
+
return cached;
|
|
242
|
+
const result = await client.query(`query($owner: String!, $repo: String!) {
|
|
243
|
+
repository(owner: $owner, name: $repo) { id }
|
|
244
|
+
}`, { owner, repo });
|
|
245
|
+
const id = result.repository?.id;
|
|
246
|
+
if (!id) {
|
|
247
|
+
throw new Error(`Repository ${owner}/${repo} not found`);
|
|
248
|
+
}
|
|
249
|
+
client.getCache().set(cacheKey, id, 60 * 60 * 1000); // 1 hour
|
|
250
|
+
return id;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Resolve repo-scoped label IDs for `debug-auto` and `ralph-self-report`.
|
|
254
|
+
* Labels that don't exist in the repo are skipped silently — issue creation
|
|
255
|
+
* still proceeds, the label just won't be applied.
|
|
256
|
+
*/
|
|
257
|
+
async function resolveLabelIds(client, owner, repo, labelNames) {
|
|
258
|
+
const cacheKey = `repo-labels:${owner}/${repo}`;
|
|
259
|
+
let labels = client.getCache().get(cacheKey);
|
|
260
|
+
if (!labels) {
|
|
261
|
+
const result = await client.query(`query($owner: String!, $repo: String!) {
|
|
262
|
+
repository(owner: $owner, name: $repo) {
|
|
263
|
+
labels(first: 100) { nodes { id name } }
|
|
264
|
+
}
|
|
265
|
+
}`, { owner, repo });
|
|
266
|
+
labels = result.repository?.labels.nodes ?? [];
|
|
267
|
+
client.getCache().set(cacheKey, labels, 5 * 60 * 1000);
|
|
268
|
+
}
|
|
269
|
+
return labelNames
|
|
270
|
+
.map((n) => labels.find((l) => l.name === n)?.id)
|
|
271
|
+
.filter((id) => typeof id === "string");
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Create a fresh `debug-auto` issue for a new signature. Returns the new
|
|
275
|
+
* issue number + node ID. Project board placement (Backlog state) is
|
|
276
|
+
* delegated to the existing route-issues.yml workflow — we set labels and
|
|
277
|
+
* the body marker; the workflow handles board routing.
|
|
278
|
+
*/
|
|
279
|
+
async function createDebugIssue(client, owner, repo, title, body) {
|
|
280
|
+
const repoId = await resolveRepoNodeId(client, owner, repo);
|
|
281
|
+
const labelIds = await resolveLabelIds(client, owner, repo, [
|
|
282
|
+
"debug-auto",
|
|
283
|
+
"ralph-self-report",
|
|
284
|
+
]);
|
|
285
|
+
const result = await client.mutate(`mutation($repoId: ID!, $title: String!, $body: String!, $labelIds: [ID!]) {
|
|
286
|
+
createIssue(input: {
|
|
287
|
+
repositoryId: $repoId,
|
|
288
|
+
title: $title,
|
|
289
|
+
body: $body,
|
|
290
|
+
labelIds: $labelIds
|
|
291
|
+
}) {
|
|
292
|
+
issue { id number url }
|
|
293
|
+
}
|
|
294
|
+
}`, {
|
|
295
|
+
repoId,
|
|
296
|
+
title,
|
|
297
|
+
body,
|
|
298
|
+
labelIds: labelIds.length ? labelIds : null,
|
|
299
|
+
});
|
|
300
|
+
const issue = result.createIssue.issue;
|
|
301
|
+
client
|
|
302
|
+
.getCache()
|
|
303
|
+
.set(`issue-node-id:${owner}/${repo}#${issue.number}`, issue.id, 30 * 60 * 1000);
|
|
304
|
+
return issue;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Append an occurrence-update comment to an existing `debug-auto` issue.
|
|
308
|
+
*/
|
|
309
|
+
async function commentOnDebugIssue(client, issueNodeId, body) {
|
|
310
|
+
const result = await client.mutate(`mutation($subjectId: ID!, $body: String!) {
|
|
311
|
+
addComment(input: { subjectId: $subjectId, body: $body }) {
|
|
312
|
+
commentEdge { node { id } }
|
|
313
|
+
}
|
|
314
|
+
}`, { subjectId: issueNodeId, body });
|
|
315
|
+
return result.addComment.commentEdge.node.id;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Read the MCP server semver from package.json next to this module. Falls
|
|
319
|
+
* back to `"unknown"` if the file is missing or unreadable. Mirrors the
|
|
320
|
+
* approach used in `telemetry.ts:resolveServiceVersion` but kept local so
|
|
321
|
+
* the debug surface has zero cross-dependency on telemetry init order.
|
|
322
|
+
*/
|
|
323
|
+
function readMcpServerVersion() {
|
|
324
|
+
try {
|
|
325
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
326
|
+
const pkgPath = resolve(here, "..", "..", "package.json");
|
|
327
|
+
const raw = readFileSync(pkgPath, "utf8");
|
|
328
|
+
const pkg = JSON.parse(raw);
|
|
329
|
+
return pkg.version ?? "unknown";
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return "unknown";
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Default `IssueShapeEnv` builder — captures the MCP server version, the
|
|
337
|
+
* Node version, and a short OS descriptor at call time. Exposed so tests
|
|
338
|
+
* can override (e.g., deterministic version stamps).
|
|
339
|
+
*/
|
|
340
|
+
function defaultEnv(mcpVersion) {
|
|
341
|
+
return {
|
|
342
|
+
mcpVersion: mcpVersion === "unknown" ? readMcpServerVersion() : mcpVersion,
|
|
343
|
+
nodeVersion: process.version,
|
|
344
|
+
os: `${platform()} ${release()}`,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Iterate groups, dedupe each against existing issues, and either create a
|
|
349
|
+
* new issue or post a comment. Returns the counts the tool surfaces back to
|
|
350
|
+
* the caller. Per-group failures are recorded and surfaced but do NOT abort
|
|
351
|
+
* the loop — partial success is preferable to losing the whole run.
|
|
352
|
+
*/
|
|
353
|
+
export async function fileOrCommentForGroups(client, owner, repo, groups, env) {
|
|
354
|
+
const results = [];
|
|
355
|
+
let issuesCreated = 0;
|
|
356
|
+
let issuesUpdated = 0;
|
|
357
|
+
for (const group of groups) {
|
|
358
|
+
try {
|
|
359
|
+
const existing = await findExistingDebugIssue(client, owner, repo, group.hash);
|
|
360
|
+
if (existing) {
|
|
361
|
+
const commentBody = buildCommentBody(group, group.count, group.exampleTraceUrl);
|
|
362
|
+
await commentOnDebugIssue(client, existing.id, commentBody);
|
|
363
|
+
issuesUpdated += 1;
|
|
364
|
+
results.push({
|
|
365
|
+
hash: group.hash,
|
|
366
|
+
action: "commented",
|
|
367
|
+
issueNumber: existing.number,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
const { title, body } = buildIssueBody(group, env);
|
|
372
|
+
const created = await createDebugIssue(client, owner, repo, title, body);
|
|
373
|
+
issuesCreated += 1;
|
|
374
|
+
results.push({
|
|
375
|
+
hash: group.hash,
|
|
376
|
+
action: "created",
|
|
377
|
+
issueNumber: created.number,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
results.push({
|
|
383
|
+
hash: group.hash,
|
|
384
|
+
action: "error",
|
|
385
|
+
error: error instanceof Error ? error.message : String(error),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return { issuesCreated, issuesUpdated, results };
|
|
390
|
+
}
|
|
184
391
|
let langfuseClientFactory = () => createLangfuseClient();
|
|
185
392
|
/**
|
|
186
393
|
* Override the Langfuse client factory. Returns a disposer that restores the
|
|
@@ -193,23 +400,20 @@ export function setLangfuseClientFactory(factory) {
|
|
|
193
400
|
langfuseClientFactory = prev;
|
|
194
401
|
};
|
|
195
402
|
}
|
|
196
|
-
export function registerDebugTools(server, client) {
|
|
403
|
+
export function registerDebugTools(server, client, mcpVersion = "unknown") {
|
|
197
404
|
const logDir = join(homedir(), ".ralph-hero", "logs");
|
|
198
|
-
// `client` is referenced by `debug_stats` (legacy) and reserved for Phase 3b
|
|
199
|
-
// (GH-1100), which will use it for GitHub dedup + issue creation.
|
|
200
|
-
void client;
|
|
201
405
|
// -------------------------------------------------------------------------
|
|
202
|
-
// ralph_hero__collate_debug (v2 — Langfuse
|
|
406
|
+
// ralph_hero__collate_debug (v2 — Langfuse + GitHub dedup)
|
|
203
407
|
// -------------------------------------------------------------------------
|
|
204
|
-
server.tool("ralph_hero__collate_debug", "Query Langfuse for error spans in a time window, normalize messages,
|
|
408
|
+
server.tool("ralph_hero__collate_debug", "Query Langfuse for error spans in a time window, normalize messages, group by signature, then either return the grouped report (dryRun=true) or dedupe against open `debug-auto` issues and create / comment (dryRun=false, default). Returns: { since, errorGroups, totalOccurrences, dryRun, issuesCreated?, issuesUpdated?, groups[] }.", {
|
|
205
409
|
since: z
|
|
206
410
|
.string()
|
|
207
411
|
.optional()
|
|
208
412
|
.describe("ISO date string. Only spans whose startTime >= this value are considered (default: 24h ago)."),
|
|
209
413
|
dryRun: zBoolish()
|
|
210
414
|
.optional()
|
|
211
|
-
.default(
|
|
212
|
-
.describe("
|
|
415
|
+
.default(false)
|
|
416
|
+
.describe("If true, return the grouped report without touching GitHub. Default false — creates / comments on `debug-auto` issues per signature."),
|
|
213
417
|
minOccurrences: z
|
|
214
418
|
.number()
|
|
215
419
|
.int()
|
|
@@ -220,13 +424,10 @@ export function registerDebugTools(server, client) {
|
|
|
220
424
|
projectNumber: z
|
|
221
425
|
.number()
|
|
222
426
|
.optional()
|
|
223
|
-
.describe("Project number override
|
|
427
|
+
.describe("Project number override. Currently informational — issues land in the configured project via the existing route-issues workflow."),
|
|
224
428
|
}, async (args) => {
|
|
225
429
|
try {
|
|
226
|
-
const dryRun = args.dryRun ??
|
|
227
|
-
if (!dryRun) {
|
|
228
|
-
return toolError("dryRun=false requires GH-1100 (Phase 3b) — not yet implemented");
|
|
229
|
-
}
|
|
430
|
+
const dryRun = args.dryRun ?? false;
|
|
230
431
|
const minOccurrences = args.minOccurrences ?? 3;
|
|
231
432
|
const sinceDate = args.since
|
|
232
433
|
? new Date(args.since)
|
|
@@ -254,20 +455,46 @@ export function registerDebugTools(server, client) {
|
|
|
254
455
|
langfuseHost: langfuse.host,
|
|
255
456
|
});
|
|
256
457
|
const totalOccurrences = groups.reduce((sum, g) => sum + g.count, 0);
|
|
458
|
+
const summaryGroups = groups.map((g) => ({
|
|
459
|
+
signature: g.signature,
|
|
460
|
+
hash: g.hash,
|
|
461
|
+
count: g.count,
|
|
462
|
+
firstSeen: g.firstSeen,
|
|
463
|
+
lastSeen: g.lastSeen,
|
|
464
|
+
exampleTraceUrl: g.exampleTraceUrl,
|
|
465
|
+
sampleSpans: g.sampleSpans.slice(0, 3),
|
|
466
|
+
}));
|
|
467
|
+
if (dryRun) {
|
|
468
|
+
return toolSuccess({
|
|
469
|
+
since: fromStartTime,
|
|
470
|
+
errorGroups: groups.length,
|
|
471
|
+
totalOccurrences,
|
|
472
|
+
dryRun: true,
|
|
473
|
+
groups: summaryGroups,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
// dryRun=false — file or comment per signature.
|
|
477
|
+
let owner;
|
|
478
|
+
let repo;
|
|
479
|
+
try {
|
|
480
|
+
const resolved = resolveConfig(client, {});
|
|
481
|
+
owner = resolved.owner;
|
|
482
|
+
repo = resolved.repo;
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
return toolError(`Cannot resolve owner/repo for issue creation: ${error instanceof Error ? error.message : String(error)}`);
|
|
486
|
+
}
|
|
487
|
+
const env = defaultEnv(mcpVersion);
|
|
488
|
+
const fileResult = await fileOrCommentForGroups(client, owner, repo, groups, env);
|
|
257
489
|
return toolSuccess({
|
|
258
490
|
since: fromStartTime,
|
|
259
491
|
errorGroups: groups.length,
|
|
260
492
|
totalOccurrences,
|
|
261
|
-
dryRun:
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
firstSeen: g.firstSeen,
|
|
267
|
-
lastSeen: g.lastSeen,
|
|
268
|
-
exampleTraceUrl: g.exampleTraceUrl,
|
|
269
|
-
sampleSpans: g.sampleSpans.slice(0, 3),
|
|
270
|
-
})),
|
|
493
|
+
dryRun: false,
|
|
494
|
+
issuesCreated: fileResult.issuesCreated,
|
|
495
|
+
issuesUpdated: fileResult.issuesUpdated,
|
|
496
|
+
results: fileResult.results,
|
|
497
|
+
groups: summaryGroups,
|
|
271
498
|
});
|
|
272
499
|
}
|
|
273
500
|
catch (error) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registers the `ralph_hero__delegation_stats` MCP tool. Pure read-only
|
|
3
|
+
* surface over the JSONL audit log written by `ralph-delegate.sh` (F1).
|
|
4
|
+
*
|
|
5
|
+
* Follows the same registration convention as `activity-tools.ts`: no
|
|
6
|
+
* GitHub client argument, defaults pulled from env var with a homedir
|
|
7
|
+
* fallback, returns `toolSuccess` on missing-file (zero-state) so callers
|
|
8
|
+
* can render a dashboard without an error path.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { readDelegationLog, aggregateDelegationStats, defaultDelegationLogPath, } from "../lib/delegation-log.js";
|
|
12
|
+
import { toolSuccess, toolError } from "../types.js";
|
|
13
|
+
const TOKENS_REASON = "F1 audit-log does not capture token usage; bytes used as a proxy";
|
|
14
|
+
export function registerDelegationTools(server) {
|
|
15
|
+
server.tool("ralph_hero__delegation_stats", "Read-only telemetry over the local ralph-delegate JSONL audit log. Returns per-task call counts, fallback counts (non-ok/non-dry_run), p50/p99 latency from successful calls, and bytes_in/bytes_out aggregates. Reads RALPH_DELEGATE_LOG_PATH (default ~/.ralph-hero/delegate.log). Missing log file returns a zero-state result, never errors.", {
|
|
16
|
+
logPath: z
|
|
17
|
+
.string()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("Optional override for the JSONL log path. Defaults to RALPH_DELEGATE_LOG_PATH or ~/.ralph-hero/delegate.log."),
|
|
20
|
+
}, async (params) => {
|
|
21
|
+
try {
|
|
22
|
+
const resolvedPath = params.logPath ?? defaultDelegationLogPath();
|
|
23
|
+
const read = await readDelegationLog({ logPath: resolvedPath });
|
|
24
|
+
const stats = aggregateDelegationStats(read.events);
|
|
25
|
+
return toolSuccess({
|
|
26
|
+
logPath: read.logPath,
|
|
27
|
+
fileExists: read.fileExists,
|
|
28
|
+
totals: {
|
|
29
|
+
calls: stats.totals.calls,
|
|
30
|
+
fallbacks: stats.totals.fallbacks,
|
|
31
|
+
bytesIn: stats.totals.bytesIn,
|
|
32
|
+
bytesOut: stats.totals.bytesOut,
|
|
33
|
+
skippedLines: read.skippedLines,
|
|
34
|
+
},
|
|
35
|
+
byTask: stats.byTask,
|
|
36
|
+
tokensReason: TOKENS_REASON,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
return toolError(err instanceof Error ? err.message : String(err));
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=delegation-tools.js.map
|