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.
- package/.claude/context.md +2 -1
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/docs/la-jobs-and-llm.md +56 -0
- package/docs/la-protocol-v2.md +19 -2
- package/env.example +11 -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/content-handlers/install.js +27 -1
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +26 -18
- package/src/la-protocol-v2/contract/README.md +127 -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/job.assign.schema.json +31 -0
- 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/jobs/config.js +60 -0
- package/src/la-protocol-v2/jobs/events.js +106 -0
- package/src/la-protocol-v2/jobs/install.js +37 -0
- package/src/la-protocol-v2/jobs/manager.js +276 -0
- package/src/la-protocol-v2/jobs/options.js +56 -0
- package/src/la-protocol-v2/jobs/smoke-runner.js +39 -0
- package/src/la-protocol-v2/llm/client.js +311 -0
- package/src/la-protocol-v2/llm/config.js +61 -0
- package/src/la-protocol-v2/llm/install.js +17 -0
- 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 +183 -0
- package/src/la-protocol-v2/runtime.js +71 -4
- 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/job.assign.vectors.json +1538 -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/fake-llm.js +64 -0
- package/test/la-protocol-v2/helpers/reference-cp.js +8 -4
- package/test/la-protocol-v2/jobs-e2e.test.js +405 -0
- package/test/la-protocol-v2/jobs-manager.test.js +370 -0
- package/test/la-protocol-v2/jobs-options.test.js +288 -0
- package/test/la-protocol-v2/llm-client.test.js +380 -0
- 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
|
@@ -5,13 +5,16 @@ sides. Design: vault `10 Library/Plans/Fase 3 Desenho Tecnico 2026-10-28.md` sec
|
|
|
5
5
|
implements it behind `deepdebug.agent-ws.protocol-v2.enabled` (default off, F3-05a); the LA side (F3-05b) does not
|
|
6
6
|
exist yet. v1 is untouched. The gateway packages `*.schema.json` + `index.json` from this folder as classpath
|
|
7
7
|
`la-protocol-v2/` (pom resource; both Dockerfiles copy this folder), so this folder stays the only source.
|
|
8
|
+
**Revision 2.1** (`index.json` `revision`, Fase 3, last section): optional LLM key delivery, chunked webhooks, size/model in `incident.candidate`, `ready.capabilities`, `job.assign.options` and the capabilities `job.assign` / `job.options`.
|
|
8
9
|
|
|
9
10
|
## Files
|
|
10
11
|
- `envelope.schema.json`: common frame, closed (`additionalProperties:false`). `<type>.schema.json` (22):
|
|
11
12
|
one per allowlisted type, each `$ref`s the envelope and closes `payload`. `defs.schema.json`: shared types.
|
|
12
13
|
- `file.list.body.schema.json` (F3-07c): not a frame type; the schema of the DECODED body of a `content.response` for kind `file.list`
|
|
13
14
|
(below). `vectors/file.list.body.vectors.json` holds its vectors (same format, `schema` names that file).
|
|
14
|
-
- `index.json`: allowlist, direction, signed/control flags, content class per type, and the
|
|
15
|
+
- `index.json`: allowlist, direction, signed/control flags, content class per type (`none`, `relay`, `transit`, `secret`), the limits below and the contract `revision`.
|
|
16
|
+
- Vector files of a BODY that travels inside a frame (not a frame) are named `*.body.vectors.json` and their schema `*.body.schema.json`; the frame
|
|
17
|
+
suites skip them (`ProtocolV2ContractTest` checks them against their own schema, `SchemaLaV2ContractTest` does not read them as frames).
|
|
15
18
|
- `vectors/<type>.vectors.json` (+ `cross-type`): valid and invalid messages with `expect` (`valid`, error `code`,
|
|
16
19
|
optional JSON Schema `keyword`, optional session `rule`). `vectors/traces/*.json`: whole sessions for the stateful rules.
|
|
17
20
|
|
|
@@ -30,7 +33,8 @@ ack timeout 5 s, <= 3 retransmissions; handshake steps 10 s; `ping`/`heartbeat`
|
|
|
30
33
|
(= Redis `la:session` TTL); clock skew <= 60 s. Duplicate detection (gateway, F3-05a): the last 512 `seq`->`msgId` pairs
|
|
31
34
|
per session, in memory (`dedupe-entries`); the 10 min Redis window of the design (`dedupeWindowSeconds` in `index.json`)
|
|
32
35
|
is NOT implemented, a replay older than those 512 frames is `seq_replayed`. CP->LA frames repeat the payload in
|
|
33
|
-
`paramsRaw`, so their effective payload budget is about 20 KiB; larger inputs travel by `ref`.
|
|
36
|
+
`paramsRaw`, so their effective payload budget is about 20 KiB; larger inputs travel by `ref`. Webhook chunking (2.1) has its own limits
|
|
37
|
+
(`webhook*` in `index.json`: 16 KiB raw per chunk, 64 chunks, 1 MiB per webhook, see the last section).
|
|
34
38
|
|
|
35
39
|
## State machine
|
|
36
40
|
`AWAIT_REGISTER -> AWAIT_HELLO -> AWAIT_READY -> READY`, after the WS upgrade authenticates (Bearer, as v1).
|
|
@@ -45,7 +49,9 @@ is NOT implemented, a replay older than those 512 frames is `seq_replayed`. CP->
|
|
|
45
49
|
handshake state, `seq` (same seq + same `msgId` = idempotent duplicate, re-ack; same seq + other `msgId` =
|
|
46
50
|
`seq_replayed`; skip = `seq_gap`), chunks (index 0.., `total` constant, `last` iff last index, none after last; then `payload.byteLength` must equal the size of
|
|
47
51
|
the base64-DECODED `payload.data`, else `chunk_inconsistent`: JSON Schema cannot state it, so the vector files carry
|
|
48
|
-
`expect.rule:"byteLength-decoded"` and both validators apply it).
|
|
52
|
+
`expect.rule:"byteLength-decoded"` and both validators apply it). Since 2.1 two more stateless rules of the same kind:
|
|
53
|
+
`webhook-chunk-size` and `llmKey-id-sha256` (last section); `webhook.forward` chunks are also grouped by `payload.webhookId` and the reassembled
|
|
54
|
+
body must hash to `bodySha256` (`chunk_inconsistent` otherwise).
|
|
49
55
|
- **Errors** (`error{code,retryable}`, then WS close 4400-4499 when fatal): `bad_frame unknown_type schema_violation
|
|
50
56
|
frame_too_large payload_too_large wrong_direction handshake_violation not_ready seq_gap seq_replayed
|
|
51
57
|
chunk_out_of_order chunk_inconsistent tenant_mismatch agent_mismatch params_mismatch signature_mode_insufficient
|
|
@@ -64,6 +70,7 @@ Everything is closed: no free-text fields, no extra properties, free strings <=
|
|
|
64
70
|
hashes, counts). Client content may exist only in three declared fields (`x-content-class`, pinned by the test):
|
|
65
71
|
`content.response.data` (relay, LA->CP, never persisted or logged by the CP), `job.assign.inputs[].inline` and
|
|
66
72
|
`webhook.forward.bodyB64` (transit, CP->LA, `transient:true`, not persisted; `paramsRaw` echoes them).
|
|
73
|
+
Since 2.1 there is one more classified field and it is the only SECRET: `config.update.llmKey.key` (`x-content-class: secret`, see the last section).
|
|
67
74
|
Long non-content strings are marked `x-large-metadata` (`paramsRaw`, `signedPutUrl`, `vhapr1` token). `error.detail` <= 200 chars
|
|
68
75
|
and `audit.event` have no summary text; stack traces, diffs, logs and paths go only through `content.*`.
|
|
69
76
|
|
|
@@ -186,3 +193,120 @@ existing kinds, `ref` convention, ids, envelope and chunking are reused; the cha
|
|
|
186
193
|
answer is HTTP 501 `not_supported`. That announcement is also how the front/CP discovers that listing exists on a given agent.
|
|
187
194
|
- **Compatibility.** Additive: the kind is added to the `content.request` and `content.index` enums, nothing else changes there. The
|
|
188
195
|
`content.request` payload gained no field. With `deepdebug.content-relay.enabled` off nothing is mapped, as before.
|
|
196
|
+
|
|
197
|
+
## Revision 2.1 (Fase 3, contract v2.1): LLM key, chunked webhooks, size and model of a candidate
|
|
198
|
+
Decisions of Edu 2026-11-03 (ADR-0017, adenda (c)). **No new frame type** (still 22), every change is additive and optional, and each is gated by a
|
|
199
|
+
capability so a 2.0 peer keeps working unchanged. The gateway code that uses these fields is a later task (G); here only the contract moved.
|
|
200
|
+
|
|
201
|
+
**Capabilities (negotiation).** The LA announces, in `register` and `hello` `capabilities`: `contract.v2.1` (it has the 2.1 schemas, so it can read the
|
|
202
|
+
new optional `ready.payload.capabilities`), `llm.key` (it handles `config.update.llmKey`) and `webhook.chunked` (it reassembles chunked `webhook.forward`;
|
|
203
|
+
it announces `webhook.forward` too). The CP announces, in `ready.payload.capabilities` (array, <= 32, same name pattern): `incident.candidate.size-model` (it
|
|
204
|
+
accepts `stackBytes` and `model`). Rules: the CP sends `llmKey` only to a session that announced `llm.key`, a chunked webhook only to one that announced
|
|
205
|
+
`webhook.chunked`, and `ready.capabilities` only to one that announced `contract.v2.1` (a 2.0 LA's closed `ready` schema does not know the field, so without
|
|
206
|
+
`contract.v2.1` the `ready` is exactly the 2.0 one); an LA sends `stackBytes`/`model` only if `ready` listed `incident.candidate.size-model` (a 2.0 CP would
|
|
207
|
+
answer the closed schema with `schema_violation`). A name that is absent means 2.0 behaviour.
|
|
208
|
+
One more LA capability, `llm.key.held` (section (e)), tells the CP the LA holds a valid key right now; it is optional and only informs the CP.
|
|
209
|
+
|
|
210
|
+
### (a) LLM key delivery: `config.update.payload.llmKey`
|
|
211
|
+
`config.update` already carried only flags, a DLP hash, a minimum version, a retention and the data-plane mode: it had no way to hand the LA the virtual
|
|
212
|
+
LiteLLM key (F3-09). `llmKey` is a closed `oneOf`:
|
|
213
|
+
- `{op:"set", keyId, endpoint, key, models[], expiresAt}`, all required. `keyId` is the 64-hex lower-case sha256 of the UTF-8 bytes of `key` (it is
|
|
214
|
+
LiteLLM's token id and the CP's `la_llm_keys.key_id`; semantic rule `llmKey-id-sha256`: the LA recomputes it and refuses a mismatch). `endpoint` is the AI
|
|
215
|
+
gateway base URL, `https://` only, host[:port][/path], no credentials, no query, no fragment, <= 256 chars. `key` is 16..256 chars of
|
|
216
|
+
`[A-Za-z0-9._~+/=-]` (no whitespace, no control character). `models` is 1..32 unique aliases (the scope of the key). `expiresAt` is UTC.
|
|
217
|
+
- `{op:"revoke", keyId}`: names the key by id, never repeats it.
|
|
218
|
+
- A `config.update` that carries `llmKey` carries NOTHING else (`maxProperties: 1`), so the frame can be recognised and redacted as a whole.
|
|
219
|
+
- **The key is a secret.** `x-content-class: secret` on `llmKey.key`; `index.json` marks `config.update` `contentClass: "secret"`. It travels only on the v2
|
|
220
|
+
channel (TLS plus the Ed25519 signature) and, between gateway instances, only inside the AEAD-sealed relay (F3-04). It appears twice in the frame, in
|
|
221
|
+
`payload` and in the `paramsRaw` copy: BOTH are secret. The CP never writes it to a log, a metric, a trace, the audit trail, a table or an unsealed Redis
|
|
222
|
+
value (it keeps it only in the in-memory unacked frame until acked or the session ends). The LA keeps it **only in memory or in a file mode `0600`
|
|
223
|
+
(directory `0700`) outside every workspace and outside what the content handlers can serve**, never logs it, never puts it in `hello`, `alert`,
|
|
224
|
+
`audit.event`, `job.event`, `content.response` or an error, and never passes it on a command line.
|
|
225
|
+
- **The ack never returns it.** The answer is the plain `ack` (its payload is only `ackSeq`, `status`, `errorCode`; the contract test pins that no LA->CP
|
|
226
|
+
frame has a field named `key`, `llmKey` or `keyId`): `accepted`, or `rejected` + `schema_violation` when the LA refuses the key (expired
|
|
227
|
+
`expiresAt`, `keyId` not the sha256 of `key`).
|
|
228
|
+
- **Rotation.** A `set` with another `keyId` replaces the key the LA holds, atomically (one key per LA); the same `keyId` again is idempotent. The CP should
|
|
229
|
+
revoke the old key at the AI gateway only after the `accepted` ack of the new one.
|
|
230
|
+
- **Revocation.** The key revoked at the AI gateway is the authoritative revocation; the `revoke` frame only makes the LA drop its copy (delete from memory,
|
|
231
|
+
unlink and overwrite the file) and stop using it. A `revoke` of an id the LA does not hold is a no-op and is acked `accepted` (it never tells which key it
|
|
232
|
+
has). The LA also drops the key at `expiresAt`.
|
|
233
|
+
|
|
234
|
+
### (b) Chunking of `webhook.forward` (CP -> LA)
|
|
235
|
+
2.0 allowed one frame with `bodyB64` <= 12288 chars (9216 raw bytes). That stays: **a body of at most 9216 bytes is sent as ONE frame without `chunk`, exactly
|
|
236
|
+
as in 2.0** (a 2.0 LA reads it). A bigger body (up to **1 MiB**) is chunked, reusing the `content.response` pattern but CP -> LA:
|
|
237
|
+
- Each chunk is a signed `webhook.forward` frame with the envelope `chunk{index,total,last}` (index from 0), its own `seq` and `msgId`, and the same
|
|
238
|
+
`webhookId`, `source`, `contentType`, **`bodyBytes`** (size of the WHOLE decoded body, 0..1048576) and **`bodySha256`** (sha256 of the whole decoded body,
|
|
239
|
+
lower-case hex). `bodyBytes` and `bodySha256` are required in a chunked frame and forbidden in an unchunked one (schema `if/then/else` on `chunk`).
|
|
240
|
+
- **Fixed chunk size**: 16384 raw bytes (`bodyB64` <= 21848 chars; twice in the frame with `paramsRaw` it still fits 48 KiB: a vector pins it).
|
|
241
|
+
`total = max(1, ceil(bodyBytes / 16384))` (<= 64); every non-last chunk has exactly 16384 bytes; the last has `bodyBytes - 16384 * (total - 1)`. That is the
|
|
242
|
+
stateless rule `webhook-chunk-size` (`chunk_inconsistent` when a frame breaks it), so the LA can size the buffer from the first chunk. A body above
|
|
243
|
+
9216 bytes is never sent unchunked; a chunked frame with a small body (`total` 1) is valid.
|
|
244
|
+
- **Order and integrity** (reference model, same codes as `content.response`): chunks in index order, `total` constant, `last` iff the last index, none after
|
|
245
|
+
the last (`chunk_out_of_order` / `chunk_inconsistent`); `bodyBytes`, `bodySha256`, `source`, `contentType` must not change between chunks; on the last
|
|
246
|
+
chunk the reassembled body must have `bodyBytes` bytes and hash to `bodySha256`, else `chunk_inconsistent`. Each chunk is also authentic on its own (signature).
|
|
247
|
+
- **Acks.** Every chunk is a normal v2 frame: the LA acks each by `seq` (piggyback or `ack{ackSeq,status}`), `accepted` = stored. The `accepted` ack of the
|
|
248
|
+
LAST chunk means the body was reassembled and its hash verified: that is the ack the CP waits for before answering the webhook (decision: ack AFTER the
|
|
249
|
+
forward). The LA answers `rejected` + `chunk_inconsistent` / `chunk_out_of_order` / `payload_too_large` / `rate_limited` and drops the assembly; the CP then
|
|
250
|
+
stops sending chunks of that `webhookId` and treats the webhook as not delivered.
|
|
251
|
+
- **LA limits** (`index.json`): an assembly older than 30 s (`webhookAssemblySeconds`) without its last chunk is dropped; at most 4 open assemblies per session
|
|
252
|
+
(`webhookMaxOpenAssemblies`; the CP sends one chunked webhook at a time per session) and 4 MiB buffered; beyond that `rejected` + `rate_limited`. The body
|
|
253
|
+
stays in memory, is never written or logged by the CP, and the LA applies its DLP to it as to any client content.
|
|
254
|
+
- `webhookId` is the group key; a retransmitted chunk (same `seq` and `msgId`) is the existing idempotent duplicate; the same `webhookId` redelivered as a
|
|
255
|
+
whole is the application's dedup (a deterministic `webhookId` is the consumer's job).
|
|
256
|
+
|
|
257
|
+
### (c) `incident.candidate`: `stackBytes` and `model`
|
|
258
|
+
Two OPTIONAL fields, no required field added: `stackBytes` (integer 0..16777216: size in bytes of the stack the LA saw; the LA saturates at the cap) and
|
|
259
|
+
`model` (string 1..48, `^[A-Za-z0-9][A-Za-z0-9._:+-]{0,47}$`: detector/parser version, e.g. `stack-detector-1.8.0`; no space, no slash, so no name, path or
|
|
260
|
+
error text fits). The frame still carries no stack, no message and no name. Sent only after `ready` announced `incident.candidate.size-model`.
|
|
261
|
+
|
|
262
|
+
### (d) `ready.payload.capabilities`
|
|
263
|
+
Optional array (<= 32 capability names). It is what lets (c) be sent safely; it carries no limit and no content. A CP that does not send it is a 2.0 CP, or the
|
|
264
|
+
session did not announce `contract.v2.1`.
|
|
265
|
+
|
|
266
|
+
### (e) `llm.key.held`: the LA says whether it still has its key
|
|
267
|
+
Problem it solves: an LA that keeps the key only in memory (the default) loses it on restart, but the CP's table still says ACTIVE, and the CP only
|
|
268
|
+
delivers again when the key is close to expiry (< 14 days), so the LA would stay without a key. Rather than the CP guessing, the LA announces it.
|
|
269
|
+
- **Name and place.** The capability string `llm.key.held` (it already matches the `capability` pattern, so no schema enum changes), announced in
|
|
270
|
+
`register.payload.capabilities` and in `hello.payload.capabilities`, never in `ready.payload.capabilities` (that list is the CP's). It is optional and
|
|
271
|
+
needs no `contract.v2.1` decision of its own: a CP that does not know it ignores an unknown capability name, as it ignores any other.
|
|
272
|
+
- **Rule for the LA.** It announces `llm.key.held` ONLY when it holds a key that is still valid (`expiresAt` in the future, accepted and
|
|
273
|
+
`keyId`-verified through `config.update.llmKey`) and that it is IN USE (it is the key its LLM calls would use). No key, an expired key, a revoked key
|
|
274
|
+
or a key it refused: it does not announce it. The announcement is a snapshot at connect time: `register` and `hello` of one session carry the same
|
|
275
|
+
answer, and the LA never announces it without also announcing `llm.key` (an LA that cannot receive a key cannot hold one). That dependency is in the
|
|
276
|
+
schema (`if capabilities contains llm.key.held then it must contain llm.key`, `register` and `hello`), so `llm.key.held` alone is `schema_violation`.
|
|
277
|
+
- **Rule for the CP.** At connect, if the capability is NOT announced (in `register` and `hello`; the CP should treat the key as held only when both
|
|
278
|
+
say so), the CP delivers a key (mint, then `config.update.llmKey set`) even when an ACTIVE row exists for that agent; the previous key follows the
|
|
279
|
+
rotation rule of (a) (revoked at the AI gateway only after the `accepted` ack of the new one). If it IS announced, the CP keeps the existing rule:
|
|
280
|
+
nothing to deliver until the key is within 14 days of `expiresAt`. A 2.0 LA, or a 2.1 LA that does not know the capability, never announces it, so
|
|
281
|
+
the CP treats it as "holds no key" and delivers one if the channel is on (`llm.key` announced): the safe direction.
|
|
282
|
+
- **Compatible.** Optional and additive: a register/hello without it is valid (2.0, or 2.1 after a restart), and one with it is valid under 2.1 only
|
|
283
|
+
together with `llm.key`. No frame type, no new field and no new limit. The CP side of this rule is gateway runtime code (a later task, not this contract).
|
|
284
|
+
|
|
285
|
+
### (f) Jobs: `job.assign`, `job.cancel`, `job.event`, `job.result` and the capabilities `job.assign` and `job.options` (F3-10)
|
|
286
|
+
The four job frames exist since 2.0 (their schemas did not change, except `options` below). What F3-10 adds is the negotiation and one optional field.
|
|
287
|
+
- **Capability `job.assign`.** An LA that runs jobs announces `job.assign` in `register.payload.capabilities` and in `hello.payload.capabilities`. It covers BOTH
|
|
288
|
+
`job.assign` and `job.cancel` (one name, because an LA that can take a job can stop it): the CP sends neither to a session that did not announce it, and a CP
|
|
289
|
+
that has no agent with it answers its caller `503 AGENT_UNSUPPORTED`. A LA announces it only when it has a runner to run the job (no runner, no capability).
|
|
290
|
+
An LA that announced it but still answers `job.assign` with `rejected protocol_unsupported` (its jobs feature is off) did NOT take the job: the CP may offer
|
|
291
|
+
it to another agent of the tenant.
|
|
292
|
+
- **Capability `job.options`.** An LA that understands `job.assign.payload.options` announces `job.options` (it implies `job.assign`). The CP sends `options` ONLY to
|
|
293
|
+
such a session. Both names match the `capability` pattern, so no enum changes.
|
|
294
|
+
- **`job.assign.payload.options` (optional, closed object, `minProperties` 1).** How to run the job; absent means the LA's own defaults, exactly as in 2.0.
|
|
295
|
+
Fields (all optional): `ticketKey` (a `shortId`: the ticket of the request), `branchName` (<= 255, `^(?!.*\.\.)(?!.*//)(?!.*/\.)(?!.*[./]$)(?!.*\.lock(/|$))[A-Za-z0-9_][A-Za-z0-9_./-]{0,254}$`:
|
|
296
|
+
no `..`, no `//`, no component starting with `.`, no trailing `.` or `/`, no `.lock` suffix, no space or shell character, no leading `-`), `createBranch` (bool),
|
|
297
|
+
`createPullRequest` (bool), `dryRun` (bool), `maxIterations` (integer 1..100). None of them is content (an id, a ref name, flags, a counter), so none is a
|
|
298
|
+
`transit` field; the request text itself still travels only in `inputs[].inline`. There is no field for a token or a path (the object is closed).
|
|
299
|
+
- **What an LA must do with it.** `dryRun:true` means: run the whole loop but push nothing, open no pull request and notify nobody. `createPullRequest:false`
|
|
300
|
+
means: do not open one (the branch may be pushed when `createBranch` is not false). `createBranch:false` means: work on `repoRef.branch` without creating a branch.
|
|
301
|
+
`maxIterations` caps the agent loop below the LA's own cap (never above it). `branchName`/`ticketKey` name the branch to create. An LA never widens its own policy because
|
|
302
|
+
of an option: the budget and the policy of the same payload still bound the job.
|
|
303
|
+
- **Rule for the CP.** Options that change external effects (`dryRun:true`, `createPullRequest:false`) are sent only to an LA that announced `job.options`; if none
|
|
304
|
+
did, the CP REFUSES the job (`OPTION_NOT_SUPPORTED`) instead of running one with a real pull request. The other options (ticket, branch, `createBranch`,
|
|
305
|
+
`maxIterations`) are an optimisation of the same default behaviour: for an LA without `job.options` the CP sends the job without `options`.
|
|
306
|
+
- **Compatible.** A `job.assign` without `options` is valid under 2.0 and 2.1 and means what it always meant. The CP gateway side is `deepdebug.jobs.dispatch-to-la.enabled`
|
|
307
|
+
(default off); the vectors are `vectors/job.assign.vectors.json` (`options-*`).
|
|
308
|
+
|
|
309
|
+
### Compatibility and roll-out order
|
|
310
|
+
Everything is additive and optional: a 2.0 frame is valid under the 2.1 schemas (the vectors of 2.0 are unchanged and still pass), and a 2.1 peer talking to a
|
|
311
|
+
2.0 peer sends nothing the 2.0 peer cannot read, because each new field is gated by a capability the other side announced (above). Roll out the gateway that
|
|
312
|
+
has the 2.1 schemas first (it accepts both shapes), then the LAs. The files changed by 2.1 are listed with their sha256 in the PR; the LA re-syncs exactly those.
|
|
@@ -52,9 +52,93 @@
|
|
|
52
52
|
},
|
|
53
53
|
"dataPlaneMode": {
|
|
54
54
|
"$ref": "defs.schema.json#/$defs/dataPlaneMode"
|
|
55
|
+
},
|
|
56
|
+
"llmKey": {
|
|
57
|
+
"oneOf": [
|
|
58
|
+
{
|
|
59
|
+
"type": "object",
|
|
60
|
+
"additionalProperties": false,
|
|
61
|
+
"properties": {
|
|
62
|
+
"op": {
|
|
63
|
+
"const": "set"
|
|
64
|
+
},
|
|
65
|
+
"keyId": {
|
|
66
|
+
"$ref": "defs.schema.json#/$defs/sha256"
|
|
67
|
+
},
|
|
68
|
+
"endpoint": {
|
|
69
|
+
"type": "string",
|
|
70
|
+
"minLength": 12,
|
|
71
|
+
"maxLength": 256,
|
|
72
|
+
"pattern": "^https://[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?(:[0-9]{1,5})?(/[A-Za-z0-9._~/-]{0,200})?$"
|
|
73
|
+
},
|
|
74
|
+
"key": {
|
|
75
|
+
"type": "string",
|
|
76
|
+
"minLength": 16,
|
|
77
|
+
"maxLength": 256,
|
|
78
|
+
"pattern": "^[A-Za-z0-9._~+/=-]{16,256}$",
|
|
79
|
+
"x-content-class": "secret"
|
|
80
|
+
},
|
|
81
|
+
"models": {
|
|
82
|
+
"type": "array",
|
|
83
|
+
"minItems": 1,
|
|
84
|
+
"maxItems": 32,
|
|
85
|
+
"uniqueItems": true,
|
|
86
|
+
"items": {
|
|
87
|
+
"type": "string",
|
|
88
|
+
"minLength": 1,
|
|
89
|
+
"maxLength": 96,
|
|
90
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,95}$"
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"expiresAt": {
|
|
94
|
+
"$ref": "defs.schema.json#/$defs/ts"
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"required": [
|
|
98
|
+
"op",
|
|
99
|
+
"keyId",
|
|
100
|
+
"endpoint",
|
|
101
|
+
"key",
|
|
102
|
+
"models",
|
|
103
|
+
"expiresAt"
|
|
104
|
+
]
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"type": "object",
|
|
108
|
+
"additionalProperties": false,
|
|
109
|
+
"properties": {
|
|
110
|
+
"op": {
|
|
111
|
+
"const": "revoke"
|
|
112
|
+
},
|
|
113
|
+
"keyId": {
|
|
114
|
+
"$ref": "defs.schema.json#/$defs/sha256"
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
"required": [
|
|
118
|
+
"op",
|
|
119
|
+
"keyId"
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
]
|
|
55
123
|
}
|
|
56
124
|
},
|
|
57
|
-
"minProperties": 1
|
|
125
|
+
"minProperties": 1,
|
|
126
|
+
"if": {
|
|
127
|
+
"type": "object",
|
|
128
|
+
"properties": {
|
|
129
|
+
"llmKey": true
|
|
130
|
+
},
|
|
131
|
+
"required": [
|
|
132
|
+
"llmKey"
|
|
133
|
+
]
|
|
134
|
+
},
|
|
135
|
+
"then": {
|
|
136
|
+
"type": "object",
|
|
137
|
+
"properties": {
|
|
138
|
+
"llmKey": true
|
|
139
|
+
},
|
|
140
|
+
"maxProperties": 1
|
|
141
|
+
}
|
|
58
142
|
},
|
|
59
143
|
"seq": {
|
|
60
144
|
"type": "integer",
|
|
@@ -94,7 +94,32 @@
|
|
|
94
94
|
"clockEpochMs",
|
|
95
95
|
"diskFreeBytes",
|
|
96
96
|
"queueDepth"
|
|
97
|
-
]
|
|
97
|
+
],
|
|
98
|
+
"if": {
|
|
99
|
+
"properties": {
|
|
100
|
+
"capabilities": {
|
|
101
|
+
"type": "array",
|
|
102
|
+
"maxItems": 32,
|
|
103
|
+
"contains": {
|
|
104
|
+
"const": "llm.key.held"
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
"required": [
|
|
109
|
+
"capabilities"
|
|
110
|
+
]
|
|
111
|
+
},
|
|
112
|
+
"then": {
|
|
113
|
+
"properties": {
|
|
114
|
+
"capabilities": {
|
|
115
|
+
"type": "array",
|
|
116
|
+
"maxItems": 32,
|
|
117
|
+
"contains": {
|
|
118
|
+
"const": "llm.key"
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
98
123
|
},
|
|
99
124
|
"seq": {
|
|
100
125
|
"const": 0
|
|
@@ -72,6 +72,17 @@
|
|
|
72
72
|
"security",
|
|
73
73
|
"other"
|
|
74
74
|
]
|
|
75
|
+
},
|
|
76
|
+
"stackBytes": {
|
|
77
|
+
"type": "integer",
|
|
78
|
+
"minimum": 0,
|
|
79
|
+
"maximum": 16777216
|
|
80
|
+
},
|
|
81
|
+
"model": {
|
|
82
|
+
"type": "string",
|
|
83
|
+
"minLength": 1,
|
|
84
|
+
"maxLength": 48,
|
|
85
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:+-]{0,47}$"
|
|
75
86
|
}
|
|
76
87
|
},
|
|
77
88
|
"required": [
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"protocol": 2,
|
|
3
|
+
"revision": "2.1",
|
|
3
4
|
"baseId": "https://schemas.visionhammer.io/la-protocol-v2/",
|
|
4
5
|
"limits": {
|
|
5
6
|
"maxFrameBytes": 49152,
|
|
@@ -12,7 +13,13 @@
|
|
|
12
13
|
"heartbeatSeconds": 30,
|
|
13
14
|
"sessionDeadSeconds": 90,
|
|
14
15
|
"dedupeWindowSeconds": 600,
|
|
15
|
-
"clockSkewSeconds": 60
|
|
16
|
+
"clockSkewSeconds": 60,
|
|
17
|
+
"webhookChunkRawBytes": 16384,
|
|
18
|
+
"webhookMaxChunks": 64,
|
|
19
|
+
"webhookMaxBytes": 1048576,
|
|
20
|
+
"webhookSingleFrameRawBytes": 9216,
|
|
21
|
+
"webhookAssemblySeconds": 30,
|
|
22
|
+
"webhookMaxOpenAssemblies": 4
|
|
16
23
|
},
|
|
17
24
|
"types": {
|
|
18
25
|
"register": {
|
|
@@ -118,7 +125,7 @@
|
|
|
118
125
|
"direction": "cp->la",
|
|
119
126
|
"control": false,
|
|
120
127
|
"signed": true,
|
|
121
|
-
"contentClass": "
|
|
128
|
+
"contentClass": "secret"
|
|
122
129
|
},
|
|
123
130
|
"scan.upload": {
|
|
124
131
|
"schema": "scan.upload.schema.json",
|
|
@@ -184,6 +184,37 @@
|
|
|
184
184
|
}
|
|
185
185
|
]
|
|
186
186
|
}
|
|
187
|
+
},
|
|
188
|
+
"options": {
|
|
189
|
+
"type": "object",
|
|
190
|
+
"additionalProperties": false,
|
|
191
|
+
"description": "Optional (revision 2.1, capability job.options): how to run the job. Absent = the Local Agent's own defaults. Sent only to a Local Agent that announced job.options; none of these fields is content (a ticket id, a branch name, flags and a counter).",
|
|
192
|
+
"properties": {
|
|
193
|
+
"ticketKey": {
|
|
194
|
+
"$ref": "defs.schema.json#/$defs/shortId"
|
|
195
|
+
},
|
|
196
|
+
"branchName": {
|
|
197
|
+
"type": "string",
|
|
198
|
+
"minLength": 1,
|
|
199
|
+
"maxLength": 255,
|
|
200
|
+
"pattern": "^(?!.*\\.\\.)(?!.*//)(?!.*/\\.)(?!.*[./]$)(?!.*\\.lock(/|$))[A-Za-z0-9_][A-Za-z0-9_./-]{0,254}$"
|
|
201
|
+
},
|
|
202
|
+
"createBranch": {
|
|
203
|
+
"type": "boolean"
|
|
204
|
+
},
|
|
205
|
+
"createPullRequest": {
|
|
206
|
+
"type": "boolean"
|
|
207
|
+
},
|
|
208
|
+
"dryRun": {
|
|
209
|
+
"type": "boolean"
|
|
210
|
+
},
|
|
211
|
+
"maxIterations": {
|
|
212
|
+
"type": "integer",
|
|
213
|
+
"minimum": 1,
|
|
214
|
+
"maximum": 100
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
"minProperties": 1
|
|
187
218
|
}
|
|
188
219
|
},
|
|
189
220
|
"required": [
|
|
@@ -75,7 +75,32 @@
|
|
|
75
75
|
"capabilities",
|
|
76
76
|
"signatureMode",
|
|
77
77
|
"workspaces"
|
|
78
|
-
]
|
|
78
|
+
],
|
|
79
|
+
"if": {
|
|
80
|
+
"properties": {
|
|
81
|
+
"capabilities": {
|
|
82
|
+
"type": "array",
|
|
83
|
+
"maxItems": 32,
|
|
84
|
+
"contains": {
|
|
85
|
+
"const": "llm.key.held"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
"required": [
|
|
90
|
+
"capabilities"
|
|
91
|
+
]
|
|
92
|
+
},
|
|
93
|
+
"then": {
|
|
94
|
+
"properties": {
|
|
95
|
+
"capabilities": {
|
|
96
|
+
"type": "array",
|
|
97
|
+
"maxItems": 32,
|
|
98
|
+
"contains": {
|
|
99
|
+
"const": "llm.key"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
79
104
|
},
|
|
80
105
|
"seq": {
|
|
81
106
|
"const": 0
|
|
@@ -40,9 +40,17 @@
|
|
|
40
40
|
"bodyB64": {
|
|
41
41
|
"type": "string",
|
|
42
42
|
"minLength": 0,
|
|
43
|
-
"maxLength":
|
|
43
|
+
"maxLength": 21848,
|
|
44
44
|
"pattern": "^[A-Za-z0-9+/]*={0,2}$",
|
|
45
45
|
"x-content-class": "transit"
|
|
46
|
+
},
|
|
47
|
+
"bodyBytes": {
|
|
48
|
+
"type": "integer",
|
|
49
|
+
"minimum": 0,
|
|
50
|
+
"maximum": 1048576
|
|
51
|
+
},
|
|
52
|
+
"bodySha256": {
|
|
53
|
+
"$ref": "defs.schema.json#/$defs/sha256"
|
|
46
54
|
}
|
|
47
55
|
},
|
|
48
56
|
"required": [
|
|
@@ -57,7 +65,7 @@
|
|
|
57
65
|
"minimum": 1,
|
|
58
66
|
"maximum": 9007199254740991
|
|
59
67
|
},
|
|
60
|
-
"chunk":
|
|
68
|
+
"chunk": true,
|
|
61
69
|
"sig": true,
|
|
62
70
|
"paramsRaw": true
|
|
63
71
|
},
|
|
@@ -65,5 +73,66 @@
|
|
|
65
73
|
"payload",
|
|
66
74
|
"sig",
|
|
67
75
|
"paramsRaw"
|
|
68
|
-
]
|
|
76
|
+
],
|
|
77
|
+
"if": {
|
|
78
|
+
"type": "object",
|
|
79
|
+
"properties": {
|
|
80
|
+
"chunk": true
|
|
81
|
+
},
|
|
82
|
+
"required": [
|
|
83
|
+
"chunk"
|
|
84
|
+
]
|
|
85
|
+
},
|
|
86
|
+
"then": {
|
|
87
|
+
"type": "object",
|
|
88
|
+
"properties": {
|
|
89
|
+
"payload": {
|
|
90
|
+
"type": "object",
|
|
91
|
+
"properties": {
|
|
92
|
+
"bodyBytes": true,
|
|
93
|
+
"bodySha256": true
|
|
94
|
+
},
|
|
95
|
+
"required": [
|
|
96
|
+
"bodyBytes",
|
|
97
|
+
"bodySha256"
|
|
98
|
+
]
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
"else": {
|
|
103
|
+
"type": "object",
|
|
104
|
+
"properties": {
|
|
105
|
+
"payload": {
|
|
106
|
+
"type": "object",
|
|
107
|
+
"properties": {
|
|
108
|
+
"bodyB64": {
|
|
109
|
+
"type": "string",
|
|
110
|
+
"maxLength": 12288
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
"not": {
|
|
114
|
+
"anyOf": [
|
|
115
|
+
{
|
|
116
|
+
"type": "object",
|
|
117
|
+
"properties": {
|
|
118
|
+
"bodyBytes": true
|
|
119
|
+
},
|
|
120
|
+
"required": [
|
|
121
|
+
"bodyBytes"
|
|
122
|
+
]
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"type": "object",
|
|
126
|
+
"properties": {
|
|
127
|
+
"bodySha256": true
|
|
128
|
+
},
|
|
129
|
+
"required": [
|
|
130
|
+
"bodySha256"
|
|
131
|
+
]
|
|
132
|
+
}
|
|
133
|
+
]
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
69
138
|
}
|
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
* unknown_type `type` is not in the allowlist (index.json)
|
|
11
11
|
* schema_violation the frame does not satisfy the closed schema of its type
|
|
12
12
|
* payload_too_large ... because a string under /payload is above its maxLength (same rule as the gateway)
|
|
13
|
-
* chunk_inconsistent content.response whose byteLength differs from the decoded size of data
|
|
13
|
+
* chunk_inconsistent content.response whose byteLength differs from the decoded size of data; webhook.forward chunk of the wrong size (2.1)
|
|
14
|
+
* (rule llmKey-id-sha256 of 2.1 is a schema_violation: the keyId is not the sha256 of the key)
|
|
14
15
|
*/
|
|
16
|
+
import crypto from "node:crypto";
|
|
15
17
|
import fs from "node:fs";
|
|
16
18
|
import path from "node:path";
|
|
17
19
|
import { fileURLToPath } from "node:url";
|
|
@@ -21,16 +23,52 @@ const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "contract");
|
|
|
21
23
|
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
22
24
|
const readJson = (p) => JSON.parse(fs.readFileSync(p, "utf8"));
|
|
23
25
|
|
|
24
|
-
/**
|
|
25
|
-
export
|
|
26
|
+
/** The base64 alphabet with padding, canonical form (no whitespace, length multiple of 4). */
|
|
27
|
+
export const isCanonicalBase64 = (s) => typeof s === "string" && BASE64.test(s);
|
|
28
|
+
const decodedLength = (s) => (isCanonicalBase64(s) ? Buffer.from(s, "base64").length : -1);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Contract 2.1, rule `webhook-chunk-size`: a chunked webhook.forward has total == max(1, ceil(bodyBytes / chunkRaw)), a non-last chunk
|
|
32
|
+
* decodes to exactly chunkRaw bytes and the last one to bodyBytes - chunkRaw * (total - 1). Stateless (one frame).
|
|
33
|
+
* @returns {boolean} true when the frame BREAKS the rule
|
|
34
|
+
*/
|
|
35
|
+
export function webhookChunkSizeViolation(payload, chunk, chunkRaw = 16384) {
|
|
36
|
+
if (!payload || !chunk || typeof payload.bodyB64 !== "string" || !Number.isSafeInteger(payload.bodyBytes)) return false;
|
|
37
|
+
const { index, total } = chunk;
|
|
38
|
+
const expectedTotal = Math.max(1, Math.ceil(payload.bodyBytes / chunkRaw));
|
|
39
|
+
const expectedSize = index < total - 1 ? chunkRaw : payload.bodyBytes - chunkRaw * (total - 1);
|
|
40
|
+
return total !== expectedTotal || index >= total || decodedLength(payload.bodyB64) !== expectedSize;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Contract 2.1, rule `llmKey-id-sha256`: llmKey (op set) keyId == lower-case hex sha256 of the UTF-8 bytes of key. @returns {boolean} true when it BREAKS the rule */
|
|
44
|
+
export function llmKeyIdMismatch(llmKey) {
|
|
45
|
+
if (!llmKey || llmKey.op !== "set" || typeof llmKey.key !== "string") return false;
|
|
46
|
+
return crypto.createHash("sha256").update(Buffer.from(llmKey.key, "utf8")).digest("hex") !== llmKey.keyId;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Rule -> wire code (the `code` the vectors expect). */
|
|
50
|
+
export const RULE_CODES = Object.freeze({ "byteLength-decoded": "chunk_inconsistent", "webhook-chunk-size": "chunk_inconsistent", "llmKey-id-sha256": "schema_violation" });
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Rules JSON Schema cannot state (README of the contract): byteLength == size of the decoded `data` (content.response), and since 2.1
|
|
54
|
+
* `webhook-chunk-size` and `llmKey-id-sha256`. `skip` names rules the caller applies itself later (the session defers the two 2.1
|
|
55
|
+
* rules to the handlers: they are answered with a non-fatal `rejected` ack, in the order of the reference model: state first).
|
|
56
|
+
*/
|
|
57
|
+
export function semanticRules(frame, { skip = [] } = {}) {
|
|
26
58
|
const rules = [];
|
|
27
|
-
|
|
59
|
+
const on = (name) => !skip.includes(name);
|
|
60
|
+
if (frame && frame.type === "content.response" && frame.payload && typeof frame.payload.data === "string" && on("byteLength-decoded")) {
|
|
28
61
|
const d = frame.payload.data;
|
|
29
62
|
if (!BASE64.test(d) || Buffer.from(d, "base64").length !== frame.payload.byteLength) rules.push("byteLength-decoded");
|
|
30
63
|
}
|
|
64
|
+
if (frame && frame.type === "webhook.forward" && frame.chunk && on("webhook-chunk-size")
|
|
65
|
+
&& webhookChunkSizeViolation(frame.payload, frame.chunk, loadLimits().webhookChunkRawBytes)) rules.push("webhook-chunk-size");
|
|
66
|
+
if (frame && frame.type === "config.update" && frame.payload && on("llmKey-id-sha256") && llmKeyIdMismatch(frame.payload.llmKey)) rules.push("llmKey-id-sha256");
|
|
31
67
|
return rules;
|
|
32
68
|
}
|
|
33
69
|
|
|
70
|
+
const loadLimits = () => loadContract().limits;
|
|
71
|
+
|
|
34
72
|
let singleton = null;
|
|
35
73
|
|
|
36
74
|
/** Loads and compiles the contract once per process (about 20 ms). */
|
|
@@ -73,7 +111,7 @@ export function loadContract() {
|
|
|
73
111
|
if (!v) throw new Error(`contract: no schema ${schemaFile}`);
|
|
74
112
|
return v(value) === true;
|
|
75
113
|
},
|
|
76
|
-
validateFrame(frame) {
|
|
114
|
+
validateFrame(frame, { skipRules = [] } = {}) {
|
|
77
115
|
if (frame === null || typeof frame !== "object" || typeof frame.type !== "string") {
|
|
78
116
|
// no `type` at all (a v1 command frame, say): the envelope says what is missing
|
|
79
117
|
const envelope = ajv.getSchema(index.baseId + "envelope.schema.json");
|
|
@@ -90,8 +128,8 @@ export function loadContract() {
|
|
|
90
128
|
const tooLong = errs.some((e) => e.keyword === "maxLength" && String(e.instancePath).startsWith("/payload"));
|
|
91
129
|
return { ok: false, code: tooLong ? "payload_too_large" : "schema_violation", keywords, rules: [] };
|
|
92
130
|
}
|
|
93
|
-
const rules = semanticRules(frame);
|
|
94
|
-
if (rules.length) return { ok: false, code: "chunk_inconsistent", keywords: [], rules };
|
|
131
|
+
const rules = semanticRules(frame, { skip: skipRules });
|
|
132
|
+
if (rules.length) return { ok: false, code: RULE_CODES[rules[0]] || "chunk_inconsistent", keywords: [], rules };
|
|
95
133
|
return { ok: true };
|
|
96
134
|
},
|
|
97
135
|
});
|