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,127 @@
1
+ // Contract 2.1 (c) on the detector side (F3-11b): incident.candidate carries `stackBytes` and `model` ONLY when the control plane announced
2
+ // `incident.candidate.size-model` in ready.capabilities. Toward a 2.0 control plane the frame is exactly what it was (9 fields).
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 { createAggregator } from "../../src/incident-detector/aggregator.js";
12
+ import { createCandidateEmitter } from "../../src/incident-detector/emitter.js";
13
+ import { resolveIncidentDetectorConfig } from "../../src/incident-detector/config.js";
14
+
15
+ const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "la-size-"));
16
+ const NINE = ["count", "environment", "errorHash", "fingerprint", "firstSeen", "lastSeen", "logCategory", "service", "severity"];
17
+ const base = { errorHash: "a".repeat(32), fingerprint: "b".repeat(64), severity: "high", service: "svc", environment: "production", logCategory: "exception", tsMs: Date.now() };
18
+
19
+ test("aggregator: stackBytes of a window is the biggest seen, saturating at 16 MiB; no stack = 0; it travels in every snapshot of the window", () => {
20
+ const out = [];
21
+ const agg = createAggregator({ windowMs: 60_000, flushMs: 0, maxTracked: 10, now: () => 1_000, onCandidate: (s) => out.push(s) });
22
+ agg.observe({ ...base, stackBytes: 100 });
23
+ agg.observe({ ...base, stackBytes: 5000 });
24
+ agg.observe({ ...base, stackBytes: 40 });
25
+ agg.observe({ ...base }); // no stack at all
26
+ assert.deepEqual(out.map((s) => s.stackBytes), [100, 5000, 5000, 5000]);
27
+ const big = createAggregator({ windowMs: 60_000, flushMs: 0, maxTracked: 10, now: () => 1_000, onCandidate: (s) => out.push(s) });
28
+ big.observe({ ...base, errorHash: "c".repeat(32), stackBytes: 2 ** 40 });
29
+ assert.equal(out.at(-1).stackBytes, 16 * 1024 * 1024);
30
+ for (const bad of [NaN, -5, Infinity, "12", null, undefined]) {
31
+ big.observe({ ...base, errorHash: "d".repeat(32), stackBytes: bad });
32
+ }
33
+ assert.equal(out.at(-1).stackBytes, 0);
34
+ });
35
+
36
+ function emitterFor({ caps = [], model = "stack-detector-1.8.0", snap = {} } = {}) {
37
+ const sent = [];
38
+ const session = { state: "ready", on() {}, off() {}, peerHas: (c) => caps.includes(c), async send(type, payload) { sent.push(payload); } };
39
+ const config = resolveIncidentDetectorConfig({ LOCAL_AGENT_INCIDENT_DETECTOR: "1", LOCAL_AGENT_INCIDENT_STORE_DIR: path.join(tmp(), "s") }, () => {});
40
+ const emitter = createCandidateEmitter({ session, config, model, env: {}, log: { warn() {}, info() {}, log() {} } });
41
+ emitter.submit({ ...base, count: 1, firstSeen: 1_000, lastSeen: 2_000, windowStart: 1_000, stackBytes: 123, ...snap });
42
+ return { sent, emitter };
43
+ }
44
+
45
+ test("emitter: toward a 2.0 control plane (no size-model capability) the payload is exactly the 9 fields; with it, + stackBytes and model", async () => {
46
+ let r = emitterFor();
47
+ await r.emitter.flush();
48
+ assert.deepEqual(Object.keys(r.sent[0]).sort(), NINE);
49
+ r = emitterFor({ caps: ["incident.candidate.size-model"] });
50
+ await r.emitter.flush();
51
+ assert.deepEqual(Object.keys(r.sent[0]).sort(), [...NINE, "model", "stackBytes"].sort());
52
+ assert.equal(r.sent[0].stackBytes, 123);
53
+ assert.equal(r.sent[0].model, "stack-detector-1.8.0");
54
+ // a capability that is not the size-model one changes nothing
55
+ r = emitterFor({ caps: ["something.else"] });
56
+ await r.emitter.flush();
57
+ assert.deepEqual(Object.keys(r.sent[0]).sort(), NINE);
58
+ });
59
+
60
+ test("emitter: a model label that the schema would refuse (space, slash, too long, empty) is not sent; stackBytes is clamped to 0..16 MiB", async () => {
61
+ for (const model of ["has space", "a/b", "x".repeat(49), "", null, "-lead"]) {
62
+ const r = emitterFor({ caps: ["incident.candidate.size-model"], model });
63
+ await r.emitter.flush();
64
+ assert.ok(!("model" in r.sent[0]), String(model));
65
+ assert.equal(r.sent[0].stackBytes, 123);
66
+ }
67
+ let r = emitterFor({ caps: ["incident.candidate.size-model"], snap: { stackBytes: 2 ** 40 } });
68
+ await r.emitter.flush();
69
+ assert.equal(r.sent[0].stackBytes, 16777216);
70
+ r = emitterFor({ caps: ["incident.candidate.size-model"], snap: { stackBytes: -3 } });
71
+ await r.emitter.flush();
72
+ assert.equal(r.sent[0].stackBytes, 0);
73
+ r = emitterFor({ caps: ["incident.candidate.size-model"], snap: { stackBytes: undefined } });
74
+ await r.emitter.flush();
75
+ assert.ok(!("stackBytes" in r.sent[0]), "no number, no field");
76
+ });
77
+
78
+ async function linkFor(cp, env, root = tmp()) {
79
+ const logs = [];
80
+ const log = { info: (m) => logs.push(m), log: (m) => logs.push(m), warn: (m) => logs.push(m), error: (m) => logs.push(m) };
81
+ const wsManager = { list: () => [{ id: "ws-1", root, tenantId: TENANT }] };
82
+ const link = await buildV2Link({ tenantId: TENANT, agentId: AGENT, signing: signingFor({ keyId: cp.keyId, publicKeyRaw: cp.publicKeyRaw }, "enforce"), wsManager, getWorkspaceRoot: () => root, log, env });
83
+ const transport = { send: (t) => cp.feed(t), close() {}, bufferedAmount: () => 0 };
84
+ cp.attach((t) => queueMicrotask(() => link.session.handleText(t, transport)), () => {});
85
+ link.session.attach(transport);
86
+ await waitFor(() => link.session.state === "ready");
87
+ return { link, logs };
88
+ }
89
+
90
+ for (const [label, caps] of [["a 2.1 control plane (announces size-model)", ["incident.candidate.size-model"]], ["a 2.0 control plane (announces nothing)", []]]) {
91
+ test(`end to end with ${label}: the candidate is closed-schema valid; stackBytes/model only with the capability; the text never on the wire`, async () => {
92
+ const dir = tmp(); const file = path.join(dir, "billing-api.log");
93
+ fs.writeFileSync(file, "");
94
+ const cp = new ReferenceCp({ capabilities: caps });
95
+ const { link } = await linkFor(cp, {
96
+ LOCAL_AGENT_INCIDENT_DETECTOR: "true", LOCAL_AGENT_INCIDENT_LOG_FILES: file, LOCAL_AGENT_INCIDENT_STORE_DIR: path.join(dir, "store"),
97
+ LOCAL_AGENT_INCIDENT_FILE_POLL_MS: "50", LOCAL_AGENT_INCIDENT_FLUSH_MS: "300", LOCAL_AGENT_DLP_MODE: "enforce",
98
+ });
99
+ try {
100
+ assert.equal(cp.registers[0].capabilities.includes("contract.v2.1"), true, "the LA announces it can read ready.capabilities");
101
+ await new Promise((r) => setTimeout(r, 120));
102
+ const stack = "\tat com.acme.SizeCanary.open(SizeCanary.java:44)\n\tat com.acme.Main.run(Main.java:9)";
103
+ fs.appendFileSync(file, `2026-11-03 10:00:00 ERROR c.a.Order : java.lang.IllegalStateException: SIZE-CANARY-1 order 1 not found\n${stack}\n2026-11-03 10:00:01 INFO nothing\n`);
104
+ await waitFor(() => cp.received.some((f) => f.type === "incident.candidate"), 5000);
105
+ assert.deepEqual(cp.errorsSent, [], "the CP's closed schema accepted every frame");
106
+ const p = cp.received.find((f) => f.type === "incident.candidate").payload;
107
+ if (caps.length) {
108
+ assert.deepEqual(Object.keys(p).sort(), [...NINE, "model", "stackBytes"].sort());
109
+ assert.equal(p.stackBytes, Buffer.byteLength(stack), "size of the stack as the source gave it");
110
+ assert.match(p.model, /^stack-detector-\d+\.\d+\.\d+$/);
111
+ } else {
112
+ assert.deepEqual(Object.keys(p).sort(), NINE, "identical to before 2.1");
113
+ }
114
+ const wire = cp.rawIn.join("\n");
115
+ for (const w of ["SIZE-CANARY-1", "SizeCanary", "SizeCanary.java"]) assert.ok(!wire.includes(w), w);
116
+ } finally { link.close(); }
117
+ });
118
+ }
119
+
120
+ test("every flag off: register/hello are byte-for-byte the baseline capabilities (no contract.v2.1, no llm.key, no webhook.*, no incident.candidate)", async () => {
121
+ const cp = new ReferenceCp({ capabilities: ["incident.candidate.size-model"] });
122
+ const { link } = await linkFor(cp, {});
123
+ assert.deepEqual([...cp.registers[0].capabilities].sort(), ["chunk.base64", "protocol.v2", "v1.commands"]);
124
+ assert.deepEqual([...cp.hellos[0].capabilities].sort(), ["chunk.base64", "protocol.v2", "v1.commands"]);
125
+ assert.deepEqual([...link.session.peerCapabilities], [], "a ready without capabilities (the LA did not announce contract.v2.1)");
126
+ link.close();
127
+ });
@@ -0,0 +1,179 @@
1
+ // F3-11b: the log sources (file tail, Loki, stream) with real files, a fake fetch and real streams.
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 { PassThrough, Readable } from "node:stream";
8
+ import { createFileSource } from "../../src/incident-detector/sources/file-source.js";
9
+ import { createLokiSource } from "../../src/incident-detector/sources/loki-source.js";
10
+ import { createStreamSource } from "../../src/incident-detector/sources/stream-source.js";
11
+
12
+ const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "la-src-"));
13
+ const collect = () => { const got = []; return { got, handler: async (entries) => { got.push(...entries); } }; };
14
+
15
+ // ------------------------------------------------------------------------------------------ file
16
+
17
+ test("file: only NEW lines by default; fromStart reads what is there; a record spanning appends is one entry; the file name is the service", async () => {
18
+ const dir = tmp(); const f = path.join(dir, "billing-api.log");
19
+ fs.writeFileSync(f, "2026-10-29 10:00:00 ERROR old one\n");
20
+ const a = collect();
21
+ const src = createFileSource({ filePath: f, idleFlushMs: 60_000 });
22
+ src.start(a.handler); await src.poll();
23
+ assert.equal(a.got.length, 0, "history is not replayed");
24
+ fs.appendFileSync(f, "2026-10-29 10:00:01 ERROR c.a.Foo : failed 7\njava.lang.IllegalStateException: x\n");
25
+ await src.poll();
26
+ fs.appendFileSync(f, "\tat com.x.A.m(A.java:1)\n2026-10-29 10:00:02 INFO ok\n");
27
+ await src.poll();
28
+ src.stop();
29
+ assert.equal(a.got.length, 1, "the INFO line closed the ERROR record; the INFO record is still in progress");
30
+ assert.equal(a.got[0].service, "billing-api");
31
+ assert.match(a.got[0].stacktrace, /IllegalStateException[\s\S]*A\.java:1/);
32
+ const b = collect();
33
+ const s2 = createFileSource({ filePath: f, fromStart: true, idleFlushMs: 60_000 });
34
+ s2.start(b.handler); await s2.poll(); s2.stop();
35
+ assert.equal(b.got.length, 2);
36
+ });
37
+
38
+ test("file: rotation (new inode) and truncation restart at 0; a missing file is waited for; a partial last line waits for its newline; CRLF and multibyte are intact", async () => {
39
+ const dir = tmp(); const f = path.join(dir, "x.log");
40
+ const a = collect();
41
+ const src = createFileSource({ filePath: f, fromStart: true, idleFlushMs: 0 });
42
+ src.start(a.handler);
43
+ await src.poll(); // no file yet
44
+ fs.writeFileSync(f, "2026-10-29 10:00:00 ERROR one é💥\r\n2026-10-29 10:00:01 ERROR par");
45
+ await src.poll();
46
+ assert.deepEqual(a.got.map((e) => e.message), ["2026-10-29 10:00:00 ERROR one é💥"], "the partial line is not an entry yet");
47
+ fs.appendFileSync(f, "tial\n");
48
+ await src.poll();
49
+ assert.equal(a.got.at(-1).message, "2026-10-29 10:00:01 ERROR partial");
50
+ fs.renameSync(f, f + ".1");
51
+ fs.writeFileSync(f, "2026-10-29 10:00:02 ERROR after rotation\n");
52
+ await src.poll();
53
+ assert.equal(a.got.at(-1).message, "2026-10-29 10:00:02 ERROR after rotation");
54
+ fs.writeFileSync(f, "2026-10-29 10:00:03 ERROR t\n"); // truncate + rewrite (shorter than the offset)
55
+ await src.poll();
56
+ assert.equal(a.got.at(-1).message, "2026-10-29 10:00:03 ERROR t");
57
+ src.stop();
58
+ });
59
+
60
+ test("file: an over-long line is cut at maxLineBytes (the rest to its newline is discarded and counted); reading is bounded per tick", async () => {
61
+ const dir = tmp(); const f = path.join(dir, "y.log");
62
+ fs.writeFileSync(f, "");
63
+ const a = collect(); const stats = {};
64
+ const src = createFileSource({ filePath: f, fromStart: true, idleFlushMs: 0, maxLineBytes: 100, maxReadBytes: 64 * 1024, stats });
65
+ src.start(a.handler); await src.poll();
66
+ fs.appendFileSync(f, "2026-10-29 10:00:00 ERROR " + "z".repeat(10_000) + "\n2026-10-29 10:00:01 ERROR next\n");
67
+ await src.poll(); await src.poll();
68
+ src.stop();
69
+ assert.equal(stats.longLines, 1);
70
+ assert.ok(a.got[0].message.length <= 100);
71
+ assert.equal(a.got.at(-1).message, "2026-10-29 10:00:01 ERROR next");
72
+ // bounded per tick: 300 KiB are read over several ticks, never more than maxReadBytes (+ one chunk) at once
73
+ const g = path.join(dir, "big.log");
74
+ fs.writeFileSync(g, "");
75
+ const sizes = [];
76
+ const big = createFileSource({ filePath: g, fromStart: true, idleFlushMs: 0, maxReadBytes: 64 * 1024 });
77
+ big.start(async (entries) => { sizes.push(entries.length); });
78
+ await big.poll();
79
+ fs.appendFileSync(g, "2026-10-29 10:00:00 ERROR line\n".repeat(10_000));
80
+ await big.poll();
81
+ const afterOne = sizes.reduce((x, y) => x + y, 0);
82
+ assert.ok(afterOne > 0 && afterOne < 10_000, `one tick reads a bounded slice (${afterOne})`);
83
+ big.stop();
84
+ });
85
+
86
+ // ------------------------------------------------------------------------------------------ Loki
87
+
88
+ const nsOf = (ms) => (BigInt(ms) * 1_000_000n).toString();
89
+
90
+ test("loki: query_range with the cursor, bearer + org headers, service from the stream labels, Loki timestamp wins, cursor advances", async () => {
91
+ const calls = [];
92
+ const t0 = Date.parse("2026-10-29T10:00:00Z");
93
+ const fetchImpl = async (url, init) => {
94
+ calls.push({ url: new URL(url), init });
95
+ return { ok: true, status: 200, body: Readable.toWeb(Readable.from([Buffer.from(JSON.stringify({ data: { result: [
96
+ { stream: { service_name: "checkout", level: "error" }, values: [[nsOf(t0 + 5), "boom 2"], [nsOf(t0 + 1), '{"level":"error","msg":"json boom"}']] },
97
+ ] } }))])) };
98
+ };
99
+ const a = collect();
100
+ const src = createLokiSource({ url: "https://loki.internal:3100", query: '{app="x"}', token: "tok", org: "tenant-1", fetchImpl, now: () => t0 + 1000, lookbackMs: 60_000, limit: 500 });
101
+ src.start(a.handler); await src.poll();
102
+ const c = calls[0];
103
+ assert.equal(c.url.pathname, "/loki/api/v1/query_range");
104
+ assert.equal(c.url.searchParams.get("query"), '{app="x"}');
105
+ assert.equal(c.url.searchParams.get("direction"), "forward");
106
+ assert.equal(c.url.searchParams.get("start"), nsOf(t0 + 1000 - 60_000));
107
+ assert.equal(c.init.headers.Authorization, "Bearer tok");
108
+ assert.equal(c.init.headers["X-Scope-OrgID"], "tenant-1");
109
+ assert.equal(c.init.redirect, "error");
110
+ assert.deepEqual(a.got.map((e) => [e.message, e.service]), [["json boom", "checkout"], ["boom 2", "checkout"]], "sorted by time");
111
+ assert.equal(a.got[0].tsNs, BigInt(nsOf(t0 + 1)));
112
+ await src.poll();
113
+ src.stop();
114
+ assert.equal(calls[1].url.searchParams.get("start"), (BigInt(nsOf(t0 + 5)) + 1n).toString(), "cursor = last ns + 1");
115
+ });
116
+
117
+ test("loki: a full page continues at once (up to maxPages); an error is counted, never thrown, and carries nothing to the log", async () => {
118
+ let n = 0;
119
+ const t0 = Date.parse("2026-10-29T10:00:00Z");
120
+ const fetchImpl = async () => {
121
+ n++;
122
+ const values = [[nsOf(t0 + n * 10), `ERROR e${n}`], [nsOf(t0 + n * 10 + 1), `ERROR f${n}`]];
123
+ return { ok: true, status: 200, body: Readable.toWeb(Readable.from([Buffer.from(JSON.stringify({ data: { result: [{ stream: { app: "a" }, values }] } }))])) };
124
+ };
125
+ const a = collect(); const stats = {};
126
+ const src = createLokiSource({ url: "http://l", query: "q", fetchImpl, limit: 2, maxPages: 3, now: () => t0, stats });
127
+ src.start(a.handler); await src.poll(); src.stop();
128
+ assert.equal(n, 3, "maxPages");
129
+ assert.equal(a.got.length, 6);
130
+ const bad = createLokiSource({ url: "http://l", query: "q", fetchImpl: async () => { throw new Error("connect ECONNREFUSED http://secret-host:3100"); }, stats: {} });
131
+ bad.start(async () => {});
132
+ await new Promise((r) => setTimeout(r, 30));
133
+ assert.equal(bad.stats.errors, 1);
134
+ bad.stop();
135
+ });
136
+
137
+ test("loki: a response over maxResponseBytes, a non-200 and malformed rows are survived", async () => {
138
+ const mk = (body, status = 200) => async () => ({ ok: status === 200, status, body: Readable.toWeb(Readable.from([Buffer.from(body)])) });
139
+ const a = collect();
140
+ const big = createLokiSource({ url: "http://l", query: "q", fetchImpl: mk("x".repeat(5000)), maxResponseBytes: 1000 });
141
+ big.start(a.handler);
142
+ await assert.rejects(big.poll(), /response_too_large/);
143
+ big.stop();
144
+ const bad = createLokiSource({ url: "http://l", query: "q", fetchImpl: mk("{}", 500) });
145
+ bad.start(a.handler);
146
+ await assert.rejects(bad.poll(), /http_500/);
147
+ bad.stop();
148
+ const odd = createLokiSource({ url: "http://l", query: "q", fetchImpl: mk(JSON.stringify({ data: { result: [{ stream: null, values: [["x", "y"], [1, 2], ["5", "ERROR ok"], "z"] }, null] } })) });
149
+ odd.start(a.handler); await odd.poll(); odd.stop();
150
+ assert.deepEqual(a.got.map((e) => e.message), ["ERROR ok"]);
151
+ });
152
+
153
+ // ------------------------------------------------------------------------------------------ stream
154
+
155
+ test("stream: lines become entries, the service label is the default, the record in progress is flushed at the end", async () => {
156
+ const input = new PassThrough();
157
+ const a = collect();
158
+ const src = createStreamSource({ stream: input, service: "container-1", idleFlushMs: 20 });
159
+ src.start(a.handler);
160
+ input.write("2026-10-29 10:00:00 ERROR boom\n\tat a.B.c(B.java:1)\n");
161
+ await new Promise((r) => setTimeout(r, 80));
162
+ assert.equal(a.got.length, 1, "idle flush");
163
+ input.end("2026-10-29 10:00:01 ERROR last");
164
+ await new Promise((r) => setTimeout(r, 50));
165
+ assert.deepEqual(a.got.map((e) => e.service), ["container-1", "container-1"]);
166
+ src.stop();
167
+ });
168
+
169
+ test("stream: backpressure, a slow consumer pauses the stream instead of buffering the world", async () => {
170
+ const input = new PassThrough({ highWaterMark: 1024 });
171
+ let consumed = 0;
172
+ const src = createStreamSource({ stream: input, idleFlushMs: 5 });
173
+ src.start(async (entries) => { consumed += entries.length; await new Promise((r) => setTimeout(r, 30)); });
174
+ let written = 0; let backpressured = false;
175
+ for (let i = 0; i < 2000; i++) { const ok = input.write("2026-10-29 10:00:00 ERROR line " + i + "\n"); written++; if (!ok) { backpressured = true; break; } }
176
+ assert.equal(backpressured, true, "the writer is told to stop long before 2000 lines");
177
+ assert.ok(written < 2000);
178
+ src.stop(); input.destroy();
179
+ });