@haikeilabs/agentware 0.1.1 → 0.3.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/README.md +19 -0
- package/dist/executor/agent_loop.d.ts +9 -0
- package/dist/executor/agent_loop.d.ts.map +1 -1
- package/dist/executor/agent_loop.js +62 -13
- package/dist/executor/executor.d.ts.map +1 -1
- package/dist/executor/executor.js +6 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/kei/index.d.ts +3 -0
- package/dist/kei/index.d.ts.map +1 -0
- package/dist/kei/index.js +2 -0
- package/dist/kei/runtimeLink.d.ts +280 -0
- package/dist/kei/runtimeLink.d.ts.map +1 -0
- package/dist/kei/runtimeLink.js +590 -0
- package/dist/kei/runtimeLinkRuntime.d.ts +26 -0
- package/dist/kei/runtimeLinkRuntime.d.ts.map +1 -0
- package/dist/kei/runtimeLinkRuntime.js +400 -0
- package/dist/middleware/middleware.js +1 -1
- package/package.json +11 -4
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RuntimeLink contract: the harness → Agentware → kei-connector-runtime heartbeat.
|
|
3
|
+
*
|
|
4
|
+
* TypeScript mirror of the Go reference `go/kei/runtimelink` for
|
|
5
|
+
* `docs/specs/runtime-heartbeat-liveness.md` (HAI-141). This module defines
|
|
6
|
+
* configuration, status, identity, failure classes, the child's JSONL wire
|
|
7
|
+
* events, and redacted lifecycle events. Process supervision is implemented
|
|
8
|
+
* in `runtimeLinkRuntime.ts`. The only heartbeat path is harness → RuntimeLink
|
|
9
|
+
* → kei-connector-runtime; Agentware does not call the catalog itself.
|
|
10
|
+
*
|
|
11
|
+
* All three SDK languages share `testing/contracts/runtime-link`. Durations
|
|
12
|
+
* are milliseconds here (`intervalMs`), seconds in Python, and
|
|
13
|
+
* `time.Duration` in Go.
|
|
14
|
+
*/
|
|
15
|
+
/** The child stdout event version ("v") this SDK accepts. */
|
|
16
|
+
export const RUNTIME_LINK_CONTRACT_VERSION = 1;
|
|
17
|
+
/** Bound on one child stdout line, excluding the newline. */
|
|
18
|
+
export const MAX_CHILD_LINE_BYTES = 4096;
|
|
19
|
+
/** Capacity of the RuntimeLink event ring. */
|
|
20
|
+
export const RUNTIME_LINK_EVENT_BUFFER_SIZE = 64;
|
|
21
|
+
export const RUNTIME_LINK_SDK_LANG = "typescript";
|
|
22
|
+
export const HARNESS_KINDS = [
|
|
23
|
+
"assistant",
|
|
24
|
+
"pde",
|
|
25
|
+
"chat-discord",
|
|
26
|
+
"chat-slack",
|
|
27
|
+
"chat-teams",
|
|
28
|
+
"cli",
|
|
29
|
+
];
|
|
30
|
+
export const LINK_STATES = [
|
|
31
|
+
"disabled",
|
|
32
|
+
"starting",
|
|
33
|
+
"connected",
|
|
34
|
+
"degraded",
|
|
35
|
+
"reconnecting",
|
|
36
|
+
"terminal",
|
|
37
|
+
"stopped",
|
|
38
|
+
];
|
|
39
|
+
/**
|
|
40
|
+
* Closed failure taxonomy (spec §6.3). Separates harness↔runtime failures
|
|
41
|
+
* from runtime↔catalog failures so alerts route to the right owner.
|
|
42
|
+
*/
|
|
43
|
+
export const FAILURE_CLASSES = [
|
|
44
|
+
"config_invalid",
|
|
45
|
+
"runtime_unavailable",
|
|
46
|
+
"runtime_crashloop",
|
|
47
|
+
"runtime_unresponsive",
|
|
48
|
+
"contract_mismatch",
|
|
49
|
+
"catalog_unreachable",
|
|
50
|
+
"catalog_timeout",
|
|
51
|
+
"catalog_error",
|
|
52
|
+
"catalog_backpressure",
|
|
53
|
+
"installation_unauthorized",
|
|
54
|
+
"installation_stale",
|
|
55
|
+
"installation_offline",
|
|
56
|
+
"audit_backlog",
|
|
57
|
+
"legacy_runtime_unverified",
|
|
58
|
+
];
|
|
59
|
+
export const BEAT_OUTCOMES = [
|
|
60
|
+
"ok",
|
|
61
|
+
"catalog_unreachable",
|
|
62
|
+
"catalog_timeout",
|
|
63
|
+
"catalog_error",
|
|
64
|
+
"catalog_backpressure",
|
|
65
|
+
"catalog_rejected",
|
|
66
|
+
"unauthorized",
|
|
67
|
+
];
|
|
68
|
+
export const LIFECYCLE_EVENT_NAMES = [
|
|
69
|
+
"runtime.link.started",
|
|
70
|
+
"runtime.link.connected",
|
|
71
|
+
"runtime.link.degraded",
|
|
72
|
+
"runtime.link.reconnecting",
|
|
73
|
+
"runtime.link.terminal",
|
|
74
|
+
"runtime.link.stopped",
|
|
75
|
+
];
|
|
76
|
+
export const SDK_LANGS = ["go", "python", "typescript"];
|
|
77
|
+
/** The exact environment a runtime child may receive; never `process.env`. */
|
|
78
|
+
export const CHILD_ENV_ALLOWLIST = [
|
|
79
|
+
"KEI_RUNTIME_TOKEN",
|
|
80
|
+
"KEI_RUNTIME_CONTROL_PLANE_URL",
|
|
81
|
+
"KEI_HARNESS_KIND",
|
|
82
|
+
"KEI_HARNESS_VERSION",
|
|
83
|
+
"KEI_DEPLOYMENT_ENV",
|
|
84
|
+
"KEI_AGENTWARE_SDK_LANG",
|
|
85
|
+
"KEI_AGENTWARE_SDK_VERSION",
|
|
86
|
+
"KEI_HEARTBEAT_RUN_ID",
|
|
87
|
+
"PATH",
|
|
88
|
+
"HOME",
|
|
89
|
+
"TZ",
|
|
90
|
+
];
|
|
91
|
+
function isOneOf(set, value) {
|
|
92
|
+
return (typeof value === "string" && set.includes(value));
|
|
93
|
+
}
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Configuration (spec §3.1, §3.2)
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
/** Environment variables. KEI_PROXY_* keep their legacy names (runtime contract). */
|
|
98
|
+
export const RUNTIME_LINK_ENV = {
|
|
99
|
+
enabled: "KEI_RUNTIME_ENABLED",
|
|
100
|
+
token: "KEI_RUNTIME_TOKEN",
|
|
101
|
+
legacyToken: "KEI_HARNESS_TOKEN",
|
|
102
|
+
controlPlaneUrl: "KEI_RUNTIME_CONTROL_PLANE_URL",
|
|
103
|
+
proxyPath: "KEI_PROXY_PATH",
|
|
104
|
+
proxySha: "KEI_PROXY_SHA",
|
|
105
|
+
interval: "KEI_HEARTBEAT_INTERVAL",
|
|
106
|
+
timeout: "KEI_HEARTBEAT_TIMEOUT",
|
|
107
|
+
restartMin: "KEI_HEARTBEAT_RESTART_MIN",
|
|
108
|
+
restartMax: "KEI_HEARTBEAT_RESTART_MAX",
|
|
109
|
+
stableSeconds: "KEI_HEARTBEAT_STABLE_SECONDS",
|
|
110
|
+
logCount: "KEI_HEARTBEAT_LOG_COUNT",
|
|
111
|
+
harnessKind: "KEI_HARNESS_KIND",
|
|
112
|
+
harnessVersion: "KEI_HARNESS_VERSION",
|
|
113
|
+
deploymentEnv: "KEI_DEPLOYMENT_ENV",
|
|
114
|
+
};
|
|
115
|
+
/** The distribution keeps the kei-proxy name; the repo is kei-connector-runtime. */
|
|
116
|
+
export const DEFAULT_RUNTIME_BINARY_PATH = "kei-proxy";
|
|
117
|
+
/**
|
|
118
|
+
* Bounds. Interval is clamped; everything else is rejected when out of range
|
|
119
|
+
* so a typo fails closed instead of silently changing behavior.
|
|
120
|
+
*/
|
|
121
|
+
export const RUNTIME_LINK_BOUNDS = {
|
|
122
|
+
minIntervalMs: 15_000,
|
|
123
|
+
maxIntervalMs: 300_000,
|
|
124
|
+
maxGraceMs: 300_000,
|
|
125
|
+
minRestartMs: 1_000,
|
|
126
|
+
maxRestartMs: 3_600_000,
|
|
127
|
+
minStableResetMs: 1_000,
|
|
128
|
+
maxStableResetMs: 3_600_000,
|
|
129
|
+
minStopTimeoutMs: 1_000,
|
|
130
|
+
maxStopTimeoutMs: 60_000,
|
|
131
|
+
maxLogCount: 100,
|
|
132
|
+
};
|
|
133
|
+
export const RUNTIME_LINK_CONFIG_ERROR_CODES = [
|
|
134
|
+
"invalid_value",
|
|
135
|
+
"out_of_range",
|
|
136
|
+
"beat_timeout",
|
|
137
|
+
"harness_kind",
|
|
138
|
+
"envelope",
|
|
139
|
+
"legacy_token",
|
|
140
|
+
"token_missing",
|
|
141
|
+
"control_plane_url",
|
|
142
|
+
"binary",
|
|
143
|
+
];
|
|
144
|
+
/**
|
|
145
|
+
* Invalid RuntimeLink configuration. Carries the offending field and a stable
|
|
146
|
+
* code, never the offending value, so a misplaced secret cannot leak.
|
|
147
|
+
*/
|
|
148
|
+
export class RuntimeLinkConfigError extends Error {
|
|
149
|
+
code;
|
|
150
|
+
field;
|
|
151
|
+
constructor(code, field) {
|
|
152
|
+
super(`runtimeLink: invalid config: ${field}: ${code}`);
|
|
153
|
+
this.code = code;
|
|
154
|
+
this.field = field;
|
|
155
|
+
this.name = "RuntimeLinkConfigError";
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** The spec defaults with the link disabled. */
|
|
159
|
+
export function defaultRuntimeLinkConfig() {
|
|
160
|
+
return {
|
|
161
|
+
enabled: false,
|
|
162
|
+
binary: { path: DEFAULT_RUNTIME_BINARY_PATH, sha256: "" },
|
|
163
|
+
controlPlaneUrl: "",
|
|
164
|
+
token: { env: RUNTIME_LINK_ENV.token },
|
|
165
|
+
intervalMs: 60_000,
|
|
166
|
+
beatTimeoutMs: 10_000,
|
|
167
|
+
graceMs: 15_000,
|
|
168
|
+
restart: { minMs: 1_000, maxMs: 300_000, stableResetMs: 300_000 },
|
|
169
|
+
stopTimeoutMs: 10_000,
|
|
170
|
+
logCount: 3,
|
|
171
|
+
harness: { kind: "", version: "", deploymentEnv: "" },
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const VERSION_RE = /^[A-Za-z0-9._+-]{1,64}$/;
|
|
175
|
+
const ENV_NAME_RE = /^[A-Za-z0-9._-]{1,32}$/;
|
|
176
|
+
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
177
|
+
const ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
178
|
+
const REASON_RE = /^[a-z0-9_]{1,64}$/;
|
|
179
|
+
const TIMESTAMP_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
|
|
180
|
+
const ENV_INT_RE = /^[0-9]{1,9}$/;
|
|
181
|
+
/**
|
|
182
|
+
* Clamp `intervalMs` to [15s, 300s] and validate every other field, throwing
|
|
183
|
+
* RuntimeLinkConfigError on the first violation. A disabled link (local-only
|
|
184
|
+
* mode) is valid, but its declared envelope and timings are still checked so
|
|
185
|
+
* typos fail loudly.
|
|
186
|
+
*/
|
|
187
|
+
export function normalizeRuntimeLinkConfig(cfg) {
|
|
188
|
+
const b = RUNTIME_LINK_BOUNDS;
|
|
189
|
+
const h = cfg.harness;
|
|
190
|
+
if ((h.kind && !isOneOf(HARNESS_KINDS, h.kind)) || (cfg.enabled && !h.kind)) {
|
|
191
|
+
throw new RuntimeLinkConfigError("harness_kind", "harness.kind");
|
|
192
|
+
}
|
|
193
|
+
if (h.version && !VERSION_RE.test(h.version)) {
|
|
194
|
+
throw new RuntimeLinkConfigError("envelope", "harness.version");
|
|
195
|
+
}
|
|
196
|
+
if (h.deploymentEnv && !ENV_NAME_RE.test(h.deploymentEnv)) {
|
|
197
|
+
throw new RuntimeLinkConfigError("envelope", "harness.deploymentEnv");
|
|
198
|
+
}
|
|
199
|
+
if (cfg.enabled) {
|
|
200
|
+
if (cfg.token.env === RUNTIME_LINK_ENV.legacyToken) {
|
|
201
|
+
throw new RuntimeLinkConfigError("legacy_token", "token");
|
|
202
|
+
}
|
|
203
|
+
if (cfg.token.env !== RUNTIME_LINK_ENV.token) {
|
|
204
|
+
throw new RuntimeLinkConfigError("invalid_value", "token");
|
|
205
|
+
}
|
|
206
|
+
validateControlPlaneUrl(cfg.controlPlaneUrl);
|
|
207
|
+
if (!cfg.binary.path || cfg.binary.path.includes("\0")) {
|
|
208
|
+
throw new RuntimeLinkConfigError("binary", "binary.path");
|
|
209
|
+
}
|
|
210
|
+
if (!cfg.binary.sha256 && h.deploymentEnv === "prod") {
|
|
211
|
+
throw new RuntimeLinkConfigError("binary", "binary.sha256");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (cfg.binary.sha256 && !SHA256_RE.test(cfg.binary.sha256)) {
|
|
215
|
+
throw new RuntimeLinkConfigError("binary", "binary.sha256");
|
|
216
|
+
}
|
|
217
|
+
const intervalMs = Math.min(Math.max(cfg.intervalMs, b.minIntervalMs), b.maxIntervalMs);
|
|
218
|
+
if (!(cfg.beatTimeoutMs > 0) || 2 * cfg.beatTimeoutMs >= intervalMs) {
|
|
219
|
+
throw new RuntimeLinkConfigError("beat_timeout", "beatTimeoutMs");
|
|
220
|
+
}
|
|
221
|
+
if (!inRange(cfg.graceMs, 0, b.maxGraceMs)) {
|
|
222
|
+
throw new RuntimeLinkConfigError("out_of_range", "graceMs");
|
|
223
|
+
}
|
|
224
|
+
const r = cfg.restart;
|
|
225
|
+
if (!(r.minMs >= b.minRestartMs) ||
|
|
226
|
+
!(r.maxMs <= b.maxRestartMs) ||
|
|
227
|
+
r.minMs > r.maxMs) {
|
|
228
|
+
throw new RuntimeLinkConfigError("out_of_range", "restart");
|
|
229
|
+
}
|
|
230
|
+
if (!inRange(r.stableResetMs, b.minStableResetMs, b.maxStableResetMs)) {
|
|
231
|
+
throw new RuntimeLinkConfigError("out_of_range", "restart.stableResetMs");
|
|
232
|
+
}
|
|
233
|
+
if (!inRange(cfg.stopTimeoutMs, b.minStopTimeoutMs, b.maxStopTimeoutMs)) {
|
|
234
|
+
throw new RuntimeLinkConfigError("out_of_range", "stopTimeoutMs");
|
|
235
|
+
}
|
|
236
|
+
if (!Number.isInteger(cfg.logCount) ||
|
|
237
|
+
!inRange(cfg.logCount, 0, b.maxLogCount)) {
|
|
238
|
+
throw new RuntimeLinkConfigError("out_of_range", "logCount");
|
|
239
|
+
}
|
|
240
|
+
return { ...cfg, intervalMs };
|
|
241
|
+
}
|
|
242
|
+
function inRange(v, lo, hi) {
|
|
243
|
+
return v >= lo && v <= hi;
|
|
244
|
+
}
|
|
245
|
+
function validateControlPlaneUrl(raw) {
|
|
246
|
+
let ok = false;
|
|
247
|
+
try {
|
|
248
|
+
const u = new URL(raw);
|
|
249
|
+
ok =
|
|
250
|
+
(u.protocol === "http:" || u.protocol === "https:") &&
|
|
251
|
+
u.hostname !== "" &&
|
|
252
|
+
u.username === "" &&
|
|
253
|
+
u.password === "";
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
ok = false;
|
|
257
|
+
}
|
|
258
|
+
if (!ok)
|
|
259
|
+
throw new RuntimeLinkConfigError("control_plane_url", "controlPlaneUrl");
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Build a normalized config from the spec §3.2 variables. Non-empty fields of
|
|
263
|
+
* `harness` override the KEI_HARNESS_* variables. The runtime token is only
|
|
264
|
+
* checked for presence; its value is never retained.
|
|
265
|
+
*/
|
|
266
|
+
export function runtimeLinkConfigFromEnv(env = process.env, harness = {}) {
|
|
267
|
+
const E = RUNTIME_LINK_ENV;
|
|
268
|
+
const get = (key) => (env[key] ?? "").trim();
|
|
269
|
+
const seconds = (key) => {
|
|
270
|
+
const raw = get(key);
|
|
271
|
+
if (raw === "")
|
|
272
|
+
return undefined;
|
|
273
|
+
if (!ENV_INT_RE.test(raw))
|
|
274
|
+
throw new RuntimeLinkConfigError("invalid_value", key);
|
|
275
|
+
return Number(raw) * 1000;
|
|
276
|
+
};
|
|
277
|
+
const cfg = defaultRuntimeLinkConfig();
|
|
278
|
+
cfg.intervalMs = seconds(E.interval) ?? cfg.intervalMs;
|
|
279
|
+
cfg.beatTimeoutMs = seconds(E.timeout) ?? cfg.beatTimeoutMs;
|
|
280
|
+
cfg.restart.minMs = seconds(E.restartMin) ?? cfg.restart.minMs;
|
|
281
|
+
cfg.restart.maxMs = seconds(E.restartMax) ?? cfg.restart.maxMs;
|
|
282
|
+
cfg.restart.stableResetMs =
|
|
283
|
+
seconds(E.stableSeconds) ?? cfg.restart.stableResetMs;
|
|
284
|
+
const logCount = get(E.logCount);
|
|
285
|
+
if (logCount !== "") {
|
|
286
|
+
if (!ENV_INT_RE.test(logCount))
|
|
287
|
+
throw new RuntimeLinkConfigError("invalid_value", E.logCount);
|
|
288
|
+
cfg.logCount = Number(logCount);
|
|
289
|
+
}
|
|
290
|
+
const hasToken = get(E.token) !== "";
|
|
291
|
+
if (!hasToken && get(E.legacyToken) !== "") {
|
|
292
|
+
throw new RuntimeLinkConfigError("legacy_token", E.legacyToken);
|
|
293
|
+
}
|
|
294
|
+
cfg.enabled = hasToken;
|
|
295
|
+
const enabled = get(E.enabled).toLowerCase();
|
|
296
|
+
if (enabled !== "") {
|
|
297
|
+
if (enabled === "true" || enabled === "1") {
|
|
298
|
+
if (!hasToken)
|
|
299
|
+
throw new RuntimeLinkConfigError("token_missing", E.token);
|
|
300
|
+
cfg.enabled = true;
|
|
301
|
+
}
|
|
302
|
+
else if (enabled === "false" || enabled === "0") {
|
|
303
|
+
cfg.enabled = false;
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
throw new RuntimeLinkConfigError("invalid_value", E.enabled);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
cfg.controlPlaneUrl = get(E.controlPlaneUrl);
|
|
310
|
+
cfg.binary = {
|
|
311
|
+
path: get(E.proxyPath) || DEFAULT_RUNTIME_BINARY_PATH,
|
|
312
|
+
sha256: get(E.proxySha),
|
|
313
|
+
};
|
|
314
|
+
cfg.harness = {
|
|
315
|
+
kind: harness.kind || get(E.harnessKind),
|
|
316
|
+
version: harness.version || get(E.harnessVersion),
|
|
317
|
+
deploymentEnv: harness.deploymentEnv || get(E.deploymentEnv),
|
|
318
|
+
};
|
|
319
|
+
return normalizeRuntimeLinkConfig(cfg);
|
|
320
|
+
}
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// Backoff and cancellation
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
const MAX_JITTER_SAMPLE = 1 - Number.EPSILON / 2;
|
|
325
|
+
/**
|
|
326
|
+
* Full-jitter restart delay for attempt n (0-based):
|
|
327
|
+
* `floor(r × min(maxMs, minMs × 2^n))`. `r` is clamped to [0, 1); negative
|
|
328
|
+
* attempts count as 0.
|
|
329
|
+
*/
|
|
330
|
+
export function backoffDelayMs(backoff, attempt, r) {
|
|
331
|
+
const n = Math.min(Math.max(Math.trunc(attempt), 0), 30);
|
|
332
|
+
const capMs = Math.min(backoff.maxMs, backoff.minMs * 2 ** n);
|
|
333
|
+
const sample = Math.min(Math.max(r, 0), MAX_JITTER_SAMPLE);
|
|
334
|
+
return Math.floor(sample * capMs);
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Sleep for the jittered delay, or reject with the signal's abort reason as
|
|
338
|
+
* soon as `signal` aborts, so a stop during backoff never waits out the delay.
|
|
339
|
+
*/
|
|
340
|
+
export function waitBackoff(backoff, attempt, options = {}) {
|
|
341
|
+
const { signal, random = Math.random } = options;
|
|
342
|
+
return new Promise((resolve, reject) => {
|
|
343
|
+
const abortReason = () => signal?.reason ?? new Error("aborted");
|
|
344
|
+
if (signal?.aborted) {
|
|
345
|
+
reject(abortReason());
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const onAbort = () => {
|
|
349
|
+
clearTimeout(timer);
|
|
350
|
+
reject(abortReason());
|
|
351
|
+
};
|
|
352
|
+
const timer = setTimeout(() => {
|
|
353
|
+
signal?.removeEventListener("abort", onAbort);
|
|
354
|
+
resolve();
|
|
355
|
+
}, backoffDelayMs(backoff, attempt, random()));
|
|
356
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
/** A child line the SDK must drop and count. */
|
|
360
|
+
export class ChildLineError extends Error {
|
|
361
|
+
kind;
|
|
362
|
+
constructor(kind, detail) {
|
|
363
|
+
super(`runtimeLink: child line ${kind}: ${detail}`);
|
|
364
|
+
this.kind = kind;
|
|
365
|
+
this.name = "ChildLineError";
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const MAX_SEQ = Number.MAX_SAFE_INTEGER;
|
|
369
|
+
const MAX_LATENCY_MS = 3_600_000;
|
|
370
|
+
const MAX_NEXT_IN_MS = 3_600_000;
|
|
371
|
+
class FieldReader {
|
|
372
|
+
raw;
|
|
373
|
+
constructor(raw) {
|
|
374
|
+
this.raw = raw;
|
|
375
|
+
}
|
|
376
|
+
text(key, required) {
|
|
377
|
+
if (!Object.hasOwn(this.raw, key)) {
|
|
378
|
+
if (required)
|
|
379
|
+
throw new ChildLineError("malformed", key);
|
|
380
|
+
return "";
|
|
381
|
+
}
|
|
382
|
+
const v = this.raw[key];
|
|
383
|
+
if (typeof v !== "string")
|
|
384
|
+
throw new ChildLineError("malformed", key);
|
|
385
|
+
return v;
|
|
386
|
+
}
|
|
387
|
+
id(key, required) {
|
|
388
|
+
const v = this.text(key, required);
|
|
389
|
+
if ((v !== "" || required) && !ID_RE.test(v))
|
|
390
|
+
throw new ChildLineError("malformed", key);
|
|
391
|
+
return v;
|
|
392
|
+
}
|
|
393
|
+
timestamp(key) {
|
|
394
|
+
const v = this.text(key, true);
|
|
395
|
+
if (!TIMESTAMP_RE.test(v))
|
|
396
|
+
throw new ChildLineError("malformed", key);
|
|
397
|
+
return v;
|
|
398
|
+
}
|
|
399
|
+
integer(key, required, lo, hi) {
|
|
400
|
+
if (!Object.hasOwn(this.raw, key)) {
|
|
401
|
+
if (required)
|
|
402
|
+
throw new ChildLineError("malformed", key);
|
|
403
|
+
return 0;
|
|
404
|
+
}
|
|
405
|
+
const v = this.raw[key];
|
|
406
|
+
if (typeof v !== "number" || !Number.isSafeInteger(v) || v < lo || v > hi) {
|
|
407
|
+
throw new ChildLineError("malformed", key);
|
|
408
|
+
}
|
|
409
|
+
return v;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const encoder = new TextEncoder();
|
|
413
|
+
const decoder = new TextDecoder();
|
|
414
|
+
/**
|
|
415
|
+
* Parse one line of child stdout (a trailing "\n" / "\r\n" is ignored). Only
|
|
416
|
+
* allowlisted fields survive; payloads, results, reasoning, tokens, or any
|
|
417
|
+
* other key the runtime might emit are dropped. Throws ChildLineError for
|
|
418
|
+
* lines the SDK must drop and count.
|
|
419
|
+
*/
|
|
420
|
+
export function parseChildLine(line) {
|
|
421
|
+
let text = typeof line === "string" ? line : decoder.decode(line);
|
|
422
|
+
if (text.endsWith("\n"))
|
|
423
|
+
text = text.slice(0, -1);
|
|
424
|
+
if (text.endsWith("\r"))
|
|
425
|
+
text = text.slice(0, -1);
|
|
426
|
+
if (encoder.encode(text).length > MAX_CHILD_LINE_BYTES) {
|
|
427
|
+
throw new ChildLineError("line_too_long", "line");
|
|
428
|
+
}
|
|
429
|
+
let raw;
|
|
430
|
+
try {
|
|
431
|
+
raw = JSON.parse(text);
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
throw new ChildLineError("malformed", "not json");
|
|
435
|
+
}
|
|
436
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
437
|
+
throw new ChildLineError("malformed", "not an object");
|
|
438
|
+
}
|
|
439
|
+
const obj = raw;
|
|
440
|
+
if (obj.v !== RUNTIME_LINK_CONTRACT_VERSION) {
|
|
441
|
+
throw new ChildLineError("contract_mismatch", "v");
|
|
442
|
+
}
|
|
443
|
+
if (typeof obj.event !== "string")
|
|
444
|
+
throw new ChildLineError("malformed", "event");
|
|
445
|
+
const f = new FieldReader(obj);
|
|
446
|
+
switch (obj.event) {
|
|
447
|
+
case "identity":
|
|
448
|
+
return {
|
|
449
|
+
kind: "identity",
|
|
450
|
+
identity: {
|
|
451
|
+
runId: f.id("run_id", true),
|
|
452
|
+
installationId: f.id("installation_id", true),
|
|
453
|
+
orgId: f.id("org_id", true),
|
|
454
|
+
workspaceId: f.id("workspace_id", false),
|
|
455
|
+
platform: f.id("platform", false),
|
|
456
|
+
status: f.id("status", false),
|
|
457
|
+
bindingStatus: f.id("binding_status", false),
|
|
458
|
+
runtimeVersion: f.id("runtime_version", false),
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
case "beat": {
|
|
462
|
+
const runId = f.id("run_id", true);
|
|
463
|
+
const seq = f.integer("seq", false, 0, MAX_SEQ);
|
|
464
|
+
const at = f.timestamp("at");
|
|
465
|
+
const outcome = f.text("outcome", true);
|
|
466
|
+
const latencyMs = f.integer("latency_ms", false, 0, MAX_LATENCY_MS);
|
|
467
|
+
const nextInMs = f.integer("next_in_ms", false, 0, MAX_NEXT_IN_MS);
|
|
468
|
+
const httpStatus = f.integer("http_status", false, 0, 599);
|
|
469
|
+
if (httpStatus > 0 && httpStatus < 100)
|
|
470
|
+
throw new ChildLineError("malformed", "http_status");
|
|
471
|
+
if (!isOneOf(BEAT_OUTCOMES, outcome))
|
|
472
|
+
throw new ChildLineError("contract_mismatch", "outcome");
|
|
473
|
+
return {
|
|
474
|
+
kind: "beat",
|
|
475
|
+
beat: { runId, seq, at, outcome, httpStatus, latencyMs, nextInMs },
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
case "terminal": {
|
|
479
|
+
const runId = f.id("run_id", true);
|
|
480
|
+
const reason = f.text("reason", true);
|
|
481
|
+
if (!REASON_RE.test(reason))
|
|
482
|
+
throw new ChildLineError("malformed", "reason");
|
|
483
|
+
return { kind: "terminal", terminal: { runId, reason } };
|
|
484
|
+
}
|
|
485
|
+
default:
|
|
486
|
+
return { kind: "ignored" };
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
// Redacted lifecycle events (spec §5.3)
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
/** A lifecycle event has a value outside its closed set or bounds. */
|
|
493
|
+
export class InvalidLinkEventError extends Error {
|
|
494
|
+
constructor(detail) {
|
|
495
|
+
super(`runtimeLink: invalid lifecycle event: ${detail}`);
|
|
496
|
+
this.name = "InvalidLinkEventError";
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
function isCount(v) {
|
|
500
|
+
return typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
501
|
+
}
|
|
502
|
+
/** Throws InvalidLinkEventError unless every closed set and bound holds. */
|
|
503
|
+
export function validateLinkEvent(ev) {
|
|
504
|
+
const h = ev.harness;
|
|
505
|
+
const ok = isOneOf(LIFECYCLE_EVENT_NAMES, ev.name) &&
|
|
506
|
+
isOneOf(LINK_STATES, ev.state) &&
|
|
507
|
+
(ev.reason === "" || isOneOf(FAILURE_CLASSES, ev.reason)) &&
|
|
508
|
+
ev.at instanceof Date &&
|
|
509
|
+
!Number.isNaN(ev.at.getTime()) &&
|
|
510
|
+
(ev.runId === "" || ID_RE.test(ev.runId)) &&
|
|
511
|
+
ID_RE.test(ev.sdkInstanceId) &&
|
|
512
|
+
isOneOf(HARNESS_KINDS, h.kind) &&
|
|
513
|
+
isOneOf(SDK_LANGS, h.sdkLang) &&
|
|
514
|
+
(h.version === "" || VERSION_RE.test(h.version)) &&
|
|
515
|
+
(h.deploymentEnv === "" || ENV_NAME_RE.test(h.deploymentEnv)) &&
|
|
516
|
+
(h.sdkVersion === "" || VERSION_RE.test(h.sdkVersion)) &&
|
|
517
|
+
isCount(ev.seq) &&
|
|
518
|
+
isCount(ev.restarts) &&
|
|
519
|
+
isCount(ev.consecutiveFails);
|
|
520
|
+
if (!ok)
|
|
521
|
+
throw new InvalidLinkEventError("value outside contract");
|
|
522
|
+
return ev;
|
|
523
|
+
}
|
|
524
|
+
function str(v, fallback = "") {
|
|
525
|
+
if (v === undefined)
|
|
526
|
+
return fallback;
|
|
527
|
+
if (typeof v !== "string")
|
|
528
|
+
throw new InvalidLinkEventError("expected string");
|
|
529
|
+
return v;
|
|
530
|
+
}
|
|
531
|
+
function parseEventTimestamp(v) {
|
|
532
|
+
const m = typeof v === "string" ? TIMESTAMP_RE.exec(v) : null;
|
|
533
|
+
if (!m)
|
|
534
|
+
throw new InvalidLinkEventError("at");
|
|
535
|
+
const fraction = (m[2] ?? ".000").slice(0, 4);
|
|
536
|
+
return new Date(`${m[1]}${fraction}${m[3]}`);
|
|
537
|
+
}
|
|
538
|
+
/** Build an event from allowlisted wire keys only; everything else is dropped. */
|
|
539
|
+
export function linkEventFromWire(raw) {
|
|
540
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
541
|
+
throw new InvalidLinkEventError("not an object");
|
|
542
|
+
}
|
|
543
|
+
const o = raw;
|
|
544
|
+
const hRaw = o.harness ?? {};
|
|
545
|
+
if (hRaw === null || typeof hRaw !== "object" || Array.isArray(hRaw)) {
|
|
546
|
+
throw new InvalidLinkEventError("harness");
|
|
547
|
+
}
|
|
548
|
+
const h = hRaw;
|
|
549
|
+
return validateLinkEvent({
|
|
550
|
+
name: str(o.name),
|
|
551
|
+
at: parseEventTimestamp(o.at),
|
|
552
|
+
state: str(o.state),
|
|
553
|
+
reason: str(o.reason),
|
|
554
|
+
runId: str(o.run_id),
|
|
555
|
+
seq: (o.seq ?? 0),
|
|
556
|
+
sdkInstanceId: str(o.sdk_instance_id),
|
|
557
|
+
harness: {
|
|
558
|
+
kind: str(h.kind),
|
|
559
|
+
version: str(h.version),
|
|
560
|
+
deploymentEnv: str(h.deployment_env),
|
|
561
|
+
sdkLang: str(h.sdk_lang),
|
|
562
|
+
sdkVersion: str(h.sdk_version),
|
|
563
|
+
},
|
|
564
|
+
restarts: (o.restarts ?? 0),
|
|
565
|
+
consecutiveFails: (o.consecutive_fails ?? 0),
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
/** Validate and emit exactly the allowlisted keys; `at` is UTC with millisecond precision. */
|
|
569
|
+
export function linkEventToWire(ev) {
|
|
570
|
+
validateLinkEvent(ev);
|
|
571
|
+
const h = ev.harness;
|
|
572
|
+
return {
|
|
573
|
+
name: ev.name,
|
|
574
|
+
at: ev.at.toISOString(),
|
|
575
|
+
state: ev.state,
|
|
576
|
+
reason: ev.reason,
|
|
577
|
+
run_id: ev.runId,
|
|
578
|
+
seq: ev.seq,
|
|
579
|
+
sdk_instance_id: ev.sdkInstanceId,
|
|
580
|
+
harness: {
|
|
581
|
+
kind: h.kind,
|
|
582
|
+
version: h.version,
|
|
583
|
+
deployment_env: h.deploymentEnv,
|
|
584
|
+
sdk_lang: h.sdkLang,
|
|
585
|
+
sdk_version: h.sdkVersion,
|
|
586
|
+
},
|
|
587
|
+
restarts: ev.restarts,
|
|
588
|
+
consecutive_fails: ev.consecutiveFails,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type RuntimeLink, type RuntimeLinkConfig } from "./runtimeLink.js";
|
|
2
|
+
export interface RuntimeChild {
|
|
3
|
+
readonly stdout: AsyncIterable<Uint8Array | string>;
|
|
4
|
+
readonly exitCode: Promise<number>;
|
|
5
|
+
kill(signal?: "SIGTERM" | "SIGKILL"): void;
|
|
6
|
+
}
|
|
7
|
+
/** Injectable process boundary for tests and embedders. */
|
|
8
|
+
export interface RuntimeChildFactory {
|
|
9
|
+
start(command: string, args: readonly string[], env: Readonly<Record<string, string>>): RuntimeChild;
|
|
10
|
+
}
|
|
11
|
+
export interface RuntimeLinkOptions {
|
|
12
|
+
env?: Readonly<Record<string, string | undefined>>;
|
|
13
|
+
childFactory?: RuntimeChildFactory;
|
|
14
|
+
now?: () => Date;
|
|
15
|
+
random?: () => number;
|
|
16
|
+
sdkVersion?: string;
|
|
17
|
+
sdkInstanceId?: string;
|
|
18
|
+
audit?: (event: Readonly<Record<string, unknown>>) => void | Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
/** Node process implementation. stderr is drained and discarded; never surfaced to audit or logs. */
|
|
21
|
+
export declare class NodeRuntimeChildFactory implements RuntimeChildFactory {
|
|
22
|
+
start(command: string, args: readonly string[], env: Readonly<Record<string, string>>): RuntimeChild;
|
|
23
|
+
}
|
|
24
|
+
/** Create a supervisor that owns the harness → kei-connector-runtime process and heartbeat boundary. */
|
|
25
|
+
export declare function newRuntimeLink(config: RuntimeLinkConfig, options?: RuntimeLinkOptions): RuntimeLink;
|
|
26
|
+
//# sourceMappingURL=runtimeLinkRuntime.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtimeLinkRuntime.d.ts","sourceRoot":"","sources":["../../src/kei/runtimeLinkRuntime.ts"],"names":[],"mappings":"AAIA,OAAO,EAYL,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACvB,MAAM,kBAAkB,CAAC;AAE1B,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC,UAAU,GAAG,MAAM,CAAC,CAAC;IACpD,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC;IACnC,IAAI,CAAC,MAAM,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,IAAI,CAAC;CAC5C;AAED,2DAA2D;AAC3D,MAAM,WAAW,mBAAmB;IAClC,KAAK,CACH,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GACpC,YAAY,CAAC;CACjB;AAED,MAAM,WAAW,kBAAkB;IACjC,GAAG,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;IACnD,YAAY,CAAC,EAAE,mBAAmB,CAAC;IACnC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5E;AAED,qGAAqG;AACrG,qBAAa,uBAAwB,YAAW,mBAAmB;IACjE,KAAK,CACH,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GACpC,YAAY;CA8BhB;AAWD,wGAAwG;AACxG,wBAAgB,cAAc,CAC5B,MAAM,EAAE,iBAAiB,EACzB,OAAO,GAAE,kBAAuB,GAC/B,WAAW,CAEb"}
|