deepdebug-local-agent 1.3.0 → 1.4.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/.github/workflows/deploy-gcp.yml +58 -26
- package/CHANGELOG.md +6 -0
- package/docs/la-protocol-v2.md +68 -0
- package/package.json +2 -1
- package/scripts/sync-la-protocol-v2-contract.mjs +58 -0
- package/src/la-protocol-v2/chunks.js +45 -0
- package/src/la-protocol-v2/config.js +27 -0
- package/src/la-protocol-v2/content.js +57 -0
- package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +81 -0
- package/src/la-protocol-v2/contract/README.md +103 -0
- package/src/la-protocol-v2/contract/ack.schema.json +51 -0
- package/src/la-protocol-v2/contract/alert.schema.json +62 -0
- package/src/la-protocol-v2/contract/approval.decision.schema.json +57 -0
- package/src/la-protocol-v2/contract/audit.event.schema.json +86 -0
- package/src/la-protocol-v2/contract/config.update.schema.json +73 -0
- package/src/la-protocol-v2/contract/content.request.schema.json +110 -0
- package/src/la-protocol-v2/contract/content.response.schema.json +90 -0
- package/src/la-protocol-v2/contract/defs.schema.json +191 -0
- package/src/la-protocol-v2/contract/envelope.schema.json +89 -0
- package/src/la-protocol-v2/contract/error.schema.json +59 -0
- package/src/la-protocol-v2/contract/heartbeat.schema.json +100 -0
- package/src/la-protocol-v2/contract/hello.schema.json +111 -0
- package/src/la-protocol-v2/contract/incident.candidate.schema.json +102 -0
- package/src/la-protocol-v2/contract/index.json +166 -0
- package/src/la-protocol-v2/contract/job.assign.schema.json +212 -0
- package/src/la-protocol-v2/contract/job.cancel.schema.json +52 -0
- package/src/la-protocol-v2/contract/job.event.schema.json +115 -0
- package/src/la-protocol-v2/contract/job.result.schema.json +100 -0
- package/src/la-protocol-v2/contract/log.stats.schema.json +79 -0
- package/src/la-protocol-v2/contract/ready.schema.json +106 -0
- package/src/la-protocol-v2/contract/register.schema.json +92 -0
- package/src/la-protocol-v2/contract/scan.upload.schema.json +59 -0
- package/src/la-protocol-v2/contract/scan.uploaded.schema.json +51 -0
- package/src/la-protocol-v2/contract/webhook.forward.schema.json +69 -0
- package/src/la-protocol-v2/contract.js +90 -0
- package/src/la-protocol-v2/errors.js +34 -0
- package/src/la-protocol-v2/index.js +8 -0
- package/src/la-protocol-v2/link.js +93 -0
- package/src/la-protocol-v2/runtime.js +72 -0
- package/src/la-protocol-v2/session.js +650 -0
- package/src/la-protocol-v2/strict-json.js +47 -0
- package/src/server.js +22 -4
- package/test/contract/la-protocol-v2/vectors/ack.vectors.json +149 -0
- package/test/contract/la-protocol-v2/vectors/alert.vectors.json +101 -0
- package/test/contract/la-protocol-v2/vectors/approval.decision.vectors.json +165 -0
- package/test/contract/la-protocol-v2/vectors/audit.event.vectors.json +156 -0
- package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +189 -0
- package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +325 -0
- package/test/contract/la-protocol-v2/vectors/content.response.vectors.json +413 -0
- package/test/contract/la-protocol-v2/vectors/cross-type.vectors.json +260 -0
- package/test/contract/la-protocol-v2/vectors/error.vectors.json +179 -0
- package/test/contract/la-protocol-v2/vectors/heartbeat.vectors.json +109 -0
- package/test/contract/la-protocol-v2/vectors/hello.vectors.json +224 -0
- package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +186 -0
- package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +631 -0
- package/test/contract/la-protocol-v2/vectors/job.cancel.vectors.json +133 -0
- package/test/contract/la-protocol-v2/vectors/job.event.vectors.json +233 -0
- package/test/contract/la-protocol-v2/vectors/job.result.vectors.json +137 -0
- package/test/contract/la-protocol-v2/vectors/log.stats.vectors.json +137 -0
- package/test/contract/la-protocol-v2/vectors/ready.vectors.json +177 -0
- package/test/contract/la-protocol-v2/vectors/register.vectors.json +399 -0
- package/test/contract/la-protocol-v2/vectors/scan.upload.vectors.json +137 -0
- package/test/contract/la-protocol-v2/vectors/scan.uploaded.vectors.json +101 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-agent-mismatch.json +140 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-after-last.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-byte-length-mismatch.json +174 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-last-flag.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-out-of-order.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-chunk-total-changes.json +200 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-clock-skew.json +46 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-event-before-ready.json +72 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-first-frame-not-register.json +48 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-frame-over-48kib.json +141 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-la-sends-job-assign.json +173 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-params-raw-differs-from-payload.json +172 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-payload-above-limit.json +174 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-resume-restarts-seq.json +144 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-gap.json +166 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-seq-repeated.json +166 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-sig-tenant-mismatch.json +172 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-signature-warn-with-enforce-required.json +77 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-tenant-mismatch.json +140 -0
- package/test/contract/la-protocol-v2/vectors/traces/invalid-type-outside-allowlist.json +140 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-content-chunks-in-order.json +224 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-handshake-and-job.json +266 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-resume-continues-seq.json +168 -0
- package/test/contract/la-protocol-v2/vectors/traces/valid-retransmit-is-idempotent.json +193 -0
- package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +170 -0
- package/test/la-protocol-v2/contract.test.js +131 -0
- package/test/la-protocol-v2/helpers/la-rig.js +83 -0
- package/test/la-protocol-v2/helpers/reference-cp.js +349 -0
- package/test/la-protocol-v2/helpers/spawn-agent.js +35 -0
- package/test/la-protocol-v2/link.test.js +141 -0
- package/test/la-protocol-v2/session-inbound.test.js +279 -0
- package/test/la-protocol-v2/session-outbound.test.js +382 -0
- package/test/la-protocol-v2/traces.test.js +270 -0
- package/test/la-protocol-v2/tunnel-e2e.test.js +124 -0
- package/tools/graphify-validation/extras/README.md +19 -0
- package/tools/graphify-validation/extras/bymethod.py +35 -0
- package/tools/graphify-validation/extras/docker/Dockerfile +16 -0
- package/tools/graphify-validation/extras/docker/inner.sh +7 -0
- package/tools/graphify-validation/extras/g10.py +59 -0
- package/tools/graphify-validation/extras/g3aug.py +49 -0
- package/tools/graphify-validation/extras/hybrid.py +34 -0
- package/tools/graphify-validation/extras/truth2.py +44 -0
- package/tools/interop/LaV2NodeInteropTest.java +266 -0
- package/tools/interop/README.md +21 -0
- package/tools/interop/la-interop-client.mjs +97 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session.js - the Local Agent side of protocol v2 (F3-05b). Contract: docs/contracts/la-protocol-v2 of
|
|
3
|
+
* vh-api-gateway, copied to ./contract (CONTRACT_SOURCE.json says which commit). Design: vault
|
|
4
|
+
* "Fase 3 Desenho Tecnico 2026-10-28" sec. 4.2. Gateway side: LaV2Session (F3-05a).
|
|
5
|
+
*
|
|
6
|
+
* One V2Session lives as long as the tunnel and survives dropped sockets (resume, 90 s on the gateway):
|
|
7
|
+
*
|
|
8
|
+
* attach(transport) -> register (+ resume{sessionId,lastRecvSeq}) then hello; CP answers `ready`
|
|
9
|
+
* handleText(text) -> inbound pipeline, in the SAME order as the gateway's rules:
|
|
10
|
+
* size, parse (duplicate keys refused), allowlist, schema, tenant, agent,
|
|
11
|
+
* direction, handshake state, signature, seq, handler
|
|
12
|
+
* enqueue/send -> outbound: allowlist + direction + schema (Ajv, strict) BEFORE a byte is written,
|
|
13
|
+
* seq per direction, piggy-backed ack, at most `maxInFlight` frames unacked
|
|
14
|
+
* (the rest wait in a bounded queue = backpressure), retransmission after
|
|
15
|
+
* ackTimeoutMs (same seq and msgId, at most maxRetries), dedupe by seq+msgId
|
|
16
|
+
*
|
|
17
|
+
* What it never does: put frame content in a log, a metric, an error frame or an exception message;
|
|
18
|
+
* execute anything itself (handlers are registered by type; with none the frame is acked `rejected`);
|
|
19
|
+
* read the tenant from a payload (tenantId/agentId are the configured ones).
|
|
20
|
+
*/
|
|
21
|
+
import crypto from "node:crypto";
|
|
22
|
+
import { EventEmitter } from "node:events";
|
|
23
|
+
import { loadContract } from "./contract.js";
|
|
24
|
+
import { ProtocolError, closeCodeOf, isFatal, isRetryable } from "./errors.js";
|
|
25
|
+
import { parseStrict } from "./strict-json.js";
|
|
26
|
+
import { ChunkStreamTracker } from "./chunks.js";
|
|
27
|
+
import { verifyWsEnvelope } from "../utils/request-signing.js";
|
|
28
|
+
|
|
29
|
+
export const STATE = Object.freeze({ IDLE: "idle", AWAIT_READY: "await_ready", READY: "ready", DETACHED: "detached", CLOSED: "closed" });
|
|
30
|
+
|
|
31
|
+
const WORKSPACE_ID = /^[A-Za-z0-9_.-]{0,64}$/;
|
|
32
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
33
|
+
const CAPABILITY = /^[a-z0-9]+([._-][a-z0-9]+)*$/;
|
|
34
|
+
const DEDUPE_ENTRIES = 512;
|
|
35
|
+
const noop = () => {};
|
|
36
|
+
|
|
37
|
+
const DEFAULTS = Object.freeze({
|
|
38
|
+
handshakeTimeoutMs: 10_000,
|
|
39
|
+
maxInFlight: 8, // LA->CP data frames not yet acked
|
|
40
|
+
maxQueued: 256, // frames waiting for a slot; beyond it enqueue() refuses (rate_limited)
|
|
41
|
+
maxInboundQueue: 128, // CP->LA frames waiting to be processed (the CP never has more than 32 unacked)
|
|
42
|
+
resumeWindowMs: 120_000, // the gateway keeps a detached session 90 s
|
|
43
|
+
handlerAckMs: 2_000, // a handler that has not decided by then is acked `accepted` (it goes on)
|
|
44
|
+
bufferHighWater: 512 * 1024,
|
|
45
|
+
heartbeatMs: null, // tests only: overrides ready.limits.heartbeatSeconds
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
49
|
+
|
|
50
|
+
function deepEqual(a, b) {
|
|
51
|
+
if (a === b) return true;
|
|
52
|
+
if (typeof a !== typeof b || a === null || b === null || typeof a !== "object") return false;
|
|
53
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
54
|
+
if (Array.isArray(a)) return a.length === b.length && a.every((x, i) => deepEqual(x, b[i]));
|
|
55
|
+
const ka = Object.keys(a);
|
|
56
|
+
const kb = Object.keys(b);
|
|
57
|
+
return ka.length === kb.length && ka.every((k) => Object.hasOwn(b, k) && deepEqual(a[k], b[k]));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class V2Session extends EventEmitter {
|
|
61
|
+
/**
|
|
62
|
+
* @param {object} o
|
|
63
|
+
* @param {string} o.tenantId lower-case UUID of the tenant (configured, never read from a payload)
|
|
64
|
+
* @param {string} o.agentId lower-case UUID of the agent authorization
|
|
65
|
+
* @param {string} [o.workspaceId] workspace of this agent for the envelope ("" when unknown)
|
|
66
|
+
* @param {{mode:string}} o.signing the signing config of request-signing.js (mode + trusted keys + nonce cache)
|
|
67
|
+
* @param {string} o.laVersion semver
|
|
68
|
+
* @param {string[]} [o.capabilities]
|
|
69
|
+
* @param {() => Array<{workspaceId:string,pathSha256?:string}>} [o.getWorkspaces]
|
|
70
|
+
* @param {() => {diskFreeBytes?:number, graphify?:object}} [o.getHealth]
|
|
71
|
+
* @param {{info:Function,warn:Function}} [o.log]
|
|
72
|
+
* @param {() => number} [o.now]
|
|
73
|
+
* @param {object} [o.options] overrides of DEFAULTS
|
|
74
|
+
*/
|
|
75
|
+
constructor(o) {
|
|
76
|
+
super();
|
|
77
|
+
this.contract = loadContract();
|
|
78
|
+
this.tenantId = o.tenantId;
|
|
79
|
+
this.agentId = o.agentId;
|
|
80
|
+
this.workspaceId = o.workspaceId || "";
|
|
81
|
+
this.signing = o.signing;
|
|
82
|
+
this.laVersion = o.laVersion;
|
|
83
|
+
this.capabilities = [...new Set(o.capabilities || [])].filter((c) => CAPABILITY.test(c) && c.length <= 48).sort();
|
|
84
|
+
this.getWorkspaces = o.getWorkspaces || (() => []);
|
|
85
|
+
this.getHealth = o.getHealth || (() => ({}));
|
|
86
|
+
this.log = o.log || { info: noop, warn: noop };
|
|
87
|
+
this.now = o.now || Date.now;
|
|
88
|
+
this.opts = { ...DEFAULTS, ...(o.options || {}) };
|
|
89
|
+
|
|
90
|
+
const L = this.contract.limits;
|
|
91
|
+
this.limits = {
|
|
92
|
+
maxFrameBytes: L.maxFrameBytes, chunkRawBytes: L.chunkRawBytes, maxChunks: L.maxChunks,
|
|
93
|
+
ackTimeoutMs: L.ackTimeoutMs, maxRetries: L.maxRetries, heartbeatSeconds: L.heartbeatSeconds,
|
|
94
|
+
};
|
|
95
|
+
this.state = STATE.IDLE;
|
|
96
|
+
this.sessionId = null; // set by `ready`
|
|
97
|
+
this.everReady = false;
|
|
98
|
+
this.sendSeq = 0; // last LA->CP data seq assigned
|
|
99
|
+
this.lastRecvSeq = 0; // highest contiguous CP->LA data seq received
|
|
100
|
+
this.recvRing = new Map(); // seq -> msgId of the last DEDUPE_ENTRIES received
|
|
101
|
+
this.unacked = new Map(); // seq -> outbound record, in seq order
|
|
102
|
+
this.waiters = []; // frames waiting for a window slot (FIFO)
|
|
103
|
+
this.handlers = new Map(); // type -> fn(ctx) -> {accept:true}|{reject:code}
|
|
104
|
+
this.tracker = new ChunkStreamTracker(this.limits);
|
|
105
|
+
this.transport = null;
|
|
106
|
+
this.inbound = Promise.resolve();
|
|
107
|
+
this.inboundDepth = 0;
|
|
108
|
+
this.timers = { handshake: null, heartbeat: null, expire: null, drain: null };
|
|
109
|
+
this.stats = {
|
|
110
|
+
framesIn: 0, framesOut: 0, dataOut: 0, retransmits: 0, duplicatesIn: 0, rejectedIn: {}, ackedOut: 0,
|
|
111
|
+
nonText: 0, unverified: 0,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------------------- public API
|
|
116
|
+
|
|
117
|
+
/** Registers the handler of a CP->LA data type. Without one the frame is acked `rejected` (protocol_unsupported). */
|
|
118
|
+
setHandler(type, fn) {
|
|
119
|
+
if (!this.contract.canReceive(type) || this.contract.typeInfo(type).control) throw new ProtocolError("unknown_type");
|
|
120
|
+
this.handlers.set(type, fn);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A new socket is open: starts (or resumes) the handshake. */
|
|
124
|
+
attach(transport) {
|
|
125
|
+
if (this.state === STATE.CLOSED) throw new ProtocolError("agent_offline", "session closed");
|
|
126
|
+
this.#cancelTimer("expire");
|
|
127
|
+
this.#cancelTimer("handshake");
|
|
128
|
+
this.#stopHeartbeat();
|
|
129
|
+
this.transport = transport;
|
|
130
|
+
this.state = STATE.AWAIT_READY;
|
|
131
|
+
for (const rec of this.unacked.values()) this.#cancelRecTimer(rec);
|
|
132
|
+
const payload = {
|
|
133
|
+
protocol: 2,
|
|
134
|
+
laVersion: this.laVersion,
|
|
135
|
+
capabilities: this.capabilities,
|
|
136
|
+
signatureMode: this.signing.mode,
|
|
137
|
+
workspaces: this.#workspaces(),
|
|
138
|
+
...(this.everReady && this.sessionId ? { resume: { sessionId: this.sessionId, lastRecvSeq: this.lastRecvSeq } } : {}),
|
|
139
|
+
};
|
|
140
|
+
this.#sendControl("register", payload);
|
|
141
|
+
const health = this.getHealth() || {};
|
|
142
|
+
const hello = {
|
|
143
|
+
laVersion: this.laVersion,
|
|
144
|
+
capabilities: this.capabilities,
|
|
145
|
+
signatureMode: this.signing.mode,
|
|
146
|
+
clockEpochMs: this.now(),
|
|
147
|
+
diskFreeBytes: Number.isSafeInteger(health.diskFreeBytes) && health.diskFreeBytes >= 0 ? health.diskFreeBytes : 0,
|
|
148
|
+
queueDepth: Math.min(this.unacked.size + this.waiters.length, 100000),
|
|
149
|
+
inflight: Math.min(this.unacked.size, 10000),
|
|
150
|
+
...(health.graphify ? { graphify: health.graphify } : {}),
|
|
151
|
+
};
|
|
152
|
+
this.#sendControl("hello", hello);
|
|
153
|
+
this.timers.handshake = this.#timer(() => {
|
|
154
|
+
this.timers.handshake = null;
|
|
155
|
+
if (this.state === STATE.AWAIT_READY && this.transport === transport) this.emit("handshake-timeout");
|
|
156
|
+
}, this.opts.handshakeTimeoutMs);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The socket is gone. A session that was ready stays resumable for resumeWindowMs. */
|
|
160
|
+
detach(transport) {
|
|
161
|
+
if (transport !== this.transport || this.state === STATE.CLOSED) return;
|
|
162
|
+
this.#onTransportGone();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Gives up v2 on the current connection (fallback to v1): nothing of this session is resumable. */
|
|
166
|
+
abandon() {
|
|
167
|
+
this.#cancelTimer("handshake");
|
|
168
|
+
this.#teardown("agent_offline");
|
|
169
|
+
this.transport = null;
|
|
170
|
+
this.state = STATE.IDLE;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Process shutdown. */
|
|
174
|
+
close() {
|
|
175
|
+
this.#teardown("agent_offline");
|
|
176
|
+
this.transport = null;
|
|
177
|
+
this.state = STATE.CLOSED;
|
|
178
|
+
this.removeAllListeners();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** One CP->LA text frame (call from the socket 'message' event). Never throws. */
|
|
182
|
+
handleText(text, transport = this.transport, isBinary = false) {
|
|
183
|
+
if (this.state === STATE.CLOSED || transport !== this.transport || !this.transport) return;
|
|
184
|
+
if (isBinary) { this.stats.nonText++; return; }
|
|
185
|
+
if (this.inboundDepth >= this.opts.maxInboundQueue) { this.#refuse("rate_limited"); return; }
|
|
186
|
+
this.inboundDepth++;
|
|
187
|
+
this.inbound = this.inbound
|
|
188
|
+
.then(() => this.#process(text, transport))
|
|
189
|
+
.catch(() => this.#refuse("internal"))
|
|
190
|
+
.finally(() => { this.inboundDepth--; });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Queues one LA->CP data frame. Resolves, once the frame has been written, with {seq, msgId, acked};
|
|
195
|
+
* `acked` settles when the CP acked it (rejects with ProtocolError: rejected ack, timeout, reset).
|
|
196
|
+
* The promise itself rejects BEFORE anything is written when the frame is not allowed
|
|
197
|
+
* (unknown_type, wrong_direction, schema_violation, frame_too_large, chunk_*, not_ready, rate_limited).
|
|
198
|
+
*/
|
|
199
|
+
enqueue(type, payload, { requestId, workspaceId, chunk } = {}) {
|
|
200
|
+
let prepared;
|
|
201
|
+
try {
|
|
202
|
+
prepared = this.#prepare(type, payload, { requestId, workspaceId, chunk });
|
|
203
|
+
} catch (e) {
|
|
204
|
+
return Promise.reject(e);
|
|
205
|
+
}
|
|
206
|
+
return new Promise((resolve, reject) => {
|
|
207
|
+
this.waiters.push({ ...prepared, resolve, reject });
|
|
208
|
+
this.#pump();
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** enqueue() + wait for the ack. */
|
|
213
|
+
async send(type, payload, opts) {
|
|
214
|
+
const h = await this.enqueue(type, payload, opts);
|
|
215
|
+
await h.acked;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Drops the frames of a request that are still waiting for a slot (cancellation of a chunk stream). */
|
|
219
|
+
cancelWaiting(requestId) {
|
|
220
|
+
const keep = [];
|
|
221
|
+
for (const w of this.waiters) {
|
|
222
|
+
if (w.requestId === requestId) {
|
|
223
|
+
if (w.type === "content.response") this.tracker.forget(requestId);
|
|
224
|
+
w.reject(new ProtocolError("agent_offline", "cancelled"));
|
|
225
|
+
} else keep.push(w);
|
|
226
|
+
}
|
|
227
|
+
this.waiters = keep;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
get snapshot() {
|
|
231
|
+
return { state: this.state, sessionId: this.sessionId, sendSeq: this.sendSeq, lastRecvSeq: this.lastRecvSeq,
|
|
232
|
+
unacked: this.unacked.size, waiting: this.waiters.length, limits: { ...this.limits }, stats: { ...this.stats } };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------------------- outbound
|
|
236
|
+
|
|
237
|
+
#workspaces() {
|
|
238
|
+
let list = [];
|
|
239
|
+
try { list = this.getWorkspaces() || []; } catch { list = []; }
|
|
240
|
+
const out = [];
|
|
241
|
+
for (const w of list) {
|
|
242
|
+
if (!w || typeof w.workspaceId !== "string" || !WORKSPACE_ID.test(w.workspaceId)) continue;
|
|
243
|
+
out.push({ workspaceId: w.workspaceId, ...(typeof w.pathSha256 === "string" && SHA256.test(w.pathSha256) ? { pathSha256: w.pathSha256 } : {}) });
|
|
244
|
+
if (out.length >= 64) break;
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#envelope(type, seq, { requestId, workspaceId, chunk, ack } = {}) {
|
|
250
|
+
return {
|
|
251
|
+
schemaVersion: 2,
|
|
252
|
+
type,
|
|
253
|
+
msgId: crypto.randomUUID(),
|
|
254
|
+
seq,
|
|
255
|
+
...(ack ? { ack } : {}),
|
|
256
|
+
...(requestId ? { requestId } : {}),
|
|
257
|
+
tenantId: this.tenantId,
|
|
258
|
+
agentId: this.agentId,
|
|
259
|
+
workspaceId: workspaceId ?? this.workspaceId,
|
|
260
|
+
ts: new Date(this.now()).toISOString(),
|
|
261
|
+
...(chunk ? { chunk } : {}),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Control frames (seq 0): register hello ack error. Validated like everything else; a bug here is an `internal` error. */
|
|
266
|
+
#sendControl(type, payload, extra) {
|
|
267
|
+
const frame = { ...this.#envelope(type, 0, extra), payload };
|
|
268
|
+
const v = this.contract.validateFrame(frame);
|
|
269
|
+
if (!v.ok) {
|
|
270
|
+
this.log.warn(`[la-v2] internal: control frame ${type} failed the contract (${v.code})`);
|
|
271
|
+
throw new ProtocolError("internal", `control frame ${type}`);
|
|
272
|
+
}
|
|
273
|
+
this.#write(JSON.stringify(frame));
|
|
274
|
+
return frame;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
#write(text) {
|
|
278
|
+
if (!this.transport) return false;
|
|
279
|
+
this.stats.framesOut++;
|
|
280
|
+
try { this.transport.send(text); return true; } catch { return false; }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Everything that can be refused before a frame is queued (so the caller gets the error, nothing is written). */
|
|
284
|
+
#prepare(type, payload, { requestId, workspaceId, chunk }) {
|
|
285
|
+
if (this.state === STATE.CLOSED) throw new ProtocolError("agent_offline");
|
|
286
|
+
if (!this.contract.typeInfo(type)) throw new ProtocolError("unknown_type");
|
|
287
|
+
if (!this.contract.canSend(type)) throw new ProtocolError("wrong_direction");
|
|
288
|
+
if (this.contract.typeInfo(type).control) throw new ProtocolError("wrong_direction", "control frames belong to the session");
|
|
289
|
+
if (this.state !== STATE.READY && !(this.everReady && (this.state === STATE.DETACHED || this.state === STATE.AWAIT_READY))) {
|
|
290
|
+
throw new ProtocolError("not_ready");
|
|
291
|
+
}
|
|
292
|
+
if (this.waiters.length >= this.opts.maxQueued) throw new ProtocolError("rate_limited", "send queue full");
|
|
293
|
+
// dry-run with a placeholder seq: the real one is assigned when the frame leaves the queue
|
|
294
|
+
const probe = { ...this.#envelope(type, 1, { requestId, workspaceId, chunk, ack: this.lastRecvSeq }), payload };
|
|
295
|
+
const text = JSON.stringify(probe);
|
|
296
|
+
if (Buffer.byteLength(text, "utf8") + 24 > this.limits.maxFrameBytes) throw new ProtocolError("frame_too_large");
|
|
297
|
+
const v = this.contract.validateFrame(probe);
|
|
298
|
+
if (!v.ok) throw new ProtocolError(v.code);
|
|
299
|
+
if (type === "content.response") {
|
|
300
|
+
const decoded = Buffer.from(payload.data, "base64").length;
|
|
301
|
+
const bad = this.tracker.accept(requestId, chunk, decoded);
|
|
302
|
+
if (bad) throw new ProtocolError(bad);
|
|
303
|
+
}
|
|
304
|
+
return { type, payload, requestId, workspaceId, chunk };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
#canTransmit() {
|
|
308
|
+
if (this.state !== STATE.READY || !this.transport) return false;
|
|
309
|
+
if (this.unacked.size >= this.opts.maxInFlight) return false;
|
|
310
|
+
const buffered = typeof this.transport.bufferedAmount === "function" ? this.transport.bufferedAmount() : 0;
|
|
311
|
+
if (buffered > this.opts.bufferHighWater) {
|
|
312
|
+
if (!this.timers.drain) this.timers.drain = this.#timer(() => { this.timers.drain = null; this.#pump(); }, 20);
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
#pump() {
|
|
319
|
+
while (this.waiters.length > 0 && this.#canTransmit()) {
|
|
320
|
+
const w = this.waiters.shift();
|
|
321
|
+
const seq = ++this.sendSeq;
|
|
322
|
+
const frame = { ...this.#envelope(w.type, seq, { requestId: w.requestId, workspaceId: w.workspaceId, chunk: w.chunk, ack: this.lastRecvSeq }), payload: w.payload };
|
|
323
|
+
const text = JSON.stringify(frame);
|
|
324
|
+
let settle;
|
|
325
|
+
const acked = new Promise((resolve, reject) => { settle = { resolve, reject }; });
|
|
326
|
+
acked.catch(noop); // the caller may ignore it; a rejection must never be "unhandled"
|
|
327
|
+
const rec = { seq, msgId: frame.msgId, type: w.type, requestId: w.requestId, text, retries: 0, timer: null, settle };
|
|
328
|
+
this.unacked.set(seq, rec);
|
|
329
|
+
this.stats.dataOut++;
|
|
330
|
+
this.#write(text);
|
|
331
|
+
this.#armRecTimer(rec);
|
|
332
|
+
w.resolve({ seq, msgId: frame.msgId, acked });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
#armRecTimer(rec) {
|
|
337
|
+
this.#cancelRecTimer(rec);
|
|
338
|
+
rec.timer = this.#timer(() => this.#onAckTimeout(rec), this.limits.ackTimeoutMs);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
#cancelRecTimer(rec) {
|
|
342
|
+
if (rec.timer) { clearTimeout(rec.timer); rec.timer = null; }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
#onAckTimeout(rec) {
|
|
346
|
+
rec.timer = null;
|
|
347
|
+
if (this.state !== STATE.READY || this.unacked.get(rec.seq) !== rec) return;
|
|
348
|
+
if (rec.retries >= this.limits.maxRetries) {
|
|
349
|
+
// The CP never confirmed: the caller gets `timeout`, the frame stays for a resume, the connection drops.
|
|
350
|
+
rec.settle.reject(new ProtocolError("timeout", "no ack"));
|
|
351
|
+
this.emit("timeout");
|
|
352
|
+
this.#closeTransport(closeCodeOf("timeout"), "ack_timeout");
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
rec.retries++;
|
|
356
|
+
this.stats.retransmits++;
|
|
357
|
+
this.#write(rec.text); // identical bytes: same seq, same msgId
|
|
358
|
+
this.#armRecTimer(rec);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
#ackUpTo(n) {
|
|
362
|
+
if (!Number.isSafeInteger(n) || n <= 0) return;
|
|
363
|
+
for (const [seq, rec] of this.unacked) {
|
|
364
|
+
if (seq > n) break;
|
|
365
|
+
this.#cancelRecTimer(rec);
|
|
366
|
+
this.unacked.delete(seq);
|
|
367
|
+
this.stats.ackedOut++;
|
|
368
|
+
rec.settle.resolve();
|
|
369
|
+
}
|
|
370
|
+
this.#pump();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
#rejectAcked(seq, code) {
|
|
374
|
+
const rec = this.unacked.get(seq);
|
|
375
|
+
if (!rec) return;
|
|
376
|
+
this.#cancelRecTimer(rec);
|
|
377
|
+
this.unacked.delete(seq);
|
|
378
|
+
rec.settle.reject(new ProtocolError(code, "rejected by the control plane"));
|
|
379
|
+
this.#pump();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------------------- inbound
|
|
383
|
+
|
|
384
|
+
async #process(text, transport) {
|
|
385
|
+
if (this.state === STATE.CLOSED || transport !== this.transport) return;
|
|
386
|
+
this.stats.framesIn++;
|
|
387
|
+
// 1. size (before any parsing)
|
|
388
|
+
if (typeof text !== "string" || Buffer.byteLength(text, "utf8") > this.limits.maxFrameBytes) return this.#refuse("frame_too_large");
|
|
389
|
+
// 2. parse, duplicate keys refused
|
|
390
|
+
let f;
|
|
391
|
+
try { f = parseStrict(text); } catch { return this.#refuse("bad_frame"); }
|
|
392
|
+
if (!isPlainObject(f)) return this.#refuse("bad_frame");
|
|
393
|
+
const ref = { refSeq: Number.isSafeInteger(f.seq) && f.seq > 0 ? f.seq : undefined, refMsgId: typeof f.msgId === "string" && f.msgId.length === 36 ? f.msgId : undefined };
|
|
394
|
+
// 3-4. allowlist + closed schema
|
|
395
|
+
const v = this.contract.validateFrame(f);
|
|
396
|
+
if (!v.ok) return this.#refuse(v.code, ref);
|
|
397
|
+
// 5-6. tenant and agent are the configured ones
|
|
398
|
+
if (f.tenantId !== this.tenantId) return this.#refuse("tenant_mismatch", ref);
|
|
399
|
+
if (f.agentId !== undefined && f.agentId !== this.agentId) return this.#refuse("agent_mismatch", ref);
|
|
400
|
+
// 7. direction
|
|
401
|
+
if (!this.contract.canReceive(f.type)) return this.#refuse("wrong_direction", ref);
|
|
402
|
+
// 8. handshake state
|
|
403
|
+
if (this.state === STATE.AWAIT_READY) {
|
|
404
|
+
if (f.type !== "ready" && f.type !== "error" && f.type !== "ack") return this.#refuse("not_ready", ref);
|
|
405
|
+
} else if (this.state === STATE.READY && f.type === "ready") {
|
|
406
|
+
return this.#refuse("handshake_violation", ref);
|
|
407
|
+
} else if (this.state !== STATE.READY) {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
// 9. signature of the signed types (ready included)
|
|
411
|
+
const info = this.contract.typeInfo(f.type);
|
|
412
|
+
let params = f.payload;
|
|
413
|
+
if (info.signed) {
|
|
414
|
+
const bad = this.#verifySigned(f);
|
|
415
|
+
if (bad) return this.#refuse(bad, ref);
|
|
416
|
+
if (this.signing.mode !== "enforce") this.stats.unverified++;
|
|
417
|
+
}
|
|
418
|
+
if (f.type === "ready") return this.#onReady(f);
|
|
419
|
+
if (f.type === "ack") return this.#onAck(f);
|
|
420
|
+
if (f.type === "error") return this.#onPeerError(f);
|
|
421
|
+
return this.#onData(f, params, ref);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* paramsRaw must equal payload (what is executed is the signed text), sig must be bound to this
|
|
426
|
+
* frame's tenant and workspace, and the Ed25519 signature must verify (same canonical string as v1:
|
|
427
|
+
* method WS, path = type). Mode off skips the cryptography, warn logs and goes on, enforce refuses.
|
|
428
|
+
*/
|
|
429
|
+
#verifySigned(f) {
|
|
430
|
+
let signed;
|
|
431
|
+
try { signed = parseStrict(f.paramsRaw); } catch { return "params_mismatch"; }
|
|
432
|
+
if (!deepEqual(signed, f.payload)) return "params_mismatch";
|
|
433
|
+
if (f.sig.tenantId !== f.tenantId || f.sig.workspaceId !== f.workspaceId) return "tenant_mismatch";
|
|
434
|
+
const out = verifyWsEnvelope({ command: f.type, paramsRaw: f.paramsRaw, sig: f.sig }, this.signing);
|
|
435
|
+
return out.ok ? null : "unauthorized";
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
#onReady(f) {
|
|
439
|
+
const p = f.payload;
|
|
440
|
+
if (p.resumed) {
|
|
441
|
+
// only a session we offered to resume, and only a position we can have reached
|
|
442
|
+
if (!this.everReady || p.sessionId !== this.sessionId || p.peerLastSeq > this.sendSeq) return this.#refuse("handshake_violation");
|
|
443
|
+
}
|
|
444
|
+
const lim = p.limits;
|
|
445
|
+
const L = this.contract.limits;
|
|
446
|
+
this.limits = {
|
|
447
|
+
maxFrameBytes: Math.min(lim.maxFrameBytes, L.maxFrameBytes),
|
|
448
|
+
chunkRawBytes: Math.min(lim.chunkRawBytes, L.chunkRawBytes),
|
|
449
|
+
maxChunks: Math.min(lim.maxChunks, L.maxChunks),
|
|
450
|
+
ackTimeoutMs: lim.ackTimeoutMs,
|
|
451
|
+
maxRetries: Math.min(lim.maxRetries, L.maxRetries),
|
|
452
|
+
heartbeatSeconds: lim.heartbeatSeconds,
|
|
453
|
+
};
|
|
454
|
+
this.tracker.chunkRawBytes = this.limits.chunkRawBytes;
|
|
455
|
+
this.tracker.maxChunks = this.limits.maxChunks;
|
|
456
|
+
this.#cancelTimer("handshake");
|
|
457
|
+
if (!p.resumed) this.#resetSession();
|
|
458
|
+
this.sessionId = p.sessionId;
|
|
459
|
+
this.state = STATE.READY;
|
|
460
|
+
this.everReady = true;
|
|
461
|
+
if (p.resumed) {
|
|
462
|
+
this.#ackUpTo(p.peerLastSeq);
|
|
463
|
+
for (const rec of this.unacked.values()) { // what the CP never saw goes again, same seq and msgId
|
|
464
|
+
rec.retries = 0;
|
|
465
|
+
this.stats.retransmits++;
|
|
466
|
+
this.#write(rec.text);
|
|
467
|
+
this.#armRecTimer(rec);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
this.#startHeartbeat();
|
|
471
|
+
this.log.info(`[la-v2] ready resumed=${p.resumed} session=${p.sessionId.slice(0, 8)} peerLastSeq=${p.peerLastSeq}`);
|
|
472
|
+
this.emit("ready", { resumed: p.resumed, sessionId: p.sessionId, limits: { ...this.limits } });
|
|
473
|
+
this.#pump();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
#onAck(f) {
|
|
477
|
+
if (this.state !== STATE.READY) return;
|
|
478
|
+
const { ackSeq, status, errorCode } = f.payload;
|
|
479
|
+
if (ackSeq > this.sendSeq) return; // acks a frame we never sent: ignored
|
|
480
|
+
if (status === "rejected") {
|
|
481
|
+
this.#ackUpTo(ackSeq - 1);
|
|
482
|
+
this.#rejectAcked(ackSeq, errorCode || "internal");
|
|
483
|
+
} else {
|
|
484
|
+
this.#ackUpTo(ackSeq);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
#onPeerError(f) {
|
|
489
|
+
const p = f.payload;
|
|
490
|
+
this.stats.rejectedIn[`peer_${p.code}`] = (this.stats.rejectedIn[`peer_${p.code}`] || 0) + 1;
|
|
491
|
+
this.log.warn(`[la-v2] control plane refused a frame: ${p.code}`);
|
|
492
|
+
if (this.state === STATE.AWAIT_READY) {
|
|
493
|
+
if (p.code === "protocol_unsupported") this.emit("unsupported");
|
|
494
|
+
else this.emit("rejected", p.code);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (p.refSeq !== undefined) this.#rejectAcked(p.refSeq, p.code);
|
|
498
|
+
this.emit("peer-error", p.code);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async #onData(f, params, ref) {
|
|
502
|
+
const { seq, msgId } = f;
|
|
503
|
+
if (seq <= this.lastRecvSeq) {
|
|
504
|
+
if (this.recvRing.get(seq) === msgId) { // a retransmission (our ack was lost): re-ack, never execute twice
|
|
505
|
+
this.stats.duplicatesIn++;
|
|
506
|
+
this.#sendAck(seq, "duplicate");
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
return this.#refuse("seq_replayed", ref);
|
|
510
|
+
}
|
|
511
|
+
if (seq !== this.lastRecvSeq + 1) return this.#refuse("seq_gap", ref);
|
|
512
|
+
this.lastRecvSeq = seq;
|
|
513
|
+
this.recvRing.set(seq, msgId);
|
|
514
|
+
while (this.recvRing.size > DEDUPE_ENTRIES) this.recvRing.delete(this.recvRing.keys().next().value);
|
|
515
|
+
if (f.ack !== undefined) this.#ackUpTo(f.ack);
|
|
516
|
+
|
|
517
|
+
const handler = this.handlers.get(f.type);
|
|
518
|
+
if (!handler) { this.#sendAck(seq, "rejected", "protocol_unsupported"); return; }
|
|
519
|
+
const ctx = Object.freeze({ type: f.type, seq, msgId, requestId: f.requestId, workspaceId: f.workspaceId, params });
|
|
520
|
+
let verdict;
|
|
521
|
+
let slow;
|
|
522
|
+
try {
|
|
523
|
+
const run = Promise.resolve().then(() => handler(ctx));
|
|
524
|
+
run.catch(noop);
|
|
525
|
+
// a handler that has not decided in handlerAckMs is acked `accepted` (it goes on in the background)
|
|
526
|
+
verdict = await Promise.race([run, new Promise((r) => { slow = this.#timer(() => r({ accept: true }), this.opts.handlerAckMs); })]);
|
|
527
|
+
} catch {
|
|
528
|
+
verdict = { reject: "internal" };
|
|
529
|
+
} finally {
|
|
530
|
+
clearTimeout(slow);
|
|
531
|
+
}
|
|
532
|
+
if (verdict && verdict.reject) this.#sendAck(seq, "rejected", verdict.reject);
|
|
533
|
+
else this.#sendAck(seq, "accepted");
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
#sendAck(ackSeq, status, errorCode) {
|
|
537
|
+
if (this.state !== STATE.READY || !this.transport) return;
|
|
538
|
+
const code = errorCode && this.contract.errorCodes.includes(errorCode) ? errorCode : undefined;
|
|
539
|
+
try {
|
|
540
|
+
this.#sendControl("ack", { ackSeq, status, ...(status === "rejected" ? { errorCode: code || "internal" } : {}) });
|
|
541
|
+
} catch { /* already logged */ }
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ---------------------------------------------------------------------------------------- errors, close
|
|
545
|
+
|
|
546
|
+
/** Refuses a frame: error frame with the wire code only (never an echo), close when the code is fatal. */
|
|
547
|
+
#refuse(code, ref = {}) {
|
|
548
|
+
this.stats.rejectedIn[code] = (this.stats.rejectedIn[code] || 0) + 1;
|
|
549
|
+
this.log.warn(`[la-v2] refused an inbound frame: ${code}`);
|
|
550
|
+
const fatal = isFatal(code);
|
|
551
|
+
const payload = {
|
|
552
|
+
code,
|
|
553
|
+
retryable: isRetryable(code),
|
|
554
|
+
...(ref.refSeq !== undefined ? { refSeq: ref.refSeq } : {}),
|
|
555
|
+
...(ref.refMsgId ? { refMsgId: ref.refMsgId } : {}),
|
|
556
|
+
...(fatal ? { closeCode: closeCodeOf(code) } : {}),
|
|
557
|
+
};
|
|
558
|
+
try { this.#sendControl("error", payload); } catch { /* logged */ }
|
|
559
|
+
this.emit("refused", code);
|
|
560
|
+
if (fatal) this.#closeTransport(closeCodeOf(code), code);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
#closeTransport(code, reason) {
|
|
564
|
+
const t = this.transport;
|
|
565
|
+
if (t) { try { t.close(code, reason); } catch { /* socket already gone */ } }
|
|
566
|
+
this.#onTransportGone();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
#onTransportGone() {
|
|
570
|
+
this.#cancelTimer("handshake");
|
|
571
|
+
this.#stopHeartbeat();
|
|
572
|
+
for (const rec of this.unacked.values()) this.#cancelRecTimer(rec);
|
|
573
|
+
this.transport = null;
|
|
574
|
+
if (!this.everReady) { this.state = STATE.IDLE; this.#failAll("agent_offline"); return; }
|
|
575
|
+
this.state = STATE.DETACHED;
|
|
576
|
+
this.#cancelTimer("expire");
|
|
577
|
+
this.timers.expire = this.#timer(() => { this.timers.expire = null; this.#teardown("agent_offline"); this.state = STATE.IDLE; }, this.opts.resumeWindowMs);
|
|
578
|
+
this.emit("detached");
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Nothing of the session survives: every pending frame fails and the next register will not resume. */
|
|
582
|
+
#teardown(code) {
|
|
583
|
+
this.#cancelTimer("expire");
|
|
584
|
+
this.#cancelTimer("handshake");
|
|
585
|
+
this.#cancelTimer("drain");
|
|
586
|
+
this.#stopHeartbeat();
|
|
587
|
+
this.#failAll(code);
|
|
588
|
+
this.sessionId = null;
|
|
589
|
+
this.everReady = false;
|
|
590
|
+
this.sendSeq = 0;
|
|
591
|
+
this.lastRecvSeq = 0;
|
|
592
|
+
this.recvRing.clear();
|
|
593
|
+
this.tracker.clear();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** The CP did not resume us: restart both numberings and fail what was in flight (README: "resumed:false"). */
|
|
597
|
+
#resetSession() {
|
|
598
|
+
this.#failAll("agent_offline", { keepWaiting: true });
|
|
599
|
+
this.sendSeq = 0;
|
|
600
|
+
this.lastRecvSeq = 0;
|
|
601
|
+
this.recvRing.clear();
|
|
602
|
+
this.tracker.clear();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
#failAll(code, { keepWaiting = false } = {}) {
|
|
606
|
+
for (const rec of this.unacked.values()) { this.#cancelRecTimer(rec); rec.settle.reject(new ProtocolError(code)); }
|
|
607
|
+
this.unacked.clear();
|
|
608
|
+
const keep = [];
|
|
609
|
+
for (const w of this.waiters) {
|
|
610
|
+
// a content stream of the old session cannot continue in a new one (the CP would see chunk n without chunk 0)
|
|
611
|
+
if (!keepWaiting || w.type === "content.response") w.reject(new ProtocolError(code));
|
|
612
|
+
else keep.push(w);
|
|
613
|
+
}
|
|
614
|
+
this.waiters = keep;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ---------------------------------------------------------------------------------------- heartbeat, timers
|
|
618
|
+
|
|
619
|
+
#startHeartbeat() {
|
|
620
|
+
this.#stopHeartbeat();
|
|
621
|
+
const ms = this.opts.heartbeatMs || Math.max(1000, this.limits.heartbeatSeconds * 1000);
|
|
622
|
+
this.timers.heartbeat = setInterval(() => {
|
|
623
|
+
if (this.state !== STATE.READY || this.unacked.size >= this.opts.maxInFlight) return;
|
|
624
|
+
const h = this.getHealth() || {};
|
|
625
|
+
this.enqueue("heartbeat", {
|
|
626
|
+
signatureMode: this.signing.mode,
|
|
627
|
+
clockEpochMs: this.now(),
|
|
628
|
+
diskFreeBytes: Number.isSafeInteger(h.diskFreeBytes) && h.diskFreeBytes >= 0 ? h.diskFreeBytes : 0,
|
|
629
|
+
queueDepth: Math.min(this.unacked.size + this.waiters.length, 100000),
|
|
630
|
+
inflight: Math.min(this.unacked.size, 10000),
|
|
631
|
+
...(h.graphify ? { graphify: h.graphify } : {}),
|
|
632
|
+
}).catch(noop);
|
|
633
|
+
}, ms);
|
|
634
|
+
this.timers.heartbeat.unref?.();
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
#stopHeartbeat() {
|
|
638
|
+
if (this.timers.heartbeat) { clearInterval(this.timers.heartbeat); this.timers.heartbeat = null; }
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Not unref'd: an unacked frame, a pending handshake and a resume window are work the process is waiting for
|
|
642
|
+
// (a closed or torn-down session clears every one of them, so none outlives close()).
|
|
643
|
+
#timer(fn, ms) {
|
|
644
|
+
return setTimeout(fn, ms);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
#cancelTimer(name) {
|
|
648
|
+
if (this.timers[name]) { clearTimeout(this.timers[name]); this.timers[name] = null; }
|
|
649
|
+
}
|
|
650
|
+
}
|