deepdebug-local-agent 1.7.0 → 1.9.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.
Files changed (71) hide show
  1. package/.claude/context.md +2 -1
  2. package/CLAUDE.md +1 -1
  3. package/docs/la-protocol-v2.md +27 -4
  4. package/env.example +12 -0
  5. package/package.json +1 -1
  6. package/scripts/incident-parity/Gen.java +256 -0
  7. package/scripts/incident-parity/LogEntry.java +17 -0
  8. package/scripts/incident-parity/extract.py +51 -0
  9. package/scripts/incident-parity/gen.sh +32 -0
  10. package/src/incident-detector/README.md +52 -0
  11. package/src/incident-detector/aggregator.js +78 -0
  12. package/src/incident-detector/categorize.js +48 -0
  13. package/src/incident-detector/config.js +95 -0
  14. package/src/incident-detector/detector.js +93 -0
  15. package/src/incident-detector/emitter.js +164 -0
  16. package/src/incident-detector/index.js +44 -0
  17. package/src/incident-detector/java-compat.js +66 -0
  18. package/src/incident-detector/normalize.js +209 -0
  19. package/src/incident-detector/parse.js +114 -0
  20. package/src/incident-detector/sources/file-source.js +113 -0
  21. package/src/incident-detector/sources/loki-source.js +101 -0
  22. package/src/incident-detector/sources/stream-source.js +39 -0
  23. package/src/incident-detector/sources/webhook-source.js +109 -0
  24. package/src/incident-detector/store.js +119 -0
  25. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +24 -16
  26. package/src/la-protocol-v2/contract/README.md +103 -3
  27. package/src/la-protocol-v2/contract/config.update.schema.json +85 -1
  28. package/src/la-protocol-v2/contract/hello.schema.json +26 -1
  29. package/src/la-protocol-v2/contract/incident.candidate.schema.json +11 -0
  30. package/src/la-protocol-v2/contract/index.json +9 -2
  31. package/src/la-protocol-v2/contract/ready.schema.json +8 -0
  32. package/src/la-protocol-v2/contract/register.schema.json +26 -1
  33. package/src/la-protocol-v2/contract/webhook.forward.schema.json +72 -3
  34. package/src/la-protocol-v2/contract.js +45 -7
  35. package/src/la-protocol-v2/llm-key/config.js +29 -0
  36. package/src/la-protocol-v2/llm-key/handler.js +33 -0
  37. package/src/la-protocol-v2/llm-key/install.js +21 -0
  38. package/src/la-protocol-v2/llm-key/store.js +173 -0
  39. package/src/la-protocol-v2/runtime.js +44 -1
  40. package/src/la-protocol-v2/session.js +47 -13
  41. package/src/la-protocol-v2/webhooks/assembler.js +85 -0
  42. package/src/la-protocol-v2/webhooks/config.js +51 -0
  43. package/src/la-protocol-v2/webhooks/handler.js +125 -0
  44. package/src/la-protocol-v2/webhooks/install.js +22 -0
  45. package/test/contract/la-protocol-v2/vectors/ack.vectors.json +91 -0
  46. package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +1339 -0
  47. package/test/contract/la-protocol-v2/vectors/hello.vectors.json +261 -0
  48. package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +623 -0
  49. package/test/contract/la-protocol-v2/vectors/ready.vectors.json +599 -0
  50. package/test/contract/la-protocol-v2/vectors/register.vectors.json +275 -0
  51. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-after-last.json +186 -0
  52. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-body-hash-changes.json +186 -0
  53. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-last-flag-wrong.json +186 -0
  54. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-non-last-too-short.json +152 -0
  55. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-out-of-order.json +152 -0
  56. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-sha-mismatch.json +186 -0
  57. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-total-changes.json +186 -0
  58. package/test/contract/la-protocol-v2/vectors/traces/valid-webhook-chunks-in-order.json +218 -0
  59. package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +1263 -0
  60. package/test/incident-detector/parity.test.js +66 -0
  61. package/test/incident-detector/parity.vectors.json +2619 -0
  62. package/test/incident-detector/size-model.test.js +127 -0
  63. package/test/incident-detector/sources.test.js +179 -0
  64. package/test/incident-detector/units.test.js +430 -0
  65. package/test/incident-detector/wiring.test.js +165 -0
  66. package/test/la-protocol-v2/contract-v21.test.js +107 -0
  67. package/test/la-protocol-v2/contract.test.js +6 -4
  68. package/test/la-protocol-v2/helpers/reference-cp.js +8 -4
  69. package/test/la-protocol-v2/llm-key.test.js +509 -0
  70. package/test/la-protocol-v2/traces.test.js +3 -3
  71. package/test/la-protocol-v2/webhook-receiver.test.js +702 -0
@@ -0,0 +1,430 @@
1
+ // F3-11b: unit tests of the pieces (config, parse, categorize, aggregator, store, emitter) with fake clocks and a fake session.
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { EventEmitter } from "node:events";
8
+ import { resolveIncidentDetectorConfig } from "../../src/incident-detector/config.js";
9
+ import { parseRecord, EntryAssembler, parseTime } from "../../src/incident-detector/parse.js";
10
+ import { severityOf, maxSeverity, inferEnvironment, sanitizeService, sanitizeEnvironment, logCategoryOf } from "../../src/incident-detector/categorize.js";
11
+ import { createAggregator } from "../../src/incident-detector/aggregator.js";
12
+ import { createDetailStore } from "../../src/incident-detector/store.js";
13
+ import { createCandidateEmitter } from "../../src/incident-detector/emitter.js";
14
+ import { ProtocolError } from "../../src/la-protocol-v2/errors.js";
15
+
16
+ const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "la-inc-"));
17
+ const H = (n) => n.toString(16).padStart(32, "0");
18
+
19
+ // ------------------------------------------------------------------------------------------ config
20
+
21
+ test("config: only true/1/yes/on enable it; unset, empty, false and unknown values keep it off (unknown warns)", () => {
22
+ for (const v of [undefined, "", "false", "0", "no", "off", " OFF ", "maybe", "2", "enabled"]) {
23
+ const warns = [];
24
+ assert.deepEqual(resolveIncidentDetectorConfig({ LOCAL_AGENT_INCIDENT_DETECTOR: v }, (m) => warns.push(m)), { enabled: false }, String(v));
25
+ assert.equal(warns.length, ["maybe", "2", "enabled"].includes(v) ? 1 : 0, String(v));
26
+ }
27
+ for (const v of ["true", "TRUE", "1", "yes", "on", " On "]) assert.equal(resolveIncidentDetectorConfig({ LOCAL_AGENT_INCIDENT_DETECTOR: v }, () => {}).enabled, true, v);
28
+ });
29
+
30
+ test("config: numbers are clamped, a bad value falls back to the default (never unlimited); sources and Loki validation", () => {
31
+ const warns = [];
32
+ const c = resolveIncidentDetectorConfig({
33
+ LOCAL_AGENT_INCIDENT_DETECTOR: "true", LOCAL_AGENT_INCIDENT_MAX_TRACKED: "99999999", LOCAL_AGENT_INCIDENT_MAX_PENDING: "0", LOCAL_AGENT_INCIDENT_FLUSH_MS: "abc",
34
+ LOCAL_AGENT_INCIDENT_MAX_EMITS_PER_MINUTE: "-1", LOCAL_AGENT_INCIDENT_LOG_FILES: " /a.log , ,/b.log", LOCAL_AGENT_INCIDENT_STDIN: "yes",
35
+ LOCAL_AGENT_INCIDENT_LOKI_URL: "ftp://x", LOCAL_AGENT_INCIDENT_MAX_ENTRY_BYTES: "1",
36
+ }, (m) => warns.push(m));
37
+ assert.equal(c.maxTracked, 5_000);
38
+ assert.equal(c.maxPending, 500);
39
+ assert.equal(c.flushMs, 30_000);
40
+ assert.equal(c.maxEmitsPerMinute, 60);
41
+ assert.equal(c.maxEntryBytes, 64 * 1024);
42
+ assert.deepEqual(c.files, ["/a.log", "/b.log"]);
43
+ assert.equal(c.stdin, true);
44
+ assert.equal(c.loki, null);
45
+ assert.equal(warns.length, 1);
46
+ const l = resolveIncidentDetectorConfig({ LOCAL_AGENT_INCIDENT_DETECTOR: "1", LOCAL_AGENT_INCIDENT_LOKI_URL: "https://loki.internal:3100/", LOCAL_AGENT_INCIDENT_LOKI_QUERY: '{app="x"} |= "error"', LOCAL_AGENT_INCIDENT_LOKI_TOKEN: "t" }, () => {});
47
+ assert.equal(l.loki.url, "https://loki.internal:3100");
48
+ assert.equal(l.loki.token, "t");
49
+ const noQuery = [];
50
+ assert.equal(resolveIncidentDetectorConfig({ LOCAL_AGENT_INCIDENT_DETECTOR: "1", LOCAL_AGENT_INCIDENT_LOKI_URL: "http://l" }, (m) => noQuery.push(m)).loki, null);
51
+ assert.equal(noQuery.length, 1);
52
+ });
53
+
54
+ // ------------------------------------------------------------------------------------------ parse
55
+
56
+ test("parse: JSON lines with aliases, separate stack field, docker json log, bad JSON as text", () => {
57
+ const a = parseRecord('{"level":"error","msg":"boom 1","stack_trace":"a.B: x\\n\\tat c.D.e(D.java:1)","service":"api","time":"2026-10-29T10:00:00Z"}');
58
+ assert.deepEqual([a.severity, a.message, a.service, a.tsMs], ["error", "boom 1", "api", Date.parse("2026-10-29T10:00:00Z")]);
59
+ assert.match(a.stacktrace, /^a\.B: x/);
60
+ const d = parseRecord('{"log":"ERROR something failed\\n","stream":"stderr","time":"2026-10-29T10:00:00.5Z"}');
61
+ assert.equal(d.severity, "ERROR");
62
+ assert.equal(d.message, "ERROR something failed");
63
+ const bad = parseRecord("{not json ERROR x");
64
+ assert.equal(bad.severity, "ERROR");
65
+ assert.equal(parseRecord(""), null);
66
+ assert.equal(parseRecord('{"nomsg":1}'), null);
67
+ const multi = parseRecord("java.lang.X: boom\n\tat a.B.c(B.java:1)");
68
+ assert.equal(multi.message, "java.lang.X: boom");
69
+ assert.equal(multi.stacktrace, "\tat a.B.c(B.java:1)");
70
+ });
71
+
72
+ test("parse: times from ISO, epoch s/ms/us/ns", () => {
73
+ const ms = Date.parse("2026-10-29T10:00:00Z");
74
+ assert.equal(parseTime("2026-10-29T10:00:00Z"), ms);
75
+ assert.equal(parseTime("2026-10-29 10:00:00,250"), ms + 250);
76
+ assert.equal(parseTime(ms), ms);
77
+ assert.equal(parseTime(ms / 1000), ms);
78
+ assert.equal(parseTime(ms * 1000), ms);
79
+ assert.equal(parseTime(ms * 1_000_000), ms);
80
+ assert.equal(parseTime("nope"), null);
81
+ assert.equal(parseTime(null), null);
82
+ });
83
+
84
+ test("assembler: a record owns the lines that do not start a new one; JSON lines stand alone; caps are enforced and counted", () => {
85
+ const a = new EntryAssembler({ maxEntryBytes: 200, maxStackLines: 3 });
86
+ const out = [];
87
+ for (const l of ["2026-10-29 10:00:00 ERROR c.a.Foo : failed 42", "java.lang.IllegalStateException: x", "\tat a.B.c(B.java:1)", "\tat a.B.d(B.java:2)", "\tat a.B.e(B.java:3)", "\tat a.B.f(B.java:4)",
88
+ "2026-10-29 10:00:01 INFO fine", '{"level":"error","msg":"json one"}', "2026-10-29 10:00:02 ERROR " + "z".repeat(500)]) out.push(...a.push(l));
89
+ out.push(...a.flush());
90
+ assert.equal(out.length, 4);
91
+ assert.equal(out[0].severity, "ERROR");
92
+ assert.equal(out[0].stacktrace.split("\n").length, 3, "maxStackLines");
93
+ assert.equal(out[1].severity, "INFO");
94
+ assert.equal(out[2].message, "json one");
95
+ assert.ok(out[3].message.length <= 200, "maxEntryBytes");
96
+ assert.equal(a.truncated, 2);
97
+ });
98
+
99
+ // ------------------------------------------------------------------------------------------ categorize
100
+
101
+ test("categorize: severity, environment, sanitised labels fit the contract patterns, categories", () => {
102
+ assert.deepEqual(["FATAL", "critical", "alert", "emerg", "ERROR", "severe", "WARN", null].map(severityOf), ["critical", "critical", "critical", "critical", "high", "high", "medium", "medium"]);
103
+ assert.equal(maxSeverity("high", "critical"), "critical");
104
+ assert.equal(maxSeverity("critical", "high"), "critical");
105
+ assert.deepEqual(["api-qa", "api-qa-2", "x-demo", "x-staging", "x-stg", "api", "", null].map(inferEnvironment), ["qa", "qa", "demo", "staging", "staging", "production", "production", "production"]);
106
+ for (const raw of ["my service!", "a".repeat(300), "", null, "ok.name:v1/x-y", "é💥"]) assert.match(sanitizeService(raw), /^[A-Za-z0-9._:/-]{1,128}$/, String(raw));
107
+ for (const raw of ["Prod!", "-x", "", null, "B".repeat(99), "staging-eu"]) assert.match(sanitizeEnvironment(raw), /^[a-z0-9][a-z0-9_-]{0,31}$/, String(raw));
108
+ const cat = (m, s = null, t = null) => logCategoryOf(m, s, t);
109
+ assert.equal(cat("java.lang.OutOfMemoryError: Java heap space timed out"), "oom");
110
+ assert.equal(cat("java.util.concurrent.TimeoutException: x"), "timeout");
111
+ assert.equal(cat("org.postgresql.util.PSQLException: boom"), "db");
112
+ assert.equal(cat("x", null, "PG_ERROR"), "db");
113
+ assert.equal(cat("AccessDeniedException: no"), "security");
114
+ assert.equal(cat("request failed HTTP 503"), "http_5xx");
115
+ assert.equal(cat("java.lang.IllegalStateException: x", null, "java.lang.IllegalStateException"), "exception");
116
+ assert.equal(cat("plain failure"), "other");
117
+ });
118
+
119
+ // ------------------------------------------------------------------------------------------ aggregator
120
+
121
+ function aggRig(opts = {}) {
122
+ const clock = { t: 1_000_000 };
123
+ const sent = [];
124
+ const agg = createAggregator({ windowMs: 10_000, flushMs: 2_000, maxTracked: 3, now: () => clock.t, onCandidate: (s) => sent.push(s), ...opts });
125
+ const obs = (hash, extra = {}) => agg.observe({ errorHash: hash, fingerprint: "f".repeat(64), severity: "high", service: "api", environment: "qa", logCategory: "exception", tsMs: clock.t, ...extra });
126
+ return { clock, sent, agg, obs };
127
+ }
128
+
129
+ test("aggregator: leading edge at once, then batched updates with growing count and a stable window identity", () => {
130
+ const { clock, sent, obs, agg } = aggRig();
131
+ assert.equal(obs(H(1)).opened, true);
132
+ assert.equal(sent.length, 1);
133
+ assert.equal(sent[0].count, 1);
134
+ clock.t += 100; obs(H(1)); clock.t += 100; obs(H(1));
135
+ assert.equal(sent.length, 1, "within flushMs nothing more is sent");
136
+ clock.t += 2_000; obs(H(1));
137
+ assert.equal(sent.length, 2);
138
+ assert.equal(sent[1].count, 4);
139
+ assert.equal(sent[1].firstSeen, sent[0].firstSeen, "same window: same firstSeen");
140
+ assert.equal(sent[1].windowStart, sent[0].windowStart);
141
+ clock.t += 500; obs(H(1));
142
+ clock.t += 2_000; agg.tick();
143
+ assert.equal(sent.length, 3, "tick sends the due update");
144
+ assert.equal(sent[2].count, 5);
145
+ clock.t += 2_000; agg.tick();
146
+ assert.equal(sent.length, 3, "nothing new: nothing sent");
147
+ });
148
+
149
+ test("aggregator: the window closes with a final update and the next occurrence opens a NEW window (new firstSeen, count restarts)", () => {
150
+ const { clock, sent, obs, agg } = aggRig();
151
+ obs(H(1)); clock.t += 500; obs(H(1)); // count 2, 1 unsent
152
+ clock.t += 10_000; agg.tick(); // window over: final update
153
+ assert.deepEqual(sent.map((s) => s.count), [1, 2]);
154
+ clock.t += 1; const r = obs(H(1));
155
+ assert.equal(r.opened, true);
156
+ assert.equal(sent.at(-1).count, 1);
157
+ assert.ok(sent.at(-1).windowStart > sent[0].windowStart);
158
+ assert.equal(agg.stats.windows, 2);
159
+ });
160
+
161
+ test("aggregator: an occurrence that arrives after the window ended (no tick yet) closes it itself: final update, then a new window", () => {
162
+ const { clock, sent, obs } = aggRig();
163
+ obs(H(1)); clock.t += 100; obs(H(1)); obs(H(1)); // count 3, 2 unsent
164
+ clock.t += 10_000;
165
+ obs(H(1));
166
+ assert.deepEqual(sent.map((s) => s.count), [1, 3, 1]);
167
+ assert.ok(sent[2].windowStart > sent[0].windowStart);
168
+ });
169
+
170
+ test("aggregator: highest severity of the window, first fingerprint/category win, firstSeen never in the future, an old line keeps the window identity", () => {
171
+ const { clock, sent, obs } = aggRig();
172
+ obs(H(1), { severity: "high", fingerprint: "a".repeat(64), logCategory: "db", tsMs: clock.t + 999_999 });
173
+ assert.equal(sent[0].firstSeen, clock.t, "clamped to now");
174
+ clock.t += 3_000;
175
+ obs(H(1), { severity: "critical", fingerprint: "b".repeat(64), logCategory: "oom", tsMs: 5 });
176
+ assert.equal(sent[1].severity, "critical");
177
+ assert.equal(sent[1].fingerprint, "a".repeat(64));
178
+ assert.equal(sent[1].logCategory, "db");
179
+ assert.equal(sent[1].firstSeen, sent[0].firstSeen);
180
+ });
181
+
182
+ test("aggregator: maxTracked evicts the least recently seen hash (with its pending update) and counts it", () => {
183
+ const { clock, sent, obs, agg } = aggRig();
184
+ obs(H(1)); obs(H(2)); obs(H(3));
185
+ clock.t += 100; obs(H(1)); // 1 is now the most recent; 2 is the oldest; 1 has an unsent update
186
+ obs(H(4)); // evicts 2 (nothing pending for it)
187
+ assert.equal(agg.tracked, 3);
188
+ assert.equal(agg.stats.evicted, 1);
189
+ clock.t += 100; obs(H(3)); obs(H(5)); // evicts 1 (it has count 2 unsent: a final update goes out)
190
+ const finalOf1 = sent.filter((s) => s.errorHash === H(1));
191
+ assert.deepEqual(finalOf1.map((s) => s.count), [1, 2]);
192
+ });
193
+
194
+ test("aggregator: drain() sends what is pending and forgets the windows", () => {
195
+ const { clock, sent, obs, agg } = aggRig();
196
+ obs(H(1)); clock.t += 10; obs(H(1)); obs(H(2));
197
+ agg.drain();
198
+ assert.equal(agg.tracked, 0);
199
+ assert.deepEqual(sent.map((s) => [s.errorHash, s.count]), [[H(1), 1], [H(2), 1], [H(1), 2]]);
200
+ });
201
+
202
+ // ------------------------------------------------------------------------------------------ store
203
+
204
+ function storeRig(extra = {}) {
205
+ const dir = path.join(tmp(), "inc");
206
+ const store = createDetailStore({ dir, maxBytes: 4096, maxQueue: 5, messageChars: 200, stackChars: 300, samplesPerWindow: 2, env: { LOCAL_AGENT_DLP_MODE: "enforce" }, ...extra });
207
+ return { dir, store, file: path.join(dir, "incident-detail.jsonl") };
208
+ }
209
+ const rec = (n, extra = {}) => ({ errorHash: H(n), fingerprint: "f".repeat(64), service: "api", severity: "high", windowStart: 1, message: `msg ${n}`, stack: `stack ${n}`, ...extra });
210
+
211
+ test("store: 0700 dir, 0600 file, one JSON per line, the detail is readable back (newest first), samples per window are capped", async () => {
212
+ const { store, dir, file } = storeRig();
213
+ for (let i = 0; i < 4; i++) await store.add(rec(1, { message: `first ${i}` }));
214
+ await store.add(rec(2, { windowStart: 2 }));
215
+ await store.flush();
216
+ assert.equal(store.stats.written, 3);
217
+ assert.equal(store.stats.sampledOut, 2);
218
+ assert.equal(fs.statSync(file).mode & 0o777, 0o600);
219
+ assert.equal(fs.statSync(dir).mode & 0o777, 0o700);
220
+ const got = await store.getDetail(H(1), { limit: 5 });
221
+ assert.deepEqual(got.map((r) => r.message), ["first 1", "first 0"]);
222
+ assert.equal(got[0].stackBytes, 7);
223
+ assert.deepEqual(await store.getDetail(H(9)), []);
224
+ });
225
+
226
+ test("store: the DLP masks secrets before they are written; text and stack are cut; a denied value is never written", async () => {
227
+ const { store, file } = storeRig();
228
+ const secret = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8";
229
+ await store.add(rec(1, { message: `token ${secret} leaked ${"x".repeat(500)}`, stack: `s ${secret}\n` + "y".repeat(900) }));
230
+ await store.flush();
231
+ const raw = fs.readFileSync(file, "utf8");
232
+ assert.ok(!raw.includes(secret), "the secret is not on disk");
233
+ const r = JSON.parse(raw);
234
+ assert.equal(r.truncated, true);
235
+ assert.ok(r.message.length <= 200 + 40 && r.stack.length <= 300 + 40);
236
+ assert.ok(store.stats.redactions >= 1);
237
+ });
238
+
239
+ test("store: rotates at maxBytes to .1 (one generation) and reads both", async () => {
240
+ const { store, file } = storeRig({ maxBytes: 1500 });
241
+ for (let i = 0; i < 12; i++) await store.add(rec(100 + i, { message: "m".repeat(100) }));
242
+ await store.flush();
243
+ assert.ok(store.stats.rotations >= 1);
244
+ assert.ok(fs.existsSync(`${file}.1`));
245
+ assert.ok(fs.statSync(file).size <= 1500 + 600);
246
+ assert.equal((await store.getDetail(H(100 + 11))).length, 1, "newest is in the current file");
247
+ });
248
+
249
+ test("store: a broken disk disables the store (counted, no throw) and add() resolves false; the queue is bounded", async () => {
250
+ const dir = tmp();
251
+ fs.writeFileSync(path.join(dir, "blocker"), "x");
252
+ const store = createDetailStore({ dir: path.join(dir, "blocker", "sub"), maxBytes: 4096, maxQueue: 2, messageChars: 100, stackChars: 100, samplesPerWindow: 5, env: {} });
253
+ assert.equal(await store.add(rec(1)), true, "queued before the failure is known");
254
+ await store.flush();
255
+ assert.equal(store.broken, true);
256
+ assert.equal(await store.add(rec(2)), false);
257
+ assert.ok(store.stats.errors >= 1);
258
+ assert.ok(store.stats.dropped >= 1);
259
+ });
260
+
261
+ test("store: add() waits (backpressure) while the queue is full instead of growing it", async () => {
262
+ const { store } = storeRig({ maxQueue: 2 });
263
+ let max = 0;
264
+ const ps = [];
265
+ for (let i = 0; i < 20; i++) { ps.push(store.add(rec(200 + i, { windowStart: i }))); max = Math.max(max, store.queued); }
266
+ await Promise.all(ps);
267
+ await store.flush();
268
+ assert.ok(max <= 2, `queue never above maxQueue (saw ${max})`);
269
+ assert.equal(store.stats.written, 20);
270
+ });
271
+
272
+ // ------------------------------------------------------------------------------------------ emitter (fake session)
273
+
274
+ class FakeSession extends EventEmitter {
275
+ constructor() { super(); this.state = "ready"; this.sent = []; this.script = []; }
276
+ async send(type, payload, opts) {
277
+ const next = this.script.shift();
278
+ if (next) throw new ProtocolError(next);
279
+ this.sent.push({ type, payload, opts });
280
+ }
281
+ }
282
+ const cfg = (extra = {}) => ({ maxPending: 3, maxEmitsPerMinute: 60, emitBurst: 10, ...extra });
283
+ const snap = (n, extra = {}) => ({ errorHash: H(n), fingerprint: "f".repeat(64), severity: "high", service: "api", environment: "qa", count: 1, firstSeen: 1_700_000_000_000, lastSeen: 1_700_000_001_000, logCategory: "exception", windowStart: 1, ...extra });
284
+ const quietLog = { warn() {}, info() {}, log() {} };
285
+
286
+ test("emitter: exactly the 9 contract fields, ISO times, the workspace of the session; nothing else", async () => {
287
+ const s = new FakeSession();
288
+ const e = createCandidateEmitter({ session: s, config: cfg(), workspaceId: "ws-1", env: {}, log: quietLog });
289
+ e.submit(snap(1));
290
+ await e.flush();
291
+ assert.equal(s.sent.length, 1);
292
+ assert.deepEqual(Object.keys(s.sent[0].payload).sort(), ["count", "environment", "errorHash", "fingerprint", "firstSeen", "lastSeen", "logCategory", "service", "severity"]);
293
+ assert.equal(s.sent[0].payload.firstSeen, "2023-11-14T22:13:20.000Z");
294
+ assert.equal(s.sent[0].opts.workspaceId, "ws-1");
295
+ assert.equal(s.sent[0].type, "incident.candidate");
296
+ e.close();
297
+ });
298
+
299
+ test("emitter: updates of the same window coalesce (the newest count wins); a full queue drops NEW candidates but still takes updates", async () => {
300
+ const s = new FakeSession(); s.state = "not_ready"; // nothing leaves
301
+ const e = createCandidateEmitter({ session: s, config: cfg(), env: {}, log: quietLog });
302
+ e.submit(snap(1)); e.submit(snap(2)); e.submit(snap(3));
303
+ assert.equal(e.submit(snap(4)), false, "full: a new candidate is dropped");
304
+ assert.equal(e.stats.droppedFull, 1);
305
+ e.submit(snap(1, { count: 9 }));
306
+ assert.equal(e.pending, 3);
307
+ assert.equal(e.stats.coalesced, 1);
308
+ s.state = "ready"; s.emit("ready", {});
309
+ await e.flush();
310
+ assert.equal(s.sent.length, 3);
311
+ assert.equal(s.sent[0].payload.count, 9);
312
+ e.close();
313
+ });
314
+
315
+ test("emitter: rate limit (token bucket) holds the rest back; the bucket refills with time", async () => {
316
+ const clock = { t: 0 };
317
+ const timers = { setTimeout: (fn, ms) => ({ fn, ms, unref() {} }), clearTimeout() {} };
318
+ const s = new FakeSession();
319
+ const e = createCandidateEmitter({ session: s, config: cfg({ maxPending: 50, maxEmitsPerMinute: 60, emitBurst: 2 }), env: {}, log: quietLog, now: () => clock.t, timers });
320
+ for (let i = 1; i <= 5; i++) e.submit(snap(i, { windowStart: i }));
321
+ await e.flush();
322
+ assert.equal(s.sent.length, 2, "burst of 2");
323
+ assert.equal(e.pending, 3);
324
+ clock.t += 3_000; // 3 s at 60/min = 3 tokens (capped at the burst of 2)
325
+ await e.flush();
326
+ assert.equal(s.sent.length, 4);
327
+ e.close();
328
+ });
329
+
330
+ test("emitter: rate_limited keeps everything and backs off; protocol_unsupported forgets the queue until the next ready; invalid is dropped", async () => {
331
+ const clock = { t: 0 };
332
+ const timers = { setTimeout: () => ({ unref() {} }), clearTimeout() {} };
333
+ const s = new FakeSession();
334
+ const e = createCandidateEmitter({ session: s, config: cfg({ maxPending: 10 }), env: {}, log: quietLog, now: () => clock.t, timers });
335
+ s.script.push("rate_limited");
336
+ e.submit(snap(1)); e.submit(snap(2));
337
+ await e.flush();
338
+ assert.equal(s.sent.length, 0);
339
+ assert.equal(e.pending, 2, "kept");
340
+ await e.flush();
341
+ assert.equal(s.sent.length, 0, "still inside the backoff window");
342
+ clock.t += 31_000;
343
+ await e.flush();
344
+ assert.equal(s.sent.length, 2);
345
+ s.script.push("protocol_unsupported");
346
+ e.submit(snap(3, { windowStart: 3 }));
347
+ await e.flush();
348
+ assert.equal(e.pending, 0, "dropped: this gateway takes none");
349
+ e.submit(snap(4, { windowStart: 4 }));
350
+ await e.flush();
351
+ assert.equal(s.sent.length, 2, "quiet");
352
+ s.emit("ready", {});
353
+ e.submit(snap(5, { windowStart: 5 }));
354
+ await e.flush();
355
+ assert.equal(s.sent.length, 3, "after ready it talks again");
356
+ s.script.push("schema_violation");
357
+ e.submit(snap(6, { windowStart: 6 }));
358
+ await e.flush();
359
+ assert.equal(e.stats.droppedInvalid, 1);
360
+ e.close();
361
+ });
362
+
363
+ test("emitter: internal/timeout retried, given up after 5 attempts in a row (counted)", async () => {
364
+ const timers = { setTimeout: () => ({ unref() {} }), clearTimeout() {} };
365
+ const s = new FakeSession();
366
+ const e = createCandidateEmitter({ session: s, config: cfg(), env: {}, log: quietLog, timers });
367
+ s.script.push("internal", "timeout", "internal", "timeout", "internal");
368
+ e.submit(snap(1));
369
+ for (let i = 0; i < 6; i++) await e.flush();
370
+ assert.equal(e.stats.droppedGaveUp, 1);
371
+ assert.equal(e.pending, 0);
372
+ e.close();
373
+ });
374
+
375
+ test("emitter: a label the DLP would change is not sent (the candidate is dropped, counted); with DLP off the label goes as is", async () => {
376
+ const secret = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8";
377
+ const s = new FakeSession();
378
+ const e = createCandidateEmitter({ session: s, config: cfg(), env: { LOCAL_AGENT_DLP_MODE: "enforce" }, log: quietLog });
379
+ e.submit(snap(1, { service: `svc-${secret}` }));
380
+ await e.flush();
381
+ assert.equal(s.sent.length, 0);
382
+ assert.equal(e.stats.droppedDlp, 1);
383
+ e.submit(snap(2, { service: "billing-api" }));
384
+ await e.flush();
385
+ assert.equal(s.sent.length, 1);
386
+ e.close();
387
+ });
388
+
389
+ test("emitter: not ready keeps the candidates; the next ready sends them (one at a time, in order)", async () => {
390
+ const s = new FakeSession(); s.state = "detached";
391
+ const e = createCandidateEmitter({ session: s, config: cfg(), env: {}, log: quietLog });
392
+ e.submit(snap(1)); e.submit(snap(2));
393
+ await e.flush();
394
+ assert.equal(s.sent.length, 0);
395
+ s.state = "ready"; s.emit("ready", { resumed: true });
396
+ await e.flush();
397
+ assert.deepEqual(s.sent.map((x) => x.payload.errorHash), [H(1), H(2)]);
398
+ e.close();
399
+ });
400
+
401
+ // ------------------------------------------------------------------------------------------ untrusted timestamps (review of #65)
402
+
403
+ test("an absurd timestamp in a log line is not a time: parse gives null, the aggregator uses the clock, the emitter never blocks", async () => {
404
+ for (const v of [-1e30, 1e30, "-99999-01-01T00:00:00Z"]) {
405
+ const t = parseTime(v);
406
+ assert.ok(t === null || Math.abs(t) <= 8.64e15, `parseTime(${v}) = ${t}: it must be null or a valid Date value`);
407
+ }
408
+ assert.equal(parseTime(-1e30), null);
409
+ assert.equal(parseTime(1e300), null);
410
+ // the aggregator ignores a negative or non-finite time: firstSeen is the clock, never -1e33
411
+ const { clock, sent, obs } = aggRig();
412
+ obs(H(7), { tsMs: -1e33 });
413
+ assert.equal(sent[0].firstSeen, clock.t);
414
+ obs(H(8), { tsMs: -5 });
415
+ assert.equal(sent[1].firstSeen, clock.t);
416
+ // even if a bad snapshot reached the emitter, it is dropped (invalid) and the next candidate still goes out
417
+ const s = new FakeSession();
418
+ const e = createCandidateEmitter({ session: s, config: cfg(), env: {}, log: quietLog });
419
+ e.submit(snap(1, { firstSeen: -1e33, lastSeen: -1e33 }));
420
+ await e.flush();
421
+ assert.equal(s.sent.length, 0, "the bad candidate is not sent");
422
+ assert.equal(e.pending, 0, "and it no longer sits at the head of the queue (it used to block it for ever)");
423
+ e.submit(snap(2));
424
+ await e.flush();
425
+ assert.equal(s.sent.length, 1, "the good one goes out");
426
+ assert.equal(s.sent[0].payload.errorHash, H(2));
427
+ assert.equal(e.stats.droppedInvalid >= 1, true);
428
+ assert.equal(e.stats.droppedDlp, 0, "it is not a DLP drop");
429
+ e.close();
430
+ });
@@ -0,0 +1,165 @@
1
+ // F3-11b: the detector wired on the REAL v2 session (buildV2Link) against the in-memory reference control plane, with real files.
2
+ // What the CP receives is checked on the wire: 9 metadata fields, and never a word of the error text.
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { ReferenceCp, TENANT, AGENT, waitFor } from "../la-protocol-v2/helpers/reference-cp.js";
9
+ import { signingFor } from "../la-protocol-v2/helpers/la-rig.js";
10
+ import { buildV2Link } from "../../src/la-protocol-v2/runtime.js";
11
+ import { createIncidentDetector } from "../../src/incident-detector/detector.js";
12
+ import { resolveIncidentDetectorConfig } from "../../src/incident-detector/config.js";
13
+
14
+ const VECTORS = JSON.parse(fs.readFileSync(path.join(path.dirname(new URL(import.meta.url).pathname), "parity.vectors.json"), "utf8"));
15
+ const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "la-incw-"));
16
+ const GH = "ghp_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8";
17
+ const CANARIES = ["CANARY-MSG-7731", "CanaryVault", "/home/customer/secret-app", "order-9001-detail", GH];
18
+
19
+ async function linkFor(cp, env, root = tmp()) {
20
+ const logs = [];
21
+ const log = { info: (m) => logs.push(m), log: (m) => logs.push(m), warn: (m) => logs.push(m), error: (m) => logs.push(m) };
22
+ const wsManager = { list: () => [{ id: "ws-1", root, tenantId: TENANT }] };
23
+ const link = await buildV2Link({ tenantId: TENANT, agentId: AGENT, signing: signingFor({ keyId: cp.keyId, publicKeyRaw: cp.publicKeyRaw }, "enforce"), wsManager, getWorkspaceRoot: () => root, log, env });
24
+ const transport = { send: (t) => cp.feed(t), close() {}, bufferedAmount: () => 0 };
25
+ cp.attach((t) => queueMicrotask(() => link.session.handleText(t, transport)), () => {});
26
+ link.session.attach(transport);
27
+ await waitFor(() => link.session.state === "ready");
28
+ return { link, logs };
29
+ }
30
+ const candidates = (cp) => cp.received.filter((f) => f.type === "incident.candidate");
31
+ const envFor = (dir, extra = {}) => ({
32
+ LOCAL_AGENT_INCIDENT_DETECTOR: "true", LOCAL_AGENT_INCIDENT_LOG_FILES: path.join(dir, "billing-api.log"), LOCAL_AGENT_INCIDENT_STORE_DIR: path.join(dir, "store"),
33
+ LOCAL_AGENT_INCIDENT_FILE_POLL_MS: "50", LOCAL_AGENT_INCIDENT_FLUSH_MS: "300", LOCAL_AGENT_DLP_MODE: "enforce", ...extra,
34
+ });
35
+
36
+ test("flag off (unset or any non-true value): no capability, nothing started, the log file is not read, no store dir; flag on without a source warns and starts nothing", async () => {
37
+ for (const v of [undefined, "false", "maybe"]) {
38
+ const dir = tmp();
39
+ fs.writeFileSync(path.join(dir, "billing-api.log"), "");
40
+ const cp = new ReferenceCp();
41
+ const { link } = await linkFor(cp, { LOCAL_AGENT_INCIDENT_DETECTOR: v, LOCAL_AGENT_INCIDENT_LOG_FILES: path.join(dir, "billing-api.log"), LOCAL_AGENT_INCIDENT_STORE_DIR: path.join(dir, "store") });
42
+ fs.appendFileSync(path.join(dir, "billing-api.log"), "2026-10-29 10:00:00 ERROR java.lang.IllegalStateException: boom\n");
43
+ await new Promise((r) => setTimeout(r, 150));
44
+ assert.equal(link.incidents, undefined);
45
+ assert.ok(!cp.registers[0].capabilities.includes("incident.candidate"));
46
+ assert.equal(candidates(cp).length, 0);
47
+ assert.ok(!fs.existsSync(path.join(dir, "store")), "no store dir");
48
+ link.close();
49
+ }
50
+ const cp = new ReferenceCp();
51
+ const { link, logs } = await linkFor(cp, { LOCAL_AGENT_INCIDENT_DETECTOR: "true" });
52
+ assert.equal(link.incidents, undefined);
53
+ assert.ok(logs.some((l) => /no source is configured/.test(l)));
54
+ assert.ok(!cp.registers[0].capabilities.includes("incident.candidate"));
55
+ link.close();
56
+ });
57
+
58
+ test("flag on + file source: candidates carry the 9 metadata fields and nothing of the text; the detail is in the local store; the capability is announced", async () => {
59
+ const dir = tmp(); const file = path.join(dir, "billing-api.log");
60
+ fs.writeFileSync(file, "");
61
+ const cp = new ReferenceCp();
62
+ const { link } = await linkFor(cp, envFor(dir));
63
+ assert.ok(cp.registers[0].capabilities.includes("incident.candidate"));
64
+ await new Promise((r) => setTimeout(r, 120)); // the tailer has taken its offset
65
+ const stack = `\tat com.acme.CanaryVault.open(CanaryVault.java:44)\n\tat com.acme.Main.run(Main.java:9)\n`;
66
+ fs.appendFileSync(file,
67
+ `2026-10-29 10:00:00 ERROR c.a.Order : java.lang.IllegalStateException: CANARY-MSG-7731 order 9001 not found\n`
68
+ + `java.lang.IllegalStateException: CANARY-MSG-7731 order-9001-detail at /home/customer/secret-app token ${GH}\n` + stack
69
+ + "2026-10-29 10:00:01 INFO nothing to see\n");
70
+ await waitFor(() => candidates(cp).length >= 1, 4000);
71
+ const c = candidates(cp)[0];
72
+ assert.deepEqual(Object.keys(c.payload).sort(), ["count", "environment", "errorHash", "fingerprint", "firstSeen", "lastSeen", "logCategory", "service", "severity"]);
73
+ assert.equal(c.payload.service, "billing-api");
74
+ assert.equal(c.payload.environment, "production");
75
+ assert.equal(c.payload.severity, "high");
76
+ assert.equal(c.payload.logCategory, "exception");
77
+ assert.equal(c.payload.count, 1);
78
+ assert.match(c.payload.errorHash, /^[0-9a-f]{32}$/);
79
+ assert.match(c.payload.fingerprint, /^[0-9a-f]{64}$/);
80
+ assert.equal(c.workspaceId, "");
81
+ // NOTHING of the text on the wire (every raw frame of the session, both the handshake and the data)
82
+ const wire = cp.rawIn.join("\n");
83
+ for (const w of CANARIES) assert.ok(!wire.includes(w), `the wire must not carry ${w.slice(0, 12)}...`);
84
+ // the detail stayed local, masked
85
+ const detail = await link.incidents.getDetail(c.payload.errorHash);
86
+ assert.equal(detail.length, 1);
87
+ assert.ok(detail[0].message.includes("CANARY-MSG-7731"));
88
+ assert.ok(!JSON.stringify(detail).includes(GH), "the DLP masked the token before it was stored");
89
+ assert.ok(detail[0].stack.includes("CanaryVault"));
90
+ assert.equal(detail[0].stackBytes, Buffer.byteLength(`java.lang.IllegalStateException: CANARY-MSG-7731 order-9001-detail at /home/customer/secret-app token ${GH}\n` + stack.replace(/\n$/, "")), "the size of the stack is local metadata");
91
+ assert.equal(fs.statSync(path.join(dir, "store", "incident-detail.jsonl")).mode & 0o777, 0o600);
92
+ // the same error with other ids: same errorHash, ONE window: the count grows in a batched update
93
+ for (let i = 0; i < 5; i++) fs.appendFileSync(file, `2026-10-29 10:00:0${2 + i} ERROR c.a.Order : java.lang.IllegalStateException: CANARY-MSG-7731 order ${9100 + i} not found\n`);
94
+ await waitFor(() => candidates(cp).length >= 2, 4000);
95
+ const u = candidates(cp).at(-1).payload;
96
+ assert.equal(u.firstSeen, c.payload.firstSeen, "same window");
97
+ assert.ok(u.count >= 2);
98
+ const wire2 = cp.rawIn.join("\n");
99
+ for (const w of CANARIES) assert.ok(!wire2.includes(w));
100
+ // the audit has no text either
101
+ assert.ok(!JSON.stringify(link.incidents.audit.tail(100)).includes("CANARY"));
102
+ link.close();
103
+ });
104
+
105
+ test("flag on: noise, INFO/WARN, duplicates and an entry without a message produce no candidate", async () => {
106
+ const dir = tmp(); const file = path.join(dir, "billing-api.log");
107
+ fs.writeFileSync(file, "");
108
+ const cp = new ReferenceCp();
109
+ const { link } = await linkFor(cp, envFor(dir));
110
+ await new Promise((r) => setTimeout(r, 120));
111
+ fs.appendFileSync(file, [
112
+ '2026-10-29 10:00:00 ERROR {code=9, message=Ready condition status changed to False}',
113
+ "2026-10-29 10:00:01 WARN java.lang.RuntimeException: not an error level",
114
+ "2026-10-29 10:00:02 ERROR GET /actuator/health Exception",
115
+ "2026-10-29 10:00:03 ERROR plain failure without any marker",
116
+ "2026-10-29 10:00:04 ERROR org.apache.maven.lifecycle.LifecycleExecutionException: build",
117
+ "",
118
+ ].join("\n"));
119
+ await new Promise((r) => setTimeout(r, 500));
120
+ assert.equal(candidates(cp).length, 0);
121
+ const s = link.incidents.detector.stats;
122
+ assert.equal(s.noise, 1, "the Maven build failure is significant to ErrorDetector but infra/build noise to LogNoiseClassifier");
123
+ assert.equal(s.insignificant, 4, JSON.stringify(s));
124
+ assert.equal(s.detected, 0);
125
+ link.close();
126
+ });
127
+
128
+ // ------------------------------------------------------------------------------------------ the Java vectors through the whole pipeline
129
+
130
+ test("parity end to end: every significant vector goes through detector -> aggregator -> emitter and the candidate carries the Java errorHash and fingerprint", async () => {
131
+ const sent = [];
132
+ const session = { state: "ready", on() {}, off() {}, async send(type, payload) { sent.push(payload); } };
133
+ const { createCandidateEmitter } = await import("../../src/incident-detector/emitter.js");
134
+ const { createDetailStore } = await import("../../src/incident-detector/store.js");
135
+ const config = resolveIncidentDetectorConfig({ LOCAL_AGENT_INCIDENT_DETECTOR: "1", LOCAL_AGENT_INCIDENT_STORE_DIR: path.join(tmp(), "s"), LOCAL_AGENT_INCIDENT_MAX_PENDING: "5000", LOCAL_AGENT_INCIDENT_MAX_EMITS_PER_MINUTE: "600", LOCAL_AGENT_INCIDENT_MAX_TRACKED: "5000" }, () => {});
136
+ config.emitBurst = 100_000;
137
+ const emitter = createCandidateEmitter({ session, config, env: {}, log: { warn() {}, info() {}, log() {} } });
138
+ const store = createDetailStore({ dir: config.storeDir, maxBytes: 8 << 20, maxQueue: 100, messageChars: 1000, stackChars: 1000, samplesPerWindow: 1, env: {} });
139
+ const det = createIncidentDetector({ config, emitter, store, log: console });
140
+ let expected = 0;
141
+ const want = new Map();
142
+ for (const c of VECTORS.cases) {
143
+ const e = c.expect;
144
+ if (!e.significant || e.noiseClass !== "app_error" || e.fingerprint == null) continue;
145
+ const svc = `svc-${expected}`;
146
+ await det.ingest({ severity: c.severity, message: c.message, stacktrace: c.stack, service: svc, tsMs: 1_700_000_000_000 + expected });
147
+ want.set(svc, e);
148
+ expected++;
149
+ }
150
+ await emitter.flush();
151
+ assert.ok(expected >= 40, `${expected} significant vectors`);
152
+ // errorHash is shared by cases with the same normalised first line: one window per errorHash, so compare per hash
153
+ const byHash = new Map(sent.map((p) => [p.errorHash, p]));
154
+ const hashes = new Set([...want.values()].map((e) => e.groupHash));
155
+ assert.equal(byHash.size, hashes.size, "one window per Java errorHash");
156
+ for (const e of want.values()) {
157
+ const p = byHash.get(e.groupHash);
158
+ assert.ok(p, "candidate for " + e.groupHash);
159
+ }
160
+ // the first occurrence of each hash carries ITS fingerprint (first one wins, as in incident_dedup_log)
161
+ const firstOfHash = new Map();
162
+ for (const e of want.values()) if (!firstOfHash.has(e.groupHash)) firstOfHash.set(e.groupHash, e);
163
+ for (const [h, e] of firstOfHash) assert.equal(byHash.get(h).fingerprint, e.fingerprint, "Java fingerprint of the first occurrence of " + h);
164
+ emitter.close();
165
+ });