@wrongstack/wrongtrace 0.313.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/LICENSE +21 -0
- package/dist/adapters/ipc.d.ts +49 -0
- package/dist/adapters/ipc.js +127 -0
- package/dist/adapters/mcp.d.ts +28 -0
- package/dist/adapters/mcp.js +42 -0
- package/dist/agent-helpers.d.ts +109 -0
- package/dist/agent-helpers.js +226 -0
- package/dist/client.d.ts +45 -0
- package/dist/client.js +304 -0
- package/dist/discovery.d.ts +37 -0
- package/dist/discovery.js +65 -0
- package/dist/gate-counters.d.ts +53 -0
- package/dist/gate-counters.js +161 -0
- package/dist/gate.d.ts +64 -0
- package/dist/gate.js +94 -0
- package/dist/hooks.d.ts +120 -0
- package/dist/hooks.js +228 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +28 -0
- package/dist/types.d.ts +237 -0
- package/dist/types.js +12 -0
- package/package.json +49 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The integrated WrongTrace client.
|
|
3
|
+
*
|
|
4
|
+
* ┌────────────────┐ ┌────────────────┐ ┌──────────────────┐
|
|
5
|
+
* │ HTTP / REST │ ←→ │ WrongTrace │ ←→ │ IPC / MCP opt. │
|
|
6
|
+
* │ (always) │ │ Client │ │ (if discovered) │
|
|
7
|
+
* └────────────────┘ └────────────────┘ └──────────────────┘
|
|
8
|
+
*
|
|
9
|
+
* Strategy:
|
|
10
|
+
* - IPC-first: when `/api/health` reports a `socket_path`, JSON-RPC 2.0
|
|
11
|
+
* over the pipe is preferred for every method the daemon exposes there.
|
|
12
|
+
* Daemon v0.3.3 (live-verified 2026-08-24) answers telemetry/file_health,
|
|
13
|
+
* telemetry/report_run, guardrail/unlock and get_atlas on the pipe;
|
|
14
|
+
* older daemons reply -32601, which the transport surfaces as
|
|
15
|
+
* {result:null} → HTTP fallback, so routing is a no-op there.
|
|
16
|
+
* - ONE exception: guardrail/lock answers on the pipe but does NOT enforce
|
|
17
|
+
* conflicts — a live probe (2026-08-24) showed IPC lock with force:false
|
|
18
|
+
* silently TAKES OVER another owner's lock instead of rejecting with the
|
|
19
|
+
* -32009 envelope the integration letter promises. lockFile therefore
|
|
20
|
+
* stays HTTP-first to preserve the 409 conflict semantics production
|
|
21
|
+
* gates depend on. Flip it when the daemon enforces conflicts on IPC.
|
|
22
|
+
* - HTTP is the universal substrate — every method has an HTTP path and
|
|
23
|
+
* serves as the fallback when the pipe is absent or fails.
|
|
24
|
+
* - MCP is used when the host runtime supplies an MCP tool bag. In that
|
|
25
|
+
* mode we prefer IPC, then the named MCP tools (lock_file,
|
|
26
|
+
* get_file_health_score, etc.), then HTTP.
|
|
27
|
+
*
|
|
28
|
+
* Every method returns `null` (or `[]` for list endpoints) when the
|
|
29
|
+
* underlying transport failed OR the daemon was never reachable. The
|
|
30
|
+
* protocol promises callers can wire this client unconditionally and
|
|
31
|
+
* "pass" when WrongTrace is offline.
|
|
32
|
+
*/
|
|
33
|
+
import { createMcpTransport } from "./adapters/mcp.js";
|
|
34
|
+
import { createIpcTransport } from "./adapters/ipc.js";
|
|
35
|
+
import { discover } from "./discovery.js";
|
|
36
|
+
const DEFAULT_TIMEOUT_MS = 4_000;
|
|
37
|
+
class HttpError extends Error {
|
|
38
|
+
status;
|
|
39
|
+
constructor(status, message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.status = status;
|
|
42
|
+
this.name = "HttpError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function httpJson(baseUrl, path, init) {
|
|
46
|
+
const fetchImpl = globalThis.fetch;
|
|
47
|
+
if (typeof fetchImpl !== "function")
|
|
48
|
+
return null;
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
const timer = setTimeout(() => controller.abort(), init?.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
51
|
+
try {
|
|
52
|
+
const headers = { Accept: "application/json" };
|
|
53
|
+
if (init?.body !== undefined)
|
|
54
|
+
headers["Content-Type"] = "application/json";
|
|
55
|
+
const reqInit = {
|
|
56
|
+
method: init?.method ?? "GET",
|
|
57
|
+
signal: controller.signal,
|
|
58
|
+
headers,
|
|
59
|
+
};
|
|
60
|
+
if (init?.body !== undefined)
|
|
61
|
+
reqInit.body = JSON.stringify(init.body);
|
|
62
|
+
const res = await fetchImpl(`${baseUrl}${path}`, reqInit);
|
|
63
|
+
// Non-2xx statuses listed in acceptStatus carry a structured body the
|
|
64
|
+
// caller wants (e.g. lock conflicts: 409 + {ok:false, owner, expires_at}).
|
|
65
|
+
// Surface those bodies instead of swallowing them into null.
|
|
66
|
+
if (!res.ok && !(init?.acceptStatus ?? []).includes(res.status)) {
|
|
67
|
+
throw new HttpError(res.status, `${init?.method ?? "GET"} ${path} → ${res.status}`);
|
|
68
|
+
}
|
|
69
|
+
return (await res.json());
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export async function createWrongTraceClient(opts = {}) {
|
|
79
|
+
const discovery = await discover(opts);
|
|
80
|
+
const mcp = createMcpTransport(opts.mcpTools);
|
|
81
|
+
const ipc = createIpcTransport(discovery.socketPath);
|
|
82
|
+
const requireBaseUrl = () => {
|
|
83
|
+
if (!discovery.available || !discovery.baseUrl)
|
|
84
|
+
return null;
|
|
85
|
+
return discovery.baseUrl;
|
|
86
|
+
};
|
|
87
|
+
const notAvailable = () => null;
|
|
88
|
+
const client = {
|
|
89
|
+
_discovery: discovery,
|
|
90
|
+
get isAvailable() {
|
|
91
|
+
return discovery.available;
|
|
92
|
+
},
|
|
93
|
+
get baseUrl() {
|
|
94
|
+
return discovery.baseUrl;
|
|
95
|
+
},
|
|
96
|
+
get socketPath() {
|
|
97
|
+
return discovery.socketPath;
|
|
98
|
+
},
|
|
99
|
+
async getHealth() {
|
|
100
|
+
const base = requireBaseUrl();
|
|
101
|
+
if (!base)
|
|
102
|
+
return notAvailable();
|
|
103
|
+
return httpJson(base, "/api/health");
|
|
104
|
+
},
|
|
105
|
+
async getFileHealth(path) {
|
|
106
|
+
const base = requireBaseUrl();
|
|
107
|
+
if (!base)
|
|
108
|
+
return notAvailable();
|
|
109
|
+
// IPC-first: live-verified JSON-RPC method on the daemon's pipe
|
|
110
|
+
// (both instances, 2026-08-24). Falls back to HTTP when the pipe is
|
|
111
|
+
// absent, unreachable, or answers with an error envelope.
|
|
112
|
+
if (ipc.isWired) {
|
|
113
|
+
const viaIpc = await ipc.call("telemetry/file_health", { file_path: path });
|
|
114
|
+
if (viaIpc.result)
|
|
115
|
+
return viaIpc.result;
|
|
116
|
+
}
|
|
117
|
+
if (mcp.isWired) {
|
|
118
|
+
const viaMcp = await mcp.invoke("get_file_health_score", { path });
|
|
119
|
+
if (viaMcp)
|
|
120
|
+
return viaMcp;
|
|
121
|
+
}
|
|
122
|
+
return httpJson(base, `/api/file/health?path=${encodeURIComponent(path)}`);
|
|
123
|
+
},
|
|
124
|
+
async getSymbolLineage(path, signature) {
|
|
125
|
+
const base = requireBaseUrl();
|
|
126
|
+
if (!base)
|
|
127
|
+
return [];
|
|
128
|
+
// Daemon round-3: signature is optional — path-only returns every
|
|
129
|
+
// symbol event for the file. Loose names like "foo()" still yield [],
|
|
130
|
+
// so callers wanting a specific symbol should pass a daemon-format
|
|
131
|
+
// signature (`function:file.go::Name`); otherwise omit it.
|
|
132
|
+
const params = new URLSearchParams();
|
|
133
|
+
params.set("path", path);
|
|
134
|
+
if (signature !== undefined && signature !== "")
|
|
135
|
+
params.set("signature", signature);
|
|
136
|
+
const data = await httpJson(base, `/api/symbol/history?${params.toString()}`);
|
|
137
|
+
return Array.isArray(data) ? data : [];
|
|
138
|
+
},
|
|
139
|
+
async getFrictionMatrix(limit = 50) {
|
|
140
|
+
const base = requireBaseUrl();
|
|
141
|
+
if (!base)
|
|
142
|
+
return [];
|
|
143
|
+
const data = await httpJson(base, `/api/metrics/friction?limit=${limit}`);
|
|
144
|
+
// The daemon returns a report object ({edges, recent_collisions,
|
|
145
|
+
// total_collisions, ...}), not a bare array. Normalize: bare array
|
|
146
|
+
// passes through; report shape yields its edges, carrying
|
|
147
|
+
// `recent_collisions` as metadata so single-call consumers
|
|
148
|
+
// (getRecentActivity) don't lose the per-file collision history.
|
|
149
|
+
if (Array.isArray(data))
|
|
150
|
+
return data;
|
|
151
|
+
if (data && typeof data === "object") {
|
|
152
|
+
const report = data;
|
|
153
|
+
if (Array.isArray(report.edges)) {
|
|
154
|
+
const rows = report.edges;
|
|
155
|
+
if (Array.isArray(report.recent_collisions))
|
|
156
|
+
rows.recent_collisions = report.recent_collisions;
|
|
157
|
+
return rows;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return [];
|
|
161
|
+
},
|
|
162
|
+
async getAtlas(query) {
|
|
163
|
+
const base = requireBaseUrl();
|
|
164
|
+
if (!base)
|
|
165
|
+
return notAvailable();
|
|
166
|
+
const params = new URLSearchParams();
|
|
167
|
+
if (query?.workspace !== undefined && query.workspace !== "")
|
|
168
|
+
params.set("workspace", query.workspace);
|
|
169
|
+
if (query?.summary === true)
|
|
170
|
+
params.set("summary", "true");
|
|
171
|
+
if (query?.includeSymbols === false)
|
|
172
|
+
params.set("include_symbols", "false");
|
|
173
|
+
if (query?.limit !== undefined)
|
|
174
|
+
params.set("limit", String(query.limit));
|
|
175
|
+
if (query?.offset !== undefined)
|
|
176
|
+
params.set("offset", String(query.offset));
|
|
177
|
+
const qs = params.size > 0 ? `?${params.toString()}` : "";
|
|
178
|
+
// IPC-first since daemon v0.3.3 (live-verified 2026-08-24): get_atlas
|
|
179
|
+
// answers JSON-RPC on the pipe. Older daemons reply -32601 →
|
|
180
|
+
// {result:null} → HTTP fallback below. summary=true keeps the pipe
|
|
181
|
+
// payload small; full atlases are multi-hundred-ms either transport.
|
|
182
|
+
if (ipc.isWired) {
|
|
183
|
+
const viaIpc = await ipc.call("get_atlas", {
|
|
184
|
+
...(query?.workspace !== undefined && query.workspace !== "" ? { workspace: query.workspace } : {}),
|
|
185
|
+
...(query?.summary === true ? { summary: true } : {}),
|
|
186
|
+
...(query?.includeSymbols === false ? { include_symbols: false } : {}),
|
|
187
|
+
...(query?.limit !== undefined ? { limit: query.limit } : {}),
|
|
188
|
+
...(query?.offset !== undefined ? { offset: query.offset } : {}),
|
|
189
|
+
});
|
|
190
|
+
if (viaIpc.result)
|
|
191
|
+
return viaIpc.result;
|
|
192
|
+
}
|
|
193
|
+
return httpJson(base, `/api/atlas${qs}`);
|
|
194
|
+
},
|
|
195
|
+
async lockFile(path, reason, opts) {
|
|
196
|
+
// Deliberately HTTP-first — the ONE IPC exception; see the strategy
|
|
197
|
+
// header. Live probe 2026-08-24: IPC guardrail/lock ignores conflicts
|
|
198
|
+
// (silently takes over even with force:false), so routing lock calls
|
|
199
|
+
// through the pipe would break the 409-conflict semantics the
|
|
200
|
+
// production guardrail depends on.
|
|
201
|
+
const body = { path, reason };
|
|
202
|
+
if (opts?.owner !== undefined)
|
|
203
|
+
body.owner = opts.owner;
|
|
204
|
+
if (opts?.ownerRunId !== undefined)
|
|
205
|
+
body.owner_run_id = opts.ownerRunId;
|
|
206
|
+
if (opts?.ttlSeconds !== undefined)
|
|
207
|
+
body.ttl_seconds = opts.ttlSeconds;
|
|
208
|
+
if (opts?.force === true)
|
|
209
|
+
body.force = true;
|
|
210
|
+
if (mcp.isWired) {
|
|
211
|
+
const viaMcp = await mcp.invoke("lock_file", { ...body });
|
|
212
|
+
if (viaMcp)
|
|
213
|
+
return viaMcp;
|
|
214
|
+
}
|
|
215
|
+
const base = requireBaseUrl();
|
|
216
|
+
if (!base)
|
|
217
|
+
return notAvailable();
|
|
218
|
+
// 409 = lock conflict: the daemon returns {ok:false, owner, owner_run_id,
|
|
219
|
+
// locked_at, expires_at, error, message} — exactly what the caller needs
|
|
220
|
+
// to decide whether to wait or take over, so pass the body through.
|
|
221
|
+
return httpJson(base, "/api/guardrail/lock", {
|
|
222
|
+
method: "POST",
|
|
223
|
+
body,
|
|
224
|
+
acceptStatus: [409],
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
async unlockFile(path) {
|
|
228
|
+
// IPC-first since daemon v0.3.3 (live-verified 2026-08-24): the pipe
|
|
229
|
+
// shares the daemon's lock store — an IPC unlock releases HTTP-acquired
|
|
230
|
+
// locks. Shape differs from HTTP: {file_path, status} with no `ok`,
|
|
231
|
+
// normalized here to the HTTP contract.
|
|
232
|
+
if (ipc.isWired) {
|
|
233
|
+
const viaIpc = await ipc.call("guardrail/unlock", { path });
|
|
234
|
+
if (viaIpc.result) {
|
|
235
|
+
return {
|
|
236
|
+
ok: true,
|
|
237
|
+
path: viaIpc.result.path ?? viaIpc.result.file_path ?? path,
|
|
238
|
+
status: viaIpc.result.status ?? "unlocked",
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const body = { path };
|
|
243
|
+
if (mcp.isWired) {
|
|
244
|
+
const viaMcp = await mcp.invoke("unlock_file", { ...body });
|
|
245
|
+
if (viaMcp)
|
|
246
|
+
return viaMcp;
|
|
247
|
+
}
|
|
248
|
+
const base = requireBaseUrl();
|
|
249
|
+
if (!base)
|
|
250
|
+
return notAvailable();
|
|
251
|
+
return httpJson(base, "/api/guardrail/unlock", { method: "POST", body });
|
|
252
|
+
},
|
|
253
|
+
async reportTelemetry(report) {
|
|
254
|
+
// IPC-first: telemetry/report_run is a live JSON-RPC method on the
|
|
255
|
+
// daemon's pipe (both instances, 2026-08-24). HTTP fallback keeps the
|
|
256
|
+
// no-op contract when the pipe is absent or fails.
|
|
257
|
+
if (ipc.isWired) {
|
|
258
|
+
const viaIpc = await ipc.call("telemetry/report_run", {
|
|
259
|
+
...report,
|
|
260
|
+
});
|
|
261
|
+
if (viaIpc.result) {
|
|
262
|
+
// Daemon answers {"status":"ok"} — normalize to the HTTP contract.
|
|
263
|
+
const ok = viaIpc.result.ok ?? viaIpc.result.status === "ok";
|
|
264
|
+
return { ok };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (mcp.isWired) {
|
|
268
|
+
const viaMcp = await mcp.invoke("report_telemetry", { ...report });
|
|
269
|
+
if (viaMcp)
|
|
270
|
+
return viaMcp;
|
|
271
|
+
}
|
|
272
|
+
const base = requireBaseUrl();
|
|
273
|
+
if (!base)
|
|
274
|
+
return notAvailable();
|
|
275
|
+
return httpJson(base, "/api/telemetry", { method: "POST", body: report });
|
|
276
|
+
},
|
|
277
|
+
async getRecentEvents(query) {
|
|
278
|
+
const base = requireBaseUrl();
|
|
279
|
+
if (!base)
|
|
280
|
+
return [];
|
|
281
|
+
const params = new URLSearchParams();
|
|
282
|
+
if (query?.limit !== undefined)
|
|
283
|
+
params.set("limit", String(query.limit));
|
|
284
|
+
if (query?.since !== undefined)
|
|
285
|
+
params.set("since", query.since);
|
|
286
|
+
if (query?.repo !== undefined)
|
|
287
|
+
params.set("repo", query.repo);
|
|
288
|
+
if (query?.filePath !== undefined)
|
|
289
|
+
params.set("file_path", query.filePath);
|
|
290
|
+
const qs = params.size > 0 ? `?${params.toString()}` : "";
|
|
291
|
+
const data = await httpJson(base, `/api/events/recent${qs}`);
|
|
292
|
+
return Array.isArray(data) ? data : [];
|
|
293
|
+
},
|
|
294
|
+
async listLocks() {
|
|
295
|
+
const base = requireBaseUrl();
|
|
296
|
+
if (!base)
|
|
297
|
+
return [];
|
|
298
|
+
const data = await httpJson(base, "/api/guardrail/locks");
|
|
299
|
+
return Array.isArray(data) ? data : [];
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
return client;
|
|
303
|
+
}
|
|
304
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovers whether the external WrongTrace AI Observability daemon is
|
|
3
|
+
* reachable, and if so, where to talk to it.
|
|
4
|
+
*
|
|
5
|
+
* Discovery protocol (per the integration spec):
|
|
6
|
+
* 1. HTTP probe of `${baseUrl}/api/health` with a tight timeout.
|
|
7
|
+
* 2. If the daemon replies 2xx, read `socket_path` from the body for the
|
|
8
|
+
* IPC path. If absent, fall back to platform-default locations.
|
|
9
|
+
* 3. If MCP tools are registered globally (e.g. via `wrongtrace mcp`),
|
|
10
|
+
* MCP transport is preferred over HTTP/IPC for actual calls.
|
|
11
|
+
*
|
|
12
|
+
* The whole routine never throws — it returns a `DiscoveryResult` whose
|
|
13
|
+
* `available` flag tells the caller to either bind the full client or
|
|
14
|
+
* install a typed no-op shim. That makes it safe to invoke from the hot
|
|
15
|
+
* path of boot.
|
|
16
|
+
*/
|
|
17
|
+
export interface DiscoveryOptions {
|
|
18
|
+
/** Override the base URL. Default: process.env.WRONGTRACE_URL ?? "http://localhost:3444". */
|
|
19
|
+
baseUrl?: string;
|
|
20
|
+
/** Probe timeout in ms. Default: 1000. */
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Inject a `fetch`-compatible implementation. Tests use this to stub the
|
|
24
|
+
* probe without monkey-patching globals.
|
|
25
|
+
*/
|
|
26
|
+
fetchImpl?: typeof fetch;
|
|
27
|
+
}
|
|
28
|
+
export interface DiscoveryResult {
|
|
29
|
+
available: boolean;
|
|
30
|
+
baseUrl?: string;
|
|
31
|
+
socketPath?: string;
|
|
32
|
+
version?: string;
|
|
33
|
+
}
|
|
34
|
+
/** Default IPC paths per platform — only consulted when `/api/health` did not return `socket_path`. */
|
|
35
|
+
export declare function defaultSocketPath(home?: string): string;
|
|
36
|
+
export declare function discover(opts?: DiscoveryOptions): Promise<DiscoveryResult>;
|
|
37
|
+
//# sourceMappingURL=discovery.d.ts.map
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovers whether the external WrongTrace AI Observability daemon is
|
|
3
|
+
* reachable, and if so, where to talk to it.
|
|
4
|
+
*
|
|
5
|
+
* Discovery protocol (per the integration spec):
|
|
6
|
+
* 1. HTTP probe of `${baseUrl}/api/health` with a tight timeout.
|
|
7
|
+
* 2. If the daemon replies 2xx, read `socket_path` from the body for the
|
|
8
|
+
* IPC path. If absent, fall back to platform-default locations.
|
|
9
|
+
* 3. If MCP tools are registered globally (e.g. via `wrongtrace mcp`),
|
|
10
|
+
* MCP transport is preferred over HTTP/IPC for actual calls.
|
|
11
|
+
*
|
|
12
|
+
* The whole routine never throws — it returns a `DiscoveryResult` whose
|
|
13
|
+
* `available` flag tells the caller to either bind the full client or
|
|
14
|
+
* install a typed no-op shim. That makes it safe to invoke from the hot
|
|
15
|
+
* path of boot.
|
|
16
|
+
*/
|
|
17
|
+
import { platform } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
/** Default IPC paths per platform — only consulted when `/api/health` did not return `socket_path`. */
|
|
20
|
+
export function defaultSocketPath(home = process.env.HOME ?? process.env.USERPROFILE ?? "") {
|
|
21
|
+
if (platform() === "win32")
|
|
22
|
+
return "\\\\.\\pipe\\wrongtrace.sock";
|
|
23
|
+
if (home)
|
|
24
|
+
return join(home, ".wrongtrace", "ipc.sock");
|
|
25
|
+
return "/tmp/wrongtrace.sock";
|
|
26
|
+
}
|
|
27
|
+
export async function discover(opts = {}) {
|
|
28
|
+
const baseUrl = opts.baseUrl ?? process.env.WRONGTRACE_URL ?? "http://localhost:3444";
|
|
29
|
+
const timeoutMs = opts.timeoutMs ?? 1000;
|
|
30
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
31
|
+
if (typeof fetchImpl !== "function") {
|
|
32
|
+
return { available: false };
|
|
33
|
+
}
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetchImpl(`${baseUrl}/api/health`, {
|
|
38
|
+
signal: controller.signal,
|
|
39
|
+
headers: { Accept: "application/json" },
|
|
40
|
+
});
|
|
41
|
+
if (!res.ok)
|
|
42
|
+
return { available: false, baseUrl };
|
|
43
|
+
const body = (await res.json().catch(() => ({})));
|
|
44
|
+
// Accept either contract the daemon might speak:
|
|
45
|
+
// * { ok: true } (older / strict boolean schema)
|
|
46
|
+
// * { status: "ok" } (current WrongProxy-style schema)
|
|
47
|
+
if (body?.ok !== true && body?.status !== "ok")
|
|
48
|
+
return { available: false, baseUrl };
|
|
49
|
+
const result = { available: true, baseUrl };
|
|
50
|
+
if (typeof body.socket_path === "string")
|
|
51
|
+
result.socketPath = body.socket_path;
|
|
52
|
+
else
|
|
53
|
+
result.socketPath = defaultSocketPath();
|
|
54
|
+
if (typeof body.version === "string")
|
|
55
|
+
result.version = body.version;
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return { available: false, baseUrl };
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=discovery.js.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WrongTrace gate-decision counter — hosted in the shared adapter so EVERY
|
|
3
|
+
* process that runs the gate (CLI leader + fleet, standalone WebUI server)
|
|
4
|
+
* records into the same tally contract and the same counters file.
|
|
5
|
+
*
|
|
6
|
+
* Design:
|
|
7
|
+
* - Pure, transport-agnostic tally: `record()` accepts the typed event;
|
|
8
|
+
* `snapshot()` returns one number per decision kind.
|
|
9
|
+
* - Deliberately NOT wired as a new EventBus listener — the host emit
|
|
10
|
+
* sites call `recordGateDecision()` inside their existing emit
|
|
11
|
+
* closures, so this module never registers a listener it must remember
|
|
12
|
+
* to dispose (the EventBus wildcard/named caps punish undisposed
|
|
13
|
+
* registrations; see the core EventBus leak board card).
|
|
14
|
+
* - A snapshot is persisted to `<projectRoot>/.wrongstack/
|
|
15
|
+
* wrongtrace-gate-counters.json` so the standalone `wstack
|
|
16
|
+
* proxy-status` command (fresh process) can report the latest writer's
|
|
17
|
+
* CUMULATIVE firing rates — cross-process measurability.
|
|
18
|
+
*
|
|
19
|
+
* Shared file contract (CLI + standalone WebUI): both processes write the
|
|
20
|
+
* same path with the same snapshot shape; each process tallies its own
|
|
21
|
+
* sessions (module singleton per process), and the last writer wins. The
|
|
22
|
+
* CLI persists at session end (finalizeExecutionCleanup); the standalone
|
|
23
|
+
* WebUI server persists on each gate decision (its host session model has
|
|
24
|
+
* no single session-end hook). `wstack proxy-status` reads whatever the
|
|
25
|
+
* latest writer produced.
|
|
26
|
+
*/
|
|
27
|
+
import type { WrongTraceGateDecisionEvent } from './hooks.js';
|
|
28
|
+
export interface WrongTraceGateCounterSnapshot {
|
|
29
|
+
deny: number;
|
|
30
|
+
allowFragile: number;
|
|
31
|
+
lockAcquired: number;
|
|
32
|
+
lockConflictRace: number;
|
|
33
|
+
lockReleased: number;
|
|
34
|
+
total: number;
|
|
35
|
+
}
|
|
36
|
+
export interface WrongTraceGateCounter {
|
|
37
|
+
record(event: WrongTraceGateDecisionEvent): void;
|
|
38
|
+
snapshot(): WrongTraceGateCounterSnapshot;
|
|
39
|
+
reset(): void;
|
|
40
|
+
}
|
|
41
|
+
export declare function createWrongTraceGateCounter(): WrongTraceGateCounter;
|
|
42
|
+
/** Record one gate decision into the process-shared tally. */
|
|
43
|
+
export declare function recordGateDecision(event: WrongTraceGateDecisionEvent): void;
|
|
44
|
+
/** Snapshot the process-shared tally (for session-end persist / doctor). */
|
|
45
|
+
export declare function snapshotGateDecisions(): WrongTraceGateCounterSnapshot;
|
|
46
|
+
export declare function resetGateDecisions(): void;
|
|
47
|
+
export declare function countersFilePath(projectRoot: string): string;
|
|
48
|
+
/** Best-effort persist — the doctor surface must never fail a session end. */
|
|
49
|
+
export declare function persistWrongTraceGateCounters(projectRoot: string, snapshot: WrongTraceGateCounterSnapshot): Promise<void>;
|
|
50
|
+
export declare function loadWrongTraceGateCounters(projectRoot: string): Promise<WrongTraceGateCounterSnapshot | null>;
|
|
51
|
+
/** Compact one-line report for the doctor surface. */
|
|
52
|
+
export declare function formatGateCounterReport(s: WrongTraceGateCounterSnapshot): string;
|
|
53
|
+
//# sourceMappingURL=gate-counters.d.ts.map
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WrongTrace gate-decision counter — hosted in the shared adapter so EVERY
|
|
3
|
+
* process that runs the gate (CLI leader + fleet, standalone WebUI server)
|
|
4
|
+
* records into the same tally contract and the same counters file.
|
|
5
|
+
*
|
|
6
|
+
* Design:
|
|
7
|
+
* - Pure, transport-agnostic tally: `record()` accepts the typed event;
|
|
8
|
+
* `snapshot()` returns one number per decision kind.
|
|
9
|
+
* - Deliberately NOT wired as a new EventBus listener — the host emit
|
|
10
|
+
* sites call `recordGateDecision()` inside their existing emit
|
|
11
|
+
* closures, so this module never registers a listener it must remember
|
|
12
|
+
* to dispose (the EventBus wildcard/named caps punish undisposed
|
|
13
|
+
* registrations; see the core EventBus leak board card).
|
|
14
|
+
* - A snapshot is persisted to `<projectRoot>/.wrongstack/
|
|
15
|
+
* wrongtrace-gate-counters.json` so the standalone `wstack
|
|
16
|
+
* proxy-status` command (fresh process) can report the latest writer's
|
|
17
|
+
* CUMULATIVE firing rates — cross-process measurability.
|
|
18
|
+
*
|
|
19
|
+
* Shared file contract (CLI + standalone WebUI): both processes write the
|
|
20
|
+
* same path with the same snapshot shape; each process tallies its own
|
|
21
|
+
* sessions (module singleton per process), and the last writer wins. The
|
|
22
|
+
* CLI persists at session end (finalizeExecutionCleanup); the standalone
|
|
23
|
+
* WebUI server persists on each gate decision (its host session model has
|
|
24
|
+
* no single session-end hook). `wstack proxy-status` reads whatever the
|
|
25
|
+
* latest writer produced.
|
|
26
|
+
*/
|
|
27
|
+
import { promises as fs } from 'node:fs';
|
|
28
|
+
import * as path from 'node:path';
|
|
29
|
+
export function createWrongTraceGateCounter() {
|
|
30
|
+
let deny = 0;
|
|
31
|
+
let allowFragile = 0;
|
|
32
|
+
let lockAcquired = 0;
|
|
33
|
+
let lockConflictRace = 0;
|
|
34
|
+
let lockReleased = 0;
|
|
35
|
+
return {
|
|
36
|
+
record(event) {
|
|
37
|
+
switch (event.kind) {
|
|
38
|
+
case 'deny':
|
|
39
|
+
deny++;
|
|
40
|
+
break;
|
|
41
|
+
case 'allow-fragile':
|
|
42
|
+
allowFragile++;
|
|
43
|
+
break;
|
|
44
|
+
case 'lock-acquired':
|
|
45
|
+
lockAcquired++;
|
|
46
|
+
break;
|
|
47
|
+
case 'lock-conflict-race':
|
|
48
|
+
lockConflictRace++;
|
|
49
|
+
break;
|
|
50
|
+
case 'lock-released':
|
|
51
|
+
lockReleased++;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
snapshot() {
|
|
56
|
+
return {
|
|
57
|
+
deny,
|
|
58
|
+
allowFragile,
|
|
59
|
+
lockAcquired,
|
|
60
|
+
lockConflictRace,
|
|
61
|
+
lockReleased,
|
|
62
|
+
total: deny + allowFragile + lockAcquired + lockConflictRace + lockReleased,
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
reset() {
|
|
66
|
+
deny = 0;
|
|
67
|
+
allowFragile = 0;
|
|
68
|
+
lockAcquired = 0;
|
|
69
|
+
lockConflictRace = 0;
|
|
70
|
+
lockReleased = 0;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const COUNTERS_FILE = path.join('.wrongstack', 'wrongtrace-gate-counters.json');
|
|
75
|
+
// Process-shared singleton — wired at the host emit sites (CLI leader +
|
|
76
|
+
// fleet, standalone WebUI server) and read at session end / on each gate
|
|
77
|
+
// decision, without registering any new EventBus listener (the leak
|
|
78
|
+
// discipline from the EventBus board card).
|
|
79
|
+
const shared = createWrongTraceGateCounter();
|
|
80
|
+
/** Record one gate decision into the process-shared tally. */
|
|
81
|
+
export function recordGateDecision(event) {
|
|
82
|
+
shared.record(event);
|
|
83
|
+
}
|
|
84
|
+
/** Snapshot the process-shared tally (for session-end persist / doctor). */
|
|
85
|
+
export function snapshotGateDecisions() {
|
|
86
|
+
return shared.snapshot();
|
|
87
|
+
}
|
|
88
|
+
export function resetGateDecisions() {
|
|
89
|
+
shared.reset();
|
|
90
|
+
}
|
|
91
|
+
export function countersFilePath(projectRoot) {
|
|
92
|
+
return path.join(projectRoot, COUNTERS_FILE);
|
|
93
|
+
}
|
|
94
|
+
// In-process serialization so unawaited per-decision persists (the
|
|
95
|
+
// standalone WebUI emit closure) cannot interleave: each write awaits the
|
|
96
|
+
// previous one, then publishes atomically via temp-file + rename (an
|
|
97
|
+
// interrupted write never leaves a truncated counters file).
|
|
98
|
+
let persistChain = Promise.resolve();
|
|
99
|
+
/** Best-effort persist — the doctor surface must never fail a session end. */
|
|
100
|
+
export function persistWrongTraceGateCounters(projectRoot, snapshot) {
|
|
101
|
+
// Chain so overlapping callers serialize; fail-open and never reject the
|
|
102
|
+
// returned promise (observability must not break teardown).
|
|
103
|
+
persistChain = persistChain.then(async () => {
|
|
104
|
+
const file = countersFilePath(projectRoot);
|
|
105
|
+
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
106
|
+
try {
|
|
107
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
108
|
+
await fs.writeFile(tmp, JSON.stringify({ at: new Date().toISOString(), ...snapshot }, null, 2), 'utf8');
|
|
109
|
+
await fs.rename(tmp, file);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// best-effort: leave the prior file intact (rename is atomic; a failed
|
|
113
|
+
// write before it only orphans the tmp file).
|
|
114
|
+
try {
|
|
115
|
+
await fs.unlink(tmp).catch(() => { });
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
/* ignore */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
return persistChain;
|
|
123
|
+
}
|
|
124
|
+
export async function loadWrongTraceGateCounters(projectRoot) {
|
|
125
|
+
try {
|
|
126
|
+
const raw = await fs.readFile(countersFilePath(projectRoot), 'utf8');
|
|
127
|
+
const parsed = JSON.parse(raw);
|
|
128
|
+
if (typeof parsed.deny !== 'number' ||
|
|
129
|
+
typeof parsed.allowFragile !== 'number' ||
|
|
130
|
+
typeof parsed.lockAcquired !== 'number' ||
|
|
131
|
+
typeof parsed.lockConflictRace !== 'number' ||
|
|
132
|
+
typeof parsed.lockReleased !== 'number') {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
// Strip the `at` timestamp persist adds — the loader returns the pure
|
|
136
|
+
// counter shape so callers (doctor readout, tests) get exactly the
|
|
137
|
+
// snapshot contract, not the storage envelope.
|
|
138
|
+
return {
|
|
139
|
+
deny: parsed.deny,
|
|
140
|
+
allowFragile: parsed.allowFragile,
|
|
141
|
+
lockAcquired: parsed.lockAcquired,
|
|
142
|
+
lockConflictRace: parsed.lockConflictRace,
|
|
143
|
+
lockReleased: parsed.lockReleased,
|
|
144
|
+
total: parsed.deny +
|
|
145
|
+
parsed.allowFragile +
|
|
146
|
+
parsed.lockAcquired +
|
|
147
|
+
parsed.lockConflictRace +
|
|
148
|
+
parsed.lockReleased,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Compact one-line report for the doctor surface. */
|
|
156
|
+
export function formatGateCounterReport(s) {
|
|
157
|
+
return (`deny=${s.deny} allow-fragile=${s.allowFragile} ` +
|
|
158
|
+
`lock-acquired=${s.lockAcquired} lock-conflict-race=${s.lockConflictRace} ` +
|
|
159
|
+
`lock-released=${s.lockReleased} total=${s.total}`);
|
|
160
|
+
}
|
|
161
|
+
//# sourceMappingURL=gate-counters.js.map
|