deepdebug-local-agent 1.8.0 → 1.10.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 (72) hide show
  1. package/.claude/context.md +2 -1
  2. package/AGENTS.md +1 -1
  3. package/CLAUDE.md +1 -1
  4. package/docs/la-jobs-and-llm.md +56 -0
  5. package/docs/la-protocol-v2.md +19 -2
  6. package/env.example +11 -0
  7. package/package.json +1 -1
  8. package/src/incident-detector/README.md +2 -1
  9. package/src/incident-detector/aggregator.js +8 -3
  10. package/src/incident-detector/detector.js +2 -1
  11. package/src/incident-detector/emitter.js +16 -6
  12. package/src/incident-detector/index.js +3 -3
  13. package/src/incident-detector/sources/webhook-source.js +109 -0
  14. package/src/la-protocol-v2/content-handlers/install.js +27 -1
  15. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +26 -18
  16. package/src/la-protocol-v2/contract/README.md +127 -3
  17. package/src/la-protocol-v2/contract/config.update.schema.json +85 -1
  18. package/src/la-protocol-v2/contract/hello.schema.json +26 -1
  19. package/src/la-protocol-v2/contract/incident.candidate.schema.json +11 -0
  20. package/src/la-protocol-v2/contract/index.json +9 -2
  21. package/src/la-protocol-v2/contract/job.assign.schema.json +31 -0
  22. package/src/la-protocol-v2/contract/ready.schema.json +8 -0
  23. package/src/la-protocol-v2/contract/register.schema.json +26 -1
  24. package/src/la-protocol-v2/contract/webhook.forward.schema.json +72 -3
  25. package/src/la-protocol-v2/contract.js +45 -7
  26. package/src/la-protocol-v2/jobs/config.js +60 -0
  27. package/src/la-protocol-v2/jobs/events.js +106 -0
  28. package/src/la-protocol-v2/jobs/install.js +37 -0
  29. package/src/la-protocol-v2/jobs/manager.js +276 -0
  30. package/src/la-protocol-v2/jobs/options.js +56 -0
  31. package/src/la-protocol-v2/jobs/smoke-runner.js +39 -0
  32. package/src/la-protocol-v2/llm/client.js +311 -0
  33. package/src/la-protocol-v2/llm/config.js +61 -0
  34. package/src/la-protocol-v2/llm/install.js +17 -0
  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 +183 -0
  39. package/src/la-protocol-v2/runtime.js +71 -4
  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/job.assign.vectors.json +1538 -0
  50. package/test/contract/la-protocol-v2/vectors/ready.vectors.json +599 -0
  51. package/test/contract/la-protocol-v2/vectors/register.vectors.json +275 -0
  52. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-after-last.json +186 -0
  53. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-body-hash-changes.json +186 -0
  54. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-last-flag-wrong.json +186 -0
  55. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-non-last-too-short.json +152 -0
  56. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-out-of-order.json +152 -0
  57. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-sha-mismatch.json +186 -0
  58. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-total-changes.json +186 -0
  59. package/test/contract/la-protocol-v2/vectors/traces/valid-webhook-chunks-in-order.json +218 -0
  60. package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +1263 -0
  61. package/test/incident-detector/size-model.test.js +127 -0
  62. package/test/la-protocol-v2/contract-v21.test.js +107 -0
  63. package/test/la-protocol-v2/contract.test.js +6 -4
  64. package/test/la-protocol-v2/helpers/fake-llm.js +64 -0
  65. package/test/la-protocol-v2/helpers/reference-cp.js +8 -4
  66. package/test/la-protocol-v2/jobs-e2e.test.js +405 -0
  67. package/test/la-protocol-v2/jobs-manager.test.js +370 -0
  68. package/test/la-protocol-v2/jobs-options.test.js +288 -0
  69. package/test/la-protocol-v2/llm-client.test.js +380 -0
  70. package/test/la-protocol-v2/llm-key.test.js +509 -0
  71. package/test/la-protocol-v2/traces.test.js +3 -3
  72. package/test/la-protocol-v2/webhook-receiver.test.js +702 -0
@@ -0,0 +1,125 @@
1
+ /**
2
+ * handler.js - `webhook.forward` (F3-12 consumer, contract 2.1 (b)). Registered by install.js only with LOCAL_AGENT_WEBHOOK_FORWARD=true.
3
+ *
4
+ * one frame (no `chunk`, body <= 9216 bytes) decoded here, handed to the sink, acked after the sink returned
5
+ * chunks every chunk acked `accepted` as it is stored; the ack of the LAST one means "rebuilt, hash
6
+ * verified AND delivered to the local consumer" (the CP answers the webhook only then)
7
+ * refusals non-fatal `rejected` acks with the wire code (chunk_out_of_order, chunk_inconsistent,
8
+ * payload_too_large, rate_limited, schema_violation for a body that is not canonical base64);
9
+ * the assembly is dropped. A sink that throws is `internal`; no sink = protocol_unsupported.
10
+ * delivery deadline the final ack waits for the sink up to config.deliveryMs (default 10 s); past it the ack is
11
+ * `rejected timeout` (the CP treats the webhook as not delivered). install.js tells the session to
12
+ * wait for this handler, so the ack never goes out as `accepted` before the delivery happened.
13
+ * resends the ids delivered in the last config.dedupeMs (10 min, at most config.dedupeMax) are remembered in
14
+ * memory with the sha256 of their body: the same id AND the same body again is acked `accepted`
15
+ * without being processed again (a CP fallback or a retry must not inflate the detector's count). Same
16
+ * id with another body is a new webhook. Nothing but the id and the hash is kept; nothing is logged.
17
+ * The body is in memory only: it is zeroed once the sink has settled (also after a timeout); nothing is written, logged or counted by content
18
+ * (counters by code). The sink is `async ({webhookId, source, contentType, body:Buffer}) => void`: it must finish its work with the body
19
+ * before it resolves.
20
+ */
21
+ import crypto from "node:crypto";
22
+ import { isCanonicalBase64, loadContract, webhookChunkSizeViolation } from "../contract.js";
23
+ import { createWebhookAssembler } from "./assembler.js";
24
+
25
+ const DEFAULT_TIMERS = { setTimeout, clearTimeout };
26
+
27
+ export function createWebhookHandler({ config, getSink, now = Date.now, timers }) {
28
+ const limits = loadContract().limits;
29
+ const T = timers || DEFAULT_TIMERS;
30
+ const assembler = createWebhookAssembler({ limits, config, now, ...(timers ? { timers } : {}) });
31
+ const deliveryMs = config.deliveryMs ?? 10_000;
32
+ const dedupeMs = config.dedupeMs ?? 600_000;
33
+ const dedupeMax = config.dedupeMax ?? 1024;
34
+ const stats = { frames: 0, single: 0, chunks: 0, delivered: 0, refused: {}, deliveryFailed: 0, deliveryTimeouts: 0, deduped: 0 };
35
+ const refused = (code) => { stats.refused[code] = (stats.refused[code] || 0) + 1; return { reject: code }; };
36
+
37
+ const seen = new Map(); // webhookId -> { sha, until } (insertion order = expiry order: the window is constant)
38
+ const inflight = new Set(); // webhookIds whose sink has not settled yet (only matters after a timeout)
39
+ const prune = () => {
40
+ const t = now();
41
+ for (const [id, e] of seen) { if (e.until > t && seen.size <= dedupeMax) break; seen.delete(id); }
42
+ };
43
+ const remember = (id, sha) => { seen.delete(id); seen.set(id, { sha, until: now() + dedupeMs }); prune(); };
44
+ const isResend = (id, sha) => { prune(); const e = seen.get(id); return !!e && e.sha === sha; };
45
+
46
+ const resends = new Map(); // webhookId -> { next, total, sha, startedAt }: a resend of an already delivered chunked webhook in progress
47
+ function resendChunk(p, chunk, rs) {
48
+ const { index, total, last } = chunk;
49
+ const t = now();
50
+ const fail = (code) => { resends.delete(p.webhookId); return refused(code); };
51
+ if (rs && t - rs.startedAt >= config.assemblyMs) { resends.delete(p.webhookId); rs = undefined; }
52
+ if (!rs) {
53
+ if (index !== 0) return refused("chunk_out_of_order");
54
+ if (resends.size >= config.maxOpen) return refused("rate_limited");
55
+ rs = { next: 0, total, sha: p.bodySha256, startedAt: t };
56
+ resends.set(p.webhookId, rs);
57
+ } else if (rs.total !== total || rs.sha !== p.bodySha256) return fail("chunk_inconsistent");
58
+ else if (index !== rs.next) return fail("chunk_out_of_order");
59
+ if (last !== (index === total - 1) || webhookChunkSizeViolation(p, chunk, limits.webhookChunkRawBytes) || !isCanonicalBase64(p.bodyB64)) return fail("chunk_inconsistent");
60
+ rs.next = index + 1;
61
+ if (last) { resends.delete(p.webhookId); stats.deduped++; }
62
+ return { accept: true };
63
+ }
64
+
65
+ async function deliver(webhook, sha) {
66
+ const sink = getSink();
67
+ if (!sink) { webhook.body.fill(0); return refused("protocol_unsupported"); }
68
+ const id = webhook.webhookId;
69
+ if (inflight.has(id)) { webhook.body.fill(0); return refused("rate_limited"); } // a timed-out attempt of this id is still running: try again later
70
+ inflight.add(id);
71
+ let timedOut = false;
72
+ let timer = null;
73
+ const work = Promise.resolve().then(() => sink(webhook));
74
+ // the body is zeroed when the sink settles, not before (after a timeout the sink may still be reading it)
75
+ work.then(() => { inflight.delete(id); if (timedOut) { stats.delivered++; remember(id, sha); } webhook.body.fill(0); },
76
+ () => { inflight.delete(id); webhook.body.fill(0); });
77
+ const deadline = new Promise((_, reject) => {
78
+ timer = T.setTimeout(() => { timedOut = true; const e = new Error("local delivery deadline"); e.code = "timeout"; reject(e); }, deliveryMs);
79
+ if (timer && typeof timer.unref === "function") timer.unref();
80
+ });
81
+ try {
82
+ await Promise.race([work, deadline]);
83
+ stats.delivered++;
84
+ remember(id, sha);
85
+ return { accept: true };
86
+ } catch (e) {
87
+ if (timedOut) { stats.deliveryTimeouts++; return refused("timeout"); }
88
+ stats.deliveryFailed++;
89
+ return refused(e && e.code === "rate_limited" ? "rate_limited" : "internal");
90
+ } finally {
91
+ if (timer) T.clearTimeout(timer);
92
+ }
93
+ }
94
+
95
+ return {
96
+ stats,
97
+ assembler,
98
+ async handle(ctx) {
99
+ stats.frames++;
100
+ const p = ctx.params;
101
+ if (!ctx.chunk) {
102
+ stats.single++;
103
+ if (!isCanonicalBase64(p.bodyB64)) return refused("schema_violation");
104
+ const body = Buffer.from(p.bodyB64, "base64");
105
+ if (body.length > limits.webhookSingleFrameRawBytes) return refused("payload_too_large");
106
+ const sha = crypto.createHash("sha256").update(body).digest("hex");
107
+ if (isResend(p.webhookId, sha)) { stats.deduped++; body.fill(0); return { accept: true }; }
108
+ return deliver({ webhookId: p.webhookId, source: p.source, contentType: p.contentType, body }, sha);
109
+ }
110
+ stats.chunks++;
111
+ // a resend of a webhook already delivered (same id, same body hash) starts at chunk 0: every chunk of it is acked in order, nothing is
112
+ // stored or processed again. A chunk with index > 0 that does not belong to an assembly or to a resend in progress stays what it was:
113
+ // chunk_out_of_order (also for "a chunk after the last one": the contract's invalid trace).
114
+ if (!assembler.has(p.webhookId)) {
115
+ const rs = resends.get(p.webhookId);
116
+ if (rs || (ctx.chunk.index === 0 && isResend(p.webhookId, p.bodySha256))) return resendChunk(p, ctx.chunk, rs);
117
+ }
118
+ const r = assembler.accept({ params: p, chunk: ctx.chunk });
119
+ if (!r.ok) return refused(r.code);
120
+ if (!r.done) return { accept: true };
121
+ return deliver(r.webhook, p.bodySha256);
122
+ },
123
+ clear() { assembler.clear(); resends.clear(); },
124
+ };
125
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * install.js - puts the webhook receiver on a v2 link (F3-12 consumer). Called by runtime.js only when LOCAL_AGENT_WEBHOOK_FORWARD=true,
3
+ * v2 is on AND the incident detector is on (the local consumer). Returns link.webhooks = { handler, setSink(fn) }.
4
+ */
5
+ import { createWebhookHandler } from "./handler.js";
6
+
7
+ export const WEBHOOK_CAPABILITIES = Object.freeze(["webhook.forward", "webhook.chunked"]);
8
+
9
+ export function installWebhookReceiver({ link, config }) {
10
+ let sink = null;
11
+ const handler = createWebhookHandler({ config, getSink: () => sink });
12
+ // the ack of the last chunk means "delivered to the local consumer": the session must wait for the handler (which has its own, shorter
13
+ // deadline: deliveryMs) instead of acking `accepted` after handlerAckMs (2 s). +1 s so the handler's own `rejected timeout` always wins.
14
+ link.session.setHandler("webhook.forward", (ctx) => handler.handle(ctx), { ackMs: (config.deliveryMs ?? 10_000) + 1_000 });
15
+ // a new session (not resumed) forgets the chunks of the old one: the CP restarts its numbering and its in-flight webhooks fail
16
+ const onReady = (e) => { if (!e.resumed) handler.clear(); };
17
+ link.session.on("ready", onReady);
18
+ const closeLink = link.close.bind(link);
19
+ link.close = () => { link.session.off("ready", onReady); handler.clear(); closeLink(); };
20
+ link.webhooks = { handler, setSink: (fn) => { sink = typeof fn === "function" ? fn : null; } };
21
+ return link.webhooks;
22
+ }
@@ -144,6 +144,97 @@
144
144
  }
145
145
  },
146
146
  "why": "agentId is required on ack/error (the gateway refuses a frame without it: agent_mismatch)"
147
+ },
148
+ {
149
+ "name": "llm-key-ack-accepted",
150
+ "why": "the LA answers a set or a revoke with the plain ack",
151
+ "expect": {
152
+ "valid": true
153
+ },
154
+ "message": {
155
+ "schemaVersion": 2,
156
+ "type": "ack",
157
+ "msgId": "00000000-0000-4000-8000-000000000001",
158
+ "seq": 0,
159
+ "tenantId": "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f",
160
+ "workspaceId": "ws-1",
161
+ "ts": "2026-10-29T10:00:00Z",
162
+ "payload": {
163
+ "ackSeq": 5,
164
+ "status": "accepted"
165
+ },
166
+ "agentId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
167
+ }
168
+ },
169
+ {
170
+ "name": "llm-key-ack-rejected-schema-violation",
171
+ "why": "the LA refuses an expired key or an id that does not match: rejected + schema_violation",
172
+ "expect": {
173
+ "valid": true
174
+ },
175
+ "message": {
176
+ "schemaVersion": 2,
177
+ "type": "ack",
178
+ "msgId": "00000000-0000-4000-8000-000000000001",
179
+ "seq": 0,
180
+ "tenantId": "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f",
181
+ "workspaceId": "ws-1",
182
+ "ts": "2026-10-29T10:00:00Z",
183
+ "payload": {
184
+ "ackSeq": 5,
185
+ "status": "rejected",
186
+ "errorCode": "schema_violation"
187
+ },
188
+ "agentId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
189
+ }
190
+ },
191
+ {
192
+ "name": "llm-key-ack-with-key-field",
193
+ "why": "an ack has no field that could carry the key",
194
+ "expect": {
195
+ "valid": false,
196
+ "code": "schema_violation",
197
+ "keyword": "additionalProperties"
198
+ },
199
+ "message": {
200
+ "schemaVersion": 2,
201
+ "type": "ack",
202
+ "msgId": "00000000-0000-4000-8000-000000000001",
203
+ "seq": 0,
204
+ "tenantId": "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f",
205
+ "workspaceId": "ws-1",
206
+ "ts": "2026-10-29T10:00:00Z",
207
+ "payload": {
208
+ "ackSeq": 1,
209
+ "status": "accepted",
210
+ "key": "sk-vector-not-a-real-key-0001"
211
+ },
212
+ "agentId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
213
+ }
214
+ },
215
+ {
216
+ "name": "llm-key-ack-with-key-id-field",
217
+ "why": "nor the key id",
218
+ "expect": {
219
+ "valid": false,
220
+ "code": "schema_violation",
221
+ "keyword": "additionalProperties"
222
+ },
223
+ "message": {
224
+ "schemaVersion": 2,
225
+ "type": "ack",
226
+ "msgId": "00000000-0000-4000-8000-000000000001",
227
+ "seq": 0,
228
+ "tenantId": "3f2b8c1e-5a4d-4e7b-9c21-0a1b2c3d4e5f",
229
+ "workspaceId": "ws-1",
230
+ "ts": "2026-10-29T10:00:00Z",
231
+ "payload": {
232
+ "ackSeq": 1,
233
+ "status": "accepted",
234
+ "keyId": "6ff70dd0f5a066910d2c77d289584f4774569fb9216f4a371c0efe1968526f48"
235
+ },
236
+ "agentId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
237
+ }
147
238
  }
148
239
  ]
149
240
  }