dsh-interconnect 0.11.8 → 0.11.9
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 +3 -1
- package/lib/interconnect/index.js +1 -0
- package/lib/interconnect/index.js.map +2 -2
- package/lib/tool-interconnect/index.js +1740 -5
- package/lib/tool-interconnect/index.js.map +4 -4
- package/lib/types/interconnect/index.d.ts +7 -0
- package/lib/types/interconnect/index.d.ts.map +1 -1
- package/lib/types/interconnect/types.d.ts +2 -1
- package/lib/types/interconnect/types.d.ts.map +1 -1
- package/lib/types/tool-interconnect/index.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -1,5 +1,1737 @@
|
|
|
1
1
|
// src/tool-interconnect/index.ts
|
|
2
2
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
3
|
+
|
|
4
|
+
// src/interconnect/index.ts
|
|
5
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
6
|
+
import { createHash, randomUUID, timingSafeEqual as constantTimeEqual } from "node:crypto";
|
|
7
|
+
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
8
|
+
import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
9
|
+
|
|
10
|
+
// ../.dsh/source/staging-20260807T143236Z/vendor/cosmokit/src/misc.ts
|
|
11
|
+
function isNullable(value) {
|
|
12
|
+
return value === null || value === void 0;
|
|
13
|
+
}
|
|
14
|
+
function isPlainObject(data) {
|
|
15
|
+
return data && typeof data === "object" && !Array.isArray(data);
|
|
16
|
+
}
|
|
17
|
+
function filterKeys(object, filter) {
|
|
18
|
+
return Object.fromEntries(Object.entries(object).filter(([key, value]) => filter(key, value)));
|
|
19
|
+
}
|
|
20
|
+
function mapValues(object, transform) {
|
|
21
|
+
return Object.fromEntries(Object.entries(object).map(([key, value]) => [key, transform(value, key)]));
|
|
22
|
+
}
|
|
23
|
+
function pick(source, keys, forced) {
|
|
24
|
+
if (!keys) return { ...source };
|
|
25
|
+
const result = {};
|
|
26
|
+
for (const key of keys) {
|
|
27
|
+
if (forced || source[key] !== void 0) result[key] = source[key];
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ../.dsh/source/staging-20260807T143236Z/vendor/cosmokit/src/types.ts
|
|
33
|
+
function is(type, value) {
|
|
34
|
+
if (arguments.length === 1) return (value2) => is(type, value2);
|
|
35
|
+
return type in globalThis && value instanceof globalThis[type] || Object.prototype.toString.call(value).slice(8, -1) === type;
|
|
36
|
+
}
|
|
37
|
+
function isArrayBufferLike(value) {
|
|
38
|
+
return is("ArrayBuffer", value) || is("SharedArrayBuffer", value);
|
|
39
|
+
}
|
|
40
|
+
function isArrayBufferSource(value) {
|
|
41
|
+
return isArrayBufferLike(value) || ArrayBuffer.isView(value);
|
|
42
|
+
}
|
|
43
|
+
var Binary;
|
|
44
|
+
((Binary2) => {
|
|
45
|
+
Binary2.is = isArrayBufferLike;
|
|
46
|
+
Binary2.isSource = isArrayBufferSource;
|
|
47
|
+
function fromSource(source) {
|
|
48
|
+
if (ArrayBuffer.isView(source)) {
|
|
49
|
+
return source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength);
|
|
50
|
+
} else {
|
|
51
|
+
return source;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
Binary2.fromSource = fromSource;
|
|
55
|
+
function toBase64(source) {
|
|
56
|
+
source = fromSource(source);
|
|
57
|
+
if (typeof Buffer !== "undefined") {
|
|
58
|
+
return Buffer.from(source).toString("base64");
|
|
59
|
+
}
|
|
60
|
+
let binary = "";
|
|
61
|
+
const bytes = new Uint8Array(source);
|
|
62
|
+
for (let i = 0; i < bytes.byteLength; i++) {
|
|
63
|
+
binary += String.fromCharCode(bytes[i]);
|
|
64
|
+
}
|
|
65
|
+
return btoa(binary);
|
|
66
|
+
}
|
|
67
|
+
Binary2.toBase64 = toBase64;
|
|
68
|
+
function fromBase64(source) {
|
|
69
|
+
if (typeof Buffer !== "undefined") return fromSource(Buffer.from(source, "base64"));
|
|
70
|
+
return Uint8Array.from(atob(source), (c) => c.charCodeAt(0));
|
|
71
|
+
}
|
|
72
|
+
Binary2.fromBase64 = fromBase64;
|
|
73
|
+
function toHex(source) {
|
|
74
|
+
source = fromSource(source);
|
|
75
|
+
if (typeof Buffer !== "undefined") return Buffer.from(source).toString("hex");
|
|
76
|
+
return Array.from(new Uint8Array(source), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
77
|
+
}
|
|
78
|
+
Binary2.toHex = toHex;
|
|
79
|
+
function fromHex(source) {
|
|
80
|
+
if (typeof Buffer !== "undefined") return fromSource(Buffer.from(source, "hex"));
|
|
81
|
+
const hex = source.length % 2 === 0 ? source : source.slice(0, source.length - 1);
|
|
82
|
+
const buffer = [];
|
|
83
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
84
|
+
buffer.push(parseInt(`${hex[i]}${hex[i + 1]}`, 16));
|
|
85
|
+
}
|
|
86
|
+
return Uint8Array.from(buffer).buffer;
|
|
87
|
+
}
|
|
88
|
+
Binary2.fromHex = fromHex;
|
|
89
|
+
})(Binary || (Binary = {}));
|
|
90
|
+
var base64ToArrayBuffer = Binary.fromBase64;
|
|
91
|
+
var arrayBufferToBase64 = Binary.toBase64;
|
|
92
|
+
var hexToArrayBuffer = Binary.fromHex;
|
|
93
|
+
var arrayBufferToHex = Binary.toHex;
|
|
94
|
+
function clone(source, refs = /* @__PURE__ */ new Map()) {
|
|
95
|
+
if (!source || typeof source !== "object") return source;
|
|
96
|
+
if (is("Date", source)) return new Date(source.valueOf());
|
|
97
|
+
if (is("RegExp", source)) return new RegExp(source.source, source.flags);
|
|
98
|
+
if (isArrayBufferLike(source)) return source.slice(0);
|
|
99
|
+
if (ArrayBuffer.isView(source)) return source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength);
|
|
100
|
+
const cached = refs.get(source);
|
|
101
|
+
if (cached) return cached;
|
|
102
|
+
if (Array.isArray(source)) {
|
|
103
|
+
const result2 = [];
|
|
104
|
+
refs.set(source, result2);
|
|
105
|
+
source.forEach((value, index) => {
|
|
106
|
+
result2[index] = Reflect.apply(clone, null, [value, refs]);
|
|
107
|
+
});
|
|
108
|
+
return result2;
|
|
109
|
+
}
|
|
110
|
+
const result = Object.create(Object.getPrototypeOf(source));
|
|
111
|
+
refs.set(source, result);
|
|
112
|
+
for (const key of Reflect.ownKeys(source)) {
|
|
113
|
+
const descriptor = { ...Reflect.getOwnPropertyDescriptor(source, key) };
|
|
114
|
+
if ("value" in descriptor) {
|
|
115
|
+
descriptor.value = Reflect.apply(clone, null, [descriptor.value, refs]);
|
|
116
|
+
}
|
|
117
|
+
Reflect.defineProperty(result, key, descriptor);
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
function deepEqual(a, b, strict) {
|
|
122
|
+
if (a === b) return true;
|
|
123
|
+
if (!strict && isNullable(a) && isNullable(b)) return true;
|
|
124
|
+
if (typeof a !== typeof b) return false;
|
|
125
|
+
if (typeof a !== "object") return false;
|
|
126
|
+
if (!a || !b) return false;
|
|
127
|
+
function check(test, then) {
|
|
128
|
+
return test(a) ? test(b) ? then(a, b) : false : test(b) ? false : void 0;
|
|
129
|
+
}
|
|
130
|
+
return check(Array.isArray, (a2, b2) => a2.length === b2.length && a2.every((item, index) => deepEqual(item, b2[index]))) ?? check(is("Date"), (a2, b2) => a2.valueOf() === b2.valueOf()) ?? check(is("RegExp"), (a2, b2) => a2.source === b2.source && a2.flags === b2.flags) ?? check(isArrayBufferLike, (a2, b2) => {
|
|
131
|
+
if (a2.byteLength !== b2.byteLength) return false;
|
|
132
|
+
const viewA = new Uint8Array(a2);
|
|
133
|
+
const viewB = new Uint8Array(b2);
|
|
134
|
+
for (let i = 0; i < viewA.length; i++) {
|
|
135
|
+
if (viewA[i] !== viewB[i]) return false;
|
|
136
|
+
}
|
|
137
|
+
return true;
|
|
138
|
+
}) ?? Object.keys({ ...a, ...b }).every((key) => deepEqual(a[key], b[key], strict));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ../.dsh/source/staging-20260807T143236Z/vendor/cosmokit/src/time.ts
|
|
142
|
+
var Time;
|
|
143
|
+
((Time2) => {
|
|
144
|
+
Time2.millisecond = 1;
|
|
145
|
+
Time2.second = 1e3;
|
|
146
|
+
Time2.minute = Time2.second * 60;
|
|
147
|
+
Time2.hour = Time2.minute * 60;
|
|
148
|
+
Time2.day = Time2.hour * 24;
|
|
149
|
+
Time2.week = Time2.day * 7;
|
|
150
|
+
let timezoneOffset = (/* @__PURE__ */ new Date()).getTimezoneOffset();
|
|
151
|
+
function setTimezoneOffset(offset) {
|
|
152
|
+
timezoneOffset = offset;
|
|
153
|
+
}
|
|
154
|
+
Time2.setTimezoneOffset = setTimezoneOffset;
|
|
155
|
+
function getTimezoneOffset() {
|
|
156
|
+
return timezoneOffset;
|
|
157
|
+
}
|
|
158
|
+
Time2.getTimezoneOffset = getTimezoneOffset;
|
|
159
|
+
function getDateNumber(date2 = /* @__PURE__ */ new Date(), offset) {
|
|
160
|
+
if (typeof date2 === "number") date2 = new Date(date2);
|
|
161
|
+
if (offset === void 0) offset = timezoneOffset;
|
|
162
|
+
return Math.floor((date2.valueOf() / Time2.minute - offset) / 1440);
|
|
163
|
+
}
|
|
164
|
+
Time2.getDateNumber = getDateNumber;
|
|
165
|
+
function fromDateNumber(value, offset) {
|
|
166
|
+
const date2 = new Date(value * Time2.day);
|
|
167
|
+
if (offset === void 0) offset = timezoneOffset;
|
|
168
|
+
return new Date(+date2 + offset * Time2.minute);
|
|
169
|
+
}
|
|
170
|
+
Time2.fromDateNumber = fromDateNumber;
|
|
171
|
+
const numeric = /\d+(?:\.\d+)?/.source;
|
|
172
|
+
const timeRegExp = new RegExp(`^${[
|
|
173
|
+
"w(?:eek(?:s)?)?",
|
|
174
|
+
"d(?:ay(?:s)?)?",
|
|
175
|
+
"h(?:our(?:s)?)?",
|
|
176
|
+
"m(?:in(?:ute)?(?:s)?)?",
|
|
177
|
+
"s(?:ec(?:ond)?(?:s)?)?"
|
|
178
|
+
].map((unit) => `(${numeric}${unit})?`).join("")}$`);
|
|
179
|
+
function parseTime(source) {
|
|
180
|
+
const capture = timeRegExp.exec(source);
|
|
181
|
+
if (!capture) return 0;
|
|
182
|
+
return (parseFloat(capture[1]) * Time2.week || 0) + (parseFloat(capture[2]) * Time2.day || 0) + (parseFloat(capture[3]) * Time2.hour || 0) + (parseFloat(capture[4]) * Time2.minute || 0) + (parseFloat(capture[5]) * Time2.second || 0);
|
|
183
|
+
}
|
|
184
|
+
Time2.parseTime = parseTime;
|
|
185
|
+
function parseDate(date2) {
|
|
186
|
+
const parsed = parseTime(date2);
|
|
187
|
+
if (parsed) {
|
|
188
|
+
date2 = Date.now() + parsed;
|
|
189
|
+
} else if (/^\d{1,2}(:\d{1,2}){1,2}$/.test(date2)) {
|
|
190
|
+
date2 = `${(/* @__PURE__ */ new Date()).toLocaleDateString()}-${date2}`;
|
|
191
|
+
} else if (/^\d{1,2}-\d{1,2}-\d{1,2}(:\d{1,2}){1,2}$/.test(date2)) {
|
|
192
|
+
date2 = `${(/* @__PURE__ */ new Date()).getFullYear()}-${date2}`;
|
|
193
|
+
}
|
|
194
|
+
return date2 ? new Date(date2) : /* @__PURE__ */ new Date();
|
|
195
|
+
}
|
|
196
|
+
Time2.parseDate = parseDate;
|
|
197
|
+
function format(ms) {
|
|
198
|
+
const abs = Math.abs(ms);
|
|
199
|
+
if (abs >= Time2.day - Time2.hour / 2) {
|
|
200
|
+
return Math.round(ms / Time2.day) + "d";
|
|
201
|
+
} else if (abs >= Time2.hour - Time2.minute / 2) {
|
|
202
|
+
return Math.round(ms / Time2.hour) + "h";
|
|
203
|
+
} else if (abs >= Time2.minute - Time2.second / 2) {
|
|
204
|
+
return Math.round(ms / Time2.minute) + "m";
|
|
205
|
+
} else if (abs >= Time2.second) {
|
|
206
|
+
return Math.round(ms / Time2.second) + "s";
|
|
207
|
+
}
|
|
208
|
+
return ms + "ms";
|
|
209
|
+
}
|
|
210
|
+
Time2.format = format;
|
|
211
|
+
function toDigits(source, length = 2) {
|
|
212
|
+
return source.toString().padStart(length, "0");
|
|
213
|
+
}
|
|
214
|
+
Time2.toDigits = toDigits;
|
|
215
|
+
function template(template2, time = /* @__PURE__ */ new Date()) {
|
|
216
|
+
return template2.replace("yyyy", time.getFullYear().toString()).replace("yy", time.getFullYear().toString().slice(2)).replace("MM", toDigits(time.getMonth() + 1)).replace("dd", toDigits(time.getDate())).replace("hh", toDigits(time.getHours())).replace("mm", toDigits(time.getMinutes())).replace("ss", toDigits(time.getSeconds())).replace("SSS", toDigits(time.getMilliseconds(), 3));
|
|
217
|
+
}
|
|
218
|
+
Time2.template = template;
|
|
219
|
+
})(Time || (Time = {}));
|
|
220
|
+
|
|
221
|
+
// ../.dsh/source/staging-20260807T143236Z/vendor/schemastery/lib/index.mjs
|
|
222
|
+
var kSchema = Symbol.for("schemastery");
|
|
223
|
+
var kValidationError = Symbol.for("ValidationError");
|
|
224
|
+
globalThis.__schemastery_index__ ??= 0;
|
|
225
|
+
globalThis.__schemastery_refs__ = void 0;
|
|
226
|
+
var ValidationError = class extends TypeError {
|
|
227
|
+
options;
|
|
228
|
+
name = "ValidationError";
|
|
229
|
+
constructor(message, options) {
|
|
230
|
+
let prefix = "$";
|
|
231
|
+
for (const segment of options.path || []) if (typeof segment === "string") prefix += "." + segment;
|
|
232
|
+
else if (typeof segment === "number") prefix += "[" + segment + "]";
|
|
233
|
+
else if (typeof segment === "symbol") prefix += `[Symbol(${segment.toString()})]`;
|
|
234
|
+
if (prefix.startsWith(".")) prefix = prefix.slice(1);
|
|
235
|
+
super((prefix === "$" ? "" : `${prefix} `) + message);
|
|
236
|
+
this.options = options;
|
|
237
|
+
}
|
|
238
|
+
static is(error) {
|
|
239
|
+
return !!error?.[kValidationError];
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
Object.defineProperty(ValidationError.prototype, kValidationError, { value: true });
|
|
243
|
+
var Schema = function(options) {
|
|
244
|
+
const schema = function(data, options2 = {}) {
|
|
245
|
+
return Schema.resolve(data, schema, options2)[0];
|
|
246
|
+
};
|
|
247
|
+
if (options.refs) {
|
|
248
|
+
const refs = mapValues(options.refs, (options2) => new Schema(options2));
|
|
249
|
+
const getRef = (uid) => refs[uid];
|
|
250
|
+
for (const key in refs) {
|
|
251
|
+
const options2 = refs[key];
|
|
252
|
+
options2.sKey = getRef(options2.sKey);
|
|
253
|
+
options2.inner = getRef(options2.inner);
|
|
254
|
+
options2.list = options2.list && options2.list.map(getRef);
|
|
255
|
+
options2.dict = options2.dict && mapValues(options2.dict, getRef);
|
|
256
|
+
}
|
|
257
|
+
return refs[options.uid];
|
|
258
|
+
}
|
|
259
|
+
Object.assign(schema, options);
|
|
260
|
+
if (typeof schema.callback === "string") try {
|
|
261
|
+
schema.callback = new Function("return " + schema.callback)();
|
|
262
|
+
} catch {
|
|
263
|
+
}
|
|
264
|
+
Object.defineProperty(schema, "uid", { value: globalThis.__schemastery_index__++ });
|
|
265
|
+
Object.setPrototypeOf(schema, Schema.prototype);
|
|
266
|
+
schema.meta ||= {};
|
|
267
|
+
schema.toString = schema.toString.bind(schema);
|
|
268
|
+
return schema;
|
|
269
|
+
};
|
|
270
|
+
Schema.prototype = Object.create(Function.prototype);
|
|
271
|
+
Schema.prototype[kSchema] = true;
|
|
272
|
+
Object.defineProperty(Schema.prototype, "~standard", { get() {
|
|
273
|
+
return {
|
|
274
|
+
version: 1,
|
|
275
|
+
vendor: "schemastery",
|
|
276
|
+
validate: (value) => {
|
|
277
|
+
try {
|
|
278
|
+
return { value: Schema.resolve(value, this, {})[0] };
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (ValidationError.is(error)) return { issues: [{
|
|
281
|
+
message: error.message,
|
|
282
|
+
path: error.options.path
|
|
283
|
+
}] };
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
} });
|
|
289
|
+
Schema.ValidationError = ValidationError;
|
|
290
|
+
Schema.prototype.toJSON = function toJSON() {
|
|
291
|
+
if (globalThis.__schemastery_refs__) {
|
|
292
|
+
globalThis.__schemastery_refs__[this.uid] ??= JSON.parse(JSON.stringify({ ...this }));
|
|
293
|
+
return this.uid;
|
|
294
|
+
}
|
|
295
|
+
globalThis.__schemastery_refs__ = { [this.uid]: { ...this } };
|
|
296
|
+
globalThis.__schemastery_refs__[this.uid] = JSON.parse(JSON.stringify({ ...this }));
|
|
297
|
+
const result = {
|
|
298
|
+
uid: this.uid,
|
|
299
|
+
refs: globalThis.__schemastery_refs__
|
|
300
|
+
};
|
|
301
|
+
globalThis.__schemastery_refs__ = void 0;
|
|
302
|
+
return result;
|
|
303
|
+
};
|
|
304
|
+
Schema.prototype.set = function set(key, value) {
|
|
305
|
+
this.dict[key] = value;
|
|
306
|
+
return this;
|
|
307
|
+
};
|
|
308
|
+
Schema.prototype.push = function push(value) {
|
|
309
|
+
this.list.push(value);
|
|
310
|
+
return this;
|
|
311
|
+
};
|
|
312
|
+
function mergeDesc(original, messages) {
|
|
313
|
+
const result = typeof original === "string" ? { "": original } : { ...original };
|
|
314
|
+
for (const locale in messages) {
|
|
315
|
+
const value = messages[locale];
|
|
316
|
+
if (value?.$description || value?.$desc) result[locale] = value.$description || value.$desc;
|
|
317
|
+
else if (typeof value === "string") result[locale] = value;
|
|
318
|
+
}
|
|
319
|
+
return result;
|
|
320
|
+
}
|
|
321
|
+
function getInner(value) {
|
|
322
|
+
return value?.$value ?? value?.$inner;
|
|
323
|
+
}
|
|
324
|
+
function extractKeys(data) {
|
|
325
|
+
return filterKeys(data ?? {}, (key) => !key.startsWith("$"));
|
|
326
|
+
}
|
|
327
|
+
Schema.prototype.i18n = function i18n(messages) {
|
|
328
|
+
const schema = Schema(this);
|
|
329
|
+
const desc = mergeDesc(schema.meta.description, messages);
|
|
330
|
+
if (Object.keys(desc).length) schema.meta.description = desc;
|
|
331
|
+
if (schema.dict) schema.dict = mapValues(schema.dict, (inner, key) => {
|
|
332
|
+
return inner.i18n(mapValues(messages, (data) => getInner(data)?.[key] ?? data?.[key]));
|
|
333
|
+
});
|
|
334
|
+
if (schema.list) schema.list = schema.list.map((inner, index) => {
|
|
335
|
+
return inner.i18n(mapValues(messages, (data = {}) => {
|
|
336
|
+
if (Array.isArray(getInner(data))) return getInner(data)[index];
|
|
337
|
+
if (Array.isArray(data)) return data[index];
|
|
338
|
+
return extractKeys(data);
|
|
339
|
+
}));
|
|
340
|
+
});
|
|
341
|
+
if (schema.inner) schema.inner = schema.inner.i18n(mapValues(messages, (data) => {
|
|
342
|
+
if (getInner(data)) return getInner(data);
|
|
343
|
+
return extractKeys(data);
|
|
344
|
+
}));
|
|
345
|
+
if (schema.sKey) schema.sKey = schema.sKey.i18n(mapValues(messages, (data) => data?.$key));
|
|
346
|
+
return schema;
|
|
347
|
+
};
|
|
348
|
+
Schema.prototype.extra = function extra(key, value) {
|
|
349
|
+
const schema = Schema(this);
|
|
350
|
+
schema.meta = {
|
|
351
|
+
...schema.meta,
|
|
352
|
+
[key]: value
|
|
353
|
+
};
|
|
354
|
+
return schema;
|
|
355
|
+
};
|
|
356
|
+
for (const key of [
|
|
357
|
+
"required",
|
|
358
|
+
"disabled",
|
|
359
|
+
"collapse",
|
|
360
|
+
"hidden",
|
|
361
|
+
"loose"
|
|
362
|
+
]) Object.assign(Schema.prototype, { [key](value = true) {
|
|
363
|
+
const schema = Schema(this);
|
|
364
|
+
schema.meta = {
|
|
365
|
+
...schema.meta,
|
|
366
|
+
[key]: value
|
|
367
|
+
};
|
|
368
|
+
return schema;
|
|
369
|
+
} });
|
|
370
|
+
Schema.prototype.deprecated = function deprecated() {
|
|
371
|
+
const schema = Schema(this);
|
|
372
|
+
schema.meta.badges ||= [];
|
|
373
|
+
schema.meta.badges.push({
|
|
374
|
+
text: "deprecated",
|
|
375
|
+
type: "danger"
|
|
376
|
+
});
|
|
377
|
+
return schema;
|
|
378
|
+
};
|
|
379
|
+
Schema.prototype.experimental = function experimental() {
|
|
380
|
+
const schema = Schema(this);
|
|
381
|
+
schema.meta.badges ||= [];
|
|
382
|
+
schema.meta.badges.push({
|
|
383
|
+
text: "experimental",
|
|
384
|
+
type: "warning"
|
|
385
|
+
});
|
|
386
|
+
return schema;
|
|
387
|
+
};
|
|
388
|
+
Schema.prototype.pattern = function pattern(regexp) {
|
|
389
|
+
const schema = Schema(this);
|
|
390
|
+
const pattern2 = pick(regexp, ["source", "flags"]);
|
|
391
|
+
schema.meta = {
|
|
392
|
+
...schema.meta,
|
|
393
|
+
pattern: pattern2
|
|
394
|
+
};
|
|
395
|
+
return schema;
|
|
396
|
+
};
|
|
397
|
+
Schema.prototype.simplify = function simplify(value) {
|
|
398
|
+
if (deepEqual(value, this.meta.default, this.type === "dict")) return null;
|
|
399
|
+
if (isNullable(value)) return value;
|
|
400
|
+
if (this.type === "object" || this.type === "dict") {
|
|
401
|
+
const result = {};
|
|
402
|
+
for (const key in value) {
|
|
403
|
+
const item = (this.type === "object" ? this.dict[key] : this.inner)?.simplify(value[key]);
|
|
404
|
+
if (this.type === "dict" || !isNullable(item)) result[key] = item;
|
|
405
|
+
}
|
|
406
|
+
if (deepEqual(result, this.meta.default, this.type === "dict")) return null;
|
|
407
|
+
return result;
|
|
408
|
+
} else if (this.type === "array" || this.type === "tuple") {
|
|
409
|
+
const result = [];
|
|
410
|
+
value.forEach((value2, index) => {
|
|
411
|
+
const schema = this.type === "array" ? this.inner : this.list[index];
|
|
412
|
+
const item = schema ? schema.simplify(value2) : value2;
|
|
413
|
+
result.push(item);
|
|
414
|
+
});
|
|
415
|
+
return result;
|
|
416
|
+
} else if (this.type === "intersect") {
|
|
417
|
+
const result = {};
|
|
418
|
+
for (const item of this.list) Object.assign(result, item.simplify(value));
|
|
419
|
+
return result;
|
|
420
|
+
} else if (this.type === "union") for (const schema of this.list) try {
|
|
421
|
+
Schema.resolve(value, schema, {});
|
|
422
|
+
return schema.simplify(value);
|
|
423
|
+
} catch {
|
|
424
|
+
}
|
|
425
|
+
return value;
|
|
426
|
+
};
|
|
427
|
+
Schema.prototype.toString = function toString(inline) {
|
|
428
|
+
return formatters[this.type]?.(this, inline) ?? `Schema<${this.type}>`;
|
|
429
|
+
};
|
|
430
|
+
Schema.prototype.role = function role(role, extra2) {
|
|
431
|
+
const schema = Schema(this);
|
|
432
|
+
schema.meta = {
|
|
433
|
+
...schema.meta,
|
|
434
|
+
role,
|
|
435
|
+
extra: extra2
|
|
436
|
+
};
|
|
437
|
+
return schema;
|
|
438
|
+
};
|
|
439
|
+
for (const key of [
|
|
440
|
+
"default",
|
|
441
|
+
"link",
|
|
442
|
+
"comment",
|
|
443
|
+
"description",
|
|
444
|
+
"max",
|
|
445
|
+
"min",
|
|
446
|
+
"step"
|
|
447
|
+
]) Object.assign(Schema.prototype, { [key](value) {
|
|
448
|
+
const schema = Schema(this);
|
|
449
|
+
schema.meta = {
|
|
450
|
+
...schema.meta,
|
|
451
|
+
[key]: value
|
|
452
|
+
};
|
|
453
|
+
return schema;
|
|
454
|
+
} });
|
|
455
|
+
var resolvers = {};
|
|
456
|
+
Schema.extend = function extend(type, resolve2) {
|
|
457
|
+
resolvers[type] = resolve2;
|
|
458
|
+
};
|
|
459
|
+
Schema.resolve = function resolve(data, schema, options = {}, strict = false) {
|
|
460
|
+
if (!schema) return [data];
|
|
461
|
+
if (options.ignore?.(data, schema)) return [data];
|
|
462
|
+
if (isNullable(data) && schema.type !== "lazy") {
|
|
463
|
+
if (schema.meta.required) throw new ValidationError(`missing required value`, options);
|
|
464
|
+
let current = schema;
|
|
465
|
+
let fallback = schema.meta.default;
|
|
466
|
+
while (current?.type === "intersect" && isNullable(fallback)) {
|
|
467
|
+
current = current.list[0];
|
|
468
|
+
fallback = current?.meta.default;
|
|
469
|
+
}
|
|
470
|
+
if (isNullable(fallback)) return [data];
|
|
471
|
+
data = clone(fallback);
|
|
472
|
+
}
|
|
473
|
+
const callback = resolvers[schema.type];
|
|
474
|
+
if (!callback) throw new ValidationError(`unsupported type "${schema.type}"`, options);
|
|
475
|
+
try {
|
|
476
|
+
return callback(data, schema, options, strict);
|
|
477
|
+
} catch (error) {
|
|
478
|
+
if (!schema.meta.loose) throw error;
|
|
479
|
+
return [schema.meta.default];
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
Schema.from = function from(source) {
|
|
483
|
+
if (isNullable(source)) return Schema.any();
|
|
484
|
+
else if ([
|
|
485
|
+
"string",
|
|
486
|
+
"number",
|
|
487
|
+
"boolean"
|
|
488
|
+
].includes(typeof source)) return Schema.const(source).required();
|
|
489
|
+
else if (source[kSchema]) return source;
|
|
490
|
+
else if (typeof source === "function") switch (source) {
|
|
491
|
+
case String:
|
|
492
|
+
return Schema.string().required();
|
|
493
|
+
case Number:
|
|
494
|
+
return Schema.number().required();
|
|
495
|
+
case Boolean:
|
|
496
|
+
return Schema.boolean().required();
|
|
497
|
+
case Function:
|
|
498
|
+
return Schema.function().required();
|
|
499
|
+
default:
|
|
500
|
+
return Schema.is(source).required();
|
|
501
|
+
}
|
|
502
|
+
else throw new TypeError(`cannot infer schema from ${source}`);
|
|
503
|
+
};
|
|
504
|
+
Schema.lazy = function lazy(builder) {
|
|
505
|
+
const toJSON2 = () => {
|
|
506
|
+
if (!schema.inner[kSchema]) {
|
|
507
|
+
schema.inner = schema.builder();
|
|
508
|
+
schema.inner.meta = {
|
|
509
|
+
...schema.meta,
|
|
510
|
+
...schema.inner.meta
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
return schema.inner.toJSON();
|
|
514
|
+
};
|
|
515
|
+
const schema = new Schema({
|
|
516
|
+
type: "lazy",
|
|
517
|
+
builder,
|
|
518
|
+
inner: { toJSON: toJSON2 }
|
|
519
|
+
});
|
|
520
|
+
return schema;
|
|
521
|
+
};
|
|
522
|
+
Schema.natural = function natural() {
|
|
523
|
+
return Schema.number().step(1).min(0);
|
|
524
|
+
};
|
|
525
|
+
Schema.percent = function percent() {
|
|
526
|
+
return Schema.number().step(0.01).min(0).max(1).role("slider");
|
|
527
|
+
};
|
|
528
|
+
Schema.date = function date() {
|
|
529
|
+
return Schema.union([Schema.is(Date), Schema.transform(Schema.string().role("datetime"), (value, options) => {
|
|
530
|
+
const date2 = new Date(value);
|
|
531
|
+
if (isNaN(+date2)) throw new ValidationError(`invalid date "${value}"`, options);
|
|
532
|
+
return date2;
|
|
533
|
+
}, true)]);
|
|
534
|
+
};
|
|
535
|
+
Schema.regExp = function regExp(flag = "") {
|
|
536
|
+
return Schema.union([Schema.is(RegExp), Schema.transform(Schema.string().role("regexp", { flag }), (value, options) => {
|
|
537
|
+
try {
|
|
538
|
+
return new RegExp(value, flag);
|
|
539
|
+
} catch (e) {
|
|
540
|
+
throw new ValidationError(e.message, options);
|
|
541
|
+
}
|
|
542
|
+
}, true)]);
|
|
543
|
+
};
|
|
544
|
+
Schema.arrayBuffer = function arrayBuffer(encoding) {
|
|
545
|
+
return Schema.union([
|
|
546
|
+
Schema.is(ArrayBuffer),
|
|
547
|
+
Schema.is(SharedArrayBuffer),
|
|
548
|
+
Schema.transform(Schema.any(), (value, options) => {
|
|
549
|
+
if (Binary.isSource(value)) return Binary.fromSource(value);
|
|
550
|
+
throw new ValidationError(`expected ArrayBufferSource but got ${value}`, options);
|
|
551
|
+
}, true),
|
|
552
|
+
...encoding ? [Schema.transform(Schema.string(), (value, options) => {
|
|
553
|
+
try {
|
|
554
|
+
return encoding === "base64" ? Binary.fromBase64(value) : Binary.fromHex(value);
|
|
555
|
+
} catch (e) {
|
|
556
|
+
throw new ValidationError(e.message, options);
|
|
557
|
+
}
|
|
558
|
+
}, true)] : []
|
|
559
|
+
]);
|
|
560
|
+
};
|
|
561
|
+
Schema.extend("lazy", (data, schema, options, strict) => {
|
|
562
|
+
if (!schema.inner[kSchema]) {
|
|
563
|
+
schema.inner = schema.builder();
|
|
564
|
+
schema.inner.meta = {
|
|
565
|
+
...schema.meta,
|
|
566
|
+
...schema.inner.meta
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
return Schema.resolve(data, schema.inner, options, strict);
|
|
570
|
+
});
|
|
571
|
+
Schema.extend("any", (data) => {
|
|
572
|
+
return [data];
|
|
573
|
+
});
|
|
574
|
+
Schema.extend("never", (data, _, options) => {
|
|
575
|
+
throw new ValidationError(`expected nullable but got ${data}`, options);
|
|
576
|
+
});
|
|
577
|
+
Schema.extend("const", (data, { value }, options) => {
|
|
578
|
+
if (deepEqual(data, value)) return [value];
|
|
579
|
+
throw new ValidationError(`expected ${value} but got ${data}`, options);
|
|
580
|
+
});
|
|
581
|
+
function checkWithinRange(data, meta, description, options, skipMin = false) {
|
|
582
|
+
const { max = Infinity, min = -Infinity } = meta;
|
|
583
|
+
if (data > max) throw new ValidationError(`expected ${description} <= ${max} but got ${data}`, options);
|
|
584
|
+
if (data < min && !skipMin) throw new ValidationError(`expected ${description} >= ${min} but got ${data}`, options);
|
|
585
|
+
}
|
|
586
|
+
Schema.extend("string", (data, { meta }, options) => {
|
|
587
|
+
if (typeof data !== "string") throw new ValidationError(`expected string but got ${data}`, options);
|
|
588
|
+
if (meta.pattern) {
|
|
589
|
+
const regexp = new RegExp(meta.pattern.source, meta.pattern.flags);
|
|
590
|
+
if (!regexp.test(data)) throw new ValidationError(`expect string to match regexp ${regexp}`, options);
|
|
591
|
+
}
|
|
592
|
+
checkWithinRange(data.length, meta, "string length", options);
|
|
593
|
+
return [data];
|
|
594
|
+
});
|
|
595
|
+
function decimalShift(data, digits) {
|
|
596
|
+
const str = data.toString();
|
|
597
|
+
if (str.includes("e")) return data * Math.pow(10, digits);
|
|
598
|
+
const index = str.indexOf(".");
|
|
599
|
+
if (index === -1) return data * Math.pow(10, digits);
|
|
600
|
+
const frac = str.slice(index + 1);
|
|
601
|
+
const integer = str.slice(0, index);
|
|
602
|
+
if (frac.length <= digits) return +(integer + frac.padEnd(digits, "0"));
|
|
603
|
+
return +(integer + frac.slice(0, digits) + "." + frac.slice(digits));
|
|
604
|
+
}
|
|
605
|
+
function isMultipleOf(data, min, step) {
|
|
606
|
+
step = Math.abs(step);
|
|
607
|
+
if (!/^\d+\.\d+$/.test(step.toString())) return (data - min) % step === 0;
|
|
608
|
+
const index = step.toString().indexOf(".");
|
|
609
|
+
const digits = step.toString().slice(index + 1).length;
|
|
610
|
+
return Math.abs(decimalShift(data, digits) - decimalShift(min, digits)) % decimalShift(step, digits) === 0;
|
|
611
|
+
}
|
|
612
|
+
Schema.extend("number", (data, { meta }, options) => {
|
|
613
|
+
if (typeof data !== "number") throw new ValidationError(`expected number but got ${data}`, options);
|
|
614
|
+
checkWithinRange(data, meta, "number", options);
|
|
615
|
+
const { step } = meta;
|
|
616
|
+
if (step && !isMultipleOf(data, meta.min ?? 0, step)) throw new ValidationError(`expected number multiple of ${step} but got ${data}`, options);
|
|
617
|
+
return [data];
|
|
618
|
+
});
|
|
619
|
+
Schema.extend("boolean", (data, _, options) => {
|
|
620
|
+
if (typeof data === "boolean") return [data];
|
|
621
|
+
throw new ValidationError(`expected boolean but got ${data}`, options);
|
|
622
|
+
});
|
|
623
|
+
Schema.extend("bitset", (data, { bits, meta }, options) => {
|
|
624
|
+
let value = 0, keys = [];
|
|
625
|
+
if (typeof data === "number") {
|
|
626
|
+
value = data;
|
|
627
|
+
for (const key in bits) if (data & bits[key]) keys.push(key);
|
|
628
|
+
} else if (Array.isArray(data)) {
|
|
629
|
+
keys = data;
|
|
630
|
+
for (const key of keys) {
|
|
631
|
+
if (typeof key !== "string") throw new ValidationError(`expected string but got ${key}`, options);
|
|
632
|
+
if (key in bits) value |= bits[key];
|
|
633
|
+
}
|
|
634
|
+
} else throw new ValidationError(`expected number or array but got ${data}`, options);
|
|
635
|
+
if (value === meta.default) return [value];
|
|
636
|
+
return [value, keys];
|
|
637
|
+
});
|
|
638
|
+
Schema.extend("function", (data, _, options) => {
|
|
639
|
+
if (typeof data === "function") return [data];
|
|
640
|
+
throw new ValidationError(`expected function but got ${data}`, options);
|
|
641
|
+
});
|
|
642
|
+
Schema.extend("is", (data, { constructor }, options) => {
|
|
643
|
+
if (typeof constructor === "function") {
|
|
644
|
+
if (data instanceof constructor) return [data];
|
|
645
|
+
throw new ValidationError(`expected ${constructor.name} but got ${data}`, options);
|
|
646
|
+
} else {
|
|
647
|
+
if (isNullable(data)) throw new ValidationError(`expected ${constructor} but got ${data}`, options);
|
|
648
|
+
let prototype = Object.getPrototypeOf(data);
|
|
649
|
+
while (prototype) {
|
|
650
|
+
if (prototype.constructor?.name === constructor) return [data];
|
|
651
|
+
prototype = Object.getPrototypeOf(prototype);
|
|
652
|
+
}
|
|
653
|
+
throw new ValidationError(`expected ${constructor} but got ${data}`, options);
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
function property(data, key, schema, options) {
|
|
657
|
+
try {
|
|
658
|
+
const [value, adapted] = Schema.resolve(data[key], schema, {
|
|
659
|
+
...options,
|
|
660
|
+
path: [...options.path || [], key]
|
|
661
|
+
});
|
|
662
|
+
if (adapted !== void 0) data[key] = adapted;
|
|
663
|
+
return value;
|
|
664
|
+
} catch (e) {
|
|
665
|
+
if (!options?.autofix) throw e;
|
|
666
|
+
delete data[key];
|
|
667
|
+
return schema.meta.default;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
Schema.extend("array", (data, { inner, meta }, options) => {
|
|
671
|
+
if (!Array.isArray(data)) throw new ValidationError(`expected array but got ${data}`, options);
|
|
672
|
+
checkWithinRange(data.length, meta, "array length", options, !isNullable(inner.meta.default));
|
|
673
|
+
return [data.map((_, index) => property(data, index, inner, options))];
|
|
674
|
+
});
|
|
675
|
+
Schema.extend("dict", (data, { inner, sKey }, options, strict) => {
|
|
676
|
+
if (!isPlainObject(data)) throw new ValidationError(`expected object but got ${data}`, options);
|
|
677
|
+
const result = {};
|
|
678
|
+
for (const key in data) {
|
|
679
|
+
let rKey;
|
|
680
|
+
try {
|
|
681
|
+
rKey = Schema.resolve(key, sKey, options)[0];
|
|
682
|
+
} catch (error) {
|
|
683
|
+
if (strict) continue;
|
|
684
|
+
throw error;
|
|
685
|
+
}
|
|
686
|
+
result[rKey] = property(data, key, inner, options);
|
|
687
|
+
data[rKey] = data[key];
|
|
688
|
+
if (key !== rKey) delete data[key];
|
|
689
|
+
}
|
|
690
|
+
return [result];
|
|
691
|
+
});
|
|
692
|
+
Schema.extend("tuple", (data, { list }, options, strict) => {
|
|
693
|
+
if (!Array.isArray(data)) throw new ValidationError(`expected array but got ${data}`, options);
|
|
694
|
+
const result = list.map((inner, index) => property(data, index, inner, options));
|
|
695
|
+
if (strict) return [result];
|
|
696
|
+
result.push(...data.slice(list.length));
|
|
697
|
+
return [result];
|
|
698
|
+
});
|
|
699
|
+
function merge(result, data) {
|
|
700
|
+
for (const key in data) {
|
|
701
|
+
if (key in result) continue;
|
|
702
|
+
result[key] = data[key];
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
Schema.extend("object", (data, { dict }, options, strict) => {
|
|
706
|
+
if (!isPlainObject(data)) throw new ValidationError(`expected object but got ${data}`, options);
|
|
707
|
+
const result = {};
|
|
708
|
+
for (const key in dict) {
|
|
709
|
+
const value = property(data, key, dict[key], options);
|
|
710
|
+
if (!isNullable(value) || key in data) result[key] = value;
|
|
711
|
+
}
|
|
712
|
+
if (!strict) merge(result, data);
|
|
713
|
+
return [result];
|
|
714
|
+
});
|
|
715
|
+
Schema.extend("union", (data, { list, toString: toString2 }, options, strict) => {
|
|
716
|
+
const messages = [];
|
|
717
|
+
for (const inner of list) try {
|
|
718
|
+
return Schema.resolve(data, inner, options, strict);
|
|
719
|
+
} catch (error) {
|
|
720
|
+
messages.push(error);
|
|
721
|
+
}
|
|
722
|
+
throw new ValidationError(`expected ${toString2()} but got ${JSON.stringify(data)}`, options);
|
|
723
|
+
});
|
|
724
|
+
Schema.extend("intersect", (data, { list, toString: toString2 }, options, strict) => {
|
|
725
|
+
if (!list.length) return [data];
|
|
726
|
+
let result;
|
|
727
|
+
for (const inner of list) {
|
|
728
|
+
const value = Schema.resolve(data, inner, options, true)[0];
|
|
729
|
+
if (isNullable(value)) continue;
|
|
730
|
+
if (isNullable(result)) result = value;
|
|
731
|
+
else if (typeof result !== typeof value) throw new ValidationError(`expected ${toString2()} but got ${JSON.stringify(data)}`, options);
|
|
732
|
+
else if (typeof value === "object") merge(result ??= {}, value);
|
|
733
|
+
else if (result !== value) throw new ValidationError(`expected ${toString2()} but got ${JSON.stringify(data)}`, options);
|
|
734
|
+
}
|
|
735
|
+
if (!strict && isPlainObject(data)) merge(result, data);
|
|
736
|
+
return [result];
|
|
737
|
+
});
|
|
738
|
+
Schema.extend("transform", (data, { inner, callback, preserve }, options) => {
|
|
739
|
+
const [result, adapted = data] = Schema.resolve(data, inner, options, true);
|
|
740
|
+
if (preserve) return [callback(result)];
|
|
741
|
+
else return [callback(result), callback(adapted)];
|
|
742
|
+
});
|
|
743
|
+
var formatters = {};
|
|
744
|
+
function defineMethod(name2, keys, format) {
|
|
745
|
+
formatters[name2] = format;
|
|
746
|
+
Object.assign(Schema, { [name2](...args) {
|
|
747
|
+
const schema = new Schema({ type: name2 });
|
|
748
|
+
keys.forEach((key, index) => {
|
|
749
|
+
switch (key) {
|
|
750
|
+
case "sKey":
|
|
751
|
+
schema.sKey = args[index] ?? Schema.string();
|
|
752
|
+
break;
|
|
753
|
+
case "inner":
|
|
754
|
+
schema.inner = Schema.from(args[index]);
|
|
755
|
+
break;
|
|
756
|
+
case "list":
|
|
757
|
+
schema.list = args[index].map(Schema.from);
|
|
758
|
+
break;
|
|
759
|
+
case "dict":
|
|
760
|
+
schema.dict = mapValues(args[index], Schema.from);
|
|
761
|
+
break;
|
|
762
|
+
case "bits":
|
|
763
|
+
schema.bits = {};
|
|
764
|
+
for (const key2 in args[index]) {
|
|
765
|
+
if (typeof args[index][key2] !== "number") continue;
|
|
766
|
+
schema.bits[key2] = args[index][key2];
|
|
767
|
+
}
|
|
768
|
+
break;
|
|
769
|
+
case "callback": {
|
|
770
|
+
const callback = schema.callback = args[index];
|
|
771
|
+
callback["toJSON"] ||= () => callback.toString();
|
|
772
|
+
break;
|
|
773
|
+
}
|
|
774
|
+
case "constructor": {
|
|
775
|
+
const constructor = schema.constructor = args[index];
|
|
776
|
+
if (typeof constructor === "function") constructor["toJSON"] ||= () => constructor["name"];
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
default:
|
|
780
|
+
schema[key] = args[index];
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
if (name2 === "object" || name2 === "dict") schema.meta.default = {};
|
|
784
|
+
else if (name2 === "array" || name2 === "tuple") schema.meta.default = [];
|
|
785
|
+
else if (name2 === "bitset") schema.meta.default = 0;
|
|
786
|
+
return schema;
|
|
787
|
+
} });
|
|
788
|
+
}
|
|
789
|
+
defineMethod("is", ["constructor"], ({ constructor }) => {
|
|
790
|
+
if (typeof constructor === "function") return constructor.name;
|
|
791
|
+
else return constructor;
|
|
792
|
+
});
|
|
793
|
+
defineMethod("any", [], () => "any");
|
|
794
|
+
defineMethod("never", [], () => "never");
|
|
795
|
+
defineMethod("const", ["value"], ({ value }) => typeof value === "string" ? JSON.stringify(value) : value);
|
|
796
|
+
defineMethod("string", [], () => "string");
|
|
797
|
+
defineMethod("number", [], () => "number");
|
|
798
|
+
defineMethod("boolean", [], () => "boolean");
|
|
799
|
+
defineMethod("bitset", ["bits"], () => "bitset");
|
|
800
|
+
defineMethod("function", [], () => "function");
|
|
801
|
+
defineMethod("array", ["inner"], ({ inner }) => `${inner.toString(true)}[]`);
|
|
802
|
+
defineMethod("dict", ["inner", "sKey"], ({ inner, sKey }) => `{ [key: ${sKey.toString()}]: ${inner.toString()} }`);
|
|
803
|
+
defineMethod("tuple", ["list"], ({ list }) => `[${list.map((inner) => inner.toString()).join(", ")}]`);
|
|
804
|
+
defineMethod("object", ["dict"], ({ dict }) => {
|
|
805
|
+
if (Object.keys(dict).length === 0) return "{}";
|
|
806
|
+
return `{ ${Object.entries(dict).map(([key, inner]) => {
|
|
807
|
+
return `${key}${inner.meta.required ? "" : "?"}: ${inner.toString()}`;
|
|
808
|
+
}).join(", ")} }`;
|
|
809
|
+
});
|
|
810
|
+
defineMethod("union", ["list"], ({ list }, inline) => {
|
|
811
|
+
const result = list.map(({ toString: format }) => format()).join(" | ");
|
|
812
|
+
return inline ? `(${result})` : result;
|
|
813
|
+
});
|
|
814
|
+
defineMethod("intersect", ["list"], ({ list }) => {
|
|
815
|
+
return `${list.map((inner) => inner.toString(true)).join(" & ")}`;
|
|
816
|
+
});
|
|
817
|
+
defineMethod("transform", [
|
|
818
|
+
"inner",
|
|
819
|
+
"callback",
|
|
820
|
+
"preserve"
|
|
821
|
+
], ({ inner }, isInner) => inner.toString(isInner));
|
|
822
|
+
|
|
823
|
+
// src/interconnect/index.ts
|
|
824
|
+
import WebSocket, { WebSocketServer } from "ws";
|
|
825
|
+
function isSessionOwnedBySubagent(ctx, session, agent) {
|
|
826
|
+
if (session.header.origin === "subagent") return true;
|
|
827
|
+
const parentId = session.header.parentSession;
|
|
828
|
+
if (parentId === void 0 || agent === void 0) return false;
|
|
829
|
+
const parent = ctx.agents.get(parentId);
|
|
830
|
+
return parent !== void 0 && ctx.agents.isOwnedBy(agent.id, parent);
|
|
831
|
+
}
|
|
832
|
+
function assertNever(value) {
|
|
833
|
+
throw new Error(`interconnect: unhandled variant ${JSON.stringify(value)}`);
|
|
834
|
+
}
|
|
835
|
+
var INTERCONNECT_TOKEN_REF = "DSH_INTERCONNECT_TOKEN";
|
|
836
|
+
var PLUGIN_SOURCE = "dsh-interconnect";
|
|
837
|
+
var MAX_LINK_FRAME_BYTES = 1024 * 1024;
|
|
838
|
+
var LINK_HANDSHAKE_TIMEOUT_MS = 1e4;
|
|
839
|
+
var LINK_CHANNEL = "/interconnect/link";
|
|
840
|
+
var LIST_FRAME_ENVELOPE_BYTES = 4096;
|
|
841
|
+
var MAX_LIST_ROWS_BYTES = MAX_LINK_FRAME_BYTES - LIST_FRAME_ENVELOPE_BYTES;
|
|
842
|
+
var MAX_LISTED_SESSIONS = 100;
|
|
843
|
+
var rowBytes = (row) => Buffer.byteLength(JSON.stringify(row), "utf8") + 1;
|
|
844
|
+
var notificationSchema = Schema.union([
|
|
845
|
+
Schema.object({ kind: Schema.const("agent/created").required(), sessionId: Schema.string().required() }),
|
|
846
|
+
Schema.object({ kind: Schema.const("agent/disposed").required(), sessionId: Schema.string().required() }),
|
|
847
|
+
Schema.object({ kind: Schema.const("agent/status").required(), sessionId: Schema.string().required(), status: Schema.union([Schema.const("idle"), Schema.const("running")]).required() }),
|
|
848
|
+
Schema.object({ kind: Schema.const("session/created").required(), sessionId: Schema.string().required(), parentSessionId: Schema.string() }),
|
|
849
|
+
Schema.object({ kind: Schema.const("session/disposed").required(), sessionId: Schema.string().required() }),
|
|
850
|
+
Schema.object({ kind: Schema.const("subagent/end").required(), provider: Schema.string().required(), childSessionId: Schema.string().required(), stopReason: Schema.string().required() })
|
|
851
|
+
]);
|
|
852
|
+
var senderSchema = Schema.object({ instanceId: Schema.string(), sessionId: Schema.string() });
|
|
853
|
+
var pingResultSchema = Schema.object({ pong: Schema.const(true).required(), instance: Schema.string().required() });
|
|
854
|
+
var listResultSchema = Schema.object({
|
|
855
|
+
instance: Schema.string().required(),
|
|
856
|
+
sessions: Schema.array(Schema.object({
|
|
857
|
+
sessionId: Schema.string().required(),
|
|
858
|
+
title: Schema.string().required(false),
|
|
859
|
+
status: Schema.string().required(false)
|
|
860
|
+
})).required()
|
|
861
|
+
});
|
|
862
|
+
var withoutNullFields = (value) => {
|
|
863
|
+
if (Array.isArray(value)) return value.map((item) => withoutNullFields(item));
|
|
864
|
+
if (value === null || typeof value !== "object") return value;
|
|
865
|
+
return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== null).map(([key, field]) => [key, withoutNullFields(field)]));
|
|
866
|
+
};
|
|
867
|
+
var projectMsgResult = (result) => result.delivered ? { delivered: true, instance: result.instance, ...result.delivery === void 0 ? {} : { delivery: result.delivery } } : { delivered: false, instance: result.instance, reason: result.reason };
|
|
868
|
+
var parseQueryResult = (schema, result) => {
|
|
869
|
+
try {
|
|
870
|
+
const [parsed] = Schema.resolve(result, schema, {});
|
|
871
|
+
return parsed;
|
|
872
|
+
} catch {
|
|
873
|
+
return void 0;
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
var projectPingResult = (result) => {
|
|
877
|
+
const parsed = parseQueryResult(pingResultSchema, result);
|
|
878
|
+
return parsed === void 0 ? void 0 : { pong: true, instance: parsed.instance };
|
|
879
|
+
};
|
|
880
|
+
var projectListResult = (result) => {
|
|
881
|
+
const parsed = parseQueryResult(listResultSchema, result);
|
|
882
|
+
if (parsed === void 0) return void 0;
|
|
883
|
+
const rows = parsed.sessions;
|
|
884
|
+
return {
|
|
885
|
+
instance: parsed.instance,
|
|
886
|
+
sessions: rows.map((row) => ({
|
|
887
|
+
sessionId: row.sessionId,
|
|
888
|
+
...row.title === void 0 || row.title === null ? {} : { title: row.title },
|
|
889
|
+
...row.status === void 0 || row.status === null ? {} : { status: row.status }
|
|
890
|
+
}))
|
|
891
|
+
};
|
|
892
|
+
};
|
|
893
|
+
var queryResultSchema = Schema.union([
|
|
894
|
+
pingResultSchema,
|
|
895
|
+
listResultSchema,
|
|
896
|
+
Schema.object({ accepted: Schema.const(true).required() })
|
|
897
|
+
]);
|
|
898
|
+
var messageSchema = Schema.object({
|
|
899
|
+
kind: Schema.const("send").required(),
|
|
900
|
+
sessionId: Schema.string().required(),
|
|
901
|
+
text: Schema.string().required(),
|
|
902
|
+
sender: senderSchema,
|
|
903
|
+
delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]),
|
|
904
|
+
resume: Schema.boolean()
|
|
905
|
+
});
|
|
906
|
+
var querySchema = Schema.union([
|
|
907
|
+
Schema.object({ kind: Schema.const("ping").required() }),
|
|
908
|
+
Schema.object({ kind: Schema.const("list").required() }),
|
|
909
|
+
Schema.object({ kind: Schema.const("event").required(), notification: notificationSchema.required() })
|
|
910
|
+
]);
|
|
911
|
+
var msgResultSchema = Schema.union([
|
|
912
|
+
Schema.object({ delivered: Schema.const(true).required(), instance: Schema.string().required(), delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]) }),
|
|
913
|
+
Schema.object({ delivered: Schema.const(false).required(), instance: Schema.string().required(), reason: Schema.union([
|
|
914
|
+
Schema.const("session-not-live"),
|
|
915
|
+
Schema.const("unreachable"),
|
|
916
|
+
Schema.const("resume-refused"),
|
|
917
|
+
Schema.const("resume-failed"),
|
|
918
|
+
Schema.const("session-owned-by-subagent"),
|
|
919
|
+
Schema.const("no-sender-known")
|
|
920
|
+
]).required() })
|
|
921
|
+
]);
|
|
922
|
+
var linkFrameSchema = Schema.union([
|
|
923
|
+
Schema.object({ type: Schema.const("hello").required(), sender: Schema.string().required() }),
|
|
924
|
+
Schema.object({ type: Schema.const("event").required(), notification: notificationSchema.required() }),
|
|
925
|
+
Schema.object({ type: Schema.const("msg").required(), reqId: Schema.string().required(), message: messageSchema.required() }),
|
|
926
|
+
Schema.object({ type: Schema.const("msg-result").required(), reqId: Schema.string().required(), result: msgResultSchema.required() }),
|
|
927
|
+
Schema.object({ type: Schema.const("query").required(), reqId: Schema.string().required(), query: querySchema.required() }),
|
|
928
|
+
Schema.object({ type: Schema.const("query-result").required(), reqId: Schema.string().required(), result: queryResultSchema.required() })
|
|
929
|
+
]);
|
|
930
|
+
var InterconnectService = class extends Service {
|
|
931
|
+
static inject = ["agents", "credentials"];
|
|
932
|
+
static Config = Schema.object({
|
|
933
|
+
instanceId: Schema.string().default("dsh"),
|
|
934
|
+
requestTimeoutMs: Schema.natural().max(6e4).default(1e4),
|
|
935
|
+
peers: Schema.dict(Schema.string()).default({}),
|
|
936
|
+
delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]).default("followup"),
|
|
937
|
+
allowResume: Schema.boolean().default(true)
|
|
938
|
+
});
|
|
939
|
+
instanceId;
|
|
940
|
+
requestTimeoutMs;
|
|
941
|
+
delivery;
|
|
942
|
+
allowResume;
|
|
943
|
+
subscriptions = [];
|
|
944
|
+
server = new WebSocketServer({ noServer: true, maxPayload: MAX_LINK_FRAME_BYTES });
|
|
945
|
+
sockets = /* @__PURE__ */ new Set();
|
|
946
|
+
/** Outbound peer links keyed by the peer's `instanceId`. */
|
|
947
|
+
linkStates = /* @__PURE__ */ new Map();
|
|
948
|
+
heartbeatTimer;
|
|
949
|
+
/** Peer identity each live socket announced via its `hello` frame, if any. */
|
|
950
|
+
peerOf = /* @__PURE__ */ new WeakMap();
|
|
951
|
+
/** Sender each local session last received a send from, keyed by local session id. */
|
|
952
|
+
senders = /* @__PURE__ */ new Map();
|
|
953
|
+
/** In-flight frames sent over a peer link, keyed by `reqId`, awaiting a correlated result. */
|
|
954
|
+
pendingMessages = /* @__PURE__ */ new Map();
|
|
955
|
+
reqIdCounter = 0;
|
|
956
|
+
/** Set when the service fiber unwinds; guards late inbound deliveries against driving disposed agents. */
|
|
957
|
+
disposed = false;
|
|
958
|
+
constructor(ctx, config) {
|
|
959
|
+
super(ctx, "interconnect");
|
|
960
|
+
this.instanceId = config.instanceId;
|
|
961
|
+
this.requestTimeoutMs = config.requestTimeoutMs;
|
|
962
|
+
this.delivery = config.delivery ?? "followup";
|
|
963
|
+
this.allowResume = config.allowResume ?? true;
|
|
964
|
+
for (const origin of Object.values(config.peers ?? {})) {
|
|
965
|
+
linkUrl(trimBase(origin));
|
|
966
|
+
}
|
|
967
|
+
for (const [peerInstanceId, origin] of Object.entries(config.peers ?? {})) {
|
|
968
|
+
this.link(peerInstanceId, origin);
|
|
969
|
+
}
|
|
970
|
+
const upgrade = {
|
|
971
|
+
path: LINK_CHANNEL,
|
|
972
|
+
handler: (req, socket, head) => {
|
|
973
|
+
void this.handleUpgrade(req, socket, head).catch((error) => {
|
|
974
|
+
this.ctx.logger.warn(`interconnect: upgrade failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
975
|
+
socket.destroy();
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
980
|
+
webCtx.effect(() => webCtx.webServer.registerUpgrade(upgrade), "interconnect: /interconnect/link websocket");
|
|
981
|
+
});
|
|
982
|
+
this.heartbeatTimer = setInterval(() => {
|
|
983
|
+
for (const socket of this.sockets) {
|
|
984
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
985
|
+
this.sockets.delete(socket);
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
if (socket.isAlive === false) {
|
|
989
|
+
socket.terminate();
|
|
990
|
+
this.sockets.delete(socket);
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
;
|
|
994
|
+
socket.isAlive = false;
|
|
995
|
+
socket.ping();
|
|
996
|
+
}
|
|
997
|
+
}, 3e4);
|
|
998
|
+
this.subscriptions.push(ctx.on("agent/status", ({ agent, status }) => {
|
|
999
|
+
this.fanout({ kind: "agent/status", sessionId: String(agent.session.id), status });
|
|
1000
|
+
}));
|
|
1001
|
+
this.subscriptions.push(ctx.on("agent/created", ({ agent }) => {
|
|
1002
|
+
this.fanout({ kind: "agent/created", sessionId: String(agent.session.id) });
|
|
1003
|
+
}));
|
|
1004
|
+
this.subscriptions.push(ctx.on("agent/disposed", ({ agent }) => {
|
|
1005
|
+
this.fanout({ kind: "agent/disposed", sessionId: String(agent.session.id) });
|
|
1006
|
+
}));
|
|
1007
|
+
this.subscriptions.push(ctx.on("session/created", (session) => {
|
|
1008
|
+
const parentSessionId = session.header.parentSession === void 0 ? void 0 : String(session.header.parentSession);
|
|
1009
|
+
this.fanout({
|
|
1010
|
+
kind: "session/created",
|
|
1011
|
+
sessionId: String(session.id),
|
|
1012
|
+
...parentSessionId === void 0 ? {} : { parentSessionId }
|
|
1013
|
+
});
|
|
1014
|
+
}));
|
|
1015
|
+
this.subscriptions.push(ctx.on("session/disposed", (session) => {
|
|
1016
|
+
this.senders.delete(String(session.id));
|
|
1017
|
+
this.fanout({ kind: "session/disposed", sessionId: String(session.id) });
|
|
1018
|
+
}));
|
|
1019
|
+
this.subscriptions.push(ctx.on("subagent/end", (info) => {
|
|
1020
|
+
if (!info.local) return;
|
|
1021
|
+
this.fanout({
|
|
1022
|
+
kind: "subagent/end",
|
|
1023
|
+
provider: info.provider,
|
|
1024
|
+
childSessionId: String(info.id),
|
|
1025
|
+
stopReason: info.stopReason
|
|
1026
|
+
});
|
|
1027
|
+
}));
|
|
1028
|
+
ctx.effect(() => () => {
|
|
1029
|
+
for (const dispose of this.subscriptions.splice(0)) dispose();
|
|
1030
|
+
}, "interconnect: event subscriptions");
|
|
1031
|
+
ctx.effect(() => () => {
|
|
1032
|
+
if (this.heartbeatTimer !== void 0) clearInterval(this.heartbeatTimer);
|
|
1033
|
+
for (const state of this.linkStates.values()) state.close();
|
|
1034
|
+
this.linkStates.clear();
|
|
1035
|
+
for (const socket of this.sockets) socket.terminate();
|
|
1036
|
+
this.sockets.clear();
|
|
1037
|
+
this.server.close();
|
|
1038
|
+
for (const pending of this.pendingMessages.values()) {
|
|
1039
|
+
clearTimeout(pending.timer);
|
|
1040
|
+
pending.resolve?.(void 0);
|
|
1041
|
+
}
|
|
1042
|
+
this.pendingMessages.clear();
|
|
1043
|
+
this.disposed = true;
|
|
1044
|
+
}, "interconnect: websocket teardown");
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* This instance's identity to attach to outbound messages. Always present:
|
|
1048
|
+
* it needs no address, only this instance's id and the calling session.
|
|
1049
|
+
* @param sessionId - the local session that is sending, used as the reply target.
|
|
1050
|
+
* @returns this instance's address-free identity for the calling session.
|
|
1051
|
+
*/
|
|
1052
|
+
selfSender(sessionId) {
|
|
1053
|
+
return { instanceId: this.instanceId, sessionId };
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Deliver one text message to a live session on a peer instance.
|
|
1057
|
+
* @param request - the peer instance id, target session, text, and optional delivery/resume overrides.
|
|
1058
|
+
* @returns the peer's answer, or an unreachable result when no link answers.
|
|
1059
|
+
*/
|
|
1060
|
+
async send(request) {
|
|
1061
|
+
const payload = {
|
|
1062
|
+
sessionId: request.sessionId,
|
|
1063
|
+
text: request.text,
|
|
1064
|
+
...request.sender === void 0 ? {} : { sender: request.sender },
|
|
1065
|
+
...request.delivery === void 0 ? {} : { delivery: request.delivery },
|
|
1066
|
+
...request.resume === void 0 ? {} : { resume: request.resume }
|
|
1067
|
+
};
|
|
1068
|
+
try {
|
|
1069
|
+
const result = await this.msgRequest(request.instanceId, "send", payload);
|
|
1070
|
+
return result ?? { delivered: false, instance: request.instanceId, reason: "unreachable" };
|
|
1071
|
+
} catch {
|
|
1072
|
+
return { delivered: false, instance: request.instanceId, reason: "unreachable" };
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Deliver one text message back to the peer that a local session last
|
|
1077
|
+
* received a send from. `sessionId` names the LOCAL replying session; the
|
|
1078
|
+
* outbound target is the `sender` that session recorded, addressed through
|
|
1079
|
+
* this instance's own link to the sender's instance.
|
|
1080
|
+
* @param request - the LOCAL replying session id, the reply text, and optional delivery/resume overrides.
|
|
1081
|
+
* @returns the recalled sender's answer, or an unreachable result when no link answers.
|
|
1082
|
+
*/
|
|
1083
|
+
async reply(request) {
|
|
1084
|
+
const sender = this.senders.get(request.sessionId);
|
|
1085
|
+
if (sender === void 0) {
|
|
1086
|
+
return { delivered: false, instance: this.instanceId, reason: "no-sender-known" };
|
|
1087
|
+
}
|
|
1088
|
+
const payload = {
|
|
1089
|
+
sessionId: sender.sessionId,
|
|
1090
|
+
text: request.text,
|
|
1091
|
+
...request.delivery === void 0 ? {} : { delivery: request.delivery },
|
|
1092
|
+
...request.resume === void 0 ? {} : { resume: request.resume },
|
|
1093
|
+
// Attribute the reply to this instance so the peer, in turn, can reply
|
|
1094
|
+
// back — chaining the conversation.
|
|
1095
|
+
sender: this.selfSender(request.sessionId)
|
|
1096
|
+
};
|
|
1097
|
+
try {
|
|
1098
|
+
const result = await this.msgRequest(sender.instanceId, "send", payload);
|
|
1099
|
+
return result ?? { delivered: false, instance: sender.instanceId, reason: "unreachable" };
|
|
1100
|
+
} catch {
|
|
1101
|
+
return { delivered: false, instance: sender.instanceId, reason: "unreachable" };
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Probe a peer instance for liveness and identity over its persistent link.
|
|
1106
|
+
* Returns the peer identity when reachable, or undefined when the link is
|
|
1107
|
+
* not up or no answer arrives.
|
|
1108
|
+
* @param instanceId - the peer's `instanceId` as configured under {@link Config.peers}.
|
|
1109
|
+
* @returns the peer's identity, or undefined when the link is not up or no answer arrives.
|
|
1110
|
+
*/
|
|
1111
|
+
async ping(instanceId) {
|
|
1112
|
+
return this.queryRequest(instanceId, { kind: "ping" });
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* List a peer instance's live sessions over its persistent link. Undefined on
|
|
1116
|
+
* transport failure, matching `ping`.
|
|
1117
|
+
* @param instanceId - the peer's `instanceId` as configured under {@link Config.peers}.
|
|
1118
|
+
* @returns the peer's live session rows, or undefined on transport failure.
|
|
1119
|
+
*/
|
|
1120
|
+
async list(instanceId) {
|
|
1121
|
+
return this.queryRequest(instanceId, { kind: "list" });
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Send a `msg` frame over the live link to a peer and resolve with its
|
|
1125
|
+
* correlated `msg-result`. Undefined when the peer has no live link here
|
|
1126
|
+
* (not configured, or the link is down) — sends can only address configured+
|
|
1127
|
+
* connected peers by design.
|
|
1128
|
+
*/
|
|
1129
|
+
async msgRequest(instanceId, kind, payload) {
|
|
1130
|
+
const state = this.linkStates.get(instanceId);
|
|
1131
|
+
if (state === void 0 || !state.writable()) return void 0;
|
|
1132
|
+
const reqId = `m${++this.reqIdCounter}-${randomUUID()}`;
|
|
1133
|
+
const message = {
|
|
1134
|
+
kind,
|
|
1135
|
+
sessionId: payload.sessionId,
|
|
1136
|
+
text: payload.text,
|
|
1137
|
+
...payload.sender === void 0 ? {} : { sender: payload.sender },
|
|
1138
|
+
...payload.delivery === void 0 ? {} : { delivery: payload.delivery },
|
|
1139
|
+
...payload.resume === void 0 ? {} : { resume: payload.resume }
|
|
1140
|
+
};
|
|
1141
|
+
return this.waitForResult(reqId, "msg", (failure) => {
|
|
1142
|
+
const wrote = state.sendFrame({ type: "msg", reqId, message });
|
|
1143
|
+
if (!wrote) failure(new Error(`interconnect: peer link to ${instanceId} closed while sending`));
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
/** Send a `query` frame over the live link to a peer and resolve its result. */
|
|
1147
|
+
async queryRequest(instanceId, query) {
|
|
1148
|
+
const state = this.linkStates.get(instanceId);
|
|
1149
|
+
if (state === void 0 || !state.writable()) return void 0;
|
|
1150
|
+
const reqId = `q${++this.reqIdCounter}-${randomUUID()}`;
|
|
1151
|
+
const accept = query.kind === "ping" ? projectPingResult : projectListResult;
|
|
1152
|
+
try {
|
|
1153
|
+
return await this.waitForResult(reqId, "query", (failure) => {
|
|
1154
|
+
const wrote = state.sendFrame({ type: "query", reqId, query });
|
|
1155
|
+
if (!wrote) failure(new Error(`interconnect: peer link to ${instanceId} closed while querying`));
|
|
1156
|
+
}, accept);
|
|
1157
|
+
} catch {
|
|
1158
|
+
return void 0;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
/** Settle a `reqId` frame against its `*-result`, timing out at `requestTimeoutMs`. */
|
|
1162
|
+
waitForResult(reqId, kind, send, accept) {
|
|
1163
|
+
return new Promise((resolve2, reject) => {
|
|
1164
|
+
const timer = setTimeout(() => {
|
|
1165
|
+
this.pendingMessages.delete(reqId);
|
|
1166
|
+
reject(new Error(`interconnect: no result for ${reqId} within ${this.requestTimeoutMs}ms`));
|
|
1167
|
+
}, this.requestTimeoutMs);
|
|
1168
|
+
const settle = (result) => {
|
|
1169
|
+
clearTimeout(timer);
|
|
1170
|
+
this.pendingMessages.delete(reqId);
|
|
1171
|
+
resolve2(result);
|
|
1172
|
+
};
|
|
1173
|
+
this.pendingMessages.set(reqId, kind === "query" ? { timer, kind, accept, resolve: settle } : { timer, kind, resolve: settle });
|
|
1174
|
+
send((error) => {
|
|
1175
|
+
clearTimeout(timer);
|
|
1176
|
+
this.pendingMessages.delete(reqId);
|
|
1177
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
1178
|
+
});
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* Add a peer route at runtime. Returns a disposer that removes it. Re-adding
|
|
1183
|
+
* an existing instanceId re-routes it to the new origin.
|
|
1184
|
+
* @param instanceId - the peer's `instanceId`.
|
|
1185
|
+
* @param origin - origin this instance dials to reach that peer.
|
|
1186
|
+
* @returns disposer removing the peer route.
|
|
1187
|
+
*/
|
|
1188
|
+
subscribe(instanceId, origin) {
|
|
1189
|
+
const linked = this.link(instanceId, origin);
|
|
1190
|
+
const dialedOrigin = trimBase(origin);
|
|
1191
|
+
return () => {
|
|
1192
|
+
const state = this.linkStates.get(instanceId);
|
|
1193
|
+
if (state === linked && state.peer === dialedOrigin) {
|
|
1194
|
+
state.close();
|
|
1195
|
+
this.linkStates.delete(instanceId);
|
|
1196
|
+
}
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Remove a peer route, closing its outbound link.
|
|
1201
|
+
* @param instanceId - the peer route to remove.
|
|
1202
|
+
*/
|
|
1203
|
+
unsubscribe(instanceId) {
|
|
1204
|
+
const state = this.linkStates.get(instanceId);
|
|
1205
|
+
if (state !== void 0) state.close();
|
|
1206
|
+
this.linkStates.delete(instanceId);
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Open (and, on drop, re-open) a persistent WebSocket link to a peer. Local
|
|
1210
|
+
* events stream over the link in real time, and events the peer pushes are
|
|
1211
|
+
* surfaced as `interconnect/event`. Repeating for the same instanceId
|
|
1212
|
+
* re-routes the link to the new origin.
|
|
1213
|
+
* @param instanceId - the peer's `instanceId`.
|
|
1214
|
+
* @param origin - receiver origin this instance dials, e.g. `http://127.0.0.1:13080`.
|
|
1215
|
+
* @returns a handle closing the link and cancelling reconnection.
|
|
1216
|
+
*/
|
|
1217
|
+
link(instanceId, origin) {
|
|
1218
|
+
const existing = this.linkStates.get(instanceId);
|
|
1219
|
+
if (existing !== void 0 && !existing.isClosed()) {
|
|
1220
|
+
existing.reroute(trimBase(origin));
|
|
1221
|
+
return existing;
|
|
1222
|
+
}
|
|
1223
|
+
const state = new LinkState(
|
|
1224
|
+
(socket) => {
|
|
1225
|
+
this.attachSocket(socket);
|
|
1226
|
+
},
|
|
1227
|
+
this.ctx.logger,
|
|
1228
|
+
() => this.resolveToken(),
|
|
1229
|
+
instanceId,
|
|
1230
|
+
trimBase(origin)
|
|
1231
|
+
);
|
|
1232
|
+
this.linkStates.set(instanceId, state);
|
|
1233
|
+
state.dial();
|
|
1234
|
+
return state;
|
|
1235
|
+
}
|
|
1236
|
+
/** Push one serialized lifecycle fact out to every linked peer over WS. */
|
|
1237
|
+
fanout(notification) {
|
|
1238
|
+
this.broadcast(notification);
|
|
1239
|
+
}
|
|
1240
|
+
/** Push one fact over every live WebSocket link, dropping closed sockets. */
|
|
1241
|
+
broadcast(notification) {
|
|
1242
|
+
if (this.sockets.size === 0) return;
|
|
1243
|
+
const frame = { type: "event", notification };
|
|
1244
|
+
const encoded = JSON.stringify(frame);
|
|
1245
|
+
const sent = /* @__PURE__ */ new Set();
|
|
1246
|
+
for (const socket of this.sockets) {
|
|
1247
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
1248
|
+
this.sockets.delete(socket);
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
const peer = this.peerOf.get(socket);
|
|
1252
|
+
if (peer !== void 0 && sent.has(peer)) continue;
|
|
1253
|
+
if (peer !== void 0) sent.add(peer);
|
|
1254
|
+
socket.send(encoded);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Dispatch one inbound `msg` frame: deliver a `send`/`reply` and answer the
|
|
1259
|
+
* correlated `msg-result` on the same socket. Errors are surfaced as the
|
|
1260
|
+
* result so a throwing handler cannot escape the socket's message callback.
|
|
1261
|
+
*/
|
|
1262
|
+
async handleMsgFrame(socket, reqId, message) {
|
|
1263
|
+
const result = await this.deliver({
|
|
1264
|
+
sessionId: message.sessionId,
|
|
1265
|
+
text: message.text,
|
|
1266
|
+
/* v8 ignore next 1 -- schemastery resolves an absent optional `sender` to an empty object, never undefined. */
|
|
1267
|
+
...message.sender === void 0 ? {} : { sender: message.sender },
|
|
1268
|
+
...message.delivery === void 0 ? {} : { delivery: message.delivery },
|
|
1269
|
+
...message.resume === void 0 ? {} : { resume: message.resume }
|
|
1270
|
+
});
|
|
1271
|
+
this.sendFrame(socket, { type: "msg-result", reqId, result });
|
|
1272
|
+
}
|
|
1273
|
+
/** Dispatch one inbound `query` frame (ping/list/event) and answer on the socket. */
|
|
1274
|
+
handleQueryFrame(socket, reqId, query) {
|
|
1275
|
+
let result;
|
|
1276
|
+
if (query.kind === "ping") {
|
|
1277
|
+
result = { pong: true, instance: this.instanceId };
|
|
1278
|
+
} else if (query.kind === "list") {
|
|
1279
|
+
result = this.listSessions();
|
|
1280
|
+
} else {
|
|
1281
|
+
const payload = {
|
|
1282
|
+
sender: this.peerOf.get(socket) ?? "unknown-peer",
|
|
1283
|
+
notification: query.notification
|
|
1284
|
+
};
|
|
1285
|
+
this.receiveEvent(payload);
|
|
1286
|
+
result = { accepted: true };
|
|
1287
|
+
}
|
|
1288
|
+
this.sendFrame(socket, { type: "query-result", reqId, result });
|
|
1289
|
+
}
|
|
1290
|
+
/** Surface one remote notification to local listeners and the log. */
|
|
1291
|
+
receiveEvent(eventPayload) {
|
|
1292
|
+
this.ctx.logger.info(`interconnect: remote event ${eventPayload.notification.kind} from ${eventPayload.sender}`);
|
|
1293
|
+
this.ctx.emit("interconnect/event", eventPayload.notification, eventPayload.sender);
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* Summarize the live local sessions so a sender can discover valid targets
|
|
1297
|
+
* instead of having to know a session id already. Only live agents are listed
|
|
1298
|
+
* because `send` can reach exactly those, and the answer carries at most
|
|
1299
|
+
* {@link MAX_LISTED_SESSIONS} rows so a large live set cannot push the
|
|
1300
|
+
* `query-result` frame past the link's frame cap.
|
|
1301
|
+
*
|
|
1302
|
+
* Title and status are best-effort: the title projection is an optional
|
|
1303
|
+
* service, and a receiver without it still returns the ids. A projection that
|
|
1304
|
+
* throws degrades that one row rather than failing the whole listing, which
|
|
1305
|
+
* matches how the Host's own session listing treats its projection column.
|
|
1306
|
+
*/
|
|
1307
|
+
listSessions() {
|
|
1308
|
+
const reachable = this.ctx.agents.list().filter((agent) => !isSessionOwnedBySubagent(this.ctx, agent.session, agent));
|
|
1309
|
+
const sessions = [];
|
|
1310
|
+
let used = 0;
|
|
1311
|
+
for (const agent of reachable.slice(0, MAX_LISTED_SESSIONS)) {
|
|
1312
|
+
const row = this.sessionRow(agent);
|
|
1313
|
+
const sized = used + rowBytes(row);
|
|
1314
|
+
if (sized > MAX_LIST_ROWS_BYTES) {
|
|
1315
|
+
const untitled = {
|
|
1316
|
+
sessionId: row.sessionId,
|
|
1317
|
+
...row.status === void 0 ? {} : { status: row.status }
|
|
1318
|
+
};
|
|
1319
|
+
const untitledSized = used + rowBytes(untitled);
|
|
1320
|
+
if (untitledSized > MAX_LIST_ROWS_BYTES) break;
|
|
1321
|
+
sessions.push(untitled);
|
|
1322
|
+
used = untitledSized;
|
|
1323
|
+
continue;
|
|
1324
|
+
}
|
|
1325
|
+
sessions.push(row);
|
|
1326
|
+
used = sized;
|
|
1327
|
+
}
|
|
1328
|
+
return { sessions, instance: this.instanceId };
|
|
1329
|
+
}
|
|
1330
|
+
/** One listing row for a live agent; the title projection is best-effort. */
|
|
1331
|
+
sessionRow(agent) {
|
|
1332
|
+
let title;
|
|
1333
|
+
try {
|
|
1334
|
+
const snapshot = this.ctx.get("sessionProjections")?.snapshot(agent.session);
|
|
1335
|
+
const value = snapshot?.values.title;
|
|
1336
|
+
if (typeof value === "string" && value !== "") title = value;
|
|
1337
|
+
} catch {
|
|
1338
|
+
title = void 0;
|
|
1339
|
+
}
|
|
1340
|
+
return {
|
|
1341
|
+
sessionId: agent.id,
|
|
1342
|
+
...title === void 0 ? {} : { title },
|
|
1343
|
+
...typeof agent.status === "string" ? { status: agent.status } : {}
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Resolve a session that is not currently live, when the sender asked to wake
|
|
1348
|
+
* it. Returns the agent, or the reason it stays undelivered.
|
|
1349
|
+
*
|
|
1350
|
+
* The resume is delegated to the Host's configured `agent` lookup rather than
|
|
1351
|
+
* calling `ctx.agents.resume()` here, and that is the whole point: a handle
|
|
1352
|
+
* from `resume()` is owned by the CALLING context, so resuming on this
|
|
1353
|
+
* plugin's fiber would tear the session down again the moment the plugin
|
|
1354
|
+
* unloads (measured: the same call through the root context leaves it alive).
|
|
1355
|
+
* The Host's resolver owns it instead, and it also composes the preset the
|
|
1356
|
+
* session recorded — so a woken agent comes back with the toolset its history
|
|
1357
|
+
* was produced under, not an empty one.
|
|
1358
|
+
*/
|
|
1359
|
+
async wake(payload) {
|
|
1360
|
+
if (payload.resume !== true) return { reason: "session-not-live" };
|
|
1361
|
+
if (!this.allowResume) return { reason: "resume-refused" };
|
|
1362
|
+
const lookup = this.ctx.get("typert")?.lookups.get("agent");
|
|
1363
|
+
if (lookup === void 0) return { reason: "session-not-live" };
|
|
1364
|
+
try {
|
|
1365
|
+
const resolved = await lookup.resolve(payload.sessionId);
|
|
1366
|
+
if (resolved === void 0 || resolved === null) return { reason: "session-not-live" };
|
|
1367
|
+
return { agent: resolved };
|
|
1368
|
+
} catch (error) {
|
|
1369
|
+
this.ctx.logger.info(
|
|
1370
|
+
`interconnect: resume refused for ${payload.sessionId}: ${error instanceof Error ? error.message : String(error)}`
|
|
1371
|
+
);
|
|
1372
|
+
return { reason: "resume-failed" };
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* Deliver one message to a live local session, waking a persisted one only
|
|
1377
|
+
* when the sender asked and this receiver allows it.
|
|
1378
|
+
*/
|
|
1379
|
+
async deliver(payload) {
|
|
1380
|
+
if (this.disposed) return { delivered: false, instance: this.instanceId, reason: "unreachable" };
|
|
1381
|
+
let agent = this.ctx.agents.get(payload.sessionId);
|
|
1382
|
+
if (agent === void 0) {
|
|
1383
|
+
const woken = await this.wake(payload);
|
|
1384
|
+
if (this.disposed) return { delivered: false, instance: this.instanceId, reason: "unreachable" };
|
|
1385
|
+
if ("reason" in woken) {
|
|
1386
|
+
return { delivered: false, instance: this.instanceId, reason: woken.reason };
|
|
1387
|
+
}
|
|
1388
|
+
agent = woken.agent;
|
|
1389
|
+
}
|
|
1390
|
+
if (isSessionOwnedBySubagent(this.ctx, agent.session, agent)) {
|
|
1391
|
+
return { delivered: false, instance: this.instanceId, reason: "session-owned-by-subagent" };
|
|
1392
|
+
}
|
|
1393
|
+
const sender = (() => {
|
|
1394
|
+
const candidate = payload.sender;
|
|
1395
|
+
if (typeof candidate !== "object" || candidate === null) return void 0;
|
|
1396
|
+
const record = candidate;
|
|
1397
|
+
if (typeof record.instanceId !== "string" || typeof record.sessionId !== "string") return void 0;
|
|
1398
|
+
return { instanceId: record.instanceId, sessionId: record.sessionId };
|
|
1399
|
+
})();
|
|
1400
|
+
const message = createUserMessage({
|
|
1401
|
+
source: {
|
|
1402
|
+
kind: "plugin",
|
|
1403
|
+
plugin: PLUGIN_SOURCE,
|
|
1404
|
+
form: "notice",
|
|
1405
|
+
summary: boundContextSummary(
|
|
1406
|
+
sender === void 0 ? `interconnect handoff delivered on instance ${this.instanceId}` : `interconnect handoff from ${sender.instanceId} (session ${sender.sessionId}) delivered on instance ${this.instanceId}`
|
|
1407
|
+
)
|
|
1408
|
+
},
|
|
1409
|
+
content: [{ type: "text", text: payload.text }]
|
|
1410
|
+
});
|
|
1411
|
+
const mode = payload.delivery ?? this.delivery;
|
|
1412
|
+
switch (mode) {
|
|
1413
|
+
case "steer":
|
|
1414
|
+
agent.steer(message);
|
|
1415
|
+
break;
|
|
1416
|
+
case "inject":
|
|
1417
|
+
agent.inject(message);
|
|
1418
|
+
break;
|
|
1419
|
+
case "followup":
|
|
1420
|
+
agent.followup(message);
|
|
1421
|
+
break;
|
|
1422
|
+
/* v8 ignore next -- closed-union exhaustiveness guard */
|
|
1423
|
+
default:
|
|
1424
|
+
assertNever(mode);
|
|
1425
|
+
}
|
|
1426
|
+
if (sender !== void 0) {
|
|
1427
|
+
this.senders.set(payload.sessionId, sender);
|
|
1428
|
+
} else {
|
|
1429
|
+
this.senders.delete(payload.sessionId);
|
|
1430
|
+
}
|
|
1431
|
+
return { delivered: true, instance: this.instanceId, delivery: mode };
|
|
1432
|
+
}
|
|
1433
|
+
async resolveToken() {
|
|
1434
|
+
const credential = await this.ctx.credentials.resolve(credentialRef(INTERCONNECT_TOKEN_REF));
|
|
1435
|
+
return credential === void 0 || credential.value.length === 0 ? void 0 : credential.value;
|
|
1436
|
+
}
|
|
1437
|
+
/**
|
|
1438
|
+
* Answer one refused upgrade and close its socket. `socket.end()` alone only
|
|
1439
|
+
* half-closes the connection: a client that keeps its own writing side open
|
|
1440
|
+
* (a socket created with `allowHalfOpen`) leaves the connection live, and a
|
|
1441
|
+
* refused socket never enters the link pool, so nothing else would reap it.
|
|
1442
|
+
* @param socket - the refused upgrade socket.
|
|
1443
|
+
* @param response - the complete HTTP response sent to the client.
|
|
1444
|
+
*/
|
|
1445
|
+
refuseUpgrade(socket, response) {
|
|
1446
|
+
socket.end(response, () => {
|
|
1447
|
+
socket.destroy();
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
/** Inbound WebSocket upgrade: authenticate the bearer header, then accept. */
|
|
1451
|
+
async handleUpgrade(req, socket, head) {
|
|
1452
|
+
let token;
|
|
1453
|
+
try {
|
|
1454
|
+
token = await this.resolveToken();
|
|
1455
|
+
} catch (error) {
|
|
1456
|
+
this.ctx.logger.warn(`interconnect: upgrade token read failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1457
|
+
this.refuseUpgrade(socket, "HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 9\r\n\r\nforbidden");
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
if (token === void 0) {
|
|
1461
|
+
this.refuseUpgrade(socket, "HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 9\r\n\r\nforbidden");
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
if (this.disposed) {
|
|
1465
|
+
this.refuseUpgrade(socket, "HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 11\r\n\r\nunavailable");
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
const expected = `Bearer ${token}`;
|
|
1469
|
+
const header = req.headers.authorization;
|
|
1470
|
+
if (header === void 0 || !timingSafeEqual(header, expected)) {
|
|
1471
|
+
this.refuseUpgrade(socket, "HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 12\r\n\r\nunauthorized");
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
this.server.handleUpgrade(req, socket, head, (websocket) => {
|
|
1475
|
+
this.attachSocket(websocket);
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
/**
|
|
1479
|
+
* Install frame + liveness handling on one socket and add it to the live
|
|
1480
|
+
* pool. Used by both the server half (accepted upgrade) and the client half
|
|
1481
|
+
* (outbound dial), so a single socket carries events both directions. Not a
|
|
1482
|
+
* model-facing entry point; the outbound dial reaches it through a private
|
|
1483
|
+
* closure.
|
|
1484
|
+
* @param websocket - the accepted or dialed socket to install handlers on.
|
|
1485
|
+
*/
|
|
1486
|
+
attachSocket(websocket) {
|
|
1487
|
+
this.sockets.add(websocket);
|
|
1488
|
+
websocket.isAlive = true;
|
|
1489
|
+
websocket.on("pong", () => {
|
|
1490
|
+
;
|
|
1491
|
+
websocket.isAlive = true;
|
|
1492
|
+
});
|
|
1493
|
+
websocket.on("message", (data, isBinary) => {
|
|
1494
|
+
if (isBinary) return;
|
|
1495
|
+
this.handleFrame(websocket, data);
|
|
1496
|
+
});
|
|
1497
|
+
websocket.on("close", () => {
|
|
1498
|
+
this.sockets.delete(websocket);
|
|
1499
|
+
this.peerOf.delete(websocket);
|
|
1500
|
+
});
|
|
1501
|
+
websocket.on("error", () => {
|
|
1502
|
+
this.sockets.delete(websocket);
|
|
1503
|
+
});
|
|
1504
|
+
websocket.send(JSON.stringify({ type: "hello", sender: this.instanceId }));
|
|
1505
|
+
}
|
|
1506
|
+
/** Parse and route one inbound link frame, attributing events to the socket's announced peer. */
|
|
1507
|
+
handleFrame(socket, data) {
|
|
1508
|
+
const byteLength = typeof data === "string" ? Buffer.byteLength(data) : Array.isArray(data) ? data.reduce((total, part) => total + part.byteLength, 0) : data.byteLength;
|
|
1509
|
+
if (byteLength > MAX_LINK_FRAME_BYTES) {
|
|
1510
|
+
this.ctx.logger.warn("interconnect: dropping oversized link frame");
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
const text = typeof data === "string" ? data : Array.isArray(data) ? Buffer.concat(data).toString("utf8") : Buffer.isBuffer(data) ? data.toString("utf8") : Buffer.from(data).toString("utf8");
|
|
1514
|
+
let parsed;
|
|
1515
|
+
try {
|
|
1516
|
+
parsed = withoutNullFields(Schema.resolve(JSON.parse(text), linkFrameSchema, {})[0]);
|
|
1517
|
+
} catch {
|
|
1518
|
+
this.ctx.logger.warn("interconnect: dropping malformed link frame");
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
1522
|
+
this.ctx.logger.warn("interconnect: dropping malformed link frame");
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
const frame = parsed;
|
|
1526
|
+
if (frame.type === "hello") {
|
|
1527
|
+
this.peerOf.set(socket, frame.sender);
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
if (frame.type === "event") {
|
|
1531
|
+
const sender = this.peerOf.get(socket) ?? "unknown-peer";
|
|
1532
|
+
const kind = frame.notification.kind;
|
|
1533
|
+
try {
|
|
1534
|
+
this.receiveEvent({ sender, notification: frame.notification });
|
|
1535
|
+
} catch (error) {
|
|
1536
|
+
this.ctx.logger.warn(
|
|
1537
|
+
`interconnect: listener for a ${kind} event from ${sender} threw: ` + (error instanceof Error ? error.message : String(error))
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
if (frame.type === "msg-result") {
|
|
1543
|
+
const pending = this.pendingMessages.get(frame.reqId);
|
|
1544
|
+
if (pending !== void 0 && pending.kind === "msg") {
|
|
1545
|
+
;
|
|
1546
|
+
pending.resolve?.(projectMsgResult(frame.result));
|
|
1547
|
+
}
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
if (frame.type === "query-result") {
|
|
1551
|
+
const pending = this.pendingMessages.get(frame.reqId);
|
|
1552
|
+
if (pending !== void 0 && pending.kind === "query") {
|
|
1553
|
+
const answer = pending.accept(frame.result);
|
|
1554
|
+
if (answer !== void 0) pending.resolve?.(answer);
|
|
1555
|
+
}
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
if (frame.type === "msg") {
|
|
1559
|
+
void Promise.resolve().then(async () => {
|
|
1560
|
+
await this.handleMsgFrame(socket, frame.reqId, frame.message);
|
|
1561
|
+
}).catch((error) => {
|
|
1562
|
+
this.ctx.logger.warn(`interconnect: msg ${frame.reqId} handler threw: ${error instanceof Error ? error.message : String(error)}`);
|
|
1563
|
+
void this.sendFrame(socket, {
|
|
1564
|
+
type: "msg-result",
|
|
1565
|
+
reqId: frame.reqId,
|
|
1566
|
+
result: { delivered: false, instance: this.instanceId, reason: "unreachable" }
|
|
1567
|
+
});
|
|
1568
|
+
});
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
void Promise.resolve().then(() => {
|
|
1572
|
+
this.handleQueryFrame(socket, frame.reqId, frame.query);
|
|
1573
|
+
}).catch((error) => {
|
|
1574
|
+
this.ctx.logger.warn(`interconnect: query ${frame.reqId} handler threw: ${error instanceof Error ? error.message : String(error)}`);
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
/** Write one frame to a socket, returning false when the socket cannot take it. */
|
|
1578
|
+
sendFrame(socket, frame) {
|
|
1579
|
+
if (socket.readyState !== WebSocket.OPEN) return false;
|
|
1580
|
+
socket.send(JSON.stringify(frame));
|
|
1581
|
+
return true;
|
|
1582
|
+
}
|
|
1583
|
+
};
|
|
1584
|
+
var LinkState = class {
|
|
1585
|
+
constructor(attachSocket, logger, resolveToken, instanceId, origin) {
|
|
1586
|
+
this.attachSocket = attachSocket;
|
|
1587
|
+
this.logger = logger;
|
|
1588
|
+
this.resolveToken = resolveToken;
|
|
1589
|
+
this.instanceId = instanceId;
|
|
1590
|
+
this.peer = origin;
|
|
1591
|
+
}
|
|
1592
|
+
socket;
|
|
1593
|
+
dialEpoch = 0;
|
|
1594
|
+
closed = false;
|
|
1595
|
+
retry = 0;
|
|
1596
|
+
reconnectTimer;
|
|
1597
|
+
warnedNoToken = false;
|
|
1598
|
+
/** Dial-registered handlers, removed by reference on close/reroute so the pool-cleanup handlers attachSocket added stay alive. */
|
|
1599
|
+
dialListeners;
|
|
1600
|
+
// `peer` is mutable so `reroute` can point the link at a new origin.
|
|
1601
|
+
peer;
|
|
1602
|
+
instanceId;
|
|
1603
|
+
/** Point this link at a different origin; re-dials immediately. */
|
|
1604
|
+
reroute(origin) {
|
|
1605
|
+
if (origin === this.peer) return;
|
|
1606
|
+
this.peer = origin;
|
|
1607
|
+
this.dialEpoch += 1;
|
|
1608
|
+
if (this.reconnectTimer !== void 0) {
|
|
1609
|
+
clearTimeout(this.reconnectTimer);
|
|
1610
|
+
this.reconnectTimer = void 0;
|
|
1611
|
+
}
|
|
1612
|
+
const socket = this.socket;
|
|
1613
|
+
if (socket !== void 0) {
|
|
1614
|
+
this.removeDialListeners(socket);
|
|
1615
|
+
socket.on("error", () => {
|
|
1616
|
+
});
|
|
1617
|
+
socket.terminate();
|
|
1618
|
+
this.socket = void 0;
|
|
1619
|
+
}
|
|
1620
|
+
this.dial();
|
|
1621
|
+
}
|
|
1622
|
+
/** Open the socket; reconnect is scheduled by the close handler. */
|
|
1623
|
+
dial() {
|
|
1624
|
+
if (this.closed) return;
|
|
1625
|
+
const epoch = this.dialEpoch;
|
|
1626
|
+
let url;
|
|
1627
|
+
try {
|
|
1628
|
+
url = linkUrl(this.peer);
|
|
1629
|
+
} catch {
|
|
1630
|
+
this.logger.warn(`interconnect: invalid peer origin ${this.peer}; link to ${this.instanceId} stays down`);
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
void this.resolveToken().then((token) => {
|
|
1634
|
+
if (token === void 0) {
|
|
1635
|
+
if (!this.warnedNoToken) {
|
|
1636
|
+
this.warnedNoToken = true;
|
|
1637
|
+
this.logger.warn(`interconnect: no shared token configured; peer ${this.instanceId} link stays down, retrying until one is set`);
|
|
1638
|
+
}
|
|
1639
|
+
if (this.closed || epoch !== this.dialEpoch) return;
|
|
1640
|
+
this.scheduleReconnect();
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
if (this.closed || epoch !== this.dialEpoch) return;
|
|
1644
|
+
const socket = new WebSocket(url, {
|
|
1645
|
+
headers: { authorization: `Bearer ${token}` },
|
|
1646
|
+
maxPayload: MAX_LINK_FRAME_BYTES,
|
|
1647
|
+
// A peer that accepts TCP but never completes the upgrade would leave
|
|
1648
|
+
// the socket in CONNECTING forever: no open (so no heartbeat) and no
|
|
1649
|
+
// close (so no reconnect). ws aborts the connection past the handshake
|
|
1650
|
+
// window, so its own error+close fire and the close handler schedules
|
|
1651
|
+
// a reconnect — and ws owns the timer, so nothing leaks on teardown.
|
|
1652
|
+
handshakeTimeout: LINK_HANDSHAKE_TIMEOUT_MS
|
|
1653
|
+
});
|
|
1654
|
+
this.socket = socket;
|
|
1655
|
+
const onOpen = () => {
|
|
1656
|
+
this.retry = 0;
|
|
1657
|
+
this.attachSocket(socket);
|
|
1658
|
+
};
|
|
1659
|
+
const onClose = () => {
|
|
1660
|
+
if (this.closed) return;
|
|
1661
|
+
this.scheduleReconnect();
|
|
1662
|
+
};
|
|
1663
|
+
const onError = () => {
|
|
1664
|
+
};
|
|
1665
|
+
this.dialListeners = { open: onOpen, close: onClose, error: onError };
|
|
1666
|
+
socket.once("open", onOpen);
|
|
1667
|
+
socket.once("close", onClose);
|
|
1668
|
+
socket.on("error", onError);
|
|
1669
|
+
}).catch(() => {
|
|
1670
|
+
if (this.closed || epoch !== this.dialEpoch) return;
|
|
1671
|
+
this.scheduleReconnect();
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
/** Whether this link has been closed; a closed state is never reused by `link()`. */
|
|
1675
|
+
isClosed() {
|
|
1676
|
+
return this.closed;
|
|
1677
|
+
}
|
|
1678
|
+
/** Whether this link currently holds an open socket that can carry frames. */
|
|
1679
|
+
writable() {
|
|
1680
|
+
return this.socket !== void 0 && this.socket.readyState === WebSocket.OPEN;
|
|
1681
|
+
}
|
|
1682
|
+
/** Write one frame over this link's outbound socket; false when not open. */
|
|
1683
|
+
sendFrame(frame) {
|
|
1684
|
+
const socket = this.socket;
|
|
1685
|
+
if (socket === void 0 || socket.readyState !== WebSocket.OPEN) return false;
|
|
1686
|
+
socket.send(JSON.stringify(frame));
|
|
1687
|
+
return true;
|
|
1688
|
+
}
|
|
1689
|
+
close() {
|
|
1690
|
+
this.closed = true;
|
|
1691
|
+
if (this.reconnectTimer !== void 0) clearTimeout(this.reconnectTimer);
|
|
1692
|
+
const socket = this.socket;
|
|
1693
|
+
if (socket !== void 0) {
|
|
1694
|
+
this.removeDialListeners(socket);
|
|
1695
|
+
socket.on("error", () => {
|
|
1696
|
+
});
|
|
1697
|
+
socket.terminate();
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
/** Detach the dial-registered handlers from a socket before terminating it. */
|
|
1701
|
+
removeDialListeners(socket) {
|
|
1702
|
+
const listeners = this.dialListeners;
|
|
1703
|
+
if (listeners === void 0) return;
|
|
1704
|
+
socket.removeListener("open", listeners.open);
|
|
1705
|
+
socket.removeListener("close", listeners.close);
|
|
1706
|
+
socket.removeListener("error", listeners.error);
|
|
1707
|
+
this.dialListeners = void 0;
|
|
1708
|
+
}
|
|
1709
|
+
scheduleReconnect() {
|
|
1710
|
+
const delay = Math.min(3e4, 1e3 * 2 ** this.retry);
|
|
1711
|
+
this.retry += 1;
|
|
1712
|
+
this.reconnectTimer = setTimeout(() => {
|
|
1713
|
+
this.reconnectTimer = void 0;
|
|
1714
|
+
this.dial();
|
|
1715
|
+
}, delay);
|
|
1716
|
+
}
|
|
1717
|
+
};
|
|
1718
|
+
function timingSafeEqual(a, b) {
|
|
1719
|
+
return constantTimeEqual(createHash("sha256").update(a, "utf8").digest(), createHash("sha256").update(b, "utf8").digest());
|
|
1720
|
+
}
|
|
1721
|
+
function linkUrl(origin) {
|
|
1722
|
+
const url = new URL("/interconnect/link", origin);
|
|
1723
|
+
if (url.protocol === "http:") url.protocol = "ws:";
|
|
1724
|
+
else if (url.protocol === "https:") url.protocol = "wss:";
|
|
1725
|
+
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
|
|
1726
|
+
throw new TypeError(`interconnect: unsupported peer origin protocol ${url.protocol}`);
|
|
1727
|
+
}
|
|
1728
|
+
return url;
|
|
1729
|
+
}
|
|
1730
|
+
function trimBase(baseUrl) {
|
|
1731
|
+
return baseUrl.replace(/\/+$/, "");
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// src/tool-interconnect/index.ts
|
|
3
1735
|
var name = "tool-interconnect";
|
|
4
1736
|
var inject = ["interconnect", "tools"];
|
|
5
1737
|
var deliveryOutcomeSchema = {
|
|
@@ -12,7 +1744,7 @@ var deliveryOutcomeSchema = {
|
|
|
12
1744
|
reason: { type: "string" }
|
|
13
1745
|
}
|
|
14
1746
|
};
|
|
15
|
-
var raceSignal = (signal, promise) => new Promise((
|
|
1747
|
+
var raceSignal = (signal, promise) => new Promise((resolve2, reject) => {
|
|
16
1748
|
const abort = () => {
|
|
17
1749
|
const reason = signal.reason;
|
|
18
1750
|
reject(reason instanceof Error ? reason : new Error(typeof reason === "string" ? reason : "aborted"));
|
|
@@ -25,7 +1757,7 @@ var raceSignal = (signal, promise) => new Promise((resolve, reject) => {
|
|
|
25
1757
|
promise.then(
|
|
26
1758
|
(value) => {
|
|
27
1759
|
signal.removeEventListener("abort", abort);
|
|
28
|
-
|
|
1760
|
+
resolve2(value);
|
|
29
1761
|
},
|
|
30
1762
|
(error) => {
|
|
31
1763
|
signal.removeEventListener("abort", abort);
|
|
@@ -115,12 +1847,12 @@ function apply(ctx) {
|
|
|
115
1847
|
}));
|
|
116
1848
|
ctx.tools.register(defineTool({
|
|
117
1849
|
name: "interconnect_ping",
|
|
118
|
-
description: "Probe a peer DSH instance for liveness and identity
|
|
1850
|
+
description: "Probe a peer DSH instance for liveness and identity. Reports reachable with the peer instance id, or unreachable when the peer does not answer.",
|
|
119
1851
|
parameters: {
|
|
120
1852
|
instanceId: {
|
|
121
1853
|
type: "string",
|
|
122
1854
|
required: true,
|
|
123
|
-
description: "The peer instance id to probe, as configured
|
|
1855
|
+
description: "The peer instance id to probe, as configured for this instance's interconnect peers."
|
|
124
1856
|
}
|
|
125
1857
|
},
|
|
126
1858
|
output: {
|
|
@@ -150,7 +1882,7 @@ function apply(ctx) {
|
|
|
150
1882
|
instanceId: {
|
|
151
1883
|
type: "string",
|
|
152
1884
|
required: true,
|
|
153
|
-
description: "The peer instance id to list, as configured
|
|
1885
|
+
description: "The peer instance id to list, as configured for this instance's interconnect peers."
|
|
154
1886
|
}
|
|
155
1887
|
},
|
|
156
1888
|
output: {
|
|
@@ -185,6 +1917,9 @@ function apply(ctx) {
|
|
|
185
1917
|
const status = session.status === void 0 ? "" : ` [${session.status}]`;
|
|
186
1918
|
return `${session.sessionId}${title}${status}`;
|
|
187
1919
|
});
|
|
1920
|
+
if (sessions.length === MAX_LISTED_SESSIONS) {
|
|
1921
|
+
lines.push(`(${String(MAX_LISTED_SESSIONS)} of possibly more live sessions; a missing target may still be live)`);
|
|
1922
|
+
}
|
|
188
1923
|
return [{ type: "text", text: lines.join("\n") }];
|
|
189
1924
|
}
|
|
190
1925
|
},
|