deepdebug-local-agent 1.8.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.
- package/.claude/context.md +2 -1
- package/CLAUDE.md +1 -1
- package/docs/la-protocol-v2.md +19 -2
- package/env.example +5 -0
- package/package.json +1 -1
- package/src/incident-detector/README.md +2 -1
- package/src/incident-detector/aggregator.js +8 -3
- package/src/incident-detector/detector.js +2 -1
- package/src/incident-detector/emitter.js +16 -6
- package/src/incident-detector/index.js +3 -3
- package/src/incident-detector/sources/webhook-source.js +109 -0
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +24 -16
- package/src/la-protocol-v2/contract/README.md +103 -3
- package/src/la-protocol-v2/contract/config.update.schema.json +85 -1
- package/src/la-protocol-v2/contract/hello.schema.json +26 -1
- package/src/la-protocol-v2/contract/incident.candidate.schema.json +11 -0
- package/src/la-protocol-v2/contract/index.json +9 -2
- package/src/la-protocol-v2/contract/ready.schema.json +8 -0
- package/src/la-protocol-v2/contract/register.schema.json +26 -1
- package/src/la-protocol-v2/contract/webhook.forward.schema.json +72 -3
- package/src/la-protocol-v2/contract.js +45 -7
- package/src/la-protocol-v2/llm-key/config.js +29 -0
- package/src/la-protocol-v2/llm-key/handler.js +33 -0
- package/src/la-protocol-v2/llm-key/install.js +21 -0
- package/src/la-protocol-v2/llm-key/store.js +173 -0
- package/src/la-protocol-v2/runtime.js +37 -3
- package/src/la-protocol-v2/session.js +47 -13
- package/src/la-protocol-v2/webhooks/assembler.js +85 -0
- package/src/la-protocol-v2/webhooks/config.js +51 -0
- package/src/la-protocol-v2/webhooks/handler.js +125 -0
- package/src/la-protocol-v2/webhooks/install.js +22 -0
- package/test/contract/la-protocol-v2/vectors/ack.vectors.json +91 -0
- package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +1339 -0
- package/test/contract/la-protocol-v2/vectors/hello.vectors.json +261 -0
- package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +623 -0
- package/test/contract/la-protocol-v2/vectors/ready.vectors.json +599 -0
- package/test/contract/la-protocol-v2/vectors/register.vectors.json +275 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-after-last.json +186 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-body-hash-changes.json +186 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-last-flag-wrong.json +186 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-non-last-too-short.json +152 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-out-of-order.json +152 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-sha-mismatch.json +186 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-total-changes.json +186 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-webhook-chunks-in-order.json +218 -0
- package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +1263 -0
- package/test/incident-detector/size-model.test.js +127 -0
- package/test/la-protocol-v2/contract-v21.test.js +107 -0
- package/test/la-protocol-v2/contract.test.js +6 -4
- package/test/la-protocol-v2/helpers/reference-cp.js +8 -4
- package/test/la-protocol-v2/llm-key.test.js +509 -0
- package/test/la-protocol-v2/traces.test.js +3 -3
- 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,107 @@
|
|
|
1
|
+
// Contract 2.1 as the Local Agent applies it: the 2.1 vectors are all classified (contract.test.js runs EVERY vector; here the new ones are pinned
|
|
2
|
+
// by group so a re-sync that loses them fails), the two stateless rules, the deferral of those rules to the handlers, and ready.capabilities.
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { ReferenceCp, waitFor } from "./helpers/reference-cp.js";
|
|
10
|
+
import { inMemory } from "./helpers/la-rig.js";
|
|
11
|
+
import { loadContract, semanticRules, webhookChunkSizeViolation, llmKeyIdMismatch, RULE_CODES } from "../../src/la-protocol-v2/contract.js";
|
|
12
|
+
|
|
13
|
+
const contract = loadContract();
|
|
14
|
+
const VECTORS = path.join(path.dirname(fileURLToPath(import.meta.url)), "../contract/la-protocol-v2/vectors");
|
|
15
|
+
const load = (f) => JSON.parse(fs.readFileSync(path.join(VECTORS, f), "utf8")).vectors;
|
|
16
|
+
const sha = (s) => crypto.createHash("sha256").update(Buffer.from(s, "utf8")).digest("hex");
|
|
17
|
+
|
|
18
|
+
test("the copy is revision 2.1 and every 2.1 vector group is present with at least its valid and invalid cases", () => {
|
|
19
|
+
assert.equal(contract.index.revision, "2.1");
|
|
20
|
+
const groups = [
|
|
21
|
+
["config.update", /^llm-key-/, 6, 27], // (a): llm-key-* in config.update (the ack and register ones are checked below)
|
|
22
|
+
["webhook.forward", /^wh-chunk-/, 9, 24], // (b)
|
|
23
|
+
["incident.candidate", /^candidate-/, 7, 14],// (c)
|
|
24
|
+
["ready", /^ready-caps-/, 4, 8], // (d)
|
|
25
|
+
];
|
|
26
|
+
for (const [file, re, minValid, minInvalid] of groups) {
|
|
27
|
+
const vs = load(`${file}.vectors.json`).filter((v) => re.test(v.name));
|
|
28
|
+
assert.ok(vs.filter((v) => v.expect.valid).length >= minValid, `${file}: valid`);
|
|
29
|
+
assert.ok(vs.filter((v) => !v.expect.valid).length >= minInvalid, `${file}: invalid`);
|
|
30
|
+
for (const v of vs) { // and every one is classified as declared
|
|
31
|
+
const r = contract.validateFrame(v.message);
|
|
32
|
+
assert.equal(r.ok, v.expect.valid === true, `${file} :: ${v.name}`);
|
|
33
|
+
// payload_too_large is the session-level refinement of schema_violation for a maxLength under /payload (as in contract.test.js)
|
|
34
|
+
if (!v.expect.valid) assert.ok(r.code === v.expect.code || (v.expect.code === "schema_violation" && r.code === "payload_too_large"), `${file} :: ${v.name} -> ${r.code}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
assert.ok(load("register.vectors.json").some((v) => v.name === "capabilities-llm-key-and-webhook-chunked" && contract.validateFrame(v.message).ok));
|
|
38
|
+
assert.ok(load("ack.vectors.json").some((v) => v.name === "llm-key-ack-with-key-field" && !contract.validateFrame(v.message).ok), "an ack with a key field is refused by the closed schema");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("rules: webhook-chunk-size -> chunk_inconsistent, llmKey-id-sha256 -> schema_violation (the codes the vectors expect), RULE_CODES is that table", () => {
|
|
42
|
+
assert.deepEqual({ ...RULE_CODES }, { "byteLength-decoded": "chunk_inconsistent", "webhook-chunk-size": "chunk_inconsistent", "llmKey-id-sha256": "schema_violation" });
|
|
43
|
+
const bad = load("webhook.forward.vectors.json").find((v) => v.name === "wh-chunk-non-last-chunk-too-short");
|
|
44
|
+
let r = contract.validateFrame(bad.message);
|
|
45
|
+
assert.deepEqual([r.ok, r.code, r.rules], [false, "chunk_inconsistent", ["webhook-chunk-size"]]);
|
|
46
|
+
const badKey = load("config.update.vectors.json").find((v) => v.name === "llm-key-key-id-differs-from-sha256-of-key");
|
|
47
|
+
r = contract.validateFrame(badKey.message);
|
|
48
|
+
assert.deepEqual([r.ok, r.code, r.rules], [false, "schema_violation", ["llmKey-id-sha256"]]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("rules: skipRules defers a rule to the caller (the session does it for the two 2.1 rules: a non-fatal rejected ack instead of a fatal error)", () => {
|
|
52
|
+
const bad = load("webhook.forward.vectors.json").find((v) => v.name === "wh-chunk-non-last-chunk-too-short");
|
|
53
|
+
assert.equal(contract.validateFrame(bad.message, { skipRules: ["webhook-chunk-size"] }).ok, true, "schema-valid: only the rule was skipped");
|
|
54
|
+
const badKey = load("config.update.vectors.json").find((v) => v.name === "llm-key-key-id-differs-from-sha256-of-key");
|
|
55
|
+
assert.equal(contract.validateFrame(badKey.message, { skipRules: ["llmKey-id-sha256"] }).ok, true);
|
|
56
|
+
assert.deepEqual(semanticRules(bad.message), ["webhook-chunk-size"]);
|
|
57
|
+
assert.deepEqual(semanticRules(bad.message, { skip: ["webhook-chunk-size"] }), []);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("rule functions: webhook chunk sizes for 0, 1, 16384, 16385 and 1 MiB bodies; llmKey id", () => {
|
|
61
|
+
const b64 = (n) => Buffer.alloc(n).toString("base64");
|
|
62
|
+
const ok = (bodyBytes, index, total, n) => !webhookChunkSizeViolation({ bodyB64: b64(n), bodyBytes }, { index, total, last: index === total - 1 });
|
|
63
|
+
assert.ok(ok(0, 0, 1, 0), "an empty body is one empty chunk");
|
|
64
|
+
assert.ok(ok(1, 0, 1, 1));
|
|
65
|
+
assert.ok(ok(16384, 0, 1, 16384));
|
|
66
|
+
assert.ok(ok(16385, 0, 2, 16384) && ok(16385, 1, 2, 1));
|
|
67
|
+
assert.ok(!ok(16385, 0, 1, 16385), "16385 bytes in one chunk is over the chunk size");
|
|
68
|
+
assert.ok(!ok(16385, 0, 3, 16384), "wrong total");
|
|
69
|
+
assert.ok(ok(1048576, 63, 64, 16384));
|
|
70
|
+
assert.ok(!ok(100, 1, 1, 100), "index beyond total");
|
|
71
|
+
assert.equal(webhookChunkSizeViolation({ bodyB64: "AAA", bodyBytes: 2 }, { index: 0, total: 1, last: true }), true, "non-canonical base64");
|
|
72
|
+
assert.equal(llmKeyIdMismatch({ op: "set", key: "k".repeat(20), keyId: sha("k".repeat(20)) }), false);
|
|
73
|
+
assert.equal(llmKeyIdMismatch({ op: "set", key: "k".repeat(20), keyId: sha("x") }), true);
|
|
74
|
+
assert.equal(llmKeyIdMismatch({ op: "revoke", keyId: sha("x") }), false);
|
|
75
|
+
assert.equal(llmKeyIdMismatch(undefined), false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("session: ready.capabilities is kept (peerHas, a read-only copy), announced in the `ready` event, empty for a 2.0 control plane", async () => {
|
|
79
|
+
// a CP that announces something, to an LA that announced contract.v2.1
|
|
80
|
+
const cp = new ReferenceCp({ capabilities: ["incident.candidate.size-model", "x.other"] });
|
|
81
|
+
const env = inMemory(cp, { sessionOptions: { capabilities: ["protocol.v2", "contract.v2.1"] } });
|
|
82
|
+
let ev = null;
|
|
83
|
+
env.session.on("ready", (e) => { ev = e; });
|
|
84
|
+
env.start();
|
|
85
|
+
await waitFor(() => env.session.state === "ready");
|
|
86
|
+
assert.equal(env.session.peerHas("incident.candidate.size-model"), true);
|
|
87
|
+
assert.equal(env.session.peerHas("nope"), false);
|
|
88
|
+
assert.deepEqual(ev.capabilities.sort(), ["incident.candidate.size-model", "x.other"]);
|
|
89
|
+
env.session.peerCapabilities.push("hack"); // a copy: it changes nothing
|
|
90
|
+
assert.equal(env.session.peerHas("hack"), false);
|
|
91
|
+
env.session.close();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("session: toward a 2.0 control plane (no capabilities in ready) peerHas is false for everything; an LA that did NOT announce contract.v2.1 never gets the field", async () => {
|
|
95
|
+
const cp = new ReferenceCp();
|
|
96
|
+
const env = inMemory(cp).start();
|
|
97
|
+
await waitFor(() => env.session.state === "ready");
|
|
98
|
+
assert.equal(env.session.peerHas("incident.candidate.size-model"), false);
|
|
99
|
+
env.session.close();
|
|
100
|
+
// the CP has capabilities but this LA did not announce contract.v2.1: the reference CP (like the gateway) sends the 2.0 ready
|
|
101
|
+
const cp2 = new ReferenceCp({ capabilities: ["incident.candidate.size-model"] });
|
|
102
|
+
const env2 = inMemory(cp2).start();
|
|
103
|
+
await waitFor(() => env2.session.state === "ready");
|
|
104
|
+
assert.equal(env2.session.peerHas("incident.candidate.size-model"), false);
|
|
105
|
+
assert.ok(!("capabilities" in JSON.parse(env2.wire.toLa[0]).payload));
|
|
106
|
+
env2.session.close();
|
|
107
|
+
});
|
|
@@ -82,10 +82,10 @@ for (const file of vectorFiles) {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
test("vectors: counts (
|
|
86
|
-
assert.equal(valid + invalid,
|
|
87
|
-
assert.equal(valid,
|
|
88
|
-
assert.equal(invalid,
|
|
85
|
+
test("vectors: counts (294 frames = 67 valid + 227 invalid in this copy (contract 2.1), plus the bodies of file.list)", () => {
|
|
86
|
+
assert.equal(valid + invalid, 294);
|
|
87
|
+
assert.equal(valid, 67);
|
|
88
|
+
assert.equal(invalid, 227);
|
|
89
89
|
assert.equal(bodyVectors, 54, "file.list.body.vectors.json (F3-07c): 54 bodies");
|
|
90
90
|
});
|
|
91
91
|
|
|
@@ -120,7 +120,9 @@ test("limits come from index.json and are the documented ones", () => {
|
|
|
120
120
|
assert.deepEqual({ ...contract.limits }, {
|
|
121
121
|
maxFrameBytes: 49152, chunkRawBytes: 32768, maxChunks: 64, maxContentBytes: 2097152, maxRetries: 3, ackTimeoutMs: 5000,
|
|
122
122
|
handshakeTimeoutMs: 10000, heartbeatSeconds: 30, sessionDeadSeconds: 90, dedupeWindowSeconds: 600, clockSkewSeconds: 60,
|
|
123
|
+
webhookChunkRawBytes: 16384, webhookMaxChunks: 64, webhookMaxBytes: 1048576, webhookSingleFrameRawBytes: 9216, webhookAssemblySeconds: 30, webhookMaxOpenAssemblies: 4,
|
|
123
124
|
});
|
|
125
|
+
assert.equal(contract.index.revision, "2.1");
|
|
124
126
|
});
|
|
125
127
|
|
|
126
128
|
// ------------------------------------------------------------------------------------------------ strict JSON
|
|
@@ -26,12 +26,13 @@ const DEFAULT_LIMITS = { maxFrameBytes: 49152, chunkRawBytes: 32768, maxChunks:
|
|
|
26
26
|
|
|
27
27
|
export class ReferenceCp {
|
|
28
28
|
constructor({ tenantId = TENANT, agentId = AGENT, privateKey, publicKeyRaw, keyId = "vh-cp-test", requireEnforce = false,
|
|
29
|
-
now = Date.now, limits = {}, workspaceId = "", skewSeconds = 60 } = {}) {
|
|
29
|
+
now = Date.now, limits = {}, workspaceId = "", skewSeconds = 60, capabilities = [] } = {}) {
|
|
30
30
|
this.publicKeyRaw = publicKeyRaw;
|
|
31
31
|
this.contract = loadContract();
|
|
32
32
|
if (!privateKey) ({ privateKey, publicKeyRaw: this.publicKeyRaw } = newKeyPair());
|
|
33
33
|
Object.assign(this, { tenantId, agentId, privateKey, keyId, requireEnforce, now, skewSeconds, workspaceId });
|
|
34
34
|
this.limits = { ...DEFAULT_LIMITS, ...limits };
|
|
35
|
+
this.capabilities = capabilities; // contract 2.1: what this CP announces in ready.capabilities (only to an LA that announced contract.v2.1)
|
|
35
36
|
this.sessionId = uuid();
|
|
36
37
|
this.laSeq = 0; this.cpSeq = 0;
|
|
37
38
|
this.ring = new Map();
|
|
@@ -223,7 +224,9 @@ export class ReferenceCp {
|
|
|
223
224
|
|
|
224
225
|
#sendReady(resumed) {
|
|
225
226
|
const f = this.#envelope("ready", 0, null, this.workspaceId);
|
|
226
|
-
|
|
227
|
+
const announced = (this.registers.at(-1) || {}).capabilities || [];
|
|
228
|
+
const caps = this.capabilities.length > 0 && announced.includes("contract.v2.1") ? { capabilities: [...this.capabilities] } : {};
|
|
229
|
+
this.#sign(f, "ready", { protocol: 2, sessionId: this.sessionId, resumed, peerLastSeq: this.laSeq, limits: { ...this.limits }, ...caps }, this.workspaceId);
|
|
227
230
|
this.sendFn(JSON.stringify(f));
|
|
228
231
|
}
|
|
229
232
|
|
|
@@ -240,9 +243,9 @@ export class ReferenceCp {
|
|
|
240
243
|
}
|
|
241
244
|
|
|
242
245
|
/** Sends a CP->LA data frame (job.assign, content.request, ...). Retransmitted (re-signed) until acked. */
|
|
243
|
-
sendData(type, payload, { workspaceId = this.workspaceId, requestId } = {}) {
|
|
246
|
+
sendData(type, payload, { workspaceId = this.workspaceId, requestId, chunk } = {}) {
|
|
244
247
|
const seq = ++this.cpSeq;
|
|
245
|
-
const u = { seq, type, payload, workspaceId, requestId, msgId: uuid(), retries: 0, timer: null };
|
|
248
|
+
const u = { seq, type, payload, workspaceId, requestId, chunk, msgId: uuid(), retries: 0, timer: null };
|
|
246
249
|
this.unacked.set(seq, u);
|
|
247
250
|
this.#transmit(u);
|
|
248
251
|
return seq;
|
|
@@ -250,6 +253,7 @@ export class ReferenceCp {
|
|
|
250
253
|
|
|
251
254
|
#transmit(u) {
|
|
252
255
|
const f = this.#envelope(u.type, u.seq, u.requestId, u.workspaceId);
|
|
256
|
+
if (u.chunk) f.chunk = u.chunk;
|
|
253
257
|
f.msgId = u.msgId;
|
|
254
258
|
if (this.laSeq > 0) f.ack = this.laSeq;
|
|
255
259
|
this.#sign(f, u.type, u.payload, u.workspaceId);
|